diff --git a/package-lock.json b/package-lock.json index e1e9535..f40e59e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@vscode/test-cli": "^0.0.11", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "^3.6.0", + "chokidar": "^4.0.3", "esbuild": "^0.25.9", "eslint": "^9.35.0", "eslint-plugin-import": "^2.32.0", @@ -1984,6 +1985,44 @@ "node": ">=18" } }, + "node_modules/@vscode/test-cli/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/@vscode/test-cli/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/@vscode/test-electron": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz", @@ -3006,28 +3045,19 @@ } }, "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 14.16.0" }, "funding": { "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" } }, "node_modules/chownr": { @@ -6243,36 +6273,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/mocha/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/mocha/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/mocha/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -7413,16 +7413,17 @@ } }, "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, "engines": { - "node": ">=8.10.0" + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/reflect.getprototypeof": { diff --git a/package.json b/package.json index 1328177..224982f 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,8 @@ "package": "node build/extension.mjs --production", "pretest": "npm run compile", "check-types": "tsc", - "lint": "eslint .", + "lint": "eslint", + "format": "biome check", "test": "vscode-test", "compile:font": "node build/icon-font.mjs" }, @@ -127,6 +128,7 @@ "@vscode/test-cli": "^0.0.11", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "^3.6.0", + "chokidar": "^4.0.3", "esbuild": "^0.25.9", "eslint": "^9.35.0", "eslint-plugin-import": "^2.32.0", diff --git a/src/utils/authenticate.ts b/src/utils/authenticate.ts index 2838e92..ca2cba4 100644 --- a/src/utils/authenticate.ts +++ b/src/utils/authenticate.ts @@ -84,7 +84,7 @@ async function redirectToLocalStack(): Promise<{ cancelled: boolean }> { return { cancelled: !openSuccessful }; } -const LOCALSTACK_AUTH_FILENAME = `${os.homedir()}/.localstack/auth.json`; +export const LOCALSTACK_AUTH_FILENAME = `${os.homedir()}/.localstack/auth.json`; const LOCALSTACK_AUTH_FILENAME_READABLE = LOCALSTACK_AUTH_FILENAME.replace( `${os.homedir()}/`, "~/", diff --git a/src/utils/configure-aws.ts b/src/utils/configure-aws.ts index 5bf81d8..37ac234 100644 --- a/src/utils/configure-aws.ts +++ b/src/utils/configure-aws.ts @@ -35,7 +35,9 @@ const LOCALSTACK_CREDENTIALS_PROPERTIES = { aws_secret_access_key: "test", }; -const AWS_DIRECTORY = path.join(os.homedir(), ".aws"); +export const AWS_DIRECTORY = path.join(os.homedir(), ".aws"); +export const AWS_CONFIG_FILENAME = path.join(AWS_DIRECTORY, "config"); +export const AWS_CREDENTIALS_FILENAME = path.join(AWS_DIRECTORY, "credentials"); async function overrideSelection( filesToModify: string[], @@ -464,13 +466,13 @@ export async function configureAwsProfiles(options: { export async function checkIsProfileConfigured(): Promise { try { - const awsConfigFilename = path.join(AWS_DIRECTORY, "config"); - const awsCredentialsFilename = path.join(AWS_DIRECTORY, "credentials"); - const [{ section: configSection }, { section: credentialsSection }] = await Promise.all([ - getProfile(awsConfigFilename, LOCALSTACK_CONFIG_PROFILE_NAME), - getProfile(awsCredentialsFilename, LOCALSTACK_CREDENTIALS_PROFILE_NAME), + getProfile(AWS_CONFIG_FILENAME, LOCALSTACK_CONFIG_PROFILE_NAME), + getProfile( + AWS_CREDENTIALS_FILENAME, + LOCALSTACK_CREDENTIALS_PROFILE_NAME, + ), ]); const [configNeedsOverride, credentialsNeedsOverride] = await Promise.all([ diff --git a/src/utils/immediate-once.ts b/src/utils/immediate-once.ts new file mode 100644 index 0000000..8dac27f --- /dev/null +++ b/src/utils/immediate-once.ts @@ -0,0 +1,23 @@ +/** + * 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 immediately once. + */ +export function immediateOnce(callback: () => T): () => void { + let timeout: NodeJS.Immediate | undefined; + + return () => { + if (timeout) { + return; + } + + timeout = setImmediate(() => { + void Promise.resolve(callback()).finally(() => { + timeout = undefined; + }); + }); + }; +} diff --git a/src/utils/license.ts b/src/utils/license.ts index e41350f..631d01e 100644 --- a/src/utils/license.ts +++ b/src/utils/license.ts @@ -1,7 +1,36 @@ +import { homedir, platform } from "node:os"; +import { join } from "node:path"; + import type { CancellationToken, LogOutputChannel } from "vscode"; import { execLocalStack } from "./cli.ts"; +/** + * See https://github.com/localstack/localstack/blob/de861e1f656a52eaa090b061bd44fc1a7069715e/localstack-core/localstack/utils/files.py#L38-L55. + * @returns The cache directory for the current platform. + */ +const cacheDirectory = () => { + switch (platform()) { + case "win32": + return join(process.env.LOCALAPPDATA!, "cache"); + case "darwin": + return join(homedir(), "Library", "Caches"); + default: + return process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"); + } +}; + +/** + * The file that contains the license information of the LocalStack CLI. + * + * The license file is stored in the cache directory for the current platform. + */ +export const LICENSE_FILENAME = join( + cacheDirectory(), + "localstack-cli", + "license.json", +); + const LICENSE_VALIDITY_MARKER = "license validity: valid"; export async function checkIsLicenseValid(outputChannel: LogOutputChannel) { diff --git a/src/utils/setup-status.ts b/src/utils/setup-status.ts index 7470c57..08b31d6 100644 --- a/src/utils/setup-status.ts +++ b/src/utils/setup-status.ts @@ -1,7 +1,19 @@ +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"; @@ -25,32 +37,66 @@ export async function createSetupStatusTracker( 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 })}`, ); - let timeout: NodeJS.Timeout | undefined; - const startChecking = async () => { + const checkStatusNow = async () => { statuses = await checkSetupStatus(outputChannel); - const setupRequired = Object.values(statuses).some( - (check) => check === false, - ); + 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)}`, + ); 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", async () => { - await startChecking(); + await timeTracker.run("setup-status.checkIsSetupRequired", () => { + startChecking(); + return Promise.resolve(); }); + await checkStatusNow(); + return { status() { // biome-ignore lint/style/noNonNullAssertion: false positive @@ -66,8 +112,143 @@ export async function createSetupStatusTracker( callback(status); } }, - dispose() { + 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)); + }); + + 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]", + [LICENSE_FILENAME], + async () => + (await checkIsLicenseValid(outputChannel)) ? "ok" : "setup_required", + ); +} diff --git a/src/utils/setup.ts b/src/utils/setup.ts index 0c1c3dc..535acc2 100644 --- a/src/utils/setup.ts +++ b/src/utils/setup.ts @@ -3,27 +3,17 @@ import * as z from "zod/v4-mini"; import { LOCALSTACK_DOCKER_IMAGE_NAME } from "../constants.ts"; -import { checkIsAuthenticated } from "./authenticate.ts"; -import { checkIsProfileConfigured } from "./configure-aws.ts"; import { exec } from "./exec.ts"; import { checkLocalstackInstalled } from "./install.ts"; -import { checkIsLicenseValid } from "./license.ts"; import { spawn } from "./spawn.ts"; export async function checkSetupStatus(outputChannel: LogOutputChannel) { - const [isInstalled, isAuthenticated, isLicenseValid, isProfileConfigured] = - await Promise.all([ - checkLocalstackInstalled(outputChannel), - checkIsAuthenticated(), - checkIsLicenseValid(outputChannel), - checkIsProfileConfigured(), - ]); + const [isInstalled] = await Promise.all([ + checkLocalstackInstalled(outputChannel), + ]); return { isInstalled, - isAuthenticated, - isLicenseValid, - isProfileConfigured, }; }