diff --git a/package.json b/package.json index 07aeda6bb..af8e375dd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@devcontainers/cli", "description": "Dev Containers CLI", - "version": "0.80.2", + "version": "0.80.3", "bin": { "devcontainer": "devcontainer.js" }, diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 59136695d..2183318e6 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -103,7 +103,7 @@ function provisionOptions(y: Argv) { 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, 'container-data-folder': { type: 'string', description: 'Container data folder where user data inside the container will be stored.' }, 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, - 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path. If not provided, defaults to the current directory.' }, 'workspace-mount-consistency': { choices: ['consistent' as 'consistent', 'cached' as 'cached', 'delegated' as 'delegated'], default: 'cached' as 'cached', description: 'Workspace mount consistency.' }, 'gpu-availability': { choices: ['all' as 'all', 'detect' as 'detect', 'none' as 'none'], default: 'detect' as 'detect', description: 'Availability of GPUs in case the dev container requires any. `all` expects a GPU to be available.' }, 'mount-workspace-git-root': { type: 'boolean', default: true, description: 'Mount the workspace using its Git root.' }, @@ -148,11 +148,9 @@ function provisionOptions(y: Argv) { if (idLabels?.some(idLabel => !/.+=.+/.test(idLabel))) { throw new Error('Unmatched argument format: id-label must match ='); } - if (!(argv['workspace-folder'] || argv['id-label'])) { - throw new Error('Missing required argument: workspace-folder or id-label'); - } - if (!(argv['workspace-folder'] || argv['override-config'])) { - throw new Error('Missing required argument: workspace-folder or override-config'); + // Default workspace-folder to current directory if not provided and no id-label or override-config + if (!argv['workspace-folder'] && !argv['id-label'] && !argv['override-config']) { + argv['workspace-folder'] = process.cwd(); } const mounts = (argv.mount && (Array.isArray(argv.mount) ? argv.mount : [argv.mount])) as string[] | undefined; if (mounts?.some(mount => !mountRegex.test(mount))) { @@ -507,7 +505,7 @@ function buildOptions(y: Argv) { 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, 'docker-path': { type: 'string', description: 'Docker CLI path.' }, 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, - 'workspace-folder': { type: 'string', required: true, description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path. If not provided, defaults to the current directory.' }, 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level.' }, 'log-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text' as 'text', description: 'Log format.' }, @@ -574,7 +572,7 @@ async function doBuild({ await Promise.all(disposables.map(d => d())); }; try { - const workspaceFolder = path.resolve(process.cwd(), workspaceFolderArg); + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : process.cwd(); const configFile: URI | undefined = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; const overrideConfigFile: URI | undefined = /* overrideConfig ? URI.file(path.resolve(process.cwd(), overrideConfig)) : */ undefined; const addCacheFroms = addCacheFrom ? (Array.isArray(addCacheFrom) ? addCacheFrom as string[] : [addCacheFrom]) : []; @@ -752,7 +750,7 @@ function runUserCommandsOptions(y: Argv) { 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, 'container-data-folder': { type: 'string', description: 'Container data folder where user data inside the container will be stored.' }, 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, - 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'workspace-folder': { type: 'string', description: 'Workspace folder path.The devcontainer.json will be looked up relative to this path. If --container-id, --id-label, and --workspace-folder are not provided, this defaults to the current directory.' }, 'mount-workspace-git-root': { type: 'boolean', default: true, description: 'Mount the workspace using its Git root.' }, 'container-id': { type: 'string', description: 'Id of the container to run the user commands for.' }, 'id-label': { type: 'string', description: 'Id label(s) of the format name=value. If no --container-id is given the id labels will be used to look up the container. If no --id-label is given, one will be inferred from the --workspace-folder path.' }, @@ -785,7 +783,7 @@ function runUserCommandsOptions(y: Argv) { throw new Error('Unmatched argument format: remote-env must match ='); } if (!argv['container-id'] && !idLabels?.length && !argv['workspace-folder']) { - throw new Error('Missing required argument: One of --container-id, --id-label or --workspace-folder is required.'); + argv['workspace-folder'] = process.cwd(); } return true; }); @@ -954,7 +952,7 @@ function readConfigurationOptions(y: Argv) { 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, 'docker-path': { type: 'string', description: 'Docker CLI path.' }, 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, - 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path. If --container-id, --id-label, and --workspace-folder are not provided, this defaults to the current directory.' }, 'mount-workspace-git-root': { type: 'boolean', default: true, description: 'Mount the workspace using its Git root.' }, 'container-id': { type: 'string', description: 'Id of the container to run the user commands for.' }, 'id-label': { type: 'string', description: 'Id label(s) of the format name=value. If no --container-id is given the id labels will be used to look up the container. If no --id-label is given, one will be inferred from the --workspace-folder path.' }, @@ -975,7 +973,7 @@ function readConfigurationOptions(y: Argv) { throw new Error('Unmatched argument format: id-label must match ='); } if (!argv['container-id'] && !idLabels?.length && !argv['workspace-folder']) { - throw new Error('Missing required argument: One of --container-id, --id-label or --workspace-folder is required.'); + argv['workspace-folder'] = process.cwd(); } return true; }); @@ -1107,7 +1105,7 @@ async function readConfiguration({ function outdatedOptions(y: Argv) { return y.options({ 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, - 'workspace-folder': { type: 'string', required: true, description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path. If --workspace-folder is not provided, defaults to the current directory.' }, 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, 'output-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text', description: 'Output format.' }, 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level for the --terminal-log-file. When set to trace, the log level for --log-file will also be set to trace.' }, @@ -1139,7 +1137,7 @@ async function outdated({ }; let output: Log | undefined; try { - const workspaceFolder = path.resolve(process.cwd(), workspaceFolderArg); + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : process.cwd(); const configFile = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; const cliHost = await getCLIHost(workspaceFolder, loadNativeModule, logFormat === 'text'); const extensionPath = path.join(__dirname, '..', '..'); @@ -1209,7 +1207,7 @@ function execOptions(y: Argv) { 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, 'container-data-folder': { type: 'string', description: 'Container data folder where user data inside the container will be stored.' }, 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, - 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path. If --container-id, --id-label, and --workspace-folder are not provided, this defaults to the current directory.' }, 'mount-workspace-git-root': { type: 'boolean', default: true, description: 'Mount the workspace using its Git root.' }, 'container-id': { type: 'string', description: 'Id of the container to run the user commands for.' }, 'id-label': { type: 'string', description: 'Id label(s) of the format name=value. If no --container-id is given the id labels will be used to look up the container. If no --id-label is given, one will be inferred from the --workspace-folder path.' }, @@ -1243,7 +1241,7 @@ function execOptions(y: Argv) { throw new Error('Unmatched argument format: remote-env must match ='); } if (!argv['container-id'] && !idLabels?.length && !argv['workspace-folder']) { - throw new Error('Missing required argument: One of --container-id, --id-label or --workspace-folder is required.'); + argv['workspace-folder'] = process.cwd(); } return true; }); diff --git a/src/spec-node/featuresCLI/resolveDependencies.ts b/src/spec-node/featuresCLI/resolveDependencies.ts index 93e569ff6..7996a5281 100644 --- a/src/spec-node/featuresCLI/resolveDependencies.ts +++ b/src/spec-node/featuresCLI/resolveDependencies.ts @@ -30,7 +30,7 @@ export function featuresResolveDependenciesOptions(y: Argv) { return y .options({ 'log-level': { choices: ['error' as 'error', 'info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'error' as 'error', description: 'Log level.' }, - 'workspace-folder': { type: 'string', description: 'Workspace folder to use for the configuration.', demandOption: true }, + 'workspace-folder': { type: 'string', description: 'Workspace folder to use for the configuration.' }, }); } @@ -41,7 +41,7 @@ export function featuresResolveDependenciesHandler(args: featuresResolveDependen } async function featuresResolveDependencies({ - 'workspace-folder': workspaceFolder, + 'workspace-folder': workspaceFolderArg, 'log-level': inputLogLevel, }: featuresResolveDependenciesArgs) { const disposables: (() => Promise | undefined)[] = []; @@ -62,6 +62,8 @@ async function featuresResolveDependencies({ let jsonOutput: JsonOutput = {}; + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : process.cwd(); + // Detect path to dev container config let configPath = path.join(workspaceFolder, '.devcontainer.json'); if (!(await isLocalFile(configPath))) { diff --git a/src/spec-node/templatesCLI/apply.ts b/src/spec-node/templatesCLI/apply.ts index ed410a8c7..24fc33d88 100644 --- a/src/spec-node/templatesCLI/apply.ts +++ b/src/spec-node/templatesCLI/apply.ts @@ -6,11 +6,12 @@ import * as jsonc from 'jsonc-parser'; import { UnpackArgv } from '../devContainersSpecCLI'; import { fetchTemplate, SelectedTemplate, TemplateFeatureOption, TemplateOptions } from '../../spec-configuration/containerTemplatesOCI'; import { runAsyncHandler } from '../utils'; +import path from 'path'; export function templateApplyOptions(y: Argv) { return y .options({ - 'workspace-folder': { type: 'string', alias: 'w', demandOption: true, default: '.', description: 'Target workspace folder to apply Template' }, + 'workspace-folder': { type: 'string', alias: 'w', default: '.', description: 'Target workspace folder to apply Template' }, 'template-id': { type: 'string', alias: 't', demandOption: true, description: 'Reference to a Template in a supported OCI registry' }, 'template-args': { type: 'string', alias: 'a', default: '{}', description: 'Arguments to replace within the provided Template, provided as JSON' }, 'features': { type: 'string', alias: 'f', default: '[]', description: 'Features to add to the provided Template, provided as JSON.' }, @@ -30,7 +31,7 @@ export function templateApplyHandler(args: TemplateApplyArgs) { } async function templateApply({ - 'workspace-folder': workspaceFolder, + 'workspace-folder': workspaceFolderArg, 'template-id': templateId, 'template-args': templateArgs, 'features': featuresArgs, @@ -42,6 +43,7 @@ async function templateApply({ const dispose = async () => { await Promise.all(disposables.map(d => d())); }; + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : process.cwd(); const pkg = getPackageConfig(); diff --git a/src/spec-node/upgradeCommand.ts b/src/spec-node/upgradeCommand.ts index 3336087c7..b3a9b1cc9 100644 --- a/src/spec-node/upgradeCommand.ts +++ b/src/spec-node/upgradeCommand.ts @@ -23,7 +23,7 @@ import { mapNodeArchitectureToGOARCH, mapNodeOSToGOOS } from '../spec-configurat export function featuresUpgradeOptions(y: Argv) { return y .options({ - 'workspace-folder': { type: 'string', description: 'Workspace folder.', demandOption: true }, + 'workspace-folder': { type: 'string', description: 'Workspace folder. If --workspace-folder is not provided defaults to the current directory.' }, 'docker-path': { type: 'string', description: 'Path to docker executable.', default: 'docker' }, 'docker-compose-path': { type: 'string', description: 'Path to docker-compose executable.', default: 'docker-compose' }, 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, @@ -37,7 +37,6 @@ export function featuresUpgradeOptions(y: Argv) { if (argv.feature && !argv['target-version'] || !argv.feature && argv['target-version']) { throw new Error('The \'--target-version\' and \'--feature\' flag must be used together.'); } - if (argv['target-version']) { const targetVersion = argv['target-version']; if (!targetVersion.match(/^\d+(\.\d+(\.\d+)?)?$/)) { @@ -70,7 +69,7 @@ async function featuresUpgrade({ }; let output: Log | undefined; try { - const workspaceFolder = path.resolve(process.cwd(), workspaceFolderArg); + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : process.cwd(); const configFile = configArg ? URI.file(path.resolve(process.cwd(), configArg)) : undefined; const cliHost = await getCLIHost(workspaceFolder, loadNativeModule, true); const extensionPath = path.join(__dirname, '..', '..'); diff --git a/src/test/cli.build.test.ts b/src/test/cli.build.test.ts index 0dfae0427..c57e2f4d5 100644 --- a/src/test/cli.build.test.ts +++ b/src/test/cli.build.test.ts @@ -433,5 +433,49 @@ describe('Dev Containers CLI', function () { const details = JSON.parse((await shellExec(`docker inspect ${response.imageName}`)).stdout)[0] as ImageDetails; assert.strictEqual(details.Config.Labels?.test_build_options, 'success'); }); + + it('should use current directory for build when no workspace-folder provided', async function () { + const testFolder = `${__dirname}/configs/image`; // Use simpler config without features + const absoluteTmpPath = path.resolve(__dirname, 'tmp'); + const absoluteCli = `npx --prefix ${absoluteTmpPath} devcontainer`; + const originalCwd = process.cwd(); + console.log(`Original cwd: ${originalCwd}`); + console.log(`Changing to test folder: ${testFolder}`); + try { + process.chdir(testFolder); + const res = await shellExec(`${absoluteCli} build`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + assert.ok(response.imageName); + } finally { + process.chdir(originalCwd); + } + }); + + it('should fail gracefully when no workspace-folder and no config in current directory', async function () { + const tempDir = path.join(os.tmpdir(), 'devcontainer-build-test-' + Date.now()); + await shellExec(`mkdir -p ${tempDir}`); + const absoluteTmpPath = path.resolve(__dirname, 'tmp'); + const absoluteCli = `npx --prefix ${absoluteTmpPath} devcontainer`; + const originalCwd = process.cwd(); + try { + process.chdir(tempDir); + let success = false; + try { + await shellExec(`${absoluteCli} build`); + success = true; + } catch (error) { + assert.equal(error.error.code, 1, 'Should fail with exit code 1'); + const res = JSON.parse(error.stdout); + assert.equal(res.outcome, 'error'); + assert.match(res.message, /Dev container config .* not found/); + } + assert.equal(success, false, 'expect non-successful call'); + } finally { + process.chdir(originalCwd); + await shellExec(`rm -rf ${tempDir}`); + } + }); + }); }); diff --git a/src/test/cli.exec.base.ts b/src/test/cli.exec.base.ts index 10e876595..681aac775 100644 --- a/src/test/cli.exec.base.ts +++ b/src/test/cli.exec.base.ts @@ -5,6 +5,7 @@ import * as assert from 'assert'; import * as path from 'path'; +import * as os from 'os'; import { BuildKitOption, commandMarkerTests, devContainerDown, devContainerStop, devContainerUp, pathExists, shellBufferExec, shellExec, shellPtyExec } from './testUtils'; const pkg = require('../../package.json'); @@ -82,6 +83,22 @@ export function describeTests1({ text, options }: BuildKitOption) { assert.strictEqual(env.FOO, 'BAR'); assert.strictEqual(env.BAZ, ''); }); + it('should exec with default workspace folder (current directory)', async () => { + const originalCwd = process.cwd(); + const absoluteTmpPath = path.resolve(__dirname, 'tmp'); + const absoluteCli = `npx --prefix ${absoluteTmpPath} devcontainer`; + process.chdir(testFolder); + + try { + // Exec without --workspace-folder should use current directory as default + const execRes = await shellExec(`${absoluteCli} exec echo "default workspace test"`); + assert.strictEqual(execRes.error, null); + assert.match(execRes.stdout, /default workspace test/); + } finally { + // Restore original directory + process.chdir(originalCwd); + } + }); }); describe(`with valid (image) config containing features [${text}]`, () => { let containerId: string | null = null; @@ -406,6 +423,60 @@ export function describeTests2({ text, options }: BuildKitOption) { await shellExec(`docker rm -f ${response.containerId}`); }); + + describe('Command exec with default workspace', () => { + it('should fail gracefully when no config in current directory and no container-id', async () => { + const tempDir = path.join(os.tmpdir(), 'devcontainer-exec-test-' + Date.now()); + await shellExec(`mkdir -p ${tempDir}`); + const originalCwd = process.cwd(); + const absoluteTmpPath = path.resolve(__dirname, 'tmp'); + const absoluteCli = `npx --prefix ${absoluteTmpPath} devcontainer`; + try { + process.chdir(tempDir); + let success = false; + try { + // Test exec without --workspace-folder (should default to current directory with no config) + await shellExec(`${absoluteCli} exec echo "test"`); + success = true; + } catch (error) { + console.log('Caught error as expected: ', error.stderr); + // Should fail because there's no container or config + assert.equal(error.error.code, 1, 'Should fail with exit code 1'); + } + assert.equal(success, false, 'expect non-successful call'); + } finally { + process.chdir(originalCwd); + await shellExec(`rm -rf ${tempDir}`); + } + }); + + describe('with valid config in current directory', () => { + let containerId: string | null = null; + const testFolder = `${__dirname}/configs/image`; + + beforeEach(async () => { + containerId = (await devContainerUp(cli, testFolder, options)).containerId; + }); + + afterEach(async () => await devContainerDown({ containerId })); + + it('should execute command successfully when using current directory', async () => { + const originalCwd = process.cwd(); + const absoluteTmpPath = path.resolve(__dirname, 'tmp'); + const absoluteCli = `npx --prefix ${absoluteTmpPath} devcontainer`; + try { + process.chdir(testFolder); + // Test exec without --workspace-folder (should default to current directory) + const res = await shellExec(`${absoluteCli} exec echo "hello world"`); + assert.strictEqual(res.error, null); + assert.match(res.stdout, /hello world/); + } finally { + process.chdir(originalCwd); + } + }); + }); + + }); }); }); } diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index 522c9073d..f409cb0fd 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -5,6 +5,7 @@ import * as assert from 'assert'; import * as path from 'path'; +import * as os from 'os'; import { devContainerDown, devContainerUp, shellExec } from './testUtils'; const pkg = require('../../package.json'); @@ -68,6 +69,59 @@ describe('Dev Containers CLI', function () { await shellExec(`docker rm -f ${upResponse.containerId}`); }); + + it('run-user-commands should run with default workspace folder (current directory)', async () => { + const testFolder = `${__dirname}/configs/image`; + const absoluteTmpPath = path.resolve(__dirname, 'tmp'); + const absoluteCli = `npx --prefix ${absoluteTmpPath} devcontainer`; + + // First, ensure container is up + const upRes = await shellExec(`${cli} up --workspace-folder ${testFolder} --skip-post-create`); + const upResponse = JSON.parse(upRes.stdout); + assert.strictEqual(upResponse.outcome, 'success'); + const containerId = upResponse.containerId; + + const originalCwd = process.cwd(); + try { + // Change to workspace folder + process.chdir(testFolder); + + // Run user commands without --workspace-folder should use current directory as default + const runRes = await shellExec(`${absoluteCli} run-user-commands`); + const runResponse = JSON.parse(runRes.stdout); + assert.strictEqual(runResponse.outcome, 'success'); + + // Verify that the postCreateCommand was executed + await shellExec(`docker exec ${containerId} test -f /postCreateCommand.txt`); + } finally { + // Restore original directory + process.chdir(originalCwd); + // Clean up container + await shellExec(`docker rm -f ${containerId}`); + } + }); + + it('run-user-commands should fail gracefully when no config in current directory and no container-id', async () => { + const tempDir = path.join(os.tmpdir(), 'devcontainer-run-test-' + Date.now()); + await shellExec(`mkdir -p ${tempDir}`); + const absoluteTmpPath = path.resolve(__dirname, 'tmp'); + const absoluteCli = `npx --prefix ${absoluteTmpPath} devcontainer`; + const originalCwd = process.cwd(); + try { + process.chdir(tempDir); + let success = false; + try { + await shellExec(`${absoluteCli} run-user-commands`); + success = true; + } catch (error) { + assert.equal(error.error.code, 1, 'Should fail with exit code 1'); + } + assert.equal(success, false, 'expect non-successful call'); + } finally { + process.chdir(originalCwd); + await shellExec(`rm -rf ${tempDir}`); + } + }); }); describe('Command read-configuration', () => { @@ -124,5 +178,42 @@ describe('Dev Containers CLI', function () { const response = JSON.parse(res.stdout); assert.strictEqual(response.configuration.remoteEnv.SUBFOLDER_CONFIG_REMOTE_ENV, 'true'); }); + + it('should use current directory for read-configuration when no workspace-folder provided', async () => { + const testFolder = `${__dirname}/configs/image`; + const absoluteTmpPath = path.resolve(__dirname, 'tmp'); + const absoluteCli = `npx --prefix ${absoluteTmpPath} devcontainer`; + const originalCwd = process.cwd(); + try { + process.chdir(testFolder); + const res = await shellExec(`${absoluteCli} read-configuration`); + const response = JSON.parse(res.stdout); + assert.equal(response.configuration.image, 'ubuntu:latest'); + } finally { + process.chdir(originalCwd); + } + }); + + it('should fail gracefully when no workspace-folder and no config in current directory', async () => { + const tempDir = path.join(os.tmpdir(), 'devcontainer-test-' + Date.now()); + await shellExec(`mkdir -p ${tempDir}`); + const absoluteTmpPath = path.resolve(__dirname, 'tmp'); + const absoluteCli = `npx --prefix ${absoluteTmpPath} devcontainer`; + const originalCwd = process.cwd(); + try { + process.chdir(tempDir); + let success = false; + try { + await shellExec(`${absoluteCli} read-configuration`); + success = true; + } catch (error) { + assert.equal(error.error.code, 1, 'Should fail with exit code 1'); + } + assert.equal(success, false, 'expect non-successful call'); + } finally { + process.chdir(originalCwd); + await shellExec(`rm -rf ${tempDir}`); + } + }); }); }); \ No newline at end of file diff --git a/src/test/cli.up.test.ts b/src/test/cli.up.test.ts index 94515e89a..688a4c84d 100644 --- a/src/test/cli.up.test.ts +++ b/src/test/cli.up.test.ts @@ -310,4 +310,52 @@ describe('Dev Containers CLI', function () { await shellExec(`docker rm -f ${response.containerId}`); }); }); + + describe('Command up with default workspace', () => { + it('should create and start container using current directory config', async () => { + const testFolder = `${__dirname}/configs/image`; + const absoluteTmpPath = path.resolve(__dirname, 'tmp'); + const absoluteCli = `npx --prefix ${absoluteTmpPath} devcontainer`; + const originalCwd = process.cwd(); + let containerId: string | null = null; + try { + process.chdir(testFolder); + const res = await shellExec(`${absoluteCli} up`); + const response = JSON.parse(res.stdout); + containerId = response.containerId; + assert.equal(response.outcome, 'success'); + assert.ok(containerId); + } finally { + process.chdir(originalCwd); + if (containerId) { + await shellExec(`docker rm -f ${containerId}`); + } + } + }); + + it('should fail gracefully when no config in current directory', async () => { + const tempDir = path.join(os.tmpdir(), 'devcontainer-up-test-' + Date.now()); + await shellExec(`mkdir -p ${tempDir}`); + const absoluteTmpPath = path.resolve(__dirname, 'tmp'); + const absoluteCli = `npx --prefix ${absoluteTmpPath} devcontainer`; + const originalCwd = process.cwd(); + try { + process.chdir(tempDir); + let success = false; + try { + await shellExec(`${absoluteCli} up`); + success = true; + } catch (error) { + assert.equal(error.error.code, 1, 'Should fail with exit code 1'); + const res = JSON.parse(error.stdout); + assert.equal(res.outcome, 'error'); + assert.match(res.message, /Dev container config .* not found/); + } + assert.equal(success, false, 'expect non-successful call'); + } finally { + process.chdir(originalCwd); + await shellExec(`rm -rf ${tempDir}`); + } + }); + }); }); \ No newline at end of file diff --git a/src/test/container-features/featuresCLICommands.test.ts b/src/test/container-features/featuresCLICommands.test.ts index 1d20d5b1a..340abdb21 100644 --- a/src/test/container-features/featuresCLICommands.test.ts +++ b/src/test/container-features/featuresCLICommands.test.ts @@ -425,6 +425,119 @@ describe('CLI features subcommands', async function () { }); }); + describe('features resolve-dependencies', function () { + + it('should resolve dependencies when workspace-folder defaults to current directory', async function () { + // Create a test config with features that have dependencies + const testConfigPath = path.resolve(__dirname, 'configs/feature-dependencies/dependsOn/oci-ab'); + const originalCwd = process.cwd(); + + try { + // Change to test config directory to test default workspace folder behavior + process.chdir(testConfigPath); + + // Use absolute path to CLI to prevent npm ENOENT errors + const absoluteTmpPath = path.resolve(originalCwd, tmp); + const absoluteCliPath = `npx --prefix ${absoluteTmpPath} devcontainer`; + + // First check if the config file exists + const configExists = require('fs').existsSync('.devcontainer/devcontainer.json') || + require('fs').existsSync('.devcontainer.json'); + assert.isTrue(configExists, 'Test config file should exist'); + + let result; + try { + result = await shellExec(`${absoluteCliPath} features resolve-dependencies --log-level trace`); + } catch (error: any) { + // If command fails, log details for debugging + console.error('Command failed:', error); + if (error.stderr) { + console.error('STDERR:', error.stderr); + } + if (error.stdout) { + console.error('STDOUT:', error.stdout); + } + throw error; + } + + // Verify the command succeeded + assert.isDefined(result); + assert.isString(result.stdout); + assert.isNotEmpty(result.stdout.trim(), 'Command should produce output'); + + // Parse the JSON output to verify it contains expected structure + let jsonOutput; + try { + // Try parsing stdout directly first + jsonOutput = JSON.parse(result.stdout.trim()); + } catch (parseError) { + // If direct parsing fails, try extracting JSON from mixed output + const lines = result.stdout.split('\n'); + + // Find the last occurrence of '{' that starts a complete JSON object + let jsonStartIndex = -1; + let jsonEndIndex = -1; + let braceCount = 0; + + // Work backwards from the end to find the complete JSON + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (line === '}' && jsonEndIndex === -1) { + jsonEndIndex = i; + braceCount = 1; + } else if (jsonEndIndex !== -1) { + // Count braces to find matching opening + for (const char of line) { + if (char === '}') { + braceCount++; + } else if (char === '{') { + braceCount--; + } + } + if (braceCount === 0 && line === '{') { + jsonStartIndex = i; + break; + } + } + } + + if (jsonStartIndex >= 0 && jsonEndIndex >= 0) { + // Extract just the JSON lines + const jsonLines = lines.slice(jsonStartIndex, jsonEndIndex + 1); + const jsonString = jsonLines.join('\n'); + try { + jsonOutput = JSON.parse(jsonString); + } catch (innerError) { + console.error('Failed to parse extracted JSON:', jsonString.substring(0, 500) + '...'); + throw new Error(`Failed to parse extracted JSON: ${innerError}`); + } + } else { + console.error('Could not find complete JSON in output'); + console.error('Last 10 lines:', lines.slice(-10)); + throw new Error(`Failed to find complete JSON in output: ${parseError}`); + } + } + + assert.isDefined(jsonOutput, 'Should have valid JSON output'); + assert.property(jsonOutput, 'installOrder'); + assert.isArray(jsonOutput.installOrder); + + // Verify the install order contains the expected features + const installOrder = jsonOutput.installOrder; + assert.isAbove(installOrder.length, 0, 'Install order should contain at least one feature'); + + // Each item should have id and options + installOrder.forEach((item: any) => { + assert.property(item, 'id'); + assert.property(item, 'options'); + }); + + } finally { + process.chdir(originalCwd); + } + }); + }); + describe('features package', function () { it('features package subcommand by collection', async function () { diff --git a/src/test/container-features/lockfile.test.ts b/src/test/container-features/lockfile.test.ts index 57034e6f2..96fcd4045 100644 --- a/src/test/container-features/lockfile.test.ts +++ b/src/test/container-features/lockfile.test.ts @@ -258,4 +258,44 @@ describe('Lockfile', function () { await cleanup(); } }); + + it('outdated command should work with default workspace folder', async () => { + const workspaceFolder = path.join(__dirname, 'configs/lockfile-outdated-command'); + const absoluteTmpPath = path.resolve(__dirname, 'tmp'); + const absoluteCli = `npx --prefix ${absoluteTmpPath} devcontainer`; + + const originalCwd = process.cwd(); + try { + process.chdir(workspaceFolder); + const res = await shellExec(`${absoluteCli} outdated --output-format json`); + const response = JSON.parse(res.stdout); + + // Should have same structure as the test with explicit workspace-folder + assert.ok(response.features); + assert.ok(response.features['ghcr.io/devcontainers/features/git:1.0']); + assert.strictEqual(response.features['ghcr.io/devcontainers/features/git:1.0'].current, '1.0.4'); + } finally { + process.chdir(originalCwd); + } + }); + + it('upgrade command should work with default workspace folder', async () => { + const workspaceFolder = path.join(__dirname, 'configs/lockfile-upgrade-command'); + const absoluteTmpPath = path.resolve(__dirname, 'tmp'); + const absoluteCli = `npx --prefix ${absoluteTmpPath} devcontainer`; + + const lockfilePath = path.join(workspaceFolder, '.devcontainer-lock.json'); + await cpLocal(path.join(workspaceFolder, 'outdated.devcontainer-lock.json'), lockfilePath); + + const originalCwd = process.cwd(); + try { + process.chdir(workspaceFolder); + await shellExec(`${absoluteCli} upgrade`); + const actual = await readLocalFile(lockfilePath); + const expected = await readLocalFile(path.join(workspaceFolder, 'upgraded.devcontainer-lock.json')); + assert.equal(actual.toString(), expected.toString()); + } finally { + process.chdir(originalCwd); + } + }); }); \ No newline at end of file diff --git a/src/test/container-templates/templatesCLICommands.test.ts b/src/test/container-templates/templatesCLICommands.test.ts index babb4f8f2..1a91e2a2d 100644 --- a/src/test/container-templates/templatesCLICommands.test.ts +++ b/src/test/container-templates/templatesCLICommands.test.ts @@ -62,6 +62,60 @@ describe('tests apply command', async function () { // Assert that the Feature included in the command was added. assert.match(file, /"ghcr.io\/devcontainers\/features\/azure-cli:1": {\n/); }); + + it('templates apply subcommand with default workspace folder', async function () { + const testOutputPath = path.resolve(__dirname, 'tmp-default-workspace'); + const originalCwd = process.cwd(); + + try { + // Create and change to test output directory to test default workspace folder behavior + await shellExec(`rm -rf ${testOutputPath}`); + await shellExec(`mkdir -p ${testOutputPath}`); + process.chdir(testOutputPath); + + // Use absolute path to CLI to prevent npm ENOENT errors + const absoluteTmpPath = path.resolve(originalCwd, tmp); + const absoluteCliPath = `npx --prefix ${absoluteTmpPath} devcontainer`; + + let success = false; + let result: ExecResult | undefined = undefined; + + try { + // Run without --workspace-folder to test default behavior + result = await shellExec(`${absoluteCliPath} templates apply \ + --template-id ghcr.io/devcontainers/templates/docker-from-docker:latest \ + --template-args '{ "installZsh": "false", "upgradePackages": "true", "dockerVersion": "20.10", "moby": "true", "enableNonRootDocker": "true" }' \ + --log-level trace`); + success = true; + + } catch (error) { + assert.fail('templates apply sub-command should not throw when using default workspace folder'); + } + + assert.isTrue(success); + assert.isDefined(result); + assert.strictEqual(result.stdout.trim(), '{"files":["./.devcontainer/devcontainer.json"]}'); + + // Verify the file was created in the current working directory (default workspace folder) + const file = (await readLocalFile(path.join(testOutputPath, '.devcontainer', 'devcontainer.json'))).toString(); + + assert.match(file, /"name": "Docker from Docker"/); + assert.match(file, /"installZsh": "false"/); + assert.match(file, /"upgradePackages": "true"/); + assert.match(file, /"version": "20.10"/); + assert.match(file, /"moby": "true"/); + assert.match(file, /"enableNonRootDocker": "true"/); + + // Assert that the Features included in the template were not removed. + assert.match(file, /"ghcr.io\/devcontainers\/features\/common-utils:1": {\n/); + assert.match(file, /"ghcr.io\/devcontainers\/features\/docker-from-docker:1": {\n/); + + } finally { + process.chdir(originalCwd); + // Clean up test directory + await shellExec(`rm -rf ${testOutputPath}`); + } + }); }); describe('tests packageTemplates()', async function () {