From d59dff96c1119954f4de88169e07d1b2c43e9217 Mon Sep 17 00:00:00 2001 From: Misha Tiurin <650819+tiurin@users.noreply.github.com> Date: Tue, 7 Oct 2025 10:44:12 +0200 Subject: [PATCH] Revert "feat: improve cli detection (#60)" This reverts commit aa0520a2171c73c92e15cf87d435b20df8a334d8. --- package.json | 6 + src/constants.ts | 10 +- src/extension.ts | 56 ++-- src/plugins.ts | 12 +- src/plugins/manage.ts | 28 +- src/plugins/setup.ts | 64 +--- src/plugins/status-bar.ts | 112 +++---- src/test/extension.test.ts | 17 ++ src/test/localstack-instance.test.ts | 110 ------- src/utils/authenticate.ts | 22 -- src/utils/cli.ts | 213 ++----------- src/utils/configure-aws.ts | 21 -- ...stack-container.ts => container-status.ts} | 50 ++-- src/utils/emitter.ts | 41 +-- src/utils/file-status-tracker.ts | 73 ----- .../{once-immediate.ts => immediate-once.ts} | 6 +- src/utils/install.ts | 37 +-- src/utils/license.ts | 78 +---- src/utils/localstack-instance.ts | 209 ------------- src/utils/localstack-status.ts | 203 +++++++++++++ src/utils/manage.ts | 7 +- src/utils/{min-delay.ts => promises.ts} | 5 + src/utils/setup-status.ts | 282 ++++++++++++++++++ src/utils/setup.ts | 142 +-------- src/utils/telemetry.ts | 2 +- 25 files changed, 720 insertions(+), 1086 deletions(-) create mode 100644 src/test/extension.test.ts delete mode 100644 src/test/localstack-instance.test.ts rename src/utils/{localstack-container.ts => container-status.ts} (79%) delete mode 100644 src/utils/file-status-tracker.ts rename src/utils/{once-immediate.ts => immediate-once.ts} (58%) delete mode 100644 src/utils/localstack-instance.ts create mode 100644 src/utils/localstack-status.ts rename src/utils/{min-delay.ts => promises.ts} (89%) create mode 100644 src/utils/setup-status.ts diff --git a/package.json b/package.json index 7363c9b..136a3cc 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,12 @@ "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/constants.ts b/src/constants.ts index d0d60af..4a94541 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -15,26 +15,20 @@ 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 75d96e6..1eeac76 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,14 +8,10 @@ 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 { createLocalStackContainerStatusTracker } from "./utils/localstack-container.ts"; -import { - createHealthStatusTracker, - createLocalStackInstanceStatusTracker, -} from "./utils/localstack-instance.ts"; +import { createContainerStatusTracker } from "./utils/container-status.ts"; +import { createLocalStackStatusTracker } from "./utils/localstack-status.ts"; import { getOrCreateExtensionSessionId } from "./utils/manage.ts"; -import { createSetupStatusTracker } from "./utils/setup.ts"; +import { createSetupStatusTracker } from "./utils/setup-status.ts"; import { createTelemetry } from "./utils/telemetry.ts"; import { createTimeTracker } from "./utils/time-tracker.ts"; @@ -33,9 +29,6 @@ export async function activate(context: ExtensionContext) { }); context.subscriptions.push(outputChannel); - const cliStatusTracker = createCliStatusTracker(outputChannel); - context.subscriptions.push(cliStatusTracker); - const timeTracker = createTimeTracker({ outputChannel }); const { @@ -53,37 +46,45 @@ export async function activate(context: ExtensionContext) { statusBarItem.text = "$(loading~spin) LocalStack"; statusBarItem.show(); - const containerStatusTracker = createLocalStackContainerStatusTracker( + const containerStatusTracker = await createContainerStatusTracker( "localstack-main", outputChannel, timeTracker, ); context.subscriptions.push(containerStatusTracker); - const healthCheckStatusTracker = createHealthStatusTracker(timeTracker); - const localStackStatusTracker = createLocalStackInstanceStatusTracker( + const localStackStatusTracker = createLocalStackStatusTracker( containerStatusTracker, - healthCheckStatusTracker, outputChannel, + timeTracker, ); context.subscriptions.push(localStackStatusTracker); - const setupStatusTracker = await timeTracker.run( - "setup-status", - async () => { - return await createSetupStatusTracker( - outputChannel, - timeTracker, - cliStatusTracker, - ); - }, + outputChannel.trace(`[setup-status]: Starting...`); + const startStatusTracker = Date.now(); + const setupStatusTracker = await createSetupStatusTracker( + outputChannel, + timeTracker, ); context.subscriptions.push(setupStatusTracker); + const endStatusTracker = Date.now(); + outputChannel.trace( + `[setup-status]: Completed in ${ms( + endStatusTracker - startStatusTracker, + { long: true }, + )}`, + ); - const telemetry = await timeTracker.run("telemetry", async () => { - const sessionId = await getOrCreateExtensionSessionId(context); - return createTelemetry(outputChannel, sessionId); - }); + 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, + })}`, + ); return { statusBarItem, @@ -99,7 +100,6 @@ export async function activate(context: ExtensionContext) { context, outputChannel, statusBarItem, - cliStatusTracker, containerStatusTracker, localStackStatusTracker, setupStatusTracker, diff --git a/src/plugins.ts b/src/plugins.ts index e136cc3..b35d3f9 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -1,10 +1,9 @@ import ms from "ms"; import type { ExtensionContext, LogOutputChannel, StatusBarItem } from "vscode"; -import type { CliStatusTracker } from "./utils/cli.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 { ContainerStatusTracker } from "./utils/container-status.ts"; +import type { LocalStackStatusTracker } from "./utils/localstack-status.ts"; +import type { SetupStatusTracker } from "./utils/setup-status.ts"; import type { Telemetry } from "./utils/telemetry.ts"; import type { TimeTracker } from "./utils/time-tracker.ts"; @@ -14,9 +13,8 @@ export interface PluginOptions { context: ExtensionContext; outputChannel: LogOutputChannel; statusBarItem: StatusBarItem; - cliStatusTracker: CliStatusTracker; - containerStatusTracker: LocalStackContainerStatusTracker; - localStackStatusTracker: LocalStackInstanceStatusTracker; + containerStatusTracker: ContainerStatusTracker; + localStackStatusTracker: LocalStackStatusTracker; setupStatusTracker: SetupStatusTracker; telemetry: Telemetry; timeTracker: TimeTracker; diff --git a/src/plugins/manage.ts b/src/plugins/manage.ts index 2fb63c5..126cd74 100644 --- a/src/plugins/manage.ts +++ b/src/plugins/manage.ts @@ -9,30 +9,16 @@ import { export default createPlugin( "manage", - ({ - context, - outputChannel, - telemetry, - localStackStatusTracker, - cliStatusTracker, - }) => { + ({ context, outputChannel, telemetry, localStackStatusTracker }) => { context.subscriptions.push( commands.registerCommand("localstack.start", async () => { - const cliPath = cliStatusTracker.cliPath(); - if (!cliPath) { - void 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(cliPath, outputChannel, telemetry); + await startLocalStack(outputChannel, telemetry); } catch { localStackStatusTracker.forceContainerStatus("stopped"); } @@ -41,20 +27,12 @@ export default createPlugin( context.subscriptions.push( commands.registerCommand("localstack.stop", () => { - const cliPath = cliStatusTracker.cliPath(); - if (!cliPath) { - void 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(cliPath, outputChannel, telemetry); + void stopLocalStack(outputChannel, telemetry); }), ); diff --git a/src/plugins/setup.ts b/src/plugins/setup.ts index 4c9e9fa..6f92d12 100644 --- a/src/plugins/setup.ts +++ b/src/plugins/setup.ts @@ -7,7 +7,6 @@ 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 { @@ -15,18 +14,10 @@ import { checkIsLicenseValid, activateLicenseUntilValid, } from "../utils/license.ts"; -import { minDelay } from "../utils/min-delay.ts"; +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", ({ @@ -34,7 +25,6 @@ export default createPlugin( outputChannel, setupStatusTracker, localStackStatusTracker, - cliStatusTracker, telemetry, }) => { context.subscriptions.push( @@ -53,7 +43,7 @@ export default createPlugin( }, }); - void window.withProgress( + window.withProgress( { location: ProgressLocation.Notification, title: "Setup LocalStack", @@ -65,14 +55,13 @@ export default createPlugin( let authenticationStatus: "COMPLETED" | "SKIPPED" = "COMPLETED"; { const installationStartedAt = new Date().toISOString(); - const { cancelled, skipped } = await runInstallProcess({ - cliPath: cliStatusTracker.cliPath(), + const { cancelled, skipped } = await runInstallProcess( progress, cancellationToken, outputChannel, telemetry, - origin: origin_trigger, - }); + origin_trigger, + ); cliStatus = skipped === true ? "SKIPPED" : "COMPLETED"; if (cancelled || cancellationToken.isCancellationRequested) { telemetry.track({ @@ -232,52 +221,14 @@ export default createPlugin( ///////////////////////////////////////////////////////////////////// progress.report({ message: "Checking LocalStack license..." }); - // 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) { - 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; - } - // 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. // Activating the license pre-emptively to know its state during the setup process. const licenseCheckStartedAt = new Date().toISOString(); const licenseIsValid = await minDelay( - activateLicense(cliPath, outputChannel).then(() => - checkIsLicenseValid(cliPath, outputChannel), + activateLicense(outputChannel).then(() => + checkIsLicenseValid(outputChannel), ), ); if (!licenseIsValid) { @@ -289,7 +240,6 @@ 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 293f9a8..e7f10ab 100644 --- a/src/plugins/status-bar.ts +++ b/src/plugins/status-bar.ts @@ -2,32 +2,12 @@ import { commands, QuickPickItemKind, ThemeColor, window } from "vscode"; import type { QuickPickItem } from "vscode"; import { createPlugin } from "../plugins.ts"; -import type { LocalStackInstanceStatus } from "../utils/localstack-instance.ts"; -import { createOnceImmediate } from "../utils/once-immediate.ts"; -import type { SetupStatus } from "../utils/setup.ts"; - -function getOverallStatusText(options: { - cliStatus: SetupStatus; - localStackStatus: LocalStackInstanceStatus; - 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", ({ context, statusBarItem, - cliStatusTracker, localStackStatusTracker, setupStatusTracker, outputChannel, @@ -35,10 +15,10 @@ export default createPlugin( context.subscriptions.push( commands.registerCommand("localstack.showCommands", async () => { const shouldShowLocalStackStart = () => - cliStatusTracker.status() === "ok" && + setupStatusTracker.statuses().isInstalled && localStackStatusTracker.status() === "stopped"; const shouldShowLocalStackStop = () => - cliStatusTracker.status() === "ok" && + setupStatusTracker.statuses().isInstalled && localStackStatusTracker.status() === "running"; const shouldShowRunSetupWizard = () => setupStatusTracker.status() === "setup_required"; @@ -97,57 +77,63 @@ export default createPlugin( }), ); - const renderStatusBar = createOnceImmediate(() => { - 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}`, - ); - - // Skip rendering the status bar if any of the status checks is not ready. - if ( - setupStatus === undefined || - localStackStatus === undefined || - cliStatus === undefined - ) { - return; + 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; + }); } + }; - 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 = getOverallStatusText({ - cliOutdated, - cliStatus, - localStackStatus, - }); - statusBarItem.text = `${icon} LocalStack: ${statusText}`; - - statusBarItem.tooltip = "Show LocalStack commands"; + context.subscriptions.push({ + dispose() { + clearImmediate(refreshStatusBarImmediateId); + }, }); + refreshStatusBarImmediate(); + localStackStatusTracker.onChange(() => { outputChannel.trace("[status-bar]: localStackStatusTracker changed"); - renderStatusBar(); + refreshStatusBarImmediate(); }); setupStatusTracker.onChange(() => { outputChannel.trace("[status-bar]: setupStatusTracker changed"); - renderStatusBar(); + refreshStatusBarImmediate(); }); }, ); diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts new file mode 100644 index 0000000..3ecf628 --- /dev/null +++ b/src/test/extension.test.ts @@ -0,0 +1,17 @@ +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)); + }); +}); diff --git a/src/test/localstack-instance.test.ts b/src/test/localstack-instance.test.ts deleted file mode 100644 index 065d45c..0000000 --- a/src/test/localstack-instance.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import * as assert from "node:assert"; -import { setImmediate } from "node:timers/promises"; - -import { window } 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 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"); - }); - - 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"); - }); -}); diff --git a/src/utils/authenticate.ts b/src/utils/authenticate.ts index e9201ca..ca2cba4 100644 --- a/src/utils/authenticate.ts +++ b/src/utils/authenticate.ts @@ -10,8 +10,6 @@ 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, @@ -162,23 +160,3 @@ 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 f6621f1..3db15a1 100644 --- a/src/utils/cli.ts +++ b/src/utils/cli.ts @@ -1,133 +1,60 @@ import { constants } from "node:fs"; import { access } from "node:fs/promises"; -import { isAbsolute } from "node:path"; -import { watch } from "chokidar"; +import type { CancellationToken, LogOutputChannel } from "vscode"; import { workspace } from "vscode"; -import type { CancellationToken, LogOutputChannel, Disposable } from "vscode"; import { CLI_PATHS, LOCALSTACK_DOCKER_IMAGE_NAME } from "../constants.ts"; -import { createValueEmitter } from "./emitter.ts"; import { exec } from "./exec.ts"; -import { createOnceImmediate } from "./once-immediate.ts"; -import type { SetupStatus } from "./setup.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"; -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; -} - -export async function findLocalStack(): Promise { +const findLocalStack = async (): Promise => { // Check if a custom path is configured const config = workspace.getConfiguration("localstack"); const customLocation = config.get("cli.location"); + if (customLocation) { - const { found, executable, upToDate } = - await verifyLocalStackCli(customLocation); - return { - cliPath: customLocation, - found, - executable, - upToDate, - }; + 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 }, + ); + } } // Fall back to default search paths for (const CLI_PATH of CLI_PATHS) { - const { found, executable, upToDate } = await verifyLocalStackCli(CLI_PATH); - if (found) { - return { - cliPath: CLI_PATH, - found, - executable, - upToDate, - }; + try { + await access(CLI_PATH, constants.X_OK); + return CLI_PATH; + } catch { + // Continue to next path } } - return { - cliPath: undefined, - found: false, - executable: undefined, - upToDate: undefined, - }; -} + throw new Error( + "LocalStack CLI could not be found in any of the default locations", + ); +}; export const execLocalStack = async ( - cliPath: string, args: string[], - options?: { + options: { outputChannel: LogOutputChannel; + // cancellationToken?: CancellationToken; }, ) => { - const response = await exec([cliPath, ...args].join(" "), { + const cli = await findLocalStack(); + + const response = await exec([`"${cli}"`, ...args].join(" "), { env: { ...process.env, IMAGE_NAME, @@ -138,7 +65,6 @@ export const execLocalStack = async ( }; export const spawnLocalStack = async ( - cliPath: string, args: string[], options: { outputChannel: LogOutputChannel; @@ -146,7 +72,9 @@ export const spawnLocalStack = async ( onStderr?: SpawnOptions["onStderr"]; }, ) => { - return spawn(cliPath, args, { + const cli = await findLocalStack(); + + return spawn(`"${cli}"`, args, { outputChannel: options.outputChannel, outputLabel: `localstack.${args[0]}`, cancellationToken: options.cancellationToken, @@ -158,86 +86,3 @@ export const spawnLocalStack = async ( onStderr: options.onStderr, }); }; - -export type LocalStackCliStatus = "not_found" | "outdated" | "ok"; - -export interface CliStatusTracker extends Disposable { - status(): SetupStatus | undefined; - 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( - outputChannel: LogOutputChannel, -): CliStatusTracker { - const status = createValueEmitter(); - const cliPath = createValueEmitter(); - const outdated = createValueEmitter(); - - const track = createOnceImmediate(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(status.value() === "ok" ? newCli?.cliPath : undefined); - outdated.setValue( - newCli?.upToDate !== undefined ? !newCli.upToDate : undefined, - ); - }); - - 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 { - cliPath() { - return cliPath.value(); - }, - onCliPathChange(callback) { - cliPath.onChange(callback); - }, - status() { - return status.value(); - }, - onStatusChange(callback) { - status.onChange(callback); - }, - outdated() { - return outdated.value(); - }, - onOutdatedChange(callback) { - outdated.onChange(callback); - }, - async dispose() { - await watcher.close(); - }, - }; -} diff --git a/src/utils/configure-aws.ts b/src/utils/configure-aws.ts index 51cc80f..f61e07f 100644 --- a/src/utils/configure-aws.ts +++ b/src/utils/configure-aws.ts @@ -7,8 +7,6 @@ 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"; @@ -503,22 +501,3 @@ 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/localstack-container.ts b/src/utils/container-status.ts similarity index 79% rename from src/utils/localstack-container.ts rename to src/utils/container-status.ts index c09d82a..d09fff3 100644 --- a/src/utils/localstack-container.ts +++ b/src/utils/container-status.ts @@ -3,58 +3,56 @@ import { exec, spawn } from "node:child_process"; import type { Disposable, LogOutputChannel } from "vscode"; import * as z from "zod/v4-mini"; -import { createValueEmitter } from "./emitter.ts"; +import { createEmitter } from "./emitter.ts"; import { JsonLinesStream } from "./json-lines-stream.ts"; import type { TimeTracker } from "./time-tracker.ts"; -export type LocalStackContainerStatus = "running" | "stopping" | "stopped"; +export type ContainerStatus = "running" | "stopping" | "stopped"; -export interface LocalStackContainerStatusTracker extends Disposable { - status(): LocalStackContainerStatus | undefined; - onChange( - callback: (status: LocalStackContainerStatus | undefined) => void, - ): void; +export interface ContainerStatusTracker extends Disposable { + status(): ContainerStatus; + onChange(callback: (status: ContainerStatus) => void): void; } /** * Checks the status of a docker container in realtime. */ -export function createLocalStackContainerStatusTracker( +export async function createContainerStatusTracker( containerName: string, outputChannel: LogOutputChannel, timeTracker: TimeTracker, -): LocalStackContainerStatusTracker { - const status = createValueEmitter(); +): Promise { + let status: ContainerStatus | undefined; + const emitter = createEmitter(outputChannel); const disposable = listenToContainerStatus( containerName, outputChannel, (newStatus) => { - status.setValue(newStatus); + if (status !== newStatus) { + status = newStatus; + void emitter.emit(status); + } }, ); - void timeTracker.run("container-status.getContainerStatus", async () => { + await timeTracker.run("container-status.getContainerStatus", async () => { await getContainerStatus(containerName).then((newStatus) => { - outputChannel.trace( - `[localstack-container-status] getContainerStatus=${newStatus} previousStatus=${status.value()}`, - ); - if (status.value() === undefined) { - status.setValue(newStatus); - } + status ??= newStatus; + void emitter.emit(status); }); }); - status.onChange((status) => { - outputChannel.trace(`[localstack-container-status] container=${status}`); - }); - return { status() { - return status.value(); + // biome-ignore lint/style/noNonNullAssertion: false positive + return status!; }, onChange(callback) { - status.onChange(callback); + emitter.on(callback); + if (status) { + callback(status); + } }, dispose() { disposable.dispose(); @@ -74,7 +72,7 @@ const DockerEventsSchema = z.object({ function listenToContainerStatus( containerName: string, outputChannel: LogOutputChannel, - onStatusChange: (status: LocalStackContainerStatus) => void, + onStatusChange: (status: ContainerStatus) => void, ): Disposable { let dockerEvents: ReturnType | undefined; let isDisposed = false; @@ -197,7 +195,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/emitter.ts b/src/utils/emitter.ts index d28f3ac..648b300 100644 --- a/src/utils/emitter.ts +++ b/src/utils/emitter.ts @@ -1,38 +1,27 @@ -import { createOnceImmediate } from "./once-immediate.ts"; +import type { LogOutputChannel } from "vscode"; -export type Callback = (value: T | undefined) => Promise | void; +export type Callback = (value: T) => Promise | void; -export interface ValueEmitter { - value(): T | undefined; - setValue(value: T | undefined): void; - onChange(callback: Callback): void; +export interface Emitter { + on(callback: Callback): void; + emit(value: T): Promise; } -export function createValueEmitter(): ValueEmitter { - let currentValue: T | undefined; +export function createEmitter(outputChannel: LogOutputChannel): Emitter { const callbacks: Callback[] = []; - const emit = createOnceImmediate(async () => { - for (const callback of callbacks) { - try { - await callback(currentValue); - } catch {} - } - }); - return { - value() { - return currentValue; + on(callback) { + callbacks.push(callback); }, - setValue(value) { - if (currentValue !== value) { - currentValue = value; - emit(); + async emit(value) { + for (const callback of callbacks) { + try { + await callback(value); + } catch (error) { + outputChannel.error(error instanceof Error ? error : String(error)); + } } }, - onChange(callback) { - callbacks.push(callback); - void Promise.resolve(callback(currentValue)).catch(() => {}); - }, }; } diff --git a/src/utils/file-status-tracker.ts b/src/utils/file-status-tracker.ts deleted file mode 100644 index 71cf017..0000000 --- a/src/utils/file-status-tracker.ts +++ /dev/null @@ -1,73 +0,0 @@ -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 { - const status = createValueEmitter(); - - const updateStatus = createOnceImmediate(async () => { - const newStatus = await Promise.resolve(check()); - status.setValue(newStatus); - }); - - 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); - }, - async dispose() { - await watcher.close(); - }, - check() { - return updateStatus(); - }, - }; -} diff --git a/src/utils/once-immediate.ts b/src/utils/immediate-once.ts similarity index 58% rename from src/utils/once-immediate.ts rename to src/utils/immediate-once.ts index 4332663..8dac27f 100644 --- a/src/utils/once-immediate.ts +++ b/src/utils/immediate-once.ts @@ -1,12 +1,12 @@ /** - * Creates a function that calls the given callback on the next tick, once per tick. + * Creates a function that calls the given callback immediately once. * * Multiple calls during the same tick are ignored. * * @param callback - The callback to call. - * @returns A function that calls the callback on the next tick, once per tick. + * @returns A function that calls the callback immediately once. */ -export function createOnceImmediate(callback: () => T): () => void { +export function immediateOnce(callback: () => T): () => void { let timeout: NodeJS.Immediate | undefined; return () => { diff --git a/src/utils/install.ts b/src/utils/install.ts index 42b61fe..7acfaaf 100644 --- a/src/utils/install.ts +++ b/src/utils/install.ts @@ -12,8 +12,9 @@ import { LOCAL_CLI_INSTALLATION_DIRNAME, } from "../constants.ts"; +import { execLocalStack } from "./cli.ts"; import { exec } from "./exec.ts"; -import { minDelay } from "./min-delay.ts"; +import { minDelay } from "./promises.ts"; import { spawnElevatedDarwin, spawnElevatedLinux, @@ -22,33 +23,33 @@ import { import { spawn } from "./spawn.ts"; import type { Telemetry } from "./telemetry.ts"; -export interface RunInstallProcessOptions { - progress: Progress<{ message: string }>; - cancellationToken: CancellationToken; - outputChannel: LogOutputChannel; - telemetry: Telemetry; - origin?: "extension_startup" | "manual_trigger"; - cliPath: string | undefined; +export async function checkLocalstackInstalled( + outputChannel: LogOutputChannel, +): Promise { + try { + await execLocalStack(["--version"], { outputChannel }); + return true; + } catch (error) { + return false; + } } export async function runInstallProcess( - options: RunInstallProcessOptions, + progress: Progress<{ message: string }>, + cancellationToken: CancellationToken, + outputChannel: LogOutputChannel, + telemetry: Telemetry, + origin?: "extension_startup" | "manual_trigger", ): 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 = cliPath !== undefined; + const isLocalStackInstalled = await minDelay( + checkLocalstackInstalled(outputChannel), + ); if (cancellationToken.isCancellationRequested) { return { cancelled: true }; } diff --git a/src/utils/license.ts b/src/utils/license.ts index 4b61abc..631d01e 100644 --- a/src/utils/license.ts +++ b/src/utils/license.ts @@ -4,9 +4,6 @@ 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. @@ -36,18 +33,11 @@ export const LICENSE_FILENAME = join( const LICENSE_VALIDITY_MARKER = "license validity: valid"; -export async function checkIsLicenseValid( - cliPath: string, - outputChannel: LogOutputChannel, -) { +export async function checkIsLicenseValid(outputChannel: LogOutputChannel) { try { - const licenseInfoResponse = await execLocalStack( - cliPath, - ["license", "info"], - { - outputChannel, - }, - ); + const licenseInfoResponse = await execLocalStack(["license", "info"], { + outputChannel, + }); return licenseInfoResponse.stdout.includes(LICENSE_VALIDITY_MARKER); } catch (error) { outputChannel.error(error instanceof Error ? error : String(error)); @@ -56,12 +46,9 @@ export async function checkIsLicenseValid( } } -export async function activateLicense( - cliPath: string, - outputChannel: LogOutputChannel, -) { +export async function activateLicense(outputChannel: LogOutputChannel) { try { - await execLocalStack(cliPath, ["license", "activate"], { + await execLocalStack(["license", "activate"], { outputChannel, }); } catch (error) { @@ -70,7 +57,6 @@ export async function activateLicense( } export async function activateLicenseUntilValid( - cliPath: string, outputChannel: LogOutputChannel, cancellationToken: CancellationToken, ): Promise { @@ -78,60 +64,12 @@ export async function activateLicenseUntilValid( if (cancellationToken.isCancellationRequested) { break; } - const licenseIsValid = await checkIsLicenseValid(cliPath, outputChannel); + const licenseIsValid = await checkIsLicenseValid(outputChannel); if (licenseIsValid) { break; } - await activateLicense(cliPath, outputChannel); + await activateLicense(outputChannel); // Wait before trying again 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 "waiting_for_dependencies"; - } - - 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/localstack-instance.ts b/src/utils/localstack-instance.ts deleted file mode 100644 index fc083cf..0000000 --- a/src/utils/localstack-instance.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { Disposable, LogOutputChannel } from "vscode"; - -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 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 createLocalStackInstanceStatusTracker( - containerStatusTracker: LocalStackContainerStatusTracker, - healthCheckStatusTracker: HealthStatusTracker, - outputChannel: LogOutputChannel, -): LocalStackInstanceStatusTracker { - let containerStatus: LocalStackContainerStatus | undefined; - const status = createValueEmitter(); - - const setStatus = (newStatus: LocalStackInstanceStatus) => { - status.setValue(newStatus); - }; - - const deriveStatus = () => { - outputChannel.trace( - `[localstack-instance-status] containerStatus=${containerStatus} healthCheckStatusTracker=${healthCheckStatusTracker.status()} previousStatus=${status.value()}`, - ); - const newStatus = getLocalStackStatus( - containerStatus, - healthCheckStatusTracker.status(), - status.value(), - ); - if (newStatus) { - setStatus(newStatus); - } - }; - - containerStatusTracker.onChange((newContainerStatus) => { - if (containerStatus !== newContainerStatus) { - containerStatus = newContainerStatus; - deriveStatus(); - } - }); - - status.onChange((newStatus) => { - outputChannel.trace(`[localstack-instances-status] status=${newStatus}`); - - if (newStatus === "running") { - healthCheckStatusTracker.stop(); - } - }); - - containerStatusTracker.onChange((newContainerStatus) => { - outputChannel.trace( - `[localstack-instance-status] container=${newContainerStatus} (localstack=${status.value()})`, - ); - - if (newContainerStatus === "running" && status.value() !== "running") { - healthCheckStatusTracker.start(); - } - }); - - healthCheckStatusTracker.onChange(() => { - deriveStatus(); - }); - - deriveStatus(); - - return { - status() { - return status.value(); - }, - forceContainerStatus(newContainerStatus) { - containerStatus = newContainerStatus; - if (newContainerStatus === "running") { - status.setValue("starting"); - } else if (newContainerStatus === "stopping") { - status.setValue("stopping"); - } - }, - onChange(callback) { - status.onChange(callback); - }, - dispose() { - healthCheckStatusTracker.dispose(); - }, - }; -} - -function getLocalStackStatus( - containerStatus: LocalStackContainerStatus | undefined, - healthStatus: HealthStatus | undefined, - previousStatus?: LocalStackInstanceStatus, -): 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"; - } - - return "starting"; - } - - if (containerStatus === "running" && healthStatus === undefined) { - return undefined; - } - - if (containerStatus === "stopping") { - return "stopping"; - } - - return "stopped"; -} - -export type HealthStatus = "healthy" | "unhealthy"; - -export interface HealthStatusTracker extends Disposable { - status(): HealthStatus | undefined; - start(): void; - stop(): void; - onChange(callback: (status: HealthStatus | undefined) => void): void; -} - -export function createHealthStatusTracker( - timeTracker: TimeTracker, -): HealthStatusTracker { - const status = createValueEmitter(); - - let healthCheckTimeout: NodeJS.Timeout | undefined; - - const updateStatus = (newStatus: HealthStatus | undefined) => { - status.setValue(newStatus); - }; - - const fetchAndUpdateStatus = async () => { - await timeTracker.run("localstack-status.health", async () => { - const newStatus = (await fetchHealth()) ? "healthy" : "unhealthy"; - updateStatus(newStatus); - }); - }; - - let enqueueAgain = false; - - const enqueueUpdateStatus = () => { - if (healthCheckTimeout) { - return; - } - - healthCheckTimeout = setTimeout(() => { - void fetchAndUpdateStatus().then(() => { - if (!enqueueAgain) { - return; - } - - healthCheckTimeout = undefined; - enqueueUpdateStatus(); - }); - }, 1_000); - }; - - return { - status() { - return status.value(); - }, - start() { - enqueueAgain = true; - enqueueUpdateStatus(); - }, - stop() { - status.setValue(undefined); - enqueueAgain = false; - clearTimeout(healthCheckTimeout); - healthCheckTimeout = undefined; - }, - onChange(callback) { - status.onChange(callback); - }, - dispose() { - clearTimeout(healthCheckTimeout); - }, - }; -} diff --git a/src/utils/localstack-status.ts b/src/utils/localstack-status.ts new file mode 100644 index 0000000..8084fa6 --- /dev/null +++ b/src/utils/localstack-status.ts @@ -0,0 +1,203 @@ +import type { Disposable, LogOutputChannel } from "vscode"; + +import type { + ContainerStatus, + ContainerStatusTracker, +} from "./container-status.ts"; +import { createEmitter } 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; + forceContainerStatus(status: ContainerStatus): void; + onChange(callback: (status: LocalStackStatus) => void): void; +} + +/** + * Checks the status of the LocalStack instance in realtime. + */ +export function createLocalStackStatusTracker( + containerStatusTracker: ContainerStatusTracker, + outputChannel: LogOutputChannel, + timeTracker: TimeTracker, +): LocalStackStatusTracker { + let containerStatus: ContainerStatus | undefined; + let status: LocalStackStatus | undefined; + const emitter = createEmitter(outputChannel); + + const healthCheckStatusTracker = createHealthStatusTracker( + outputChannel, + timeTracker, + ); + + const setStatus = (newStatus: LocalStackStatus) => { + if (status !== newStatus) { + status = newStatus; + void emitter.emit(status); + } + }; + + const deriveStatus = () => { + const newStatus = getLocalStackStatus( + containerStatus, + healthCheckStatusTracker.status(), + status, + ); + setStatus(newStatus); + }; + + containerStatusTracker.onChange((newContainerStatus) => { + if (containerStatus !== newContainerStatus) { + containerStatus = newContainerStatus; + deriveStatus(); + } + }); + + emitter.on((newStatus) => { + outputChannel.trace(`[localstack-status] localstack=${newStatus}`); + + if (newStatus === "running") { + healthCheckStatusTracker.stop(); + } + }); + + containerStatusTracker.onChange((newContainerStatus) => { + outputChannel.trace( + `[localstack-status] container=${newContainerStatus} (localstack=${status})`, + ); + + if (newContainerStatus === "running" && status !== "running") { + healthCheckStatusTracker.start(); + } + }); + + healthCheckStatusTracker.onChange(() => { + deriveStatus(); + }); + + return { + status() { + // biome-ignore lint/style/noNonNullAssertion: false positive + return status!; + }, + forceContainerStatus(newContainerStatus) { + if (containerStatus !== newContainerStatus) { + containerStatus = newContainerStatus; + deriveStatus(); + } + }, + onChange(callback) { + emitter.on(callback); + if (status) { + callback(status); + } + }, + dispose() { + healthCheckStatusTracker.dispose(); + }, + }; +} + +function getLocalStackStatus( + containerStatus: ContainerStatus | undefined, + healthStatus: HealthStatus | undefined, + previousStatus?: LocalStackStatus, +): LocalStackStatus { + 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"; + } + } else if (containerStatus === "stopping") { + return "stopping"; + } else { + return "stopped"; + } +} + +type HealthStatus = "healthy" | "unhealthy"; + +interface HealthStatusTracker extends Disposable { + status(): HealthStatus | undefined; + start(): void; + stop(): void; + onChange(callback: (status: HealthStatus | undefined) => void): void; +} + +function createHealthStatusTracker( + outputChannel: LogOutputChannel, + timeTracker: TimeTracker, +): HealthStatusTracker { + let status: HealthStatus | undefined; + const emitter = createEmitter(outputChannel); + + let healthCheckTimeout: NodeJS.Timeout | undefined; + + const updateStatus = (newStatus: HealthStatus | undefined) => { + if (status !== newStatus) { + status = newStatus; + void emitter.emit(status); + } + }; + + const fetchAndUpdateStatus = async () => { + await timeTracker.run("localstack-status.health", async () => { + const newStatus = (await fetchHealth()) ? "healthy" : "unhealthy"; + updateStatus(newStatus); + }); + }; + + let enqueueAgain = false; + + const enqueueUpdateStatus = () => { + if (healthCheckTimeout) { + return; + } + + healthCheckTimeout = setTimeout(() => { + void fetchAndUpdateStatus().then(() => { + if (!enqueueAgain) { + return; + } + + healthCheckTimeout = undefined; + enqueueUpdateStatus(); + }); + }, 1_000); + }; + + return { + status() { + return status; + }, + start() { + enqueueAgain = true; + enqueueUpdateStatus(); + }, + stop() { + status = undefined; + enqueueAgain = false; + clearTimeout(healthCheckTimeout); + healthCheckTimeout = undefined; + }, + onChange(callback) { + emitter.on(callback); + if (status) { + callback(status); + } + }, + dispose() { + clearTimeout(healthCheckTimeout); + }, + }; +} diff --git a/src/utils/manage.ts b/src/utils/manage.ts index 3b55644..837f739 100644 --- a/src/utils/manage.ts +++ b/src/utils/manage.ts @@ -38,7 +38,6 @@ async function fetchLocalStackSessionId(): Promise { } export async function startLocalStack( - cliPath: string, outputChannel: LogOutputChannel, telemetry: Telemetry, ): Promise { @@ -50,7 +49,6 @@ export async function startLocalStack( const authToken = await readAuthToken(); try { await spawnLocalStack( - cliPath, [ "start", // DO NOT REMOVE! @@ -91,7 +89,7 @@ export async function startLocalStack( }, }); } catch (error) { - const isLicenseValid = await checkIsLicenseValid(cliPath, outputChannel); + const isLicenseValid = await checkIsLicenseValid(outputChannel); if (isLicenseValid === false) { void showErrorMessage("No valid LocalStack license found.", { title: "Go to License settings", @@ -118,7 +116,6 @@ export async function startLocalStack( } export async function stopLocalStack( - cliPath: string, outputChannel: LogOutputChannel, telemetry: Telemetry, ) { @@ -129,7 +126,7 @@ export async function stopLocalStack( // get session id before killing container const emulatorSessionId = await fetchLocalStackSessionId(); - await spawnLocalStack(cliPath, ["stop"], { + await spawnLocalStack(["stop"], { outputChannel, }); diff --git a/src/utils/min-delay.ts b/src/utils/promises.ts similarity index 89% rename from src/utils/min-delay.ts rename to src/utils/promises.ts index 157133a..725e59e 100644 --- a/src/utils/min-delay.ts +++ b/src/utils/promises.ts @@ -32,3 +32,8 @@ export function minDelay( MIN_TIME_BETWEEN_STEPS_MS, ); } + +/** + * Extracts the resolved type from a Promise. + */ +export type UnwrapPromise = T extends Promise ? U : T; diff --git a/src/utils/setup-status.ts b/src/utils/setup-status.ts new file mode 100644 index 0000000..c3b70e0 --- /dev/null +++ b/src/utils/setup-status.ts @@ -0,0 +1,282 @@ +import { watch } from "chokidar"; +import ms from "ms"; +import type { Disposable, LogOutputChannel } from "vscode"; + +import { + checkIsAuthenticated, + LOCALSTACK_AUTH_FILENAME, +} from "./authenticate.ts"; +import { + AWS_CONFIG_FILENAME, + AWS_CREDENTIALS_FILENAME, + checkIsProfileConfigured, +} from "./configure-aws.ts"; +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>; + onChange(callback: (status: SetupStatus) => void): void; +} + +/** + * Checks the status of the LocalStack installation. + */ +export async function createSetupStatusTracker( + outputChannel: LogOutputChannel, + timeTracker: TimeTracker, +): 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 end = Date.now(); + outputChannel.trace( + `[setup-status]: Initialized dependencies in ${ms(end - start, { long: true })}`, + ); + + const checkStatusNow = async () => { + const allStatusesInitialized = Object.values({ + awsProfileTracker: awsProfileTracker.status(), + authTracker: localStackAuthenticationTracker.status(), + licenseTracker: licenseTracker.status(), + }).every((check) => check !== undefined); + + if (!allStatusesInitialized) { + outputChannel.trace( + `[setup-status] File watchers not initialized yet, skipping status check : ${JSON.stringify( + { + awsProfileTracker: awsProfileTracker.status() ?? "undefined", + authTracker: + localStackAuthenticationTracker.status() ?? "undefined", + licenseTracker: licenseTracker.status() ?? "undefined", + }, + )}`, + ); + return; + } + + statuses = await checkSetupStatus(outputChannel); + + const setupRequired = [ + ...Object.values(statuses), + awsProfileTracker.status() === "ok", + localStackAuthenticationTracker.status() === "ok", + licenseTracker.status() === "ok", + ].some((check) => check === false); + + const newStatus = setupRequired ? "setup_required" : "ok"; + if (status !== newStatus) { + status = newStatus; + outputChannel.trace( + `[setup-status] Status changed to ${JSON.stringify({ + ...statuses, + awsProfileTracker: awsProfileTracker.status() ?? "undefined", + authTracker: localStackAuthenticationTracker.status() ?? "undefined", + licenseTracker: licenseTracker.status() ?? "undefined", + })}`, + ); + await emitter.emit(status); + } + }; + + 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() { + // 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) { + callback(status); + } + }, + async dispose() { + clearTimeout(timeout); + await Promise.all([ + awsProfileTracker.dispose(), + localStackAuthenticationTracker.dispose(), + ]); + }, + }; +} + +interface StatusTracker { + status(): SetupStatus | undefined; + onChange(callback: (status: SetupStatus) => void): void; + dispose(): Promise; +} + +/** + * 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, +): StatusTracker { + let status: SetupStatus | undefined; + + const emitter = createEmitter(outputChannel); + + 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); + } + }); + + 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; + }, + onChange(callback) { + emitter.on(callback); + if (status) { + callback(status); + } + }, + async dispose() { + await watcher.close(); + }, + }; +} + +/** + * 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( + outputChannel: LogOutputChannel, +): StatusTracker { + return createFileStatusTracker( + outputChannel, + "[setup-status.license]", + [LOCALSTACK_AUTH_FILENAME, LICENSE_FILENAME], //TODO rewrite to depend on change in localStackAuthenticationTracker + async () => + (await checkIsLicenseValid(outputChannel)) ? "ok" : "setup_required", + ); +} diff --git a/src/utils/setup.ts b/src/utils/setup.ts index 400c009..535acc2 100644 --- a/src/utils/setup.ts +++ b/src/utils/setup.ts @@ -1,18 +1,21 @@ -import ms from "ms"; -import type { CancellationToken, Disposable, LogOutputChannel } from "vscode"; +import type { CancellationToken, 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 { checkLocalstackInstalled } from "./install.ts"; import { spawn } from "./spawn.ts"; -import type { TimeTracker } from "./time-tracker.ts"; + +export async function checkSetupStatus(outputChannel: LogOutputChannel) { + const [isInstalled] = await Promise.all([ + checkLocalstackInstalled(outputChannel), + ]); + + return { + isInstalled, + }; +} export async function updateDockerImage( outputChannel: LogOutputChannel, @@ -76,124 +79,3 @@ async function pullDockerImage( outputChannel.error(error instanceof Error ? error : String(error)); } } - -export type SetupStatus = "ok" | "setup_required" | "waiting_for_dependencies"; - -export interface SetupStatusTracker extends Disposable { - status(): SetupStatus | undefined; - onChange(callback: (status: SetupStatus | undefined) => 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(), - ]); - }, - }; -} diff --git a/src/utils/telemetry.ts b/src/utils/telemetry.ts index d1e72c3..824a97a 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" | "FAILED", + overall_status: "CANCELLED" | "COMPLETED", origin: "manual_trigger" | "extension_startup", auth_token: string = "", ): Events {