From 2c78b161258103a6980286f6fa68532830368450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Wed, 10 Sep 2025 15:57:13 +0200 Subject: [PATCH 01/27] WIP Assignments wip WIP Assignments --- src/constants.ts | 10 +- src/extension.ts | 6 + src/plugins.ts | 2 + src/plugins/manage.ts | 28 +++- src/plugins/setup.ts | 23 ++- src/plugins/status-bar.ts | 119 +++++++++------ src/utils/cli.ts | 297 ++++++++++++++++++++++++++++++++++---- src/utils/emitter.ts | 36 +++++ src/utils/install.ts | 46 +++--- src/utils/license.ts | 27 +++- src/utils/manage.ts | 7 +- src/utils/promises.ts | 41 ++++++ src/utils/setup-status.ts | 67 +++++---- src/utils/setup.ts | 11 -- 14 files changed, 566 insertions(+), 154 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 4a94541..d0d60af 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -15,20 +15,26 @@ export const GLOBAL_CLI_INSTALLATION_DIRNAME = join( ); const CLI_UNIX_PATHS = [ + // The local installation path takes precedence. + join(LOCAL_CLI_INSTALLATION_DIRNAME, "localstack"), + // Check if it's in the PATH. "localstack", + // Common installation paths. join("/", "usr", "bin", "localstack"), join("/", "usr", "local", "bin", "localstack"), join("/", "opt", "homebrew", "bin", "localstack"), join("/", "home", "linuxbrew", ".linuxbrew", "bin", "localstack"), join(homedir(), ".linuxbrew", "bin", "localstack"), join(homedir(), ".local", "bin", "localstack"), - join(LOCAL_CLI_INSTALLATION_DIRNAME, "localstack"), ]; const CLI_WINDOWS_PATHS = [ + // The local installation path takes precedence. + join(LOCAL_CLI_INSTALLATION_DIRNAME, "localstack.exe"), + // Check if it's in the PATH. "localstack.exe", + // Common installation paths. join(GLOBAL_CLI_INSTALLATION_DIRNAME, "localstack", "localstack.exe"), - join(LOCAL_CLI_INSTALLATION_DIRNAME, "localstack.exe"), ]; export const CLI_PATHS = diff --git a/src/extension.ts b/src/extension.ts index 1eeac76..45de807 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,6 +8,7 @@ import manage from "./plugins/manage.ts"; import setup from "./plugins/setup.ts"; import statusBar from "./plugins/status-bar.ts"; import { PluginManager } from "./plugins.ts"; +import { createCliStatusTracker } from "./utils/cli.ts"; import { createContainerStatusTracker } from "./utils/container-status.ts"; import { createLocalStackStatusTracker } from "./utils/localstack-status.ts"; import { getOrCreateExtensionSessionId } from "./utils/manage.ts"; @@ -29,6 +30,9 @@ export async function activate(context: ExtensionContext) { }); context.subscriptions.push(outputChannel); + const cliStatusTracker = createCliStatusTracker(outputChannel); + context.subscriptions.push(cliStatusTracker); + const timeTracker = createTimeTracker({ outputChannel }); const { @@ -65,6 +69,7 @@ export async function activate(context: ExtensionContext) { const setupStatusTracker = await createSetupStatusTracker( outputChannel, timeTracker, + cliStatusTracker, ); context.subscriptions.push(setupStatusTracker); const endStatusTracker = Date.now(); @@ -100,6 +105,7 @@ export async function activate(context: ExtensionContext) { context, outputChannel, statusBarItem, + cliStatusTracker, containerStatusTracker, localStackStatusTracker, setupStatusTracker, diff --git a/src/plugins.ts b/src/plugins.ts index b35d3f9..5c57c9b 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -1,6 +1,7 @@ import ms from "ms"; import type { ExtensionContext, LogOutputChannel, StatusBarItem } from "vscode"; +import type { LocalStackCliTracker } from "./utils/cli.ts"; import type { ContainerStatusTracker } from "./utils/container-status.ts"; import type { LocalStackStatusTracker } from "./utils/localstack-status.ts"; import type { SetupStatusTracker } from "./utils/setup-status.ts"; @@ -13,6 +14,7 @@ export interface PluginOptions { context: ExtensionContext; outputChannel: LogOutputChannel; statusBarItem: StatusBarItem; + cliStatusTracker: LocalStackCliTracker; containerStatusTracker: ContainerStatusTracker; localStackStatusTracker: LocalStackStatusTracker; setupStatusTracker: SetupStatusTracker; diff --git a/src/plugins/manage.ts b/src/plugins/manage.ts index 126cd74..db18ab4 100644 --- a/src/plugins/manage.ts +++ b/src/plugins/manage.ts @@ -9,16 +9,30 @@ import { export default createPlugin( "manage", - ({ context, outputChannel, telemetry, localStackStatusTracker }) => { + ({ + context, + outputChannel, + telemetry, + localStackStatusTracker, + cliStatusTracker, + }) => { context.subscriptions.push( commands.registerCommand("localstack.start", async () => { + const cliPath = cliStatusTracker.cliPath(); + if (!cliPath) { + window.showInformationMessage( + "LocalStack CLI could not be found. Please, run the setup wizard.", + ); + return; + } + if (localStackStatusTracker.status() !== "stopped") { window.showInformationMessage("LocalStack is already running."); return; } localStackStatusTracker.forceContainerStatus("running"); try { - await startLocalStack(outputChannel, telemetry); + await startLocalStack(cliPath, outputChannel, telemetry); } catch { localStackStatusTracker.forceContainerStatus("stopped"); } @@ -27,12 +41,20 @@ export default createPlugin( context.subscriptions.push( commands.registerCommand("localstack.stop", () => { + const cliPath = cliStatusTracker.cliPath(); + if (!cliPath) { + window.showInformationMessage( + "LocalStack CLI could not be found. Please, run the setup wizard.", + ); + return; + } + if (localStackStatusTracker.status() !== "running") { window.showInformationMessage("LocalStack is not running."); return; } localStackStatusTracker.forceContainerStatus("stopping"); - void stopLocalStack(outputChannel, telemetry); + void stopLocalStack(cliPath, outputChannel, telemetry); }), ); diff --git a/src/plugins/setup.ts b/src/plugins/setup.ts index 6f92d12..5695ded 100644 --- a/src/plugins/setup.ts +++ b/src/plugins/setup.ts @@ -25,6 +25,7 @@ export default createPlugin( outputChannel, setupStatusTracker, localStackStatusTracker, + cliStatusTracker, telemetry, }) => { context.subscriptions.push( @@ -43,7 +44,15 @@ export default createPlugin( }, }); - window.withProgress( + const cliPath = cliStatusTracker.cliPath(); + if (!cliPath) { + void window.showErrorMessage( + "LocalStack CLI is not configured. Please set it up before running the setup wizard.", + ); + return; + } + + void window.withProgress( { location: ProgressLocation.Notification, title: "Setup LocalStack", @@ -55,13 +64,14 @@ export default createPlugin( let authenticationStatus: "COMPLETED" | "SKIPPED" = "COMPLETED"; { const installationStartedAt = new Date().toISOString(); - const { cancelled, skipped } = await runInstallProcess( + const { cancelled, skipped } = await runInstallProcess({ + cliPath, progress, cancellationToken, outputChannel, telemetry, - origin_trigger, - ); + origin: origin_trigger, + }); cliStatus = skipped === true ? "SKIPPED" : "COMPLETED"; if (cancelled || cancellationToken.isCancellationRequested) { telemetry.track({ @@ -227,8 +237,8 @@ export default createPlugin( // Activating the license pre-emptively to know its state during the setup process. const licenseCheckStartedAt = new Date().toISOString(); const licenseIsValid = await minDelay( - activateLicense(outputChannel).then(() => - checkIsLicenseValid(outputChannel), + activateLicense(cliPath, outputChannel).then(() => + checkIsLicenseValid(cliPath, outputChannel), ), ); if (!licenseIsValid) { @@ -240,6 +250,7 @@ export default createPlugin( await commands.executeCommand("localstack.openLicensePage"); await activateLicenseUntilValid( + cliPath, outputChannel, cancellationToken, ); diff --git a/src/plugins/status-bar.ts b/src/plugins/status-bar.ts index e7f10ab..4261129 100644 --- a/src/plugins/status-bar.ts +++ b/src/plugins/status-bar.ts @@ -2,12 +2,14 @@ import { commands, QuickPickItemKind, ThemeColor, window } from "vscode"; import type { QuickPickItem } from "vscode"; import { createPlugin } from "../plugins.ts"; +import { immediateOnce } from "../utils/immediate-once.ts"; export default createPlugin( "status-bar", ({ context, statusBarItem, + cliStatusTracker, localStackStatusTracker, setupStatusTracker, outputChannel, @@ -15,10 +17,10 @@ export default createPlugin( context.subscriptions.push( commands.registerCommand("localstack.showCommands", async () => { const shouldShowLocalStackStart = () => - setupStatusTracker.statuses().isInstalled && + cliStatusTracker.status() === "ok" && localStackStatusTracker.status() === "stopped"; const shouldShowLocalStackStop = () => - setupStatusTracker.statuses().isInstalled && + cliStatusTracker.status() === "ok" && localStackStatusTracker.status() === "running"; const shouldShowRunSetupWizard = () => setupStatusTracker.status() === "setup_required"; @@ -77,63 +79,84 @@ export default createPlugin( }), ); - context.subscriptions.push( - commands.registerCommand("localstack.refreshStatusBar", () => { - const setupStatus = setupStatusTracker.status(); - const localStackStatus = localStackStatusTracker.status(); - const localStackInstalled = setupStatusTracker.statuses().isInstalled; - - statusBarItem.command = "localstack.showCommands"; - statusBarItem.backgroundColor = - setupStatus === "setup_required" - ? new ThemeColor("statusBarItem.errorBackground") - : undefined; - - const shouldSpin = - localStackStatus === "starting" || localStackStatus === "stopping"; - const icon = - setupStatus === "setup_required" - ? "$(error)" - : shouldSpin - ? "$(sync~spin)" - : "$(localstack-logo)"; - - const statusText = localStackInstalled - ? `${localStackStatus}` - : "not installed"; - statusBarItem.text = `${icon} LocalStack: ${statusText}`; - - statusBarItem.tooltip = "Show LocalStack commands"; - statusBarItem.show(); - }), - ); - - let refreshStatusBarImmediateId: NodeJS.Immediate | undefined; - const refreshStatusBarImmediate = () => { - if (!refreshStatusBarImmediateId) { - refreshStatusBarImmediateId = setImmediate(() => { - void commands.executeCommand("localstack.refreshStatusBar"); - refreshStatusBarImmediateId = undefined; - }); + // context.subscriptions.push( + // commands.registerCommand("localstack.refreshStatusBar", () => { + // const setupStatus = setupStatusTracker.status(); + // const localStackStatus = localStackStatusTracker.status(); + // const localStackInstalled = cliStatusTracker.status() === "ok" + + // statusBarItem.command = "localstack.showCommands"; + // statusBarItem.backgroundColor = + // setupStatus === "setup_required" + // ? new ThemeColor("statusBarItem.errorBackground") + // : undefined; + + // const shouldSpin = + // localStackStatus === "starting" || localStackStatus === "stopping"; + // const icon = + // setupStatus === "setup_required" + // ? "$(error)" + // : shouldSpin + // ? "$(sync~spin)" + // : "$(localstack-logo)"; + + // const statusText = localStackInstalled + // ? `${localStackStatus}` + // : "not installed"; + // statusBarItem.text = `${icon} LocalStack: ${statusText}`; + + // statusBarItem.tooltip = "Show LocalStack commands"; + // statusBarItem.show(); + // }), + // ); + + const refreshStatusBar = immediateOnce(() => { + // await commands.executeCommand("localstack.refreshStatusBar"); + const setupStatus = setupStatusTracker.status(); + const localStackStatus = localStackStatusTracker.status(); + const cliStatus = cliStatusTracker.status(); + + if ( + setupStatus === undefined || + localStackStatus === undefined || + cliStatus === undefined + ) { + return; } - }; - context.subscriptions.push({ - dispose() { - clearImmediate(refreshStatusBarImmediateId); - }, + statusBarItem.command = "localstack.showCommands"; + statusBarItem.backgroundColor = + setupStatus === "setup_required" + ? new ThemeColor("statusBarItem.errorBackground") + : undefined; + + const shouldSpin = + localStackStatus === "starting" || localStackStatus === "stopping"; + const icon = + setupStatus === "setup_required" + ? "$(error)" + : shouldSpin + ? "$(sync~spin)" + : "$(localstack-logo)"; + + const statusText = + cliStatus === "ok" ? `${localStackStatus}` : "not installed"; + statusBarItem.text = `${icon} LocalStack: ${statusText}`; + + statusBarItem.tooltip = "Show LocalStack commands"; + // statusBarItem.show(); }); - refreshStatusBarImmediate(); + // refreshStatusBar(); localStackStatusTracker.onChange(() => { outputChannel.trace("[status-bar]: localStackStatusTracker changed"); - refreshStatusBarImmediate(); + refreshStatusBar(); }); setupStatusTracker.onChange(() => { outputChannel.trace("[status-bar]: setupStatusTracker changed"); - refreshStatusBarImmediate(); + refreshStatusBar(); }); }, ); diff --git a/src/utils/cli.ts b/src/utils/cli.ts index 3db15a1..29fb56c 100644 --- a/src/utils/cli.ts +++ b/src/utils/cli.ts @@ -1,60 +1,146 @@ +import EventEmitter from "node:events"; import { constants } from "node:fs"; import { access } from "node:fs/promises"; +import { isAbsolute } from "node:path"; -import type { CancellationToken, LogOutputChannel } from "vscode"; +import { watch } from "chokidar"; import { workspace } from "vscode"; +import type { CancellationToken, LogOutputChannel, Disposable } from "vscode"; import { CLI_PATHS, LOCALSTACK_DOCKER_IMAGE_NAME } from "../constants.ts"; +import { createEmitter, createValueEmitter } from "./emitter.ts"; +import type { Callback } from "./emitter.ts"; import { exec } from "./exec.ts"; +import { immediateOnce } from "./immediate-once.ts"; +import { setIntervalPromise } from "./promises.ts"; +import type { SetupStatus } from "./setup-status.ts"; import { spawn } from "./spawn.ts"; import type { SpawnOptions } from "./spawn.ts"; const IMAGE_NAME = LOCALSTACK_DOCKER_IMAGE_NAME; // not using the import directly as the constant name should match the env var const LOCALSTACK_LDM_PREVIEW = "1"; -const findLocalStack = async (): Promise => { +async function getLocalStackVersion( + cliPath: string, +): Promise { + try { + const { stdout } = await exec([cliPath, "--version"].join(" "), { + env: { + ...process.env, + IMAGE_NAME, + LOCALSTACK_LDM_PREVIEW, + }, + }); + + const versionMatch = stdout.match(/\b([\d]+\.[\d]+.(?:\d[\d\w]*))\b/); + if (!versionMatch) { + return undefined; + } + const [, stdoutVersion] = versionMatch; + return stdoutVersion; + } catch { + return undefined; + } +} + +async function getLocalStackMajorVersion( + cliPath: string, +): Promise { + const version = await getLocalStackVersion(cliPath); + if (!version) { + return undefined; + } + const majorVersionMatch = version.match(/^(\d+)\./); + if (!majorVersionMatch) { + return undefined; + } + const [, majorVersionStr] = majorVersionMatch; + const majorVersion = parseInt(majorVersionStr, 10); + if (Number.isNaN(majorVersion)) { + return undefined; + } + return majorVersion; +} + +async function verifyLocalStackCli(cliPath: string) { + const [found, executable, version] = await Promise.all([ + access(cliPath, constants.F_OK) + .then(() => true) + .catch(() => false), + access(cliPath, constants.X_OK) + .then(() => true) + .catch(() => false), + getLocalStackMajorVersion(cliPath), + ]); + return { + found, + executable, + upToDate: version !== undefined ? version >= 4 : undefined, + }; +} + +interface CliCheckResult { + cliPath: string | undefined; + found: boolean; + executable: boolean | undefined; + upToDate: boolean | undefined; +} + +async function findLocalStack(): Promise { // Check if a custom path is configured const config = workspace.getConfiguration("localstack"); const customLocation = config.get("cli.location"); - if (customLocation) { - try { - await access(customLocation, constants.X_OK); - return customLocation; - } catch (error) { - throw new Error( - `Configured LocalStack CLI location '${customLocation}' is not accessible: ${error instanceof Error ? error.message : String(error)}`, - { cause: error }, - ); - } + const { found, executable, upToDate } = + await verifyLocalStackCli(customLocation); + return { + cliPath: customLocation, + found, + executable, + upToDate, + }; + // const {found, executable, upToDate} = await verifyLocalStackCli(customLocation); + // if (!found) { + // throw new Error(`Configured LocalStack CLI location '${customLocation}' does not exist`); + // } + // if (!executable) { + // throw new Error(`Configured LocalStack CLI location '${customLocation}' is not executable`); + // } + // if (!upToDate) { + // throw new Error(`Configured LocalStack CLI location '${customLocation}' is outdated (version < 4)`); + // } } // Fall back to default search paths for (const CLI_PATH of CLI_PATHS) { - try { - await access(CLI_PATH, constants.X_OK); - return CLI_PATH; - } catch { - // Continue to next path + const { found, executable, upToDate } = await verifyLocalStackCli(CLI_PATH); + if (found) { + return { + cliPath: CLI_PATH, + found, + executable, + upToDate, + }; } } - throw new Error( - "LocalStack CLI could not be found in any of the default locations", - ); -}; + return { + cliPath: undefined, + found: false, + executable: undefined, + upToDate: undefined, + }; +} export const execLocalStack = async ( + cliPath: string, args: string[], - options: { + options?: { outputChannel: LogOutputChannel; - // cancellationToken?: CancellationToken; }, ) => { - const cli = await findLocalStack(); - - const response = await exec([`"${cli}"`, ...args].join(" "), { + const response = await exec([cliPath, ...args].join(" "), { env: { ...process.env, IMAGE_NAME, @@ -65,6 +151,7 @@ export const execLocalStack = async ( }; export const spawnLocalStack = async ( + cliPath: string, args: string[], options: { outputChannel: LogOutputChannel; @@ -72,9 +159,7 @@ export const spawnLocalStack = async ( onStderr?: SpawnOptions["onStderr"]; }, ) => { - const cli = await findLocalStack(); - - return spawn(`"${cli}"`, args, { + return spawn(cliPath, args, { outputChannel: options.outputChannel, outputLabel: `localstack.${args[0]}`, cancellationToken: options.cancellationToken, @@ -86,3 +171,157 @@ export const spawnLocalStack = async ( onStderr: options.onStderr, }); }; + +export type LocalStackCliStatus = "not_found" | "outdated" | "ok"; + +export interface LocalStackCliTracker extends Disposable { + // setupStatus(): SetupStatus | undefined; + // onSetupStatusChange( + // callback: (status: SetupStatus | undefined) => void, + // ): void; + // cli(): CliCheckResult | undefined; + // onCliChange(callback: (cli: CliCheckResult | undefined) => void): void; + // cliStatus(): LocalStackCliStatus | undefined; + // onCliStatusChange(callback: (status: LocalStackCliStatus) => void): void; + + status(): SetupStatus | undefined; + onStatusChange(callback: (status: SetupStatus | undefined) => void): void; + cliPath(): string | undefined; + onCliPathChange(callback: (cliPath: string | undefined) => void): void; +} + +function areCliCheckResultsDifferent( + resultA: CliCheckResult | undefined, + resultB: CliCheckResult | undefined, +): boolean { + if (resultA?.cliPath !== resultB?.cliPath) { + return true; + } + if (resultA?.found !== resultB?.found) { + return true; + } + if (resultA?.executable !== resultB?.executable) { + return true; + } + return false; +} + +function statusFromCliCheckResult( + cli: CliCheckResult | undefined, +): LocalStackCliStatus { + if (!cli?.found || !cli.executable) { + return "not_found"; + } + if (cli.upToDate === false) { + return "outdated"; + } + return "ok"; +} + +export function createCliStatusTracker( + outputChannel: LogOutputChannel, +): LocalStackCliTracker { + // const emitter = new EventEmitter<{ + // setupStatus: [SetupStatus | undefined]; + // cliStatus: [LocalStackCliStatus | undefined]; + // cli: [CliCheckResult | undefined]; + // }>(); + + // emitter.emit("setupStatus", ) + + const status = createValueEmitter(); + const cliPath = createValueEmitter(); + // const cliStatus = createValueEmitter(); + + // const statusEmitter = createEmitter(outputChannel); + // let currentStatus: LocalStackCliStatus | undefined; + + // let currentCli: CliCheckResult | undefined; + // const cliPathEmitter = createEmitter( + // outputChannel, + // ); + + const track = immediateOnce(async () => { + const newCli = await findLocalStack().catch(() => undefined); + outputChannel.info(`[cli]: findLocalStack = ${newCli?.cliPath}`); + + status.setValue( + newCli?.found && newCli.executable && newCli.upToDate + ? "ok" + : "setup_required", + ); + cliPath.setValue(newCli?.cliPath); + + // if (areCliCheckResultsDifferent(currentCli, newCli)) { + // currentCli = newCli; + // void cliPathEmitter.emit(currentCli); + // } + + // const newStatus = statusFromCliCheckResult(newCli); + // if (currentStatus !== newStatus) { + // currentStatus = newStatus; + // void statusEmitter.emit(newStatus); + // } + }); + + const watcher = watch( + // Watch absolute paths only, since `localstack` is not a real path. + CLI_PATHS.filter((path) => isAbsolute(path)), + ) + .on("add", (path) => { + outputChannel.trace( + `[cli]: Detected new file at ${path}, re-checking CLI`, + ); + track(); + }) + .on("change", (path) => { + outputChannel.trace( + `[cli]: Detected change to file at ${path}, re-checking CLI`, + ); + track(); + }) + .on("unlink", (path) => { + outputChannel.trace( + `[cli]: Detected removal of file at ${path}, re-checking CLI`, + ); + track(); + }); + + track(); + + return { + // cli() { + // return currentCli; + // }, + // onCliChange(callback) { + // cliPathEmitter.on(callback); + // if (currentCli) { + // callback(currentCli); + // } + // }, + // cliStatus() { + // return currentStatus; + // }, + // onCliStatusChange(callback) { + // statusEmitter.on(callback); + // if (currentStatus) { + // callback(currentStatus); + // } + // }, + cliPath() { + return cliPath.value(); + }, + onCliPathChange(callback) { + cliPath.on(callback); + }, + status() { + return status.value(); + }, + onStatusChange(callback) { + status.on(callback); + }, + async dispose() { + await watcher.close(); + }, + }; +} diff --git a/src/utils/emitter.ts b/src/utils/emitter.ts index 648b300..6e731e8 100644 --- a/src/utils/emitter.ts +++ b/src/utils/emitter.ts @@ -1,5 +1,7 @@ import type { LogOutputChannel } from "vscode"; +import { immediateOnce } from "./immediate-once.ts"; + export type Callback = (value: T) => Promise | void; export interface Emitter { @@ -25,3 +27,37 @@ export function createEmitter(outputChannel: LogOutputChannel): Emitter { }, }; } + +export interface ValueEmitter { + value(): T | undefined; + setValue(value: T): void; + on(callback: Callback): void; +} + +export function createValueEmitter(): ValueEmitter { + let currentValue: T; + const callbacks: Callback[] = []; + + const emit = immediateOnce(async () => { + for (const callback of callbacks) { + try { + await callback(currentValue); + } catch {} + } + }); + + return { + value() { + return currentValue; + }, + setValue(value) { + if (currentValue !== value) { + currentValue = value; + emit(); + } + }, + on(callback) { + callbacks.push(callback); + }, + }; +} diff --git a/src/utils/install.ts b/src/utils/install.ts index 7acfaaf..99d26c2 100644 --- a/src/utils/install.ts +++ b/src/utils/install.ts @@ -23,33 +23,45 @@ import { import { spawn } from "./spawn.ts"; import type { Telemetry } from "./telemetry.ts"; -export async function checkLocalstackInstalled( - outputChannel: LogOutputChannel, -): Promise { - try { - await execLocalStack(["--version"], { outputChannel }); - return true; - } catch (error) { - return false; - } +// export async function checkLocalstackInstalled( +// cliPath: string|undefined, +// outputChannel: LogOutputChannel, +// ): Promise { +// try { +// await execLocalStack(cliPath, ["--version"], { outputChannel }); +// return true; +// } catch (error) { +// return false; +// } +// } + +export interface RunInstallProcessOptions { + progress: Progress<{ message: string }>; + cancellationToken: CancellationToken; + outputChannel: LogOutputChannel; + telemetry: Telemetry; + origin?: "extension_startup" | "manual_trigger"; + cliPath: string | undefined; } export async function runInstallProcess( - progress: Progress<{ message: string }>, - cancellationToken: CancellationToken, - outputChannel: LogOutputChannel, - telemetry: Telemetry, - origin?: "extension_startup" | "manual_trigger", + options: RunInstallProcessOptions, ): Promise<{ cancelled: boolean; skipped?: boolean }> { + const { + progress, + cancellationToken, + outputChannel, + telemetry, + origin, + cliPath, + } = options; ///////////////////////////////////////////////////////////////////// const origin_trigger = origin ? origin : "manual_trigger"; progress.report({ message: "Verifying CLI installation...", }); const startedAt = new Date().toISOString(); - const isLocalStackInstalled = await minDelay( - checkLocalstackInstalled(outputChannel), - ); + const isLocalStackInstalled = cliPath !== undefined; if (cancellationToken.isCancellationRequested) { return { cancelled: true }; } diff --git a/src/utils/license.ts b/src/utils/license.ts index 631d01e..313bdde 100644 --- a/src/utils/license.ts +++ b/src/utils/license.ts @@ -33,11 +33,18 @@ export const LICENSE_FILENAME = join( const LICENSE_VALIDITY_MARKER = "license validity: valid"; -export async function checkIsLicenseValid(outputChannel: LogOutputChannel) { +export async function checkIsLicenseValid( + cliPath: string, + outputChannel: LogOutputChannel, +) { try { - const licenseInfoResponse = await execLocalStack(["license", "info"], { - outputChannel, - }); + const licenseInfoResponse = await execLocalStack( + cliPath, + ["license", "info"], + { + outputChannel, + }, + ); return licenseInfoResponse.stdout.includes(LICENSE_VALIDITY_MARKER); } catch (error) { outputChannel.error(error instanceof Error ? error : String(error)); @@ -46,9 +53,12 @@ export async function checkIsLicenseValid(outputChannel: LogOutputChannel) { } } -export async function activateLicense(outputChannel: LogOutputChannel) { +export async function activateLicense( + cliPath: string, + outputChannel: LogOutputChannel, +) { try { - await execLocalStack(["license", "activate"], { + await execLocalStack(cliPath, ["license", "activate"], { outputChannel, }); } catch (error) { @@ -57,6 +67,7 @@ export async function activateLicense(outputChannel: LogOutputChannel) { } export async function activateLicenseUntilValid( + cliPath: string, outputChannel: LogOutputChannel, cancellationToken: CancellationToken, ): Promise { @@ -64,11 +75,11 @@ export async function activateLicenseUntilValid( if (cancellationToken.isCancellationRequested) { break; } - const licenseIsValid = await checkIsLicenseValid(outputChannel); + const licenseIsValid = await checkIsLicenseValid(cliPath, outputChannel); if (licenseIsValid) { break; } - await activateLicense(outputChannel); + await activateLicense(cliPath, outputChannel); // Wait before trying again await new Promise((resolve) => setTimeout(resolve, 1000)); } diff --git a/src/utils/manage.ts b/src/utils/manage.ts index 837f739..3b55644 100644 --- a/src/utils/manage.ts +++ b/src/utils/manage.ts @@ -38,6 +38,7 @@ async function fetchLocalStackSessionId(): Promise { } export async function startLocalStack( + cliPath: string, outputChannel: LogOutputChannel, telemetry: Telemetry, ): Promise { @@ -49,6 +50,7 @@ export async function startLocalStack( const authToken = await readAuthToken(); try { await spawnLocalStack( + cliPath, [ "start", // DO NOT REMOVE! @@ -89,7 +91,7 @@ export async function startLocalStack( }, }); } catch (error) { - const isLicenseValid = await checkIsLicenseValid(outputChannel); + const isLicenseValid = await checkIsLicenseValid(cliPath, outputChannel); if (isLicenseValid === false) { void showErrorMessage("No valid LocalStack license found.", { title: "Go to License settings", @@ -116,6 +118,7 @@ export async function startLocalStack( } export async function stopLocalStack( + cliPath: string, outputChannel: LogOutputChannel, telemetry: Telemetry, ) { @@ -126,7 +129,7 @@ export async function stopLocalStack( // get session id before killing container const emulatorSessionId = await fetchLocalStackSessionId(); - await spawnLocalStack(["stop"], { + await spawnLocalStack(cliPath, ["stop"], { outputChannel, }); diff --git a/src/utils/promises.ts b/src/utils/promises.ts index 725e59e..04b7443 100644 --- a/src/utils/promises.ts +++ b/src/utils/promises.ts @@ -1,4 +1,5 @@ import pMinDelay from "p-min-delay"; +import type { Disposable } from "vscode"; /** * Setting up a minimum wait time allows users @@ -37,3 +38,43 @@ export function minDelay( * Extracts the resolved type from a Promise. */ export type UnwrapPromise = T extends Promise ? U : T; + +export function setIntervalPromise( + callback: () => Promise, + intervalMs: number, +): Disposable { + let timeout: NodeJS.Timeout | undefined; + let disposed = false; + + const runLater = () => { + timeout = setTimeout(() => void run(), intervalMs); + }; + + const run = async () => { + if (disposed) { + return; + } + + try { + await callback(); + } catch { + // Ignore errors + } finally { + if (!disposed) { + runLater(); + } + } + }; + + runLater(); + + return { + dispose: () => { + disposed = true; + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + }, + }; +} diff --git a/src/utils/setup-status.ts b/src/utils/setup-status.ts index c3b70e0..897dcf2 100644 --- a/src/utils/setup-status.ts +++ b/src/utils/setup-status.ts @@ -6,6 +6,7 @@ import { checkIsAuthenticated, LOCALSTACK_AUTH_FILENAME, } from "./authenticate.ts"; +import type { LocalStackCliTracker } from "./cli.ts"; import { AWS_CONFIG_FILENAME, AWS_CREDENTIALS_FILENAME, @@ -15,14 +16,12 @@ import { createEmitter } from "./emitter.ts"; import { immediateOnce } from "./immediate-once.ts"; import { checkIsLicenseValid, LICENSE_FILENAME } from "./license.ts"; import type { UnwrapPromise } from "./promises.ts"; -import { checkSetupStatus } from "./setup.ts"; import type { TimeTracker } from "./time-tracker.ts"; export type SetupStatus = "ok" | "setup_required"; export interface SetupStatusTracker extends Disposable { - status(): SetupStatus; - statuses(): UnwrapPromise>; + status(): SetupStatus|undefined; onChange(callback: (status: SetupStatus) => void): void; } @@ -32,31 +31,34 @@ export interface SetupStatusTracker extends Disposable { export async function createSetupStatusTracker( outputChannel: LogOutputChannel, timeTracker: TimeTracker, + cliTracker: LocalStackCliTracker, ): Promise { const start = Date.now(); - let statuses: UnwrapPromise> | undefined; let status: SetupStatus | undefined; const emitter = createEmitter(outputChannel); const awsProfileTracker = createAwsProfileStatusTracker(outputChannel); const localStackAuthenticationTracker = createLocalStackAuthenticationStatusTracker(outputChannel); - const licenseTracker = createLicenseStatusTracker(outputChannel); + const licenseTracker = createLicenseStatusTracker(cliTracker, localStackAuthenticationTracker, outputChannel); const end = Date.now(); outputChannel.trace( `[setup-status]: Initialized dependencies in ${ms(end - start, { long: true })}`, ); const checkStatusNow = async () => { - const allStatusesInitialized = Object.values({ + const statuses = { + cliTracker: cliTracker.status(), awsProfileTracker: awsProfileTracker.status(), authTracker: localStackAuthenticationTracker.status(), licenseTracker: licenseTracker.status(), - }).every((check) => check !== undefined); + }; - if (!allStatusesInitialized) { + const notInitialized = Object.values(statuses).some((check) => check === undefined); + if (notInitialized) { outputChannel.trace( `[setup-status] File watchers not initialized yet, skipping status check : ${JSON.stringify( { + cliTracker: cliTracker.status() ?? "undefined", awsProfileTracker: awsProfileTracker.status() ?? "undefined", authTracker: localStackAuthenticationTracker.status() ?? "undefined", @@ -67,21 +69,13 @@ export async function createSetupStatusTracker( return; } - statuses = await checkSetupStatus(outputChannel); - - const setupRequired = [ - ...Object.values(statuses), - awsProfileTracker.status() === "ok", - localStackAuthenticationTracker.status() === "ok", - licenseTracker.status() === "ok", - ].some((check) => check === false); - + const setupRequired = Object.values(statuses).some((status) => status === "setup_required"); const newStatus = setupRequired ? "setup_required" : "ok"; if (status !== newStatus) { status = newStatus; outputChannel.trace( `[setup-status] Status changed to ${JSON.stringify({ - ...statuses, + cliTracker: cliTracker.status() ?? "undefined", awsProfileTracker: awsProfileTracker.status() ?? "undefined", authTracker: localStackAuthenticationTracker.status() ?? "undefined", licenseTracker: licenseTracker.status() ?? "undefined", @@ -127,10 +121,6 @@ export async function createSetupStatusTracker( // biome-ignore lint/style/noNonNullAssertion: false positive return status!; }, - statuses() { - // biome-ignore lint/style/noNonNullAssertion: false positive - return statuses!; - }, onChange(callback) { emitter.on(callback); if (status) { @@ -149,8 +139,9 @@ export async function createSetupStatusTracker( interface StatusTracker { status(): SetupStatus | undefined; - onChange(callback: (status: SetupStatus) => void): void; + onChange(callback: (status: SetupStatus|undefined) => void): void; dispose(): Promise; + check(): void; } /** @@ -168,11 +159,11 @@ function createFileStatusTracker( outputChannel: LogOutputChannel, outputChannelPrefix: string, files: string[], - check: () => Promise | SetupStatus, + check: () => Promise | SetupStatus|undefined, ): StatusTracker { let status: SetupStatus | undefined; - const emitter = createEmitter(outputChannel); + const emitter = createEmitter(outputChannel); const updateStatus = immediateOnce(async () => { const newStatus = await Promise.resolve(check()); @@ -219,6 +210,9 @@ function createFileStatusTracker( async dispose() { await watcher.close(); }, + check() { + return updateStatus(); + }, }; } @@ -270,13 +264,30 @@ function createLocalStackAuthenticationStatusTracker( * @returns A {@link StatusTracker} instance for querying status, subscribing to changes, and disposing resources. */ function createLicenseStatusTracker( + cliTracker: LocalStackCliTracker, + authTracker: StatusTracker, outputChannel: LogOutputChannel, ): StatusTracker { - return createFileStatusTracker( + const tracker = createFileStatusTracker( outputChannel, "[setup-status.license]", - [LOCALSTACK_AUTH_FILENAME, LICENSE_FILENAME], //TODO rewrite to depend on change in localStackAuthenticationTracker + [LICENSE_FILENAME], async () => - (await checkIsLicenseValid(outputChannel)) ? "ok" : "setup_required", + { + const cliPath = cliTracker.cliPath(); + if (!cliPath) { + return undefined + } + + const isLicenseValid = await checkIsLicenseValid(cliPath, outputChannel); + + return isLicenseValid? "ok" : "setup_required" + }, ); + + cliTracker.onCliPathChange(() => { + tracker.check(); + }) + + return tracker; } diff --git a/src/utils/setup.ts b/src/utils/setup.ts index 535acc2..e591bec 100644 --- a/src/utils/setup.ts +++ b/src/utils/setup.ts @@ -4,19 +4,8 @@ import * as z from "zod/v4-mini"; import { LOCALSTACK_DOCKER_IMAGE_NAME } from "../constants.ts"; import { exec } from "./exec.ts"; -import { checkLocalstackInstalled } from "./install.ts"; import { spawn } from "./spawn.ts"; -export async function checkSetupStatus(outputChannel: LogOutputChannel) { - const [isInstalled] = await Promise.all([ - checkLocalstackInstalled(outputChannel), - ]); - - return { - isInstalled, - }; -} - export async function updateDockerImage( outputChannel: LogOutputChannel, cancellationToken: CancellationToken, From aefe72f1fbc07ab2ed95b7814cb30d2c6b5b05cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Fri, 12 Sep 2025 17:28:13 +0200 Subject: [PATCH 02/27] wip --- src/utils/setup-status.ts | 55 +++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/src/utils/setup-status.ts b/src/utils/setup-status.ts index 897dcf2..6059f4a 100644 --- a/src/utils/setup-status.ts +++ b/src/utils/setup-status.ts @@ -21,7 +21,7 @@ import type { TimeTracker } from "./time-tracker.ts"; export type SetupStatus = "ok" | "setup_required"; export interface SetupStatusTracker extends Disposable { - status(): SetupStatus|undefined; + status(): SetupStatus | undefined; onChange(callback: (status: SetupStatus) => void): void; } @@ -39,7 +39,11 @@ export async function createSetupStatusTracker( const awsProfileTracker = createAwsProfileStatusTracker(outputChannel); const localStackAuthenticationTracker = createLocalStackAuthenticationStatusTracker(outputChannel); - const licenseTracker = createLicenseStatusTracker(cliTracker, localStackAuthenticationTracker, outputChannel); + const licenseTracker = createLicenseStatusTracker( + cliTracker, + localStackAuthenticationTracker, + outputChannel, + ); const end = Date.now(); outputChannel.trace( `[setup-status]: Initialized dependencies in ${ms(end - start, { long: true })}`, @@ -53,7 +57,9 @@ export async function createSetupStatusTracker( licenseTracker: licenseTracker.status(), }; - const notInitialized = Object.values(statuses).some((check) => check === undefined); + const notInitialized = Object.values(statuses).some( + (check) => check === undefined, + ); if (notInitialized) { outputChannel.trace( `[setup-status] File watchers not initialized yet, skipping status check : ${JSON.stringify( @@ -69,7 +75,9 @@ export async function createSetupStatusTracker( return; } - const setupRequired = Object.values(statuses).some((status) => status === "setup_required"); + const setupRequired = Object.values(statuses).some( + (status) => status === "setup_required", + ); const newStatus = setupRequired ? "setup_required" : "ok"; if (status !== newStatus) { status = newStatus; @@ -139,7 +147,7 @@ export async function createSetupStatusTracker( interface StatusTracker { status(): SetupStatus | undefined; - onChange(callback: (status: SetupStatus|undefined) => void): void; + onChange(callback: (status: SetupStatus | undefined) => void): void; dispose(): Promise; check(): void; } @@ -159,11 +167,11 @@ function createFileStatusTracker( outputChannel: LogOutputChannel, outputChannelPrefix: string, files: string[], - check: () => Promise | SetupStatus|undefined, + check: () => Promise | SetupStatus | undefined, ): StatusTracker { let status: SetupStatus | undefined; - const emitter = createEmitter(outputChannel); + const emitter = createEmitter(outputChannel); const updateStatus = immediateOnce(async () => { const newStatus = await Promise.resolve(check()); @@ -210,7 +218,7 @@ function createFileStatusTracker( async dispose() { await watcher.close(); }, - check() { + check() { return updateStatus(); }, }; @@ -268,26 +276,29 @@ function createLicenseStatusTracker( authTracker: StatusTracker, outputChannel: LogOutputChannel, ): StatusTracker { - const tracker = createFileStatusTracker( + const licenseTracker = createFileStatusTracker( outputChannel, "[setup-status.license]", [LICENSE_FILENAME], - async () => - { - const cliPath = cliTracker.cliPath(); - if (!cliPath) { - return undefined - } + async () => { + const cliPath = cliTracker.cliPath(); + if (!cliPath) { + return undefined; + } + + const isLicenseValid = await checkIsLicenseValid(cliPath, outputChannel); - const isLicenseValid = await checkIsLicenseValid(cliPath, outputChannel); - - return isLicenseValid? "ok" : "setup_required" - }, + return isLicenseValid ? "ok" : "setup_required"; + }, ); + authTracker.onChange(() => { + licenseTracker.check(); + }); + cliTracker.onCliPathChange(() => { - tracker.check(); - }) + licenseTracker.check(); + }); - return tracker; + return licenseTracker; } From e5185f4a765799aec978561662bd84eeb9beecc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Fri, 12 Sep 2025 17:51:23 +0200 Subject: [PATCH 03/27] wip --- package.json | 6 -- src/plugins/status-bar.ts | 48 +++------------- src/utils/cli.ts | 102 +-------------------------------- src/utils/container-status.ts | 33 +++++------ src/utils/emitter.ts | 33 ++--------- src/utils/localstack-status.ts | 55 ++++++------------ src/utils/setup-status.ts | 51 ++++++++--------- 7 files changed, 72 insertions(+), 256 deletions(-) diff --git a/package.json b/package.json index 136a3cc..7363c9b 100644 --- a/package.json +++ b/package.json @@ -55,12 +55,6 @@ "title": "Run Setup Wizard", "category": "LocalStack" }, - { - "command": "localstack.refreshStatusBar", - "title": "Refresh Status Bar", - "category": "LocalStack", - "enablement": "false" - }, { "command": "localstack.showCommands", "title": "Show Commands", diff --git a/src/plugins/status-bar.ts b/src/plugins/status-bar.ts index 4261129..6f8e7a6 100644 --- a/src/plugins/status-bar.ts +++ b/src/plugins/status-bar.ts @@ -79,48 +79,16 @@ export default createPlugin( }), ); - // context.subscriptions.push( - // commands.registerCommand("localstack.refreshStatusBar", () => { - // const setupStatus = setupStatusTracker.status(); - // const localStackStatus = localStackStatusTracker.status(); - // const localStackInstalled = cliStatusTracker.status() === "ok" - - // statusBarItem.command = "localstack.showCommands"; - // statusBarItem.backgroundColor = - // setupStatus === "setup_required" - // ? new ThemeColor("statusBarItem.errorBackground") - // : undefined; - - // const shouldSpin = - // localStackStatus === "starting" || localStackStatus === "stopping"; - // const icon = - // setupStatus === "setup_required" - // ? "$(error)" - // : shouldSpin - // ? "$(sync~spin)" - // : "$(localstack-logo)"; - - // const statusText = localStackInstalled - // ? `${localStackStatus}` - // : "not installed"; - // statusBarItem.text = `${icon} LocalStack: ${statusText}`; - - // statusBarItem.tooltip = "Show LocalStack commands"; - // statusBarItem.show(); - // }), - // ); - - const refreshStatusBar = immediateOnce(() => { - // await commands.executeCommand("localstack.refreshStatusBar"); + const renderStatusBar = immediateOnce(() => { const setupStatus = setupStatusTracker.status(); const localStackStatus = localStackStatusTracker.status(); const cliStatus = cliStatusTracker.status(); + outputChannel.trace( + `[status-bar] setupStatus=${setupStatus} localStackStatus=${localStackStatus} cliStatus=${cliStatus}`, + ); - if ( - setupStatus === undefined || - localStackStatus === undefined || - cliStatus === undefined - ) { + // Skip rendering the status bar if any of the status checks is not ready. + if (setupStatus === undefined || cliStatus === undefined) { return; } @@ -151,12 +119,12 @@ export default createPlugin( localStackStatusTracker.onChange(() => { outputChannel.trace("[status-bar]: localStackStatusTracker changed"); - refreshStatusBar(); + renderStatusBar(); }); setupStatusTracker.onChange(() => { outputChannel.trace("[status-bar]: setupStatusTracker changed"); - refreshStatusBar(); + renderStatusBar(); }); }, ); diff --git a/src/utils/cli.ts b/src/utils/cli.ts index 29fb56c..a644826 100644 --- a/src/utils/cli.ts +++ b/src/utils/cli.ts @@ -1,4 +1,3 @@ -import EventEmitter from "node:events"; import { constants } from "node:fs"; import { access } from "node:fs/promises"; import { isAbsolute } from "node:path"; @@ -9,11 +8,9 @@ import type { CancellationToken, LogOutputChannel, Disposable } from "vscode"; import { CLI_PATHS, LOCALSTACK_DOCKER_IMAGE_NAME } from "../constants.ts"; -import { createEmitter, createValueEmitter } from "./emitter.ts"; -import type { Callback } from "./emitter.ts"; +import { createValueEmitter } from "./emitter.ts"; import { exec } from "./exec.ts"; import { immediateOnce } from "./immediate-once.ts"; -import { setIntervalPromise } from "./promises.ts"; import type { SetupStatus } from "./setup-status.ts"; import { spawn } from "./spawn.ts"; import type { SpawnOptions } from "./spawn.ts"; @@ -100,16 +97,6 @@ async function findLocalStack(): Promise { executable, upToDate, }; - // const {found, executable, upToDate} = await verifyLocalStackCli(customLocation); - // if (!found) { - // throw new Error(`Configured LocalStack CLI location '${customLocation}' does not exist`); - // } - // if (!executable) { - // throw new Error(`Configured LocalStack CLI location '${customLocation}' is not executable`); - // } - // if (!upToDate) { - // throw new Error(`Configured LocalStack CLI location '${customLocation}' is outdated (version < 4)`); - // } } // Fall back to default search paths @@ -175,71 +162,17 @@ export const spawnLocalStack = async ( export type LocalStackCliStatus = "not_found" | "outdated" | "ok"; export interface LocalStackCliTracker extends Disposable { - // setupStatus(): SetupStatus | undefined; - // onSetupStatusChange( - // callback: (status: SetupStatus | undefined) => void, - // ): void; - // cli(): CliCheckResult | undefined; - // onCliChange(callback: (cli: CliCheckResult | undefined) => void): void; - // cliStatus(): LocalStackCliStatus | undefined; - // onCliStatusChange(callback: (status: LocalStackCliStatus) => void): void; - status(): SetupStatus | undefined; onStatusChange(callback: (status: SetupStatus | undefined) => void): void; cliPath(): string | undefined; onCliPathChange(callback: (cliPath: string | undefined) => void): void; } -function areCliCheckResultsDifferent( - resultA: CliCheckResult | undefined, - resultB: CliCheckResult | undefined, -): boolean { - if (resultA?.cliPath !== resultB?.cliPath) { - return true; - } - if (resultA?.found !== resultB?.found) { - return true; - } - if (resultA?.executable !== resultB?.executable) { - return true; - } - return false; -} - -function statusFromCliCheckResult( - cli: CliCheckResult | undefined, -): LocalStackCliStatus { - if (!cli?.found || !cli.executable) { - return "not_found"; - } - if (cli.upToDate === false) { - return "outdated"; - } - return "ok"; -} - export function createCliStatusTracker( outputChannel: LogOutputChannel, ): LocalStackCliTracker { - // const emitter = new EventEmitter<{ - // setupStatus: [SetupStatus | undefined]; - // cliStatus: [LocalStackCliStatus | undefined]; - // cli: [CliCheckResult | undefined]; - // }>(); - - // emitter.emit("setupStatus", ) - const status = createValueEmitter(); const cliPath = createValueEmitter(); - // const cliStatus = createValueEmitter(); - - // const statusEmitter = createEmitter(outputChannel); - // let currentStatus: LocalStackCliStatus | undefined; - - // let currentCli: CliCheckResult | undefined; - // const cliPathEmitter = createEmitter( - // outputChannel, - // ); const track = immediateOnce(async () => { const newCli = await findLocalStack().catch(() => undefined); @@ -251,17 +184,6 @@ export function createCliStatusTracker( : "setup_required", ); cliPath.setValue(newCli?.cliPath); - - // if (areCliCheckResultsDifferent(currentCli, newCli)) { - // currentCli = newCli; - // void cliPathEmitter.emit(currentCli); - // } - - // const newStatus = statusFromCliCheckResult(newCli); - // if (currentStatus !== newStatus) { - // currentStatus = newStatus; - // void statusEmitter.emit(newStatus); - // } }); const watcher = watch( @@ -290,35 +212,17 @@ export function createCliStatusTracker( track(); return { - // cli() { - // return currentCli; - // }, - // onCliChange(callback) { - // cliPathEmitter.on(callback); - // if (currentCli) { - // callback(currentCli); - // } - // }, - // cliStatus() { - // return currentStatus; - // }, - // onCliStatusChange(callback) { - // statusEmitter.on(callback); - // if (currentStatus) { - // callback(currentStatus); - // } - // }, cliPath() { return cliPath.value(); }, onCliPathChange(callback) { - cliPath.on(callback); + cliPath.onChange(callback); }, status() { return status.value(); }, onStatusChange(callback) { - status.on(callback); + status.onChange(callback); }, async dispose() { await watcher.close(); diff --git a/src/utils/container-status.ts b/src/utils/container-status.ts index d09fff3..5056eaa 100644 --- a/src/utils/container-status.ts +++ b/src/utils/container-status.ts @@ -3,56 +3,49 @@ import { exec, spawn } from "node:child_process"; import type { Disposable, LogOutputChannel } from "vscode"; import * as z from "zod/v4-mini"; -import { createEmitter } from "./emitter.ts"; +import { createValueEmitter } from "./emitter.ts"; import { JsonLinesStream } from "./json-lines-stream.ts"; import type { TimeTracker } from "./time-tracker.ts"; export type ContainerStatus = "running" | "stopping" | "stopped"; export interface ContainerStatusTracker extends Disposable { - status(): ContainerStatus; - onChange(callback: (status: ContainerStatus) => void): void; + status(): ContainerStatus | undefined; + onChange(callback: (status: ContainerStatus | undefined) => void): void; } /** * Checks the status of a docker container in realtime. */ -export async function createContainerStatusTracker( +export function createContainerStatusTracker( containerName: string, outputChannel: LogOutputChannel, timeTracker: TimeTracker, -): Promise { - let status: ContainerStatus | undefined; - const emitter = createEmitter(outputChannel); +): ContainerStatusTracker { + const status = createValueEmitter(); const disposable = listenToContainerStatus( containerName, outputChannel, (newStatus) => { - if (status !== newStatus) { - status = newStatus; - void emitter.emit(status); - } + status.setValue(newStatus); }, ); - await timeTracker.run("container-status.getContainerStatus", async () => { + void timeTracker.run("container-status.getContainerStatus", async () => { await getContainerStatus(containerName).then((newStatus) => { - status ??= newStatus; - void emitter.emit(status); + if (status.value() !== undefined) { + status.setValue(newStatus); + } }); }); return { status() { - // biome-ignore lint/style/noNonNullAssertion: false positive - return status!; + return status.value(); }, onChange(callback) { - emitter.on(callback); - if (status) { - callback(status); - } + status.onChange(callback); }, dispose() { disposable.dispose(); diff --git a/src/utils/emitter.ts b/src/utils/emitter.ts index 6e731e8..a554f4a 100644 --- a/src/utils/emitter.ts +++ b/src/utils/emitter.ts @@ -1,37 +1,11 @@ -import type { LogOutputChannel } from "vscode"; - import { immediateOnce } from "./immediate-once.ts"; export type Callback = (value: T) => Promise | void; -export interface Emitter { - on(callback: Callback): void; - emit(value: T): Promise; -} - -export function createEmitter(outputChannel: LogOutputChannel): Emitter { - const callbacks: Callback[] = []; - - return { - on(callback) { - callbacks.push(callback); - }, - async emit(value) { - for (const callback of callbacks) { - try { - await callback(value); - } catch (error) { - outputChannel.error(error instanceof Error ? error : String(error)); - } - } - }, - }; -} - export interface ValueEmitter { value(): T | undefined; setValue(value: T): void; - on(callback: Callback): void; + onChange(callback: Callback): void; } export function createValueEmitter(): ValueEmitter { @@ -56,8 +30,11 @@ export function createValueEmitter(): ValueEmitter { emit(); } }, - on(callback) { + onChange(callback) { callbacks.push(callback); + if (currentValue) { + void Promise.resolve(callback(currentValue)).catch(() => {}); + } }, }; } diff --git a/src/utils/localstack-status.ts b/src/utils/localstack-status.ts index 8084fa6..0fc0c80 100644 --- a/src/utils/localstack-status.ts +++ b/src/utils/localstack-status.ts @@ -4,16 +4,16 @@ import type { ContainerStatus, ContainerStatusTracker, } from "./container-status.ts"; -import { createEmitter } from "./emitter.ts"; +import { createValueEmitter } from "./emitter.ts"; import { fetchHealth } from "./manage.ts"; import type { TimeTracker } from "./time-tracker.ts"; export type LocalStackStatus = "starting" | "running" | "stopping" | "stopped"; export interface LocalStackStatusTracker extends Disposable { - status(): LocalStackStatus; + status(): LocalStackStatus | undefined; forceContainerStatus(status: ContainerStatus): void; - onChange(callback: (status: LocalStackStatus) => void): void; + onChange(callback: (status: LocalStackStatus | undefined) => void): void; } /** @@ -25,26 +25,19 @@ export function createLocalStackStatusTracker( timeTracker: TimeTracker, ): LocalStackStatusTracker { let containerStatus: ContainerStatus | undefined; - let status: LocalStackStatus | undefined; - const emitter = createEmitter(outputChannel); + const status = createValueEmitter(); - const healthCheckStatusTracker = createHealthStatusTracker( - outputChannel, - timeTracker, - ); + const healthCheckStatusTracker = createHealthStatusTracker(timeTracker); const setStatus = (newStatus: LocalStackStatus) => { - if (status !== newStatus) { - status = newStatus; - void emitter.emit(status); - } + status.setValue(newStatus); }; const deriveStatus = () => { const newStatus = getLocalStackStatus( containerStatus, healthCheckStatusTracker.status(), - status, + status.value(), ); setStatus(newStatus); }; @@ -56,7 +49,7 @@ export function createLocalStackStatusTracker( } }); - emitter.on((newStatus) => { + status.onChange((newStatus) => { outputChannel.trace(`[localstack-status] localstack=${newStatus}`); if (newStatus === "running") { @@ -66,10 +59,10 @@ export function createLocalStackStatusTracker( containerStatusTracker.onChange((newContainerStatus) => { outputChannel.trace( - `[localstack-status] container=${newContainerStatus} (localstack=${status})`, + `[localstack-status] container=${newContainerStatus} (localstack=${status.value()})`, ); - if (newContainerStatus === "running" && status !== "running") { + if (newContainerStatus === "running" && status.value() !== "running") { healthCheckStatusTracker.start(); } }); @@ -78,10 +71,11 @@ export function createLocalStackStatusTracker( deriveStatus(); }); + deriveStatus(); + return { status() { - // biome-ignore lint/style/noNonNullAssertion: false positive - return status!; + return status.value(); }, forceContainerStatus(newContainerStatus) { if (containerStatus !== newContainerStatus) { @@ -90,10 +84,7 @@ export function createLocalStackStatusTracker( } }, onChange(callback) { - emitter.on(callback); - if (status) { - callback(status); - } + status.onChange(callback); }, dispose() { healthCheckStatusTracker.dispose(); @@ -135,19 +126,14 @@ interface HealthStatusTracker extends Disposable { } function createHealthStatusTracker( - outputChannel: LogOutputChannel, timeTracker: TimeTracker, ): HealthStatusTracker { - let status: HealthStatus | undefined; - const emitter = createEmitter(outputChannel); + const status = createValueEmitter(); let healthCheckTimeout: NodeJS.Timeout | undefined; const updateStatus = (newStatus: HealthStatus | undefined) => { - if (status !== newStatus) { - status = newStatus; - void emitter.emit(status); - } + status.setValue(newStatus); }; const fetchAndUpdateStatus = async () => { @@ -178,23 +164,20 @@ function createHealthStatusTracker( return { status() { - return status; + return status.value(); }, start() { enqueueAgain = true; enqueueUpdateStatus(); }, stop() { - status = undefined; + status.setValue(undefined); enqueueAgain = false; clearTimeout(healthCheckTimeout); healthCheckTimeout = undefined; }, onChange(callback) { - emitter.on(callback); - if (status) { - callback(status); - } + status.onChange(callback); }, dispose() { clearTimeout(healthCheckTimeout); diff --git a/src/utils/setup-status.ts b/src/utils/setup-status.ts index 6059f4a..a228a2e 100644 --- a/src/utils/setup-status.ts +++ b/src/utils/setup-status.ts @@ -12,10 +12,9 @@ import { AWS_CREDENTIALS_FILENAME, checkIsProfileConfigured, } from "./configure-aws.ts"; -import { createEmitter } from "./emitter.ts"; +import { createValueEmitter } from "./emitter.ts"; import { immediateOnce } from "./immediate-once.ts"; import { checkIsLicenseValid, LICENSE_FILENAME } from "./license.ts"; -import type { UnwrapPromise } from "./promises.ts"; import type { TimeTracker } from "./time-tracker.ts"; export type SetupStatus = "ok" | "setup_required"; @@ -34,8 +33,7 @@ export async function createSetupStatusTracker( cliTracker: LocalStackCliTracker, ): Promise { const start = Date.now(); - let status: SetupStatus | undefined; - const emitter = createEmitter(outputChannel); + const status = createValueEmitter(); const awsProfileTracker = createAwsProfileStatusTracker(outputChannel); const localStackAuthenticationTracker = createLocalStackAuthenticationStatusTracker(outputChannel); @@ -79,8 +77,7 @@ export async function createSetupStatusTracker( (status) => status === "setup_required", ); const newStatus = setupRequired ? "setup_required" : "ok"; - if (status !== newStatus) { - status = newStatus; + if (status.value() !== newStatus) { outputChannel.trace( `[setup-status] Status changed to ${JSON.stringify({ cliTracker: cliTracker.status() ?? "undefined", @@ -89,8 +86,8 @@ export async function createSetupStatusTracker( licenseTracker: licenseTracker.status() ?? "undefined", })}`, ); - await emitter.emit(status); } + status.setValue(newStatus); }; const checkStatus = immediateOnce(async () => { @@ -126,14 +123,10 @@ export async function createSetupStatusTracker( return { status() { - // biome-ignore lint/style/noNonNullAssertion: false positive - return status!; + return status.value(); }, onChange(callback) { - emitter.on(callback); - if (status) { - callback(status); - } + status.onChange(callback); }, async dispose() { clearTimeout(timeout); @@ -169,19 +162,22 @@ function createFileStatusTracker( files: string[], check: () => Promise | SetupStatus | undefined, ): StatusTracker { - let status: SetupStatus | undefined; + // let status: SetupStatus | undefined; + + // const emitter = createEmitter(outputChannel); - const emitter = createEmitter(outputChannel); + const status = createValueEmitter(); const updateStatus = immediateOnce(async () => { const newStatus = await Promise.resolve(check()); - if (status !== newStatus) { - status = newStatus; - outputChannel.trace( - `${outputChannelPrefix} File status changed to ${status}`, - ); - await emitter.emit(status); - } + status.setValue(newStatus); + // if (status !== newStatus) { + // status = newStatus; + // outputChannel.trace( + // `${outputChannelPrefix} File status changed to ${status}`, + // ); + // await emitter.emit(status); + // } }); const watcher = watch(files) @@ -207,13 +203,14 @@ function createFileStatusTracker( return { status() { - return status; + return status.value(); }, onChange(callback) { - emitter.on(callback); - if (status) { - callback(status); - } + status.onChange(callback); + // emitter.on(callback); + // if (status) { + // callback(status); + // } }, async dispose() { await watcher.close(); From be5d7798c826e15a61c6cf3ec1f368059e362f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Fri, 12 Sep 2025 17:52:34 +0200 Subject: [PATCH 04/27] rename --- src/plugins.ts | 4 ++-- src/utils/cli.ts | 4 ++-- src/utils/setup-status.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/plugins.ts b/src/plugins.ts index 5c57c9b..2183c03 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -1,7 +1,7 @@ import ms from "ms"; import type { ExtensionContext, LogOutputChannel, StatusBarItem } from "vscode"; -import type { LocalStackCliTracker } from "./utils/cli.ts"; +import type { CliStatusTracker } from "./utils/cli.ts"; import type { ContainerStatusTracker } from "./utils/container-status.ts"; import type { LocalStackStatusTracker } from "./utils/localstack-status.ts"; import type { SetupStatusTracker } from "./utils/setup-status.ts"; @@ -14,7 +14,7 @@ export interface PluginOptions { context: ExtensionContext; outputChannel: LogOutputChannel; statusBarItem: StatusBarItem; - cliStatusTracker: LocalStackCliTracker; + cliStatusTracker: CliStatusTracker; containerStatusTracker: ContainerStatusTracker; localStackStatusTracker: LocalStackStatusTracker; setupStatusTracker: SetupStatusTracker; diff --git a/src/utils/cli.ts b/src/utils/cli.ts index a644826..2f5b959 100644 --- a/src/utils/cli.ts +++ b/src/utils/cli.ts @@ -161,7 +161,7 @@ export const spawnLocalStack = async ( export type LocalStackCliStatus = "not_found" | "outdated" | "ok"; -export interface LocalStackCliTracker extends Disposable { +export interface CliStatusTracker extends Disposable { status(): SetupStatus | undefined; onStatusChange(callback: (status: SetupStatus | undefined) => void): void; cliPath(): string | undefined; @@ -170,7 +170,7 @@ export interface LocalStackCliTracker extends Disposable { export function createCliStatusTracker( outputChannel: LogOutputChannel, -): LocalStackCliTracker { +): CliStatusTracker { const status = createValueEmitter(); const cliPath = createValueEmitter(); diff --git a/src/utils/setup-status.ts b/src/utils/setup-status.ts index a228a2e..ddb75ed 100644 --- a/src/utils/setup-status.ts +++ b/src/utils/setup-status.ts @@ -6,7 +6,7 @@ import { checkIsAuthenticated, LOCALSTACK_AUTH_FILENAME, } from "./authenticate.ts"; -import type { LocalStackCliTracker } from "./cli.ts"; +import type { CliStatusTracker } from "./cli.ts"; import { AWS_CONFIG_FILENAME, AWS_CREDENTIALS_FILENAME, @@ -30,7 +30,7 @@ export interface SetupStatusTracker extends Disposable { export async function createSetupStatusTracker( outputChannel: LogOutputChannel, timeTracker: TimeTracker, - cliTracker: LocalStackCliTracker, + cliTracker: CliStatusTracker, ): Promise { const start = Date.now(); const status = createValueEmitter(); @@ -269,7 +269,7 @@ function createLocalStackAuthenticationStatusTracker( * @returns A {@link StatusTracker} instance for querying status, subscribing to changes, and disposing resources. */ function createLicenseStatusTracker( - cliTracker: LocalStackCliTracker, + cliTracker: CliStatusTracker, authTracker: StatusTracker, outputChannel: LogOutputChannel, ): StatusTracker { From 4d2ddb86f3edfe07167c8d6b9aaa36a356c2e947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Fri, 12 Sep 2025 18:03:27 +0200 Subject: [PATCH 05/27] refactor --- src/plugins/manage.ts | 4 ++-- src/plugins/setup.ts | 16 +++++++++------- src/plugins/status-bar.ts | 3 --- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/plugins/manage.ts b/src/plugins/manage.ts index db18ab4..2fb63c5 100644 --- a/src/plugins/manage.ts +++ b/src/plugins/manage.ts @@ -20,7 +20,7 @@ export default createPlugin( commands.registerCommand("localstack.start", async () => { const cliPath = cliStatusTracker.cliPath(); if (!cliPath) { - window.showInformationMessage( + void window.showInformationMessage( "LocalStack CLI could not be found. Please, run the setup wizard.", ); return; @@ -43,7 +43,7 @@ export default createPlugin( commands.registerCommand("localstack.stop", () => { const cliPath = cliStatusTracker.cliPath(); if (!cliPath) { - window.showInformationMessage( + void window.showInformationMessage( "LocalStack CLI could not be found. Please, run the setup wizard.", ); return; diff --git a/src/plugins/setup.ts b/src/plugins/setup.ts index 5695ded..6083f31 100644 --- a/src/plugins/setup.ts +++ b/src/plugins/setup.ts @@ -45,12 +45,6 @@ export default createPlugin( }); const cliPath = cliStatusTracker.cliPath(); - if (!cliPath) { - void window.showErrorMessage( - "LocalStack CLI is not configured. Please set it up before running the setup wizard.", - ); - return; - } void window.withProgress( { @@ -65,7 +59,7 @@ export default createPlugin( { const installationStartedAt = new Date().toISOString(); const { cancelled, skipped } = await runInstallProcess({ - cliPath, + cliPath: cliStatusTracker.cliPath(), progress, cancellationToken, outputChannel, @@ -231,6 +225,14 @@ export default createPlugin( ///////////////////////////////////////////////////////////////////// progress.report({ message: "Checking LocalStack license..." }); + const cliPath = cliStatusTracker.cliPath(); + if (!cliPath) { + void window.showErrorMessage( + "LocalStack CLI is not configured. Please set it up before running the setup wizard.", + ); + return; + } + // If an auth token has just been obtained or LocalStack has never been started, // then there will be no license info to be reported by `localstack license info`. // Also, an expired license could be cached. diff --git a/src/plugins/status-bar.ts b/src/plugins/status-bar.ts index 6f8e7a6..516b682 100644 --- a/src/plugins/status-bar.ts +++ b/src/plugins/status-bar.ts @@ -112,11 +112,8 @@ export default createPlugin( statusBarItem.text = `${icon} LocalStack: ${statusText}`; statusBarItem.tooltip = "Show LocalStack commands"; - // statusBarItem.show(); }); - // refreshStatusBar(); - localStackStatusTracker.onChange(() => { outputChannel.trace("[status-bar]: localStackStatusTracker changed"); renderStatusBar(); From ad32d32aa1dfe6311b801ee7095adc4e7cb80bf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Fri, 12 Sep 2025 18:07:17 +0200 Subject: [PATCH 06/27] wip --- src/utils/install.ts | 13 ------------ src/utils/promises.ts | 46 ------------------------------------------- 2 files changed, 59 deletions(-) diff --git a/src/utils/install.ts b/src/utils/install.ts index 99d26c2..654680c 100644 --- a/src/utils/install.ts +++ b/src/utils/install.ts @@ -12,7 +12,6 @@ import { LOCAL_CLI_INSTALLATION_DIRNAME, } from "../constants.ts"; -import { execLocalStack } from "./cli.ts"; import { exec } from "./exec.ts"; import { minDelay } from "./promises.ts"; import { @@ -23,18 +22,6 @@ import { import { spawn } from "./spawn.ts"; import type { Telemetry } from "./telemetry.ts"; -// export async function checkLocalstackInstalled( -// cliPath: string|undefined, -// outputChannel: LogOutputChannel, -// ): Promise { -// try { -// await execLocalStack(cliPath, ["--version"], { outputChannel }); -// return true; -// } catch (error) { -// return false; -// } -// } - export interface RunInstallProcessOptions { progress: Progress<{ message: string }>; cancellationToken: CancellationToken; diff --git a/src/utils/promises.ts b/src/utils/promises.ts index 04b7443..157133a 100644 --- a/src/utils/promises.ts +++ b/src/utils/promises.ts @@ -1,5 +1,4 @@ import pMinDelay from "p-min-delay"; -import type { Disposable } from "vscode"; /** * Setting up a minimum wait time allows users @@ -33,48 +32,3 @@ export function minDelay( MIN_TIME_BETWEEN_STEPS_MS, ); } - -/** - * Extracts the resolved type from a Promise. - */ -export type UnwrapPromise = T extends Promise ? U : T; - -export function setIntervalPromise( - callback: () => Promise, - intervalMs: number, -): Disposable { - let timeout: NodeJS.Timeout | undefined; - let disposed = false; - - const runLater = () => { - timeout = setTimeout(() => void run(), intervalMs); - }; - - const run = async () => { - if (disposed) { - return; - } - - try { - await callback(); - } catch { - // Ignore errors - } finally { - if (!disposed) { - runLater(); - } - } - }; - - runLater(); - - return { - dispose: () => { - disposed = true; - if (timeout) { - clearTimeout(timeout); - timeout = undefined; - } - }, - }; -} From 2ed715d210910f5efdc7d617d811965d003ebaf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Fri, 12 Sep 2025 18:22:43 +0200 Subject: [PATCH 07/27] wip --- src/plugins/status-bar.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/plugins/status-bar.ts b/src/plugins/status-bar.ts index 516b682..97170e5 100644 --- a/src/plugins/status-bar.ts +++ b/src/plugins/status-bar.ts @@ -88,7 +88,11 @@ export default createPlugin( ); // Skip rendering the status bar if any of the status checks is not ready. - if (setupStatus === undefined || cliStatus === undefined) { + if ( + setupStatus === undefined || + localStackStatus === undefined || + cliStatus === undefined + ) { return; } From 971c86d1fd52dc28af6e8462b31b0cf7cacde8f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Fri, 12 Sep 2025 18:23:15 +0200 Subject: [PATCH 08/27] wip --- src/extension.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extension.ts b/src/extension.ts index 45de807..e751ee5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -50,7 +50,7 @@ export async function activate(context: ExtensionContext) { statusBarItem.text = "$(loading~spin) LocalStack"; statusBarItem.show(); - const containerStatusTracker = await createContainerStatusTracker( + const containerStatusTracker = createContainerStatusTracker( "localstack-main", outputChannel, timeTracker, From 51a86bcacb589cbe124e80a8f9fbe8bfa5b75801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Fri, 12 Sep 2025 18:34:00 +0200 Subject: [PATCH 09/27] wip --- src/plugins/setup.ts | 17 +++++++++++++++-- src/utils/cli.ts | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/plugins/setup.ts b/src/plugins/setup.ts index 6083f31..50eedd2 100644 --- a/src/plugins/setup.ts +++ b/src/plugins/setup.ts @@ -7,6 +7,7 @@ import { saveAuthToken, readAuthToken, } from "../utils/authenticate.ts"; +import { findLocalStack } from "../utils/cli.ts"; import { configureAwsProfiles } from "../utils/configure-aws.ts"; import { runInstallProcess } from "../utils/install.ts"; import { @@ -18,6 +19,14 @@ import { minDelay } from "../utils/promises.ts"; import { updateDockerImage } from "../utils/setup.ts"; import { get_setup_ended } from "../utils/telemetry.ts"; +async function getValidCliPath() { + const cli = await findLocalStack() + if (!cli.cliPath || !cli.executable || !cli.found || !cli.upToDate) { + return + } + return cli.cliPath +} + export default createPlugin( "setup", ({ @@ -225,10 +234,14 @@ export default createPlugin( ///////////////////////////////////////////////////////////////////// progress.report({ message: "Checking LocalStack license..." }); - const cliPath = cliStatusTracker.cliPath(); + // If the CLI status tracker doesn't have a valid CLI path yet, + // we must find it manually. This may occur when installing the + // CLI as part of the setup process: the CLI status tracker will + // detect the CLI path the next tick. + const cliPath = cliStatusTracker.cliPath() ?? await getValidCliPath(); if (!cliPath) { void window.showErrorMessage( - "LocalStack CLI is not configured. Please set it up before running the setup wizard.", + "Could not access the LocalStack CLI.", ); return; } diff --git a/src/utils/cli.ts b/src/utils/cli.ts index 2f5b959..9d8deae 100644 --- a/src/utils/cli.ts +++ b/src/utils/cli.ts @@ -84,7 +84,7 @@ interface CliCheckResult { upToDate: boolean | undefined; } -async function findLocalStack(): Promise { +export async function findLocalStack(): Promise { // Check if a custom path is configured const config = workspace.getConfiguration("localstack"); const customLocation = config.get("cli.location"); From f59e00fb48f20862a3e837bafcbcaefc7fe29247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Mon, 15 Sep 2025 11:31:47 +0200 Subject: [PATCH 10/27] wip --- src/plugins/setup.ts | 11 +++++------ src/plugins/status-bar.ts | 26 ++++++++++++++++++++++++-- src/utils/cli.ts | 15 ++++++++++++++- src/utils/setup-status.ts | 8 ++++++++ 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/plugins/setup.ts b/src/plugins/setup.ts index 50eedd2..2b2c64c 100644 --- a/src/plugins/setup.ts +++ b/src/plugins/setup.ts @@ -20,11 +20,11 @@ import { updateDockerImage } from "../utils/setup.ts"; import { get_setup_ended } from "../utils/telemetry.ts"; async function getValidCliPath() { - const cli = await findLocalStack() + const cli = await findLocalStack(); if (!cli.cliPath || !cli.executable || !cli.found || !cli.upToDate) { - return + return; } - return cli.cliPath + return cli.cliPath; } export default createPlugin( @@ -53,8 +53,6 @@ export default createPlugin( }, }); - const cliPath = cliStatusTracker.cliPath(); - void window.withProgress( { location: ProgressLocation.Notification, @@ -238,7 +236,8 @@ export default createPlugin( // we must find it manually. This may occur when installing the // CLI as part of the setup process: the CLI status tracker will // detect the CLI path the next tick. - const cliPath = cliStatusTracker.cliPath() ?? await getValidCliPath(); + const cliPath = + cliStatusTracker.cliPath() ?? (await getValidCliPath()); if (!cliPath) { void window.showErrorMessage( "Could not access the LocalStack CLI.", diff --git a/src/plugins/status-bar.ts b/src/plugins/status-bar.ts index 97170e5..c4ddbbb 100644 --- a/src/plugins/status-bar.ts +++ b/src/plugins/status-bar.ts @@ -3,6 +3,24 @@ import type { QuickPickItem } from "vscode"; import { createPlugin } from "../plugins.ts"; import { immediateOnce } from "../utils/immediate-once.ts"; +import type { LocalStackStatus } from "../utils/localstack-status.ts"; +import type { SetupStatus } from "../utils/setup-status.ts"; + +function getStatusText(options: { + cliStatus: SetupStatus; + localStackStatus: LocalStackStatus; + cliOutdated: boolean | undefined; +}) { + if (options.cliStatus === "ok") { + return options.localStackStatus; + } + + if (options.cliOutdated) { + return "CLI outdated"; + } + + return "CLI not installed"; +} export default createPlugin( "status-bar", @@ -83,6 +101,7 @@ export default createPlugin( const setupStatus = setupStatusTracker.status(); const localStackStatus = localStackStatusTracker.status(); const cliStatus = cliStatusTracker.status(); + const cliOutdated = cliStatusTracker.outdated(); outputChannel.trace( `[status-bar] setupStatus=${setupStatus} localStackStatus=${localStackStatus} cliStatus=${cliStatus}`, ); @@ -111,8 +130,11 @@ export default createPlugin( ? "$(sync~spin)" : "$(localstack-logo)"; - const statusText = - cliStatus === "ok" ? `${localStackStatus}` : "not installed"; + const statusText = getStatusText({ + cliOutdated, + cliStatus, + localStackStatus, + }); statusBarItem.text = `${icon} LocalStack: ${statusText}`; statusBarItem.tooltip = "Show LocalStack commands"; diff --git a/src/utils/cli.ts b/src/utils/cli.ts index 9d8deae..659bf7e 100644 --- a/src/utils/cli.ts +++ b/src/utils/cli.ts @@ -166,6 +166,8 @@ export interface CliStatusTracker extends Disposable { onStatusChange(callback: (status: SetupStatus | undefined) => void): void; cliPath(): string | undefined; onCliPathChange(callback: (cliPath: string | undefined) => void): void; + outdated(): boolean | undefined; + onOutdatedChange(callback: (outdated: boolean | undefined) => void): void; } export function createCliStatusTracker( @@ -173,6 +175,7 @@ export function createCliStatusTracker( ): CliStatusTracker { const status = createValueEmitter(); const cliPath = createValueEmitter(); + const outdated = createValueEmitter(); const track = immediateOnce(async () => { const newCli = await findLocalStack().catch(() => undefined); @@ -183,7 +186,11 @@ export function createCliStatusTracker( ? "ok" : "setup_required", ); - cliPath.setValue(newCli?.cliPath); + // cliPath.setValue(newCli?.cliPath); + cliPath.setValue(newCli?.upToDate ? newCli?.cliPath : undefined); + outdated.setValue( + newCli?.upToDate !== undefined ? !newCli.upToDate : undefined, + ); }); const watcher = watch( @@ -224,6 +231,12 @@ export function createCliStatusTracker( onStatusChange(callback) { status.onChange(callback); }, + outdated() { + return outdated.value(); + }, + onOutdatedChange(callback) { + outdated.onChange(callback); + }, async dispose() { await watcher.close(); }, diff --git a/src/utils/setup-status.ts b/src/utils/setup-status.ts index ddb75ed..cd2251f 100644 --- a/src/utils/setup-status.ts +++ b/src/utils/setup-status.ts @@ -278,6 +278,10 @@ function createLicenseStatusTracker( "[setup-status.license]", [LICENSE_FILENAME], async () => { + if (cliTracker.outdated()) { + return "setup_required"; + } + const cliPath = cliTracker.cliPath(); if (!cliPath) { return undefined; @@ -297,5 +301,9 @@ function createLicenseStatusTracker( licenseTracker.check(); }); + cliTracker.onOutdatedChange(() => { + licenseTracker.check(); + }); + return licenseTracker; } From 79ed1b04eb0e423886e7e0f2cdaf8b150cfa01cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Mon, 15 Sep 2025 11:44:07 +0200 Subject: [PATCH 11/27] refactor --- src/extension.ts | 10 +- src/plugins.ts | 10 +- src/plugins/setup.ts | 2 +- src/plugins/status-bar.ts | 10 +- src/utils/authenticate.ts | 22 ++ src/utils/cli.ts | 6 +- src/utils/configure-aws.ts | 21 ++ src/utils/emitter.ts | 4 +- src/utils/file-status-tracker.ts | 86 +++++ src/utils/install.ts | 2 +- src/utils/license.ts | 51 +++ ...iner-status.ts => localstack-container.ts} | 20 +- ...stack-status.ts => localstack-instance.ts} | 44 +-- src/utils/{promises.ts => min-delay.ts} | 0 .../{immediate-once.ts => once-immediate.ts} | 6 +- src/utils/setup-status.ts | 309 ------------------ src/utils/setup.ts | 131 +++++++- 17 files changed, 371 insertions(+), 363 deletions(-) create mode 100644 src/utils/file-status-tracker.ts rename src/utils/{container-status.ts => localstack-container.ts} (88%) rename src/utils/{localstack-status.ts => localstack-instance.ts} (80%) rename src/utils/{promises.ts => min-delay.ts} (100%) rename src/utils/{immediate-once.ts => once-immediate.ts} (58%) delete mode 100644 src/utils/setup-status.ts diff --git a/src/extension.ts b/src/extension.ts index e751ee5..d9b5f06 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -9,10 +9,10 @@ import setup from "./plugins/setup.ts"; import statusBar from "./plugins/status-bar.ts"; import { PluginManager } from "./plugins.ts"; import { createCliStatusTracker } from "./utils/cli.ts"; -import { createContainerStatusTracker } from "./utils/container-status.ts"; -import { createLocalStackStatusTracker } from "./utils/localstack-status.ts"; +import { createLocalStackContainerStatusTracker } from "./utils/localstack-container.ts"; +import { createLocalStackInstanceStatusTracker } from "./utils/localstack-instance.ts"; import { getOrCreateExtensionSessionId } from "./utils/manage.ts"; -import { createSetupStatusTracker } from "./utils/setup-status.ts"; +import { createSetupStatusTracker } from "./utils/setup.ts"; import { createTelemetry } from "./utils/telemetry.ts"; import { createTimeTracker } from "./utils/time-tracker.ts"; @@ -50,14 +50,14 @@ export async function activate(context: ExtensionContext) { statusBarItem.text = "$(loading~spin) LocalStack"; statusBarItem.show(); - const containerStatusTracker = createContainerStatusTracker( + const containerStatusTracker = createLocalStackContainerStatusTracker( "localstack-main", outputChannel, timeTracker, ); context.subscriptions.push(containerStatusTracker); - const localStackStatusTracker = createLocalStackStatusTracker( + const localStackStatusTracker = createLocalStackInstanceStatusTracker( containerStatusTracker, outputChannel, timeTracker, diff --git a/src/plugins.ts b/src/plugins.ts index 2183c03..e136cc3 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -2,9 +2,9 @@ import ms from "ms"; import type { ExtensionContext, LogOutputChannel, StatusBarItem } from "vscode"; import type { CliStatusTracker } from "./utils/cli.ts"; -import type { ContainerStatusTracker } from "./utils/container-status.ts"; -import type { LocalStackStatusTracker } from "./utils/localstack-status.ts"; -import type { SetupStatusTracker } from "./utils/setup-status.ts"; +import type { LocalStackContainerStatusTracker } from "./utils/localstack-container.ts"; +import type { LocalStackInstanceStatusTracker } from "./utils/localstack-instance.ts"; +import type { SetupStatusTracker } from "./utils/setup.ts"; import type { Telemetry } from "./utils/telemetry.ts"; import type { TimeTracker } from "./utils/time-tracker.ts"; @@ -15,8 +15,8 @@ export interface PluginOptions { outputChannel: LogOutputChannel; statusBarItem: StatusBarItem; cliStatusTracker: CliStatusTracker; - containerStatusTracker: ContainerStatusTracker; - localStackStatusTracker: LocalStackStatusTracker; + containerStatusTracker: LocalStackContainerStatusTracker; + localStackStatusTracker: LocalStackInstanceStatusTracker; setupStatusTracker: SetupStatusTracker; telemetry: Telemetry; timeTracker: TimeTracker; diff --git a/src/plugins/setup.ts b/src/plugins/setup.ts index 2b2c64c..d33e283 100644 --- a/src/plugins/setup.ts +++ b/src/plugins/setup.ts @@ -15,7 +15,7 @@ import { checkIsLicenseValid, activateLicenseUntilValid, } from "../utils/license.ts"; -import { minDelay } from "../utils/promises.ts"; +import { minDelay } from "../utils/min-delay.ts"; import { updateDockerImage } from "../utils/setup.ts"; import { get_setup_ended } from "../utils/telemetry.ts"; diff --git a/src/plugins/status-bar.ts b/src/plugins/status-bar.ts index c4ddbbb..b9a57fb 100644 --- a/src/plugins/status-bar.ts +++ b/src/plugins/status-bar.ts @@ -2,13 +2,13 @@ import { commands, QuickPickItemKind, ThemeColor, window } from "vscode"; import type { QuickPickItem } from "vscode"; import { createPlugin } from "../plugins.ts"; -import { immediateOnce } from "../utils/immediate-once.ts"; -import type { LocalStackStatus } from "../utils/localstack-status.ts"; -import type { SetupStatus } from "../utils/setup-status.ts"; +import type { LocalStackInstanceStatus } from "../utils/localstack-instance.ts"; +import { createOnceImmediate } from "../utils/once-immediate.ts"; +import type { SetupStatus } from "../utils/setup.ts"; function getStatusText(options: { cliStatus: SetupStatus; - localStackStatus: LocalStackStatus; + localStackStatus: LocalStackInstanceStatus; cliOutdated: boolean | undefined; }) { if (options.cliStatus === "ok") { @@ -97,7 +97,7 @@ export default createPlugin( }), ); - const renderStatusBar = immediateOnce(() => { + const renderStatusBar = createOnceImmediate(() => { const setupStatus = setupStatusTracker.status(); const localStackStatus = localStackStatusTracker.status(); const cliStatus = cliStatusTracker.status(); diff --git a/src/utils/authenticate.ts b/src/utils/authenticate.ts index ca2cba4..e9201ca 100644 --- a/src/utils/authenticate.ts +++ b/src/utils/authenticate.ts @@ -10,6 +10,8 @@ import type { import { env, Uri, window } from "vscode"; import { assertIsError } from "./assert.ts"; +import { createFileStatusTracker } from "./file-status-tracker.ts"; +import type { StatusTracker } from "./file-status-tracker.ts"; /** * Registers a {@link UriHandler} that waits for an authentication token from the browser, @@ -160,3 +162,23 @@ export async function readAuthToken(): Promise { export async function checkIsAuthenticated() { return (await readAuthToken()) !== ""; } + +/** + * Creates a status tracker that monitors the LocalStack authentication file for changes. + * When the file is changed, the provided check function is called to determine the current setup status. + * Emits status changes to registered listeners. + * + * @param outputChannel - Channel for logging output and trace messages. + * @param outputChannel + * @returns A {@link StatusTracker} instance for querying status, subscribing to changes, and disposing resources. + */ +export function createLocalStackAuthenticationStatusTracker( + outputChannel: LogOutputChannel, +): StatusTracker { + return createFileStatusTracker( + outputChannel, + "[setup-status.localstack-authentication]", + [LOCALSTACK_AUTH_FILENAME], + async () => ((await checkIsAuthenticated()) ? "ok" : "setup_required"), + ); +} diff --git a/src/utils/cli.ts b/src/utils/cli.ts index 659bf7e..fffe72f 100644 --- a/src/utils/cli.ts +++ b/src/utils/cli.ts @@ -10,8 +10,8 @@ import { CLI_PATHS, LOCALSTACK_DOCKER_IMAGE_NAME } from "../constants.ts"; import { createValueEmitter } from "./emitter.ts"; import { exec } from "./exec.ts"; -import { immediateOnce } from "./immediate-once.ts"; -import type { SetupStatus } from "./setup-status.ts"; +import { createOnceImmediate } from "./once-immediate.ts"; +import type { SetupStatus } from "./setup.ts"; import { spawn } from "./spawn.ts"; import type { SpawnOptions } from "./spawn.ts"; @@ -177,7 +177,7 @@ export function createCliStatusTracker( const cliPath = createValueEmitter(); const outdated = createValueEmitter(); - const track = immediateOnce(async () => { + const track = createOnceImmediate(async () => { const newCli = await findLocalStack().catch(() => undefined); outputChannel.info(`[cli]: findLocalStack = ${newCli?.cliPath}`); diff --git a/src/utils/configure-aws.ts b/src/utils/configure-aws.ts index f61e07f..51cc80f 100644 --- a/src/utils/configure-aws.ts +++ b/src/utils/configure-aws.ts @@ -7,6 +7,8 @@ import { window } from "vscode"; import type { LogOutputChannel } from "vscode"; import { readAuthToken } from "./authenticate.ts"; +import { createFileStatusTracker } from "./file-status-tracker.ts"; +import type { StatusTracker } from "./file-status-tracker.ts"; import { parseIni, serializeIni, updateIniSection } from "./ini-parser.ts"; import type { IniFile, IniSection } from "./ini-parser.ts"; import type { Telemetry } from "./telemetry.ts"; @@ -501,3 +503,22 @@ export async function checkIsProfileConfigured(): Promise { return false; } } + +/** + * Creates a status tracker that monitors the AWS profile files for changes. + * When the file is changed, the provided check function is called to determine the current setup status. + * Emits status changes to registered listeners. + * + * @param outputChannel - Channel for logging output and trace messages. + * @returns A {@link StatusTracker} instance for querying status, subscribing to changes, and disposing resources. + */ +export function createAwsProfileStatusTracker( + outputChannel: LogOutputChannel, +): StatusTracker { + return createFileStatusTracker( + outputChannel, + "[setup-status.aws-profile]", + [AWS_CONFIG_FILENAME, AWS_CREDENTIALS_FILENAME], + async () => ((await checkIsProfileConfigured()) ? "ok" : "setup_required"), + ); +} diff --git a/src/utils/emitter.ts b/src/utils/emitter.ts index a554f4a..f33b55d 100644 --- a/src/utils/emitter.ts +++ b/src/utils/emitter.ts @@ -1,4 +1,4 @@ -import { immediateOnce } from "./immediate-once.ts"; +import { createOnceImmediate } from "./once-immediate.ts"; export type Callback = (value: T) => Promise | void; @@ -12,7 +12,7 @@ export function createValueEmitter(): ValueEmitter { let currentValue: T; const callbacks: Callback[] = []; - const emit = immediateOnce(async () => { + const emit = createOnceImmediate(async () => { for (const callback of callbacks) { try { await callback(currentValue); diff --git a/src/utils/file-status-tracker.ts b/src/utils/file-status-tracker.ts new file mode 100644 index 0000000..56112f1 --- /dev/null +++ b/src/utils/file-status-tracker.ts @@ -0,0 +1,86 @@ +import { watch } from "chokidar"; +import type { LogOutputChannel } from "vscode"; + +import { createValueEmitter } from "./emitter.ts"; +import { createOnceImmediate } from "./once-immediate.ts"; +import type { SetupStatus } from "./setup.ts"; + +export interface StatusTracker { + status(): SetupStatus | undefined; + onChange(callback: (status: SetupStatus | undefined) => void): void; + dispose(): Promise; + check(): void; +} +/** + * Creates a status tracker that monitors the given files for changes. + * When a file is added, changed, or deleted, the provided check function is called + * to determine the current setup status. Emits status changes to registered listeners. + * + * @param outputChannel - Channel for logging output and trace messages. + * @param outputChannelPrefix - Prefix for log messages. + * @param files - Array of file paths to watch. + * @param check - Function that returns the current SetupStatus (sync or async). + * @returns A {@link StatusTracker} instance for querying status, subscribing to changes, and disposing resources. + */ +export function createFileStatusTracker( + outputChannel: LogOutputChannel, + outputChannelPrefix: string, + files: string[], + check: () => Promise | SetupStatus | undefined, +): StatusTracker { + // let status: SetupStatus | undefined; + // const emitter = createEmitter(outputChannel); + const status = createValueEmitter(); + + const updateStatus = createOnceImmediate(async () => { + const newStatus = await Promise.resolve(check()); + status.setValue(newStatus); + // if (status !== newStatus) { + // status = newStatus; + // outputChannel.trace( + // `${outputChannelPrefix} File status changed to ${status}`, + // ); + // await emitter.emit(status); + // } + }); + + const watcher = watch(files) + .on("change", (path) => { + outputChannel.trace(`${outputChannelPrefix} ${path} changed`); + updateStatus(); + }) + .on("unlink", (path) => { + outputChannel.trace(`${outputChannelPrefix} ${path} deleted`); + updateStatus(); + }) + .on("add", (path) => { + outputChannel.trace(`${outputChannelPrefix} ${path} added`); + updateStatus(); + }) + .on("error", (error) => { + outputChannel.error(`${outputChannelPrefix} Error watching file`); + outputChannel.error(error instanceof Error ? error : String(error)); + }); + + // Update the status immediately on file tracker initialization + void updateStatus(); + + return { + status() { + return status.value(); + }, + onChange(callback) { + status.onChange(callback); + // emitter.on(callback); + // if (status) { + // callback(status); + // } + }, + async dispose() { + await watcher.close(); + }, + check() { + return updateStatus(); + }, + }; +} diff --git a/src/utils/install.ts b/src/utils/install.ts index 654680c..42b61fe 100644 --- a/src/utils/install.ts +++ b/src/utils/install.ts @@ -13,7 +13,7 @@ import { } from "../constants.ts"; import { exec } from "./exec.ts"; -import { minDelay } from "./promises.ts"; +import { minDelay } from "./min-delay.ts"; import { spawnElevatedDarwin, spawnElevatedLinux, diff --git a/src/utils/license.ts b/src/utils/license.ts index 313bdde..24d4571 100644 --- a/src/utils/license.ts +++ b/src/utils/license.ts @@ -4,6 +4,9 @@ import { join } from "node:path"; import type { CancellationToken, LogOutputChannel } from "vscode"; import { execLocalStack } from "./cli.ts"; +import type { CliStatusTracker } from "./cli.ts"; +import { createFileStatusTracker } from "./file-status-tracker.ts"; +import type { StatusTracker } from "./file-status-tracker.ts"; /** * See https://github.com/localstack/localstack/blob/de861e1f656a52eaa090b061bd44fc1a7069715e/localstack-core/localstack/utils/files.py#L38-L55. @@ -84,3 +87,51 @@ export async function activateLicenseUntilValid( await new Promise((resolve) => setTimeout(resolve, 1000)); } } + +/** + * Creates a status tracker that monitors the LocalStack license file for changes. + * When the file is changed, the provided check function is called to determine the current setup status. + * Emits status changes to registered listeners. + * + * @param outputChannel - Channel for logging output and trace messages. + * @returns A {@link StatusTracker} instance for querying status, subscribing to changes, and disposing resources. + */ +export function createLicenseStatusTracker( + cliTracker: CliStatusTracker, + authTracker: StatusTracker, + outputChannel: LogOutputChannel, +): StatusTracker { + const licenseTracker = createFileStatusTracker( + outputChannel, + "[setup-status.license]", + [LICENSE_FILENAME], + async () => { + if (cliTracker.outdated()) { + return "setup_required"; + } + + const cliPath = cliTracker.cliPath(); + if (!cliPath) { + return undefined; + } + + const isLicenseValid = await checkIsLicenseValid(cliPath, outputChannel); + + return isLicenseValid ? "ok" : "setup_required"; + }, + ); + + authTracker.onChange(() => { + licenseTracker.check(); + }); + + cliTracker.onCliPathChange(() => { + licenseTracker.check(); + }); + + cliTracker.onOutdatedChange(() => { + licenseTracker.check(); + }); + + return licenseTracker; +} diff --git a/src/utils/container-status.ts b/src/utils/localstack-container.ts similarity index 88% rename from src/utils/container-status.ts rename to src/utils/localstack-container.ts index 5056eaa..5c303ba 100644 --- a/src/utils/container-status.ts +++ b/src/utils/localstack-container.ts @@ -7,22 +7,24 @@ import { createValueEmitter } from "./emitter.ts"; import { JsonLinesStream } from "./json-lines-stream.ts"; import type { TimeTracker } from "./time-tracker.ts"; -export type ContainerStatus = "running" | "stopping" | "stopped"; +export type LocalStackContainerStatus = "running" | "stopping" | "stopped"; -export interface ContainerStatusTracker extends Disposable { - status(): ContainerStatus | undefined; - onChange(callback: (status: ContainerStatus | undefined) => void): void; +export interface LocalStackContainerStatusTracker extends Disposable { + status(): LocalStackContainerStatus | undefined; + onChange( + callback: (status: LocalStackContainerStatus | undefined) => void, + ): void; } /** * Checks the status of a docker container in realtime. */ -export function createContainerStatusTracker( +export function createLocalStackContainerStatusTracker( containerName: string, outputChannel: LogOutputChannel, timeTracker: TimeTracker, -): ContainerStatusTracker { - const status = createValueEmitter(); +): LocalStackContainerStatusTracker { + const status = createValueEmitter(); const disposable = listenToContainerStatus( containerName, @@ -65,7 +67,7 @@ const DockerEventsSchema = z.object({ function listenToContainerStatus( containerName: string, outputChannel: LogOutputChannel, - onStatusChange: (status: ContainerStatus) => void, + onStatusChange: (status: LocalStackContainerStatus) => void, ): Disposable { let dockerEvents: ReturnType | undefined; let isDisposed = false; @@ -188,7 +190,7 @@ function listenToContainerStatus( async function getContainerStatus( containerName: string, -): Promise { +): Promise { return new Promise((resolve) => { // timeout after 1s setTimeout(() => resolve("stopped"), 1_000); diff --git a/src/utils/localstack-status.ts b/src/utils/localstack-instance.ts similarity index 80% rename from src/utils/localstack-status.ts rename to src/utils/localstack-instance.ts index 0fc0c80..cd2382a 100644 --- a/src/utils/localstack-status.ts +++ b/src/utils/localstack-instance.ts @@ -1,35 +1,41 @@ import type { Disposable, LogOutputChannel } from "vscode"; -import type { - ContainerStatus, - ContainerStatusTracker, -} from "./container-status.ts"; import { createValueEmitter } from "./emitter.ts"; +import type { + LocalStackContainerStatus, + LocalStackContainerStatusTracker, +} from "./localstack-container.ts"; import { fetchHealth } from "./manage.ts"; import type { TimeTracker } from "./time-tracker.ts"; -export type LocalStackStatus = "starting" | "running" | "stopping" | "stopped"; - -export interface LocalStackStatusTracker extends Disposable { - status(): LocalStackStatus | undefined; - forceContainerStatus(status: ContainerStatus): void; - onChange(callback: (status: LocalStackStatus | undefined) => void): void; +export type LocalStackInstanceStatus = + | "starting" + | "running" + | "stopping" + | "stopped"; + +export interface LocalStackInstanceStatusTracker extends Disposable { + status(): LocalStackInstanceStatus | undefined; + forceContainerStatus(status: LocalStackContainerStatus): void; + onChange( + callback: (status: LocalStackInstanceStatus | undefined) => void, + ): void; } /** * Checks the status of the LocalStack instance in realtime. */ -export function createLocalStackStatusTracker( - containerStatusTracker: ContainerStatusTracker, +export function createLocalStackInstanceStatusTracker( + containerStatusTracker: LocalStackContainerStatusTracker, outputChannel: LogOutputChannel, timeTracker: TimeTracker, -): LocalStackStatusTracker { - let containerStatus: ContainerStatus | undefined; - const status = createValueEmitter(); +): LocalStackInstanceStatusTracker { + let containerStatus: LocalStackContainerStatus | undefined; + const status = createValueEmitter(); const healthCheckStatusTracker = createHealthStatusTracker(timeTracker); - const setStatus = (newStatus: LocalStackStatus) => { + const setStatus = (newStatus: LocalStackInstanceStatus) => { status.setValue(newStatus); }; @@ -93,10 +99,10 @@ export function createLocalStackStatusTracker( } function getLocalStackStatus( - containerStatus: ContainerStatus | undefined, + containerStatus: LocalStackContainerStatus | undefined, healthStatus: HealthStatus | undefined, - previousStatus?: LocalStackStatus, -): LocalStackStatus { + previousStatus?: LocalStackInstanceStatus, +): LocalStackInstanceStatus { if (containerStatus === "running") { if (healthStatus === "healthy") { return "running"; diff --git a/src/utils/promises.ts b/src/utils/min-delay.ts similarity index 100% rename from src/utils/promises.ts rename to src/utils/min-delay.ts diff --git a/src/utils/immediate-once.ts b/src/utils/once-immediate.ts similarity index 58% rename from src/utils/immediate-once.ts rename to src/utils/once-immediate.ts index 8dac27f..4332663 100644 --- a/src/utils/immediate-once.ts +++ b/src/utils/once-immediate.ts @@ -1,12 +1,12 @@ /** - * Creates a function that calls the given callback immediately once. + * Creates a function that calls the given callback on the next tick, once per tick. * * Multiple calls during the same tick are ignored. * * @param callback - The callback to call. - * @returns A function that calls the callback immediately once. + * @returns A function that calls the callback on the next tick, once per tick. */ -export function immediateOnce(callback: () => T): () => void { +export function createOnceImmediate(callback: () => T): () => void { let timeout: NodeJS.Immediate | undefined; return () => { diff --git a/src/utils/setup-status.ts b/src/utils/setup-status.ts deleted file mode 100644 index cd2251f..0000000 --- a/src/utils/setup-status.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { watch } from "chokidar"; -import ms from "ms"; -import type { Disposable, LogOutputChannel } from "vscode"; - -import { - checkIsAuthenticated, - LOCALSTACK_AUTH_FILENAME, -} from "./authenticate.ts"; -import type { CliStatusTracker } from "./cli.ts"; -import { - AWS_CONFIG_FILENAME, - AWS_CREDENTIALS_FILENAME, - checkIsProfileConfigured, -} from "./configure-aws.ts"; -import { createValueEmitter } from "./emitter.ts"; -import { immediateOnce } from "./immediate-once.ts"; -import { checkIsLicenseValid, LICENSE_FILENAME } from "./license.ts"; -import type { TimeTracker } from "./time-tracker.ts"; - -export type SetupStatus = "ok" | "setup_required"; - -export interface SetupStatusTracker extends Disposable { - status(): SetupStatus | undefined; - onChange(callback: (status: SetupStatus) => void): void; -} - -/** - * Checks the status of the LocalStack installation. - */ -export async function createSetupStatusTracker( - outputChannel: LogOutputChannel, - timeTracker: TimeTracker, - cliTracker: CliStatusTracker, -): Promise { - const start = Date.now(); - const status = createValueEmitter(); - const awsProfileTracker = createAwsProfileStatusTracker(outputChannel); - const localStackAuthenticationTracker = - createLocalStackAuthenticationStatusTracker(outputChannel); - const licenseTracker = createLicenseStatusTracker( - cliTracker, - localStackAuthenticationTracker, - outputChannel, - ); - const end = Date.now(); - outputChannel.trace( - `[setup-status]: Initialized dependencies in ${ms(end - start, { long: true })}`, - ); - - const checkStatusNow = async () => { - const statuses = { - cliTracker: cliTracker.status(), - awsProfileTracker: awsProfileTracker.status(), - authTracker: localStackAuthenticationTracker.status(), - licenseTracker: licenseTracker.status(), - }; - - const notInitialized = Object.values(statuses).some( - (check) => check === undefined, - ); - if (notInitialized) { - outputChannel.trace( - `[setup-status] File watchers not initialized yet, skipping status check : ${JSON.stringify( - { - cliTracker: cliTracker.status() ?? "undefined", - awsProfileTracker: awsProfileTracker.status() ?? "undefined", - authTracker: - localStackAuthenticationTracker.status() ?? "undefined", - licenseTracker: licenseTracker.status() ?? "undefined", - }, - )}`, - ); - return; - } - - const setupRequired = Object.values(statuses).some( - (status) => status === "setup_required", - ); - const newStatus = setupRequired ? "setup_required" : "ok"; - if (status.value() !== newStatus) { - outputChannel.trace( - `[setup-status] Status changed to ${JSON.stringify({ - cliTracker: cliTracker.status() ?? "undefined", - awsProfileTracker: awsProfileTracker.status() ?? "undefined", - authTracker: localStackAuthenticationTracker.status() ?? "undefined", - licenseTracker: licenseTracker.status() ?? "undefined", - })}`, - ); - } - status.setValue(newStatus); - }; - - const checkStatus = immediateOnce(async () => { - await checkStatusNow(); - }); - - awsProfileTracker.onChange(() => { - checkStatus(); - }); - - localStackAuthenticationTracker.onChange(() => { - checkStatus(); - }); - - licenseTracker.onChange(() => { - checkStatus(); - }); - - let timeout: NodeJS.Timeout | undefined; - const startChecking = () => { - checkStatus(); - - // TODO: Find a smarter way to check the status (e.g. watch for changes in AWS credentials or LocalStack installation) - timeout = setTimeout(() => void startChecking(), 1_000); - }; - - await timeTracker.run("setup-status.checkIsSetupRequired", () => { - startChecking(); - return Promise.resolve(); - }); - - await checkStatusNow(); - - return { - status() { - return status.value(); - }, - onChange(callback) { - status.onChange(callback); - }, - async dispose() { - clearTimeout(timeout); - await Promise.all([ - awsProfileTracker.dispose(), - localStackAuthenticationTracker.dispose(), - ]); - }, - }; -} - -interface StatusTracker { - status(): SetupStatus | undefined; - onChange(callback: (status: SetupStatus | undefined) => void): void; - dispose(): Promise; - check(): void; -} - -/** - * Creates a status tracker that monitors the given files for changes. - * When a file is added, changed, or deleted, the provided check function is called - * to determine the current setup status. Emits status changes to registered listeners. - * - * @param outputChannel - Channel for logging output and trace messages. - * @param outputChannelPrefix - Prefix for log messages. - * @param files - Array of file paths to watch. - * @param check - Function that returns the current SetupStatus (sync or async). - * @returns A {@link StatusTracker} instance for querying status, subscribing to changes, and disposing resources. - */ -function createFileStatusTracker( - outputChannel: LogOutputChannel, - outputChannelPrefix: string, - files: string[], - check: () => Promise | SetupStatus | undefined, -): StatusTracker { - // let status: SetupStatus | undefined; - - // const emitter = createEmitter(outputChannel); - - const status = createValueEmitter(); - - const updateStatus = immediateOnce(async () => { - const newStatus = await Promise.resolve(check()); - status.setValue(newStatus); - // if (status !== newStatus) { - // status = newStatus; - // outputChannel.trace( - // `${outputChannelPrefix} File status changed to ${status}`, - // ); - // await emitter.emit(status); - // } - }); - - const watcher = watch(files) - .on("change", (path) => { - outputChannel.trace(`${outputChannelPrefix} ${path} changed`); - updateStatus(); - }) - .on("unlink", (path) => { - outputChannel.trace(`${outputChannelPrefix} ${path} deleted`); - updateStatus(); - }) - .on("add", (path) => { - outputChannel.trace(`${outputChannelPrefix} ${path} added`); - updateStatus(); - }) - .on("error", (error) => { - outputChannel.error(`${outputChannelPrefix} Error watching file`); - outputChannel.error(error instanceof Error ? error : String(error)); - }); - - // Update the status immediately on file tracker initialization - void updateStatus(); - - return { - status() { - return status.value(); - }, - onChange(callback) { - status.onChange(callback); - // emitter.on(callback); - // if (status) { - // callback(status); - // } - }, - async dispose() { - await watcher.close(); - }, - check() { - return updateStatus(); - }, - }; -} - -/** - * Creates a status tracker that monitors the AWS profile files for changes. - * When the file is changed, the provided check function is called to determine the current setup status. - * Emits status changes to registered listeners. - * - * @param outputChannel - Channel for logging output and trace messages. - * @returns A {@link StatusTracker} instance for querying status, subscribing to changes, and disposing resources. - */ -function createAwsProfileStatusTracker( - outputChannel: LogOutputChannel, -): StatusTracker { - return createFileStatusTracker( - outputChannel, - "[setup-status.aws-profile]", - [AWS_CONFIG_FILENAME, AWS_CREDENTIALS_FILENAME], - async () => ((await checkIsProfileConfigured()) ? "ok" : "setup_required"), - ); -} - -/** - * Creates a status tracker that monitors the LocalStack authentication file for changes. - * When the file is changed, the provided check function is called to determine the current setup status. - * Emits status changes to registered listeners. - * - * @param outputChannel - Channel for logging output and trace messages. - * @param outputChannel - * @returns A {@link StatusTracker} instance for querying status, subscribing to changes, and disposing resources. - */ -function createLocalStackAuthenticationStatusTracker( - outputChannel: LogOutputChannel, -): StatusTracker { - return createFileStatusTracker( - outputChannel, - "[setup-status.localstack-authentication]", - [LOCALSTACK_AUTH_FILENAME], - async () => ((await checkIsAuthenticated()) ? "ok" : "setup_required"), - ); -} - -/** - * Creates a status tracker that monitors the LocalStack license file for changes. - * When the file is changed, the provided check function is called to determine the current setup status. - * Emits status changes to registered listeners. - * - * @param outputChannel - Channel for logging output and trace messages. - * @returns A {@link StatusTracker} instance for querying status, subscribing to changes, and disposing resources. - */ -function createLicenseStatusTracker( - cliTracker: CliStatusTracker, - authTracker: StatusTracker, - outputChannel: LogOutputChannel, -): StatusTracker { - const licenseTracker = createFileStatusTracker( - outputChannel, - "[setup-status.license]", - [LICENSE_FILENAME], - async () => { - if (cliTracker.outdated()) { - return "setup_required"; - } - - const cliPath = cliTracker.cliPath(); - if (!cliPath) { - return undefined; - } - - const isLicenseValid = await checkIsLicenseValid(cliPath, outputChannel); - - return isLicenseValid ? "ok" : "setup_required"; - }, - ); - - authTracker.onChange(() => { - licenseTracker.check(); - }); - - cliTracker.onCliPathChange(() => { - licenseTracker.check(); - }); - - cliTracker.onOutdatedChange(() => { - licenseTracker.check(); - }); - - return licenseTracker; -} diff --git a/src/utils/setup.ts b/src/utils/setup.ts index e591bec..fd328bc 100644 --- a/src/utils/setup.ts +++ b/src/utils/setup.ts @@ -1,10 +1,18 @@ -import type { CancellationToken, LogOutputChannel } from "vscode"; +import ms from "ms"; +import type { CancellationToken, Disposable, LogOutputChannel } from "vscode"; import * as z from "zod/v4-mini"; import { LOCALSTACK_DOCKER_IMAGE_NAME } from "../constants.ts"; +import { createLocalStackAuthenticationStatusTracker } from "./authenticate.ts"; +import type { CliStatusTracker } from "./cli.ts"; +import { createAwsProfileStatusTracker } from "./configure-aws.ts"; +import { createValueEmitter } from "./emitter.ts"; import { exec } from "./exec.ts"; +import { createLicenseStatusTracker } from "./license.ts"; +import { createOnceImmediate } from "./once-immediate.ts"; import { spawn } from "./spawn.ts"; +import type { TimeTracker } from "./time-tracker.ts"; export async function updateDockerImage( outputChannel: LogOutputChannel, @@ -68,3 +76,124 @@ async function pullDockerImage( outputChannel.error(error instanceof Error ? error : String(error)); } } + +export type SetupStatus = "ok" | "setup_required"; + +export interface SetupStatusTracker extends Disposable { + status(): SetupStatus | undefined; + onChange(callback: (status: SetupStatus) => void): void; +} +/** + * Checks the status of the LocalStack installation. + */ + +export async function createSetupStatusTracker( + outputChannel: LogOutputChannel, + timeTracker: TimeTracker, + cliTracker: CliStatusTracker, +): Promise { + const start = Date.now(); + const status = createValueEmitter(); + const awsProfileTracker = createAwsProfileStatusTracker(outputChannel); + const localStackAuthenticationTracker = + createLocalStackAuthenticationStatusTracker(outputChannel); + const licenseTracker = createLicenseStatusTracker( + cliTracker, + localStackAuthenticationTracker, + outputChannel, + ); + const end = Date.now(); + outputChannel.trace( + `[setup-status]: Initialized dependencies in ${ms(end - start, { long: true })}`, + ); + + const checkStatusNow = async () => { + const statuses = { + cliTracker: cliTracker.status(), + awsProfileTracker: awsProfileTracker.status(), + authTracker: localStackAuthenticationTracker.status(), + licenseTracker: licenseTracker.status(), + }; + + const notInitialized = Object.values(statuses).some( + (check) => check === undefined, + ); + if (notInitialized) { + outputChannel.trace( + `[setup-status] File watchers not initialized yet, skipping status check : ${JSON.stringify( + { + cliTracker: cliTracker.status() ?? "undefined", + awsProfileTracker: awsProfileTracker.status() ?? "undefined", + authTracker: + localStackAuthenticationTracker.status() ?? "undefined", + licenseTracker: licenseTracker.status() ?? "undefined", + }, + )}`, + ); + return; + } + + const setupRequired = Object.values(statuses).some( + (status) => status === "setup_required", + ); + const newStatus = setupRequired ? "setup_required" : "ok"; + if (status.value() !== newStatus) { + outputChannel.trace( + `[setup-status] Status changed to ${JSON.stringify({ + cliTracker: cliTracker.status() ?? "undefined", + awsProfileTracker: awsProfileTracker.status() ?? "undefined", + authTracker: localStackAuthenticationTracker.status() ?? "undefined", + licenseTracker: licenseTracker.status() ?? "undefined", + })}`, + ); + } + status.setValue(newStatus); + }; + + const checkStatus = createOnceImmediate(async () => { + await checkStatusNow(); + }); + + awsProfileTracker.onChange(() => { + checkStatus(); + }); + + localStackAuthenticationTracker.onChange(() => { + checkStatus(); + }); + + licenseTracker.onChange(() => { + checkStatus(); + }); + + let timeout: NodeJS.Timeout | undefined; + const startChecking = () => { + checkStatus(); + + // TODO: Find a smarter way to check the status (e.g. watch for changes in AWS credentials or LocalStack installation) + timeout = setTimeout(() => void startChecking(), 1000); + }; + + await timeTracker.run("setup-status.checkIsSetupRequired", () => { + startChecking(); + return Promise.resolve(); + }); + + await checkStatusNow(); + + return { + status() { + return status.value(); + }, + onChange(callback) { + status.onChange(callback); + }, + async dispose() { + clearTimeout(timeout); + await Promise.all([ + awsProfileTracker.dispose(), + localStackAuthenticationTracker.dispose(), + ]); + }, + }; +} From effc29247eb16d974eae4c49ae45f43d5f2c3aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Mon, 15 Sep 2025 13:11:18 +0200 Subject: [PATCH 12/27] fix localstack instance status tracker --- src/utils/emitter.ts | 7 ++++--- src/utils/localstack-container.ts | 9 ++++++++- src/utils/localstack-instance.ts | 9 +++++++-- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/utils/emitter.ts b/src/utils/emitter.ts index f33b55d..a5ca09d 100644 --- a/src/utils/emitter.ts +++ b/src/utils/emitter.ts @@ -32,9 +32,10 @@ export function createValueEmitter(): ValueEmitter { }, onChange(callback) { callbacks.push(callback); - if (currentValue) { - void Promise.resolve(callback(currentValue)).catch(() => {}); - } + void Promise.resolve(callback(currentValue)).catch(() => {}); + // if (currentValue !== undefined) { + // void Promise.resolve(callback(currentValue)).catch(() => {}); + // } }, }; } diff --git a/src/utils/localstack-container.ts b/src/utils/localstack-container.ts index 5c303ba..4e73eea 100644 --- a/src/utils/localstack-container.ts +++ b/src/utils/localstack-container.ts @@ -36,12 +36,19 @@ export function createLocalStackContainerStatusTracker( void timeTracker.run("container-status.getContainerStatus", async () => { await getContainerStatus(containerName).then((newStatus) => { - if (status.value() !== undefined) { + outputChannel.trace( + `[localstack-container-status] getContainerStatus=${newStatus} currentStatus=${status.value()}`, + ); + if (status.value() === undefined) { status.setValue(newStatus); } }); }); + status.onChange((status) => { + outputChannel.trace(`[localstack-container-status] container=${status}`); + }); + return { status() { return status.value(); diff --git a/src/utils/localstack-instance.ts b/src/utils/localstack-instance.ts index cd2382a..4e98a1a 100644 --- a/src/utils/localstack-instance.ts +++ b/src/utils/localstack-instance.ts @@ -40,12 +40,17 @@ export function createLocalStackInstanceStatusTracker( }; const deriveStatus = () => { + outputChannel.trace( + `[localstack-instance-status] containerStatus=${containerStatus} healthCheckStatusTracker=${healthCheckStatusTracker.status()} previousStatus=${status.value()}`, + ); const newStatus = getLocalStackStatus( containerStatus, healthCheckStatusTracker.status(), status.value(), ); - setStatus(newStatus); + if (newStatus) { + setStatus(newStatus); + } }; containerStatusTracker.onChange((newContainerStatus) => { @@ -65,7 +70,7 @@ export function createLocalStackInstanceStatusTracker( containerStatusTracker.onChange((newContainerStatus) => { outputChannel.trace( - `[localstack-status] container=${newContainerStatus} (localstack=${status.value()})`, + `[localstack-instance-status] container=${newContainerStatus} (localstack=${status.value()})`, ); if (newContainerStatus === "running" && status.value() !== "running") { From 6c1ec090799509331e67b630ab3770dac2fbc86f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Mon, 15 Sep 2025 13:11:28 +0200 Subject: [PATCH 13/27] fix localstack instance status tracker --- src/utils/emitter.ts | 6 ++-- src/utils/localstack-instance.ts | 53 ++++++++++++++++++++------------ 2 files changed, 37 insertions(+), 22 deletions(-) diff --git a/src/utils/emitter.ts b/src/utils/emitter.ts index a5ca09d..05f9c9b 100644 --- a/src/utils/emitter.ts +++ b/src/utils/emitter.ts @@ -1,15 +1,15 @@ import { createOnceImmediate } from "./once-immediate.ts"; -export type Callback = (value: T) => Promise | void; +export type Callback = (value: T | undefined) => Promise | void; export interface ValueEmitter { value(): T | undefined; - setValue(value: T): void; + setValue(value: T | undefined): void; onChange(callback: Callback): void; } export function createValueEmitter(): ValueEmitter { - let currentValue: T; + let currentValue: T | undefined; const callbacks: Callback[] = []; const emit = createOnceImmediate(async () => { diff --git a/src/utils/localstack-instance.ts b/src/utils/localstack-instance.ts index 4e98a1a..a7b35d6 100644 --- a/src/utils/localstack-instance.ts +++ b/src/utils/localstack-instance.ts @@ -61,7 +61,7 @@ export function createLocalStackInstanceStatusTracker( }); status.onChange((newStatus) => { - outputChannel.trace(`[localstack-status] localstack=${newStatus}`); + outputChannel.trace(`[localstack-instances-status] status=${newStatus}`); if (newStatus === "running") { healthCheckStatusTracker.stop(); @@ -89,9 +89,11 @@ export function createLocalStackInstanceStatusTracker( return status.value(); }, forceContainerStatus(newContainerStatus) { - if (containerStatus !== newContainerStatus) { - containerStatus = newContainerStatus; - deriveStatus(); + containerStatus = newContainerStatus; + if (newContainerStatus === "running") { + status.setValue("starting"); + } else if (newContainerStatus === "stopping") { + status.setValue("stopping"); } }, onChange(callback) { @@ -107,24 +109,37 @@ function getLocalStackStatus( containerStatus: LocalStackContainerStatus | undefined, healthStatus: HealthStatus | undefined, previousStatus?: LocalStackInstanceStatus, -): LocalStackInstanceStatus { - if (containerStatus === "running") { - if (healthStatus === "healthy") { - return "running"; - } else { - // When the LS container is running, and the health check fails: - // - If the previous status was "running", we are likely stopping LS - // - If the previous status was "stopping", we are still stopping LS - if (previousStatus === "running" || previousStatus === "stopping") { - return "stopping"; - } - return "starting"; +): LocalStackInstanceStatus | undefined { + // There's no LS container status yet, so can't derive LS instance status. + if (containerStatus === undefined) { + return undefined; + } + + if (containerStatus === "running" && healthStatus === "healthy") { + return "running"; + } + + if (containerStatus === "running" && healthStatus === "unhealthy") { + // When the LS container is running, and the health check fails: + // - If the previous status was "running", we are likely stopping LS + // - If the previous status was "stopping", we are still stopping LS + if (previousStatus === "running" || previousStatus === "stopping") { + return "stopping"; } - } else if (containerStatus === "stopping") { + + return "starting"; + } + + if (containerStatus === "running" && healthStatus === undefined) { + // return previousStatus; + return undefined; + } + + if (containerStatus === "stopping") { return "stopping"; - } else { - return "stopped"; } + + return "stopped"; } type HealthStatus = "healthy" | "unhealthy"; From df37b16e218f99ac0f90156c0780dcb57e0b99f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Mon, 15 Sep 2025 13:13:02 +0200 Subject: [PATCH 14/27] wip --- src/utils/localstack-instance.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/utils/localstack-instance.ts b/src/utils/localstack-instance.ts index a7b35d6..dcd0ce3 100644 --- a/src/utils/localstack-instance.ts +++ b/src/utils/localstack-instance.ts @@ -131,7 +131,6 @@ function getLocalStackStatus( } if (containerStatus === "running" && healthStatus === undefined) { - // return previousStatus; return undefined; } From 571f7d52e9cae789522397d220523d466044fd0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Mon, 15 Sep 2025 13:29:34 +0200 Subject: [PATCH 15/27] adds tests --- src/extension.ts | 8 ++- src/test/localstack-instance.test.ts | 82 ++++++++++++++++++++++++++++ src/utils/localstack-instance.ts | 10 ++-- 3 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 src/test/localstack-instance.test.ts diff --git a/src/extension.ts b/src/extension.ts index d9b5f06..ca0fe1a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,7 +10,10 @@ import statusBar from "./plugins/status-bar.ts"; import { PluginManager } from "./plugins.ts"; import { createCliStatusTracker } from "./utils/cli.ts"; import { createLocalStackContainerStatusTracker } from "./utils/localstack-container.ts"; -import { createLocalStackInstanceStatusTracker } from "./utils/localstack-instance.ts"; +import { + createHealthStatusTracker, + createLocalStackInstanceStatusTracker, +} from "./utils/localstack-instance.ts"; import { getOrCreateExtensionSessionId } from "./utils/manage.ts"; import { createSetupStatusTracker } from "./utils/setup.ts"; import { createTelemetry } from "./utils/telemetry.ts"; @@ -57,10 +60,11 @@ export async function activate(context: ExtensionContext) { ); context.subscriptions.push(containerStatusTracker); + const healthCheckStatusTracker = createHealthStatusTracker(timeTracker); const localStackStatusTracker = createLocalStackInstanceStatusTracker( containerStatusTracker, + healthCheckStatusTracker, outputChannel, - timeTracker, ); context.subscriptions.push(localStackStatusTracker); diff --git a/src/test/localstack-instance.test.ts b/src/test/localstack-instance.test.ts new file mode 100644 index 0000000..faaf015 --- /dev/null +++ b/src/test/localstack-instance.test.ts @@ -0,0 +1,82 @@ +import * as assert from "node:assert"; +import { setImmediate } from "node:timers/promises"; + +import { window } from "vscode"; +import type { LogOutputChannel } from "vscode"; + +import { createValueEmitter } from "../utils/emitter.ts"; +import type { + LocalStackContainerStatus, + LocalStackContainerStatusTracker, +} from "../utils/localstack-container.ts"; +import { createLocalStackInstanceStatusTracker } from "../utils/localstack-instance.ts"; +import type { + HealthStatus, + HealthStatusTracker, +} from "../utils/localstack-instance.ts"; + +function createFixtures() { + const containerStatus = createValueEmitter(); + const containerStatusTracker: LocalStackContainerStatusTracker = { + status() { + return containerStatus.value(); + }, + onChange(callback) { + containerStatus.onChange(callback); + }, + dispose() {}, + }; + + const healthStatus = createValueEmitter(); + const healthCheckStatusTracker: HealthStatusTracker = { + start() {}, + stop() {}, + status() { + return healthStatus.value(); + }, + onChange(callback) { + healthStatus.onChange(callback); + }, + dispose() {}, + }; + + const outputChannel = window.createOutputChannel("LocalStack", { + log: true, + }); + + const tracker = createLocalStackInstanceStatusTracker( + containerStatusTracker, + healthCheckStatusTracker, + outputChannel, + ); + + return { + containerStatus, + healthStatus, + tracker, + }; +} + +suite("LocalStack Instance Test Suite", () => { + test("Derives LocalStack instance status correctly", async () => { + const { containerStatus, healthStatus, tracker } = createFixtures(); + + /////////////////////////////////////////////////////////////////////////// + containerStatus.setValue(undefined); + healthStatus.setValue(undefined); + await setImmediate(); + assert.strictEqual(tracker.status(), undefined); + + /////////////////////////////////////////////////////////////////////////// + containerStatus.setValue("running"); + healthStatus.setValue("unhealthy"); + await setImmediate(); + assert.strictEqual(tracker.status(), "starting"); + + /////////////////////////////////////////////////////////////////////////// + containerStatus.setValue("running"); + healthStatus.setValue("healthy"); + await setImmediate(); + assert.strictEqual(tracker.status(), "running"); + }); +}); diff --git a/src/utils/localstack-instance.ts b/src/utils/localstack-instance.ts index dcd0ce3..fc083cf 100644 --- a/src/utils/localstack-instance.ts +++ b/src/utils/localstack-instance.ts @@ -27,14 +27,12 @@ export interface LocalStackInstanceStatusTracker extends Disposable { */ export function createLocalStackInstanceStatusTracker( containerStatusTracker: LocalStackContainerStatusTracker, + healthCheckStatusTracker: HealthStatusTracker, outputChannel: LogOutputChannel, - timeTracker: TimeTracker, ): LocalStackInstanceStatusTracker { let containerStatus: LocalStackContainerStatus | undefined; const status = createValueEmitter(); - const healthCheckStatusTracker = createHealthStatusTracker(timeTracker); - const setStatus = (newStatus: LocalStackInstanceStatus) => { status.setValue(newStatus); }; @@ -141,16 +139,16 @@ function getLocalStackStatus( return "stopped"; } -type HealthStatus = "healthy" | "unhealthy"; +export type HealthStatus = "healthy" | "unhealthy"; -interface HealthStatusTracker extends Disposable { +export interface HealthStatusTracker extends Disposable { status(): HealthStatus | undefined; start(): void; stop(): void; onChange(callback: (status: HealthStatus | undefined) => void): void; } -function createHealthStatusTracker( +export function createHealthStatusTracker( timeTracker: TimeTracker, ): HealthStatusTracker { const status = createValueEmitter(); From d6cf82b6575eed59036526674188428ae38bd798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Mon, 15 Sep 2025 13:32:37 +0200 Subject: [PATCH 16/27] add tests --- src/test/localstack-instance.test.ts | 32 ++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/test/localstack-instance.test.ts b/src/test/localstack-instance.test.ts index faaf015..065d45c 100644 --- a/src/test/localstack-instance.test.ts +++ b/src/test/localstack-instance.test.ts @@ -2,7 +2,6 @@ import * as assert from "node:assert"; import { setImmediate } from "node:timers/promises"; import { window } from "vscode"; -import type { LogOutputChannel } from "vscode"; import { createValueEmitter } from "../utils/emitter.ts"; import type { @@ -58,7 +57,7 @@ function createFixtures() { } suite("LocalStack Instance Test Suite", () => { - test("Derives LocalStack instance status correctly", async () => { + test("Derives instance status correctly", async () => { const { containerStatus, healthStatus, tracker } = createFixtures(); /////////////////////////////////////////////////////////////////////////// @@ -79,4 +78,33 @@ suite("LocalStack Instance Test Suite", () => { await setImmediate(); assert.strictEqual(tracker.status(), "running"); }); + + test("Forcing container status derives instance status correctly", async () => { + const { containerStatus, healthStatus, tracker } = createFixtures(); + + /////////////////////////////////////////////////////////////////////////// + tracker.forceContainerStatus("running"); + await setImmediate(); + assert.strictEqual(tracker.status(), "starting"); + + /////////////////////////////////////////////////////////////////////////// + containerStatus.setValue("running"); + await setImmediate(); + assert.strictEqual(tracker.status(), "starting"); + + /////////////////////////////////////////////////////////////////////////// + healthStatus.setValue("healthy"); + await setImmediate(); + assert.strictEqual(tracker.status(), "running"); + + /////////////////////////////////////////////////////////////////////////// + tracker.forceContainerStatus("stopping"); + await setImmediate(); + assert.strictEqual(tracker.status(), "stopping"); + + /////////////////////////////////////////////////////////////////////////// + containerStatus.setValue("stopped"); + await setImmediate(); + assert.strictEqual(tracker.status(), "stopped"); + }); }); From 4049511362e0f2980eea4c6ad9cdeaab34d0e7fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Mon, 15 Sep 2025 13:44:39 +0200 Subject: [PATCH 17/27] fix --- src/utils/setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/setup.ts b/src/utils/setup.ts index fd328bc..e95e7d4 100644 --- a/src/utils/setup.ts +++ b/src/utils/setup.ts @@ -81,7 +81,7 @@ export type SetupStatus = "ok" | "setup_required"; export interface SetupStatusTracker extends Disposable { status(): SetupStatus | undefined; - onChange(callback: (status: SetupStatus) => void): void; + onChange(callback: (status: SetupStatus | undefined) => void): void; } /** * Checks the status of the LocalStack installation. From 55e77ca4b410d150348433d7aacc71d75925395a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Mon, 15 Sep 2025 15:25:07 +0200 Subject: [PATCH 18/27] remove commented code --- src/utils/emitter.ts | 3 --- src/utils/file-status-tracker.ts | 13 ------------- 2 files changed, 16 deletions(-) diff --git a/src/utils/emitter.ts b/src/utils/emitter.ts index 05f9c9b..d28f3ac 100644 --- a/src/utils/emitter.ts +++ b/src/utils/emitter.ts @@ -33,9 +33,6 @@ export function createValueEmitter(): ValueEmitter { onChange(callback) { callbacks.push(callback); void Promise.resolve(callback(currentValue)).catch(() => {}); - // if (currentValue !== undefined) { - // void Promise.resolve(callback(currentValue)).catch(() => {}); - // } }, }; } diff --git a/src/utils/file-status-tracker.ts b/src/utils/file-status-tracker.ts index 56112f1..71cf017 100644 --- a/src/utils/file-status-tracker.ts +++ b/src/utils/file-status-tracker.ts @@ -28,20 +28,11 @@ export function createFileStatusTracker( files: string[], check: () => Promise | SetupStatus | undefined, ): StatusTracker { - // let status: SetupStatus | undefined; - // const emitter = createEmitter(outputChannel); const status = createValueEmitter(); const updateStatus = createOnceImmediate(async () => { const newStatus = await Promise.resolve(check()); status.setValue(newStatus); - // if (status !== newStatus) { - // status = newStatus; - // outputChannel.trace( - // `${outputChannelPrefix} File status changed to ${status}`, - // ); - // await emitter.emit(status); - // } }); const watcher = watch(files) @@ -71,10 +62,6 @@ export function createFileStatusTracker( }, onChange(callback) { status.onChange(callback); - // emitter.on(callback); - // if (status) { - // callback(status); - // } }, async dispose() { await watcher.close(); From 8b81d0ff166a6ebfe9691246ccb3dad4c68bd9c8 Mon Sep 17 00:00:00 2001 From: Misha Tiurin <650819+tiurin@users.noreply.github.com> Date: Thu, 18 Sep 2025 12:43:28 +0200 Subject: [PATCH 19/27] Improve log message Co-authored-by: Anisa Oshafi --- src/utils/localstack-container.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/localstack-container.ts b/src/utils/localstack-container.ts index 4e73eea..c09d82a 100644 --- a/src/utils/localstack-container.ts +++ b/src/utils/localstack-container.ts @@ -37,7 +37,7 @@ export function createLocalStackContainerStatusTracker( void timeTracker.run("container-status.getContainerStatus", async () => { await getContainerStatus(containerName).then((newStatus) => { outputChannel.trace( - `[localstack-container-status] getContainerStatus=${newStatus} currentStatus=${status.value()}`, + `[localstack-container-status] getContainerStatus=${newStatus} previousStatus=${status.value()}`, ); if (status.value() === undefined) { status.setValue(newStatus); From f97af4b83f704b72d2edbd44cbb638ae3cd4a286 Mon Sep 17 00:00:00 2001 From: Misha Tiurin Date: Thu, 18 Sep 2025 09:35:27 +0200 Subject: [PATCH 20/27] Rename variables for clarity It is confusing that time stamp variables end with status tracker because there are many more actual status trackers defined alongside. Renaming to make it clear these ones are about time. --- src/extension.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index ca0fe1a..a14d5ec 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -69,17 +69,17 @@ export async function activate(context: ExtensionContext) { context.subscriptions.push(localStackStatusTracker); outputChannel.trace(`[setup-status]: Starting...`); - const startStatusTracker = Date.now(); + const setupStatusTrackerStartTime = Date.now(); const setupStatusTracker = await createSetupStatusTracker( outputChannel, timeTracker, cliStatusTracker, ); context.subscriptions.push(setupStatusTracker); - const endStatusTracker = Date.now(); + const setupStatusTrackerEndTime = Date.now(); outputChannel.trace( `[setup-status]: Completed in ${ms( - endStatusTracker - startStatusTracker, + setupStatusTrackerEndTime - setupStatusTrackerStartTime, { long: true }, )}`, ); From 117b675b72fa733481a50b703130b36f357e3342 Mon Sep 17 00:00:00 2001 From: Misha Tiurin Date: Thu, 18 Sep 2025 11:32:24 +0200 Subject: [PATCH 21/27] License status tracker reports as waiting for dependencies instead of undefined --- src/utils/license.ts | 2 +- src/utils/setup.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/license.ts b/src/utils/license.ts index 24d4571..4b61abc 100644 --- a/src/utils/license.ts +++ b/src/utils/license.ts @@ -112,7 +112,7 @@ export function createLicenseStatusTracker( const cliPath = cliTracker.cliPath(); if (!cliPath) { - return undefined; + return "waiting_for_dependencies"; } const isLicenseValid = await checkIsLicenseValid(cliPath, outputChannel); diff --git a/src/utils/setup.ts b/src/utils/setup.ts index e95e7d4..400c009 100644 --- a/src/utils/setup.ts +++ b/src/utils/setup.ts @@ -77,7 +77,7 @@ async function pullDockerImage( } } -export type SetupStatus = "ok" | "setup_required"; +export type SetupStatus = "ok" | "setup_required" | "waiting_for_dependencies"; export interface SetupStatusTracker extends Disposable { status(): SetupStatus | undefined; From 29cbef5d6e19d8f7da839fa9dcb145268e200a6d Mon Sep 17 00:00:00 2001 From: Misha Tiurin Date: Thu, 18 Sep 2025 11:43:07 +0200 Subject: [PATCH 22/27] remove redundant comment --- src/utils/cli.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/utils/cli.ts b/src/utils/cli.ts index fffe72f..44449dc 100644 --- a/src/utils/cli.ts +++ b/src/utils/cli.ts @@ -186,7 +186,6 @@ export function createCliStatusTracker( ? "ok" : "setup_required", ); - // cliPath.setValue(newCli?.cliPath); cliPath.setValue(newCli?.upToDate ? newCli?.cliPath : undefined); outdated.setValue( newCli?.upToDate !== undefined ? !newCli.upToDate : undefined, From d1f6bcf67e1d0eb5f05b6753bd972f3c11840870 Mon Sep 17 00:00:00 2001 From: Misha Tiurin Date: Thu, 18 Sep 2025 11:45:56 +0200 Subject: [PATCH 23/27] only set new cli if status requirements are met --- src/utils/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/cli.ts b/src/utils/cli.ts index 44449dc..f6621f1 100644 --- a/src/utils/cli.ts +++ b/src/utils/cli.ts @@ -186,7 +186,7 @@ export function createCliStatusTracker( ? "ok" : "setup_required", ); - cliPath.setValue(newCli?.upToDate ? newCli?.cliPath : undefined); + cliPath.setValue(status.value() === "ok" ? newCli?.cliPath : undefined); outdated.setValue( newCli?.upToDate !== undefined ? !newCli.upToDate : undefined, ); From ab1f14b78d6dae082e8fbf5602ed9e574e4498f9 Mon Sep 17 00:00:00 2001 From: Misha Tiurin Date: Thu, 18 Sep 2025 12:11:42 +0200 Subject: [PATCH 24/27] use time tracker instead of manual time tracking --- src/extension.ts | 36 +++++++++++++----------------------- 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index a14d5ec..75d96e6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -68,32 +68,22 @@ export async function activate(context: ExtensionContext) { ); context.subscriptions.push(localStackStatusTracker); - outputChannel.trace(`[setup-status]: Starting...`); - const setupStatusTrackerStartTime = Date.now(); - const setupStatusTracker = await createSetupStatusTracker( - outputChannel, - timeTracker, - cliStatusTracker, + const setupStatusTracker = await timeTracker.run( + "setup-status", + async () => { + return await createSetupStatusTracker( + outputChannel, + timeTracker, + cliStatusTracker, + ); + }, ); context.subscriptions.push(setupStatusTracker); - const setupStatusTrackerEndTime = Date.now(); - outputChannel.trace( - `[setup-status]: Completed in ${ms( - setupStatusTrackerEndTime - setupStatusTrackerStartTime, - { long: true }, - )}`, - ); - const startTelemetry = Date.now(); - outputChannel.trace(`[telemetry]: Starting...`); - const sessionId = await getOrCreateExtensionSessionId(context); - const telemetry = createTelemetry(outputChannel, sessionId); - const endTelemetry = Date.now(); - outputChannel.trace( - `[telemetry]: Completed in ${ms(endTelemetry - startTelemetry, { - long: true, - })}`, - ); + const telemetry = await timeTracker.run("telemetry", async () => { + const sessionId = await getOrCreateExtensionSessionId(context); + return createTelemetry(outputChannel, sessionId); + }); return { statusBarItem, From 0d019cb0134ea5d7343397f2ecbfabd11dce1213 Mon Sep 17 00:00:00 2001 From: Misha Tiurin Date: Thu, 18 Sep 2025 12:20:55 +0200 Subject: [PATCH 25/27] delete boilerplate test --- src/test/extension.test.ts | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 src/test/extension.test.ts diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts deleted file mode 100644 index 3ecf628..0000000 --- a/src/test/extension.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as assert from "node:assert"; - -import { window } from "vscode"; - -// You can import and use all API from the 'vscode' module -// as well as import your extension to test it - -// import * as myExtension from '../../extension'; - -suite("Extension Test Suite", () => { - window.showInformationMessage("Start all tests."); - - test("Sample test", () => { - assert.strictEqual(-1, [1, 2, 3].indexOf(5)); - assert.strictEqual(-1, [1, 2, 3].indexOf(0)); - }); -}); From 92ce0feeb0c4b022b0399cb725eccfc511f4931c Mon Sep 17 00:00:00 2001 From: Misha Tiurin Date: Thu, 18 Sep 2025 15:41:22 +0200 Subject: [PATCH 26/27] Add buttons to restart and view logs in case of CLI unavailability on license check If there is no valid CLI path at this point then something wrong happened to CLI _between_ CLI installation step and license check, otherwise setup would have exited before. This is unlikely situation that would probably only happen if the user or any other 3rd party program tampers with localstack installation while authentication work is happening in the step before. However, it's a good idea to add two buttons to re-run the wizard and show logs! Added both. Also adds telemetry `setup_ended` event with failed state. In this case we can't really say that license check has failed because the setup didn't get to it yet. However, overall the setup has failed even if any individual step has not, therefore the event says so, thus indicating a possibility of an external error. --- src/plugins/setup.ts | 29 +++++++++++++++++++++++++++-- src/utils/telemetry.ts | 2 +- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/plugins/setup.ts b/src/plugins/setup.ts index d33e283..4c9e9fa 100644 --- a/src/plugins/setup.ts +++ b/src/plugins/setup.ts @@ -239,9 +239,34 @@ export default createPlugin( const cliPath = cliStatusTracker.cliPath() ?? (await getValidCliPath()); if (!cliPath) { - void window.showErrorMessage( - "Could not access the LocalStack CLI.", + telemetry.track( + get_setup_ended( + cliStatus, + authenticationStatus, + "CANCELLED", + "CANCELLED", + "FAILED", + origin_trigger, + await readAuthToken(), + ), ); + void window + .showErrorMessage( + "Could not access the LocalStack CLI.", + { + title: "Restart Setup", + command: "localstack.setup", + }, + { + title: "View Logs", + command: "localstack.viewLogs", + }, + ) + .then((selection) => { + if (selection) { + void commands.executeCommand(selection.command); + } + }); return; } diff --git a/src/utils/telemetry.ts b/src/utils/telemetry.ts index 824a97a..d1e72c3 100644 --- a/src/utils/telemetry.ts +++ b/src/utils/telemetry.ts @@ -220,7 +220,7 @@ export function get_setup_ended( authentication_status: "COMPLETED" | "SKIPPED" | "CANCELLED", license_setup_status: "COMPLETED" | "SKIPPED" | "CANCELLED", aws_profile_status: "COMPLETED" | "SKIPPED" | "CANCELLED", - overall_status: "CANCELLED" | "COMPLETED", + overall_status: "CANCELLED" | "COMPLETED" | "FAILED", origin: "manual_trigger" | "extension_startup", auth_token: string = "", ): Events { From a5095d79f64baeda28b9dda30b62bb7b68c48e69 Mon Sep 17 00:00:00 2001 From: Misha Tiurin Date: Thu, 18 Sep 2025 15:45:10 +0200 Subject: [PATCH 27/27] Update function name to a more specific one --- src/plugins/status-bar.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/status-bar.ts b/src/plugins/status-bar.ts index b9a57fb..293f9a8 100644 --- a/src/plugins/status-bar.ts +++ b/src/plugins/status-bar.ts @@ -6,7 +6,7 @@ import type { LocalStackInstanceStatus } from "../utils/localstack-instance.ts"; import { createOnceImmediate } from "../utils/once-immediate.ts"; import type { SetupStatus } from "../utils/setup.ts"; -function getStatusText(options: { +function getOverallStatusText(options: { cliStatus: SetupStatus; localStackStatus: LocalStackInstanceStatus; cliOutdated: boolean | undefined; @@ -130,7 +130,7 @@ export default createPlugin( ? "$(sync~spin)" : "$(localstack-logo)"; - const statusText = getStatusText({ + const statusText = getOverallStatusText({ cliOutdated, cliStatus, localStackStatus,