diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json index 55c2bfcef..53c5a53c2 100644 --- a/.devcontainer/devcontainer-lock.json +++ b/.devcontainer/devcontainer-lock.json @@ -6,4 +6,4 @@ "integrity": "sha256:ce078b7bf7d9ef3bcb9813b32103795d8d72172446890b64772cbe1dec6baafd" } } -} \ No newline at end of file +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 03c54439d..974aaedbb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -27,7 +27,8 @@ "vscode": { "extensions": [ "dbaeumer.vscode-eslint", - "GitHub.vscode-pull-request-github" + "GitHub.vscode-pull-request-github", + "hbenl.vscode-mocha-test-adapter" ] }, "codespaces": { diff --git a/CHANGELOG.md b/CHANGELOG.md index 569454ce4..7d18f0c19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Notable changes. ## April 2026 +### [0.87.0] +- Graduate lockfile from experimental to stable: lockfiles are now generated by default on `build` and `up`. (https://github.com/devcontainers/cli/issues/1195) + - New `--no-lockfile` flag to opt out of lockfile generation. + - New `--frozen-lockfile` flag to ensure the lockfile exists and remains unchanged. + - `--experimental-lockfile` and `--experimental-frozen-lockfile` are deprecated (still accepted with a warning). + ### [0.86.0] - Bump basic-ftp from 5.2.0 to 5.2.2. (https://github.com/devcontainers/cli/pull/1201) - Always write devcontainer.metadata label as JSON array. (https://github.com/devcontainers/cli/pull/1199) diff --git a/README.md b/README.md index 622decd57..39b043ba0 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,15 @@ This CLI is in active development. Current status: - [x] `devcontainer run-user-commands` - Runs lifecycle commands like `postCreateCommand` - [x] `devcontainer read-configuration` - Outputs current configuration for workspace - [x] `devcontainer exec` - Executes a command in a container with `userEnvProbe`, `remoteUser`, `remoteEnv`, and other properties applied +- [x] `devcontainer outdated` - Show outdated lockfile features +- [x] `devcontainer upgrade` - Upgrade lockfile features - [x] `devcontainer features <...>` - Tools to assist in authoring and testing [Dev Container Features](https://containers.dev/implementors/features/) - [x] `devcontainer templates <...>` - Tools to assist in authoring and testing [Dev Container Templates](https://containers.dev/implementors/templates/) - [ ] `devcontainer stop` - Stops containers - [ ] `devcontainer down` - Stops and deletes containers +Lockfiles (`.devcontainer-lock.json`) are generated by default when running `build` or `up` to pin feature versions for reproducible builds. Use `--no-lockfile` to opt out, or `--frozen-lockfile` to enforce an existing lockfile. + ## Try it out We'd love for you to try out the dev container CLI and let us know what you think. You can quickly try it out in just a few simple steps, either by using the install script, installing its npm package, or building the CLI repo from sources (see "[Build from sources](#build-from-sources)"). diff --git a/docs/contributing-code.md b/docs/contributing-code.md index cc61bd046..abfde9c5c 100644 --- a/docs/contributing-code.md +++ b/docs/contributing-code.md @@ -95,6 +95,14 @@ node devcontainer.js run-user-commands --workspace-folder Tests use [Mocha](https://mochajs.org/) and [Chai](https://www.chaijs.com/) and require Docker because they create and tear down real containers. +Before running tests, package the CLI into a tarball: + +```sh +npm run package +``` + +Tests install the CLI from the generated `devcontainers-cli-.tgz` and shell out to it as a subprocess. You must re-run `npm run package` after any code change so that the tarball reflects your latest changes. Running `npm run compile` alone is **not** sufficient — it builds the JavaScript output but does not create the tarball that the tests depend on. + ```sh npm test # all tests npm run test-container-features # Features tests only diff --git a/src/spec-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index 78d6c8018..96042c091 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -195,6 +195,8 @@ export interface ContainerFeatureInternalParams { platform: NodeJS.Platform; experimentalLockfile?: boolean; experimentalFrozenLockfile?: boolean; + noLockfile?: boolean; + frozenLockfile?: boolean; } // TODO: Move to node layer. @@ -485,7 +487,7 @@ export async function generateFeaturesConfig(params: ContainerFeatureInternalPar const ociCacheDir = await prepareOCICache(dstFolder); - const { lockfile, initLockfile } = await readLockfile(config); + const { lockfile } = params.noLockfile ? { lockfile: undefined } : await readLockfile(config); const processFeature = async (_userFeature: DevContainerFeature) => { return await processFeatureIdentifier(params, configPath, workspaceRoot, _userFeature, lockfile); @@ -508,7 +510,9 @@ export async function generateFeaturesConfig(params: ContainerFeatureInternalPar await fetchFeatures(params, featuresConfig, dstFolder, ociCacheDir, lockfile); await logFeatureAdvisories(params, featuresConfig); - await writeLockfile(params, config, await generateLockfile(featuresConfig), initLockfile); + if (!params.noLockfile) { + await writeLockfile(params, config, await generateLockfile(featuresConfig)); + } return featuresConfig; } diff --git a/src/spec-configuration/lockfile.ts b/src/spec-configuration/lockfile.ts index 02d0841c3..7f84d768d 100644 --- a/src/spec-configuration/lockfile.ts +++ b/src/spec-configuration/lockfile.ts @@ -40,7 +40,11 @@ export async function generateLockfile(featuresConfig: FeaturesConfig): Promise< }); } -export async function writeLockfile(params: ContainerFeatureInternalParams, config: DevContainerConfig, lockfile: Lockfile, forceInitLockfile?: boolean): Promise { +export async function writeLockfile(params: ContainerFeatureInternalParams, config: DevContainerConfig, lockfile: Lockfile): Promise { + if (params.noLockfile) { + return; + } + const lockfilePath = getLockfilePath(config); const oldLockfileContent = await readLocalFile(lockfilePath) .catch(err => { @@ -49,14 +53,10 @@ export async function writeLockfile(params: ContainerFeatureInternalParams, conf } }); - if (!forceInitLockfile && !oldLockfileContent && !params.experimentalLockfile && !params.experimentalFrozenLockfile) { - return; - } - // Trailing newline per POSIX convention const newLockfileContentString = JSON.stringify(lockfile, null, 2) + '\n'; const newLockfileContent = Buffer.from(newLockfileContentString); - if (params.experimentalFrozenLockfile && !oldLockfileContent) { + if ((params.frozenLockfile || params.experimentalFrozenLockfile) && !oldLockfileContent) { throw new Error('Lockfile does not exist.'); } // Normalize the existing lockfile through JSON.parse -> JSON.stringify to produce @@ -71,7 +71,7 @@ export async function writeLockfile(params: ContainerFeatureInternalParams, conf } } if (!oldLockfileNormalized || oldLockfileNormalized !== newLockfileContentString) { - if (params.experimentalFrozenLockfile) { + if (params.frozenLockfile || params.experimentalFrozenLockfile) { throw new Error('Lockfile does not match.'); } await writeLocalFile(lockfilePath, newLockfileContent); diff --git a/src/spec-node/containerFeatures.ts b/src/spec-node/containerFeatures.ts index e05822d1b..c6b4fe596 100644 --- a/src/spec-node/containerFeatures.ts +++ b/src/spec-node/containerFeatures.ts @@ -147,8 +147,8 @@ export async function getExtendImageBuildInfo(params: DockerResolverParameters, const platform = params.common.cliHost.platform; const cacheFolder = await getCacheFolder(params.common.cliHost); - const { experimentalLockfile, experimentalFrozenLockfile } = params; - const featuresConfig = await generateFeaturesConfig({ ...params.common, platform, cacheFolder, experimentalLockfile, experimentalFrozenLockfile }, dstFolder, config.config, additionalFeatures); + const { experimentalLockfile, experimentalFrozenLockfile, noLockfile, frozenLockfile } = params; + const featuresConfig = await generateFeaturesConfig({ ...params.common, platform, cacheFolder, experimentalLockfile, experimentalFrozenLockfile, noLockfile, frozenLockfile }, dstFolder, config.config, additionalFeatures); if (!featuresConfig) { if (canAddLabelsToContainer && !imageBuildInfo.dockerfile) { return { diff --git a/src/spec-node/devContainers.ts b/src/spec-node/devContainers.ts index a856890c6..bfd5a118f 100644 --- a/src/spec-node/devContainers.ts +++ b/src/spec-node/devContainers.ts @@ -70,6 +70,8 @@ export interface ProvisionOptions { }; experimentalLockfile?: boolean; experimentalFrozenLockfile?: boolean; + noLockfile?: boolean; + frozenLockfile?: boolean; secretsP?: Promise>; omitSyntaxDirective?: boolean; includeConfig?: boolean; @@ -103,7 +105,7 @@ export async function launch(options: ProvisionOptions, providedIdLabels: string } export async function createDockerParams(options: ProvisionOptions, disposables: (() => Promise | undefined)[]): Promise { - const { persistedFolder, additionalMounts, updateRemoteUserUIDDefault, containerDataFolder, containerSystemDataFolder, workspaceMountConsistency, gpuAvailability, mountWorkspaceGitRoot, mountGitWorktreeCommonDir, remoteEnv, experimentalLockfile, experimentalFrozenLockfile, omitLoggerHeader, secretsP } = options; + const { persistedFolder, additionalMounts, updateRemoteUserUIDDefault, containerDataFolder, containerSystemDataFolder, workspaceMountConsistency, gpuAvailability, mountWorkspaceGitRoot, mountGitWorktreeCommonDir, remoteEnv, experimentalLockfile, experimentalFrozenLockfile, noLockfile, frozenLockfile, omitLoggerHeader, secretsP } = options; let parsedAuthority: DevContainerAuthority | undefined; if (options.workspaceFolder) { parsedAuthority = { hostPath: options.workspaceFolder } as DevContainerAuthority; @@ -248,6 +250,8 @@ export async function createDockerParams(options: ProvisionOptions, disposables: isTTY: process.stdout.isTTY || options.logFormat === 'json', experimentalLockfile, experimentalFrozenLockfile, + noLockfile, + frozenLockfile, buildxPlatform: common.buildxPlatform, buildxPush: common.buildxPush, additionalLabels: options.additionalLabels, diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 18c44136b..801c5424d 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -140,6 +140,8 @@ function provisionOptions(y: Argv) { 'secrets-file': { type: 'string', description: 'Path to a json file containing secret environment variables as key-value pairs.' }, 'experimental-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Write lockfile' }, 'experimental-frozen-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Ensure lockfile remains unchanged' }, + 'no-lockfile': { type: 'boolean', default: false, description: 'Disable lockfile generation and verification.' }, + 'frozen-lockfile': { type: 'boolean', default: false, description: 'Ensure lockfile exists and remains unchanged; fail otherwise.' }, 'omit-syntax-directive': { type: 'boolean', default: false, hidden: true, description: 'Omit Dockerfile syntax directives' }, 'include-configuration': { type: 'boolean', default: false, description: 'Include configuration in result.' }, 'include-merged-configuration': { type: 'boolean', default: false, description: 'Include merged configuration in result.' }, @@ -161,6 +163,15 @@ function provisionOptions(y: Argv) { if (remoteEnvs?.some(remoteEnv => !/.+=.*/.test(remoteEnv))) { throw new Error('Unmatched argument format: remote-env must match ='); } + if (argv['no-lockfile'] && argv['frozen-lockfile']) { + throw new Error('--no-lockfile and --frozen-lockfile are mutually exclusive.'); + } + if (argv['no-lockfile'] && argv['experimental-frozen-lockfile']) { + throw new Error('--no-lockfile and --experimental-frozen-lockfile are mutually exclusive.'); + } + if (argv['no-lockfile'] && argv['experimental-lockfile']) { + throw new Error('--no-lockfile and --experimental-lockfile are mutually exclusive.'); + } return true; }); } @@ -213,11 +224,21 @@ async function provision({ 'secrets-file': secretsFile, 'experimental-lockfile': experimentalLockfile, 'experimental-frozen-lockfile': experimentalFrozenLockfile, + 'no-lockfile': noLockfile, + 'frozen-lockfile': frozenLockfile, 'omit-syntax-directive': omitSyntaxDirective, 'include-configuration': includeConfig, 'include-merged-configuration': includeMergedConfig, }: ProvisionArgs) { + if (experimentalLockfile) { + process.stderr.write('Warning: --experimental-lockfile is deprecated. Lockfiles are now enabled by default.\n'); + } + if (experimentalFrozenLockfile) { + process.stderr.write('Warning: --experimental-frozen-lockfile is deprecated. Use --frozen-lockfile instead.\n'); + } + const effectiveFrozenLockfile = frozenLockfile || experimentalFrozenLockfile; + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : undefined; const addRemoteEnvs = addRemoteEnv ? (Array.isArray(addRemoteEnv) ? addRemoteEnv as string[] : [addRemoteEnv]) : []; const addCacheFroms = addCacheFrom ? (Array.isArray(addCacheFrom) ? addCacheFrom as string[] : [addCacheFrom]) : []; @@ -284,6 +305,8 @@ async function provision({ omitConfigRemotEnvFromMetadata, experimentalLockfile, experimentalFrozenLockfile, + noLockfile, + frozenLockfile: effectiveFrozenLockfile, omitSyntaxDirective, includeConfig, includeMergedConfig, @@ -527,8 +550,22 @@ function buildOptions(y: Argv) { 'skip-persisting-customizations-from-features': { type: 'boolean', default: false, hidden: true, description: 'Do not save customizations from referenced Features as image metadata' }, 'experimental-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Write lockfile' }, 'experimental-frozen-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Ensure lockfile remains unchanged' }, + 'no-lockfile': { type: 'boolean', default: false, description: 'Disable lockfile generation and verification.' }, + 'frozen-lockfile': { type: 'boolean', default: false, description: 'Ensure lockfile exists and remains unchanged; fail otherwise.' }, 'omit-syntax-directive': { type: 'boolean', default: false, hidden: true, description: 'Omit Dockerfile syntax directives' }, - }); + }) + .check(argv => { + if (argv['no-lockfile'] && argv['frozen-lockfile']) { + throw new Error('--no-lockfile and --frozen-lockfile are mutually exclusive.'); + } + if (argv['no-lockfile'] && argv['experimental-frozen-lockfile']) { + throw new Error('--no-lockfile and --experimental-frozen-lockfile are mutually exclusive.'); + } + if (argv['no-lockfile'] && argv['experimental-lockfile']) { + throw new Error('--no-lockfile and --experimental-lockfile are mutually exclusive.'); + } + return true; + }); } type BuildArgs = UnpackArgv>; @@ -569,8 +606,18 @@ async function doBuild({ 'skip-persisting-customizations-from-features': skipPersistingCustomizationsFromFeatures, 'experimental-lockfile': experimentalLockfile, 'experimental-frozen-lockfile': experimentalFrozenLockfile, + 'no-lockfile': noLockfile, + 'frozen-lockfile': frozenLockfile, 'omit-syntax-directive': omitSyntaxDirective, }: BuildArgs) { + if (experimentalLockfile) { + process.stderr.write('Warning: --experimental-lockfile is deprecated. Lockfiles are now enabled by default.\n'); + } + if (experimentalFrozenLockfile) { + process.stderr.write('Warning: --experimental-frozen-lockfile is deprecated. Use --frozen-lockfile instead.\n'); + } + const effectiveFrozenLockfile = frozenLockfile || experimentalFrozenLockfile; + const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { await Promise.all(disposables.map(d => d())); @@ -619,6 +666,8 @@ async function doBuild({ dotfiles: {}, experimentalLockfile, experimentalFrozenLockfile, + noLockfile, + frozenLockfile: effectiveFrozenLockfile, omitSyntaxDirective, }, disposables); diff --git a/src/spec-node/featureUtils.ts b/src/spec-node/featureUtils.ts index 0b3fabc01..a36205295 100644 --- a/src/spec-node/featureUtils.ts +++ b/src/spec-node/featureUtils.ts @@ -9,5 +9,5 @@ export async function readFeaturesConfig(params: DockerCLIParameters, pkg: Packa const { cwd, env, platform } = cliHost; const featuresTmpFolder = await createFeaturesTempFolder({ cliHost, package: pkg }); const cacheFolder = await getCacheFolder(cliHost); - return generateFeaturesConfig({ extensionPath, cacheFolder, cwd, output, env, skipFeatureAutoMapping, platform }, featuresTmpFolder, config, additionalFeatures); + return generateFeaturesConfig({ extensionPath, cacheFolder, cwd, output, env, skipFeatureAutoMapping, platform, noLockfile: true }, featuresTmpFolder, config, additionalFeatures); } \ No newline at end of file diff --git a/src/spec-node/upgradeCommand.ts b/src/spec-node/upgradeCommand.ts index 51410de07..8773fd5de 100644 --- a/src/spec-node/upgradeCommand.ts +++ b/src/spec-node/upgradeCommand.ts @@ -138,7 +138,7 @@ async function featuresUpgrade({ const lockfilePath = getLockfilePath(config); await writeLocalFile(lockfilePath, ''); // Update lockfile - await writeLockfile(params, config, lockfile, true); + await writeLockfile(params, config, lockfile); } catch (err) { if (output) { output.write(err && (err.stack || err.message) || String(err)); diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index 2fafbca41..cf4310ffe 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -129,6 +129,8 @@ export interface DockerResolverParameters { isTTY: boolean; experimentalLockfile?: boolean; experimentalFrozenLockfile?: boolean; + noLockfile?: boolean; + frozenLockfile?: boolean; buildxPlatform: string | undefined; buildxPush: boolean; additionalLabels: string[]; diff --git a/src/test/container-features/configs/lockfile-no-lockfile/.devcontainer.json b/src/test/container-features/configs/lockfile-no-lockfile/.devcontainer.json new file mode 100644 index 000000000..e54a9012f --- /dev/null +++ b/src/test/container-features/configs/lockfile-no-lockfile/.devcontainer.json @@ -0,0 +1,7 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/codspace/features/flower:1.0.0": {}, + "ghcr.io/codspace/features/color:1.0.4": {} + } +} diff --git a/src/test/container-features/lockfile.test.ts b/src/test/container-features/lockfile.test.ts index 8702429b1..a118df248 100644 --- a/src/test/container-features/lockfile.test.ts +++ b/src/test/container-features/lockfile.test.ts @@ -372,32 +372,151 @@ describe('Lockfile', function () { } }); - it('no lockfile flags and no existing lockfile is a no-op', async () => { - const workspaceFolder = path.join(__dirname, 'configs/lockfile'); - const lockfilePath = path.join(workspaceFolder, '.devcontainer-lock.json'); - const expectedPath = path.join(workspaceFolder, 'expected.devcontainer-lock.json'); + // -- Graduated lockfile tests -- + + async function isolateFixture(name: string): Promise { + const src = path.join(__dirname, 'configs', name); + const dst = path.join(__dirname, 'tmp-fixtures', `${name}-${process.hrtime.bigint()}`); + await shellExec(`mkdir -p ${path.dirname(dst)} && cp -r ${src} ${dst}`); + return dst; + } + + async function lockfileExists(p: string): Promise { + return readLocalFile(p).then(() => true, err => { + if (err?.code === 'ENOENT') { + return false; + } + throw err; + }); + } + + after(async () => { + await shellExec(`rm -rf ${path.join(__dirname, 'tmp-fixtures')}`); + }); + + it('auto-generates lockfile by default without any flags', async () => { + const tmpDir = await isolateFixture('lockfile'); + const lockfilePath = path.join(tmpDir, '.devcontainer-lock.json'); + // Remove the committed lockfile so we can verify auto-creation from scratch. + await rmLocal(lockfilePath, { force: true }); + + const res = await shellExec(`${cli} build --workspace-folder ${tmpDir}`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + + const actual = await readLocalFile(lockfilePath); + const expected = await readLocalFile(path.join(tmpDir, 'expected.devcontainer-lock.json')); + assert.equal(actual.toString(), expected.toString()); + }); + + it('--no-lockfile prevents lockfile creation', async () => { + const tmpDir = await isolateFixture('lockfile-no-lockfile'); + const lockfilePath = path.join(tmpDir, '.devcontainer-lock.json'); + + const res = await shellExec(`${cli} build --workspace-folder ${tmpDir} --no-lockfile`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + + assert.equal(await lockfileExists(lockfilePath), false, 'Lockfile should not be created when --no-lockfile is set'); + }); + + it('--no-lockfile ignores existing lockfile', async () => { + const tmpDir = await isolateFixture('lockfile-frozen'); + const lockfilePath = path.join(tmpDir, '.devcontainer-lock.json'); + + const lockfileBefore = (await readLocalFile(lockfilePath)).toString(); + + const res = await shellExec(`${cli} build --workspace-folder ${tmpDir} --no-lockfile`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + + const lockfileAfter = (await readLocalFile(lockfilePath)).toString(); + assert.equal(lockfileAfter, lockfileBefore, 'Lockfile should be unchanged when --no-lockfile is set'); + }); + + it('--frozen-lockfile succeeds with matching lockfile', async () => { + const tmpDir = await isolateFixture('lockfile-frozen'); + const lockfilePath = path.join(tmpDir, '.devcontainer-lock.json'); + const lockfileBefore = (await readLocalFile(lockfilePath)).toString(); + + const res = await shellExec(`${cli} build --workspace-folder ${tmpDir} --frozen-lockfile`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + + const lockfileAfter = (await readLocalFile(lockfilePath)).toString(); + assert.equal(lockfileAfter, lockfileBefore, 'Lockfile should be unchanged'); + }); + + it('--frozen-lockfile fails when lockfile missing', async () => { + const tmpDir = await isolateFixture('lockfile-no-lockfile'); try { - await rmLocal(lockfilePath, { force: true }); + throw await shellExec(`${cli} build --workspace-folder ${tmpDir} --frozen-lockfile`); + } catch (res) { + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'error'); + assert.equal(response.message, 'Lockfile does not exist.'); + } + }); + + for (const secondFlag of ['--frozen-lockfile', '--experimental-frozen-lockfile', '--experimental-lockfile']) { + it(`--no-lockfile and ${secondFlag} are mutually exclusive`, async () => { + const tmpDir = await isolateFixture('lockfile-no-lockfile'); + + try { + throw await shellExec(`${cli} build --workspace-folder ${tmpDir} --no-lockfile ${secondFlag}`); + } catch (res) { + assert.match(res.stderr, /mutually exclusive/i, 'Should fail with mutually exclusive error'); + } + }); + } + + for (const { fixture, flag } of [ + { fixture: 'lockfile', flag: '--experimental-lockfile' }, + { fixture: 'lockfile-frozen', flag: '--experimental-frozen-lockfile' }, + ]) { + it(`deprecation warning for ${flag}`, async () => { + const tmpDir = await isolateFixture(fixture); + + const res = await shellExec(`${cli} build --workspace-folder ${tmpDir} ${flag}`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + assert.ok(res.stderr.includes(`${flag} is deprecated`), 'Should emit deprecation warning'); + }); + } + + it('devcontainer up auto-generates lockfile by default', async () => { + const tmpDir = await isolateFixture('lockfile-no-lockfile'); + const lockfilePath = path.join(tmpDir, '.devcontainer-lock.json'); + const idLabel = `test-lockfile-up=${process.hrtime.bigint()}`; - // Build without any lockfile flags - const res = await shellExec(`${cli} build --workspace-folder ${workspaceFolder}`); + try { + const res = await shellExec(`${cli} up --workspace-folder ${tmpDir} --id-label ${idLabel}`); const response = JSON.parse(res.stdout); assert.equal(response.outcome, 'success'); - // Lockfile should not have been created - let exists = true; - await readLocalFile(lockfilePath).catch(err => { - if (err?.code === 'ENOENT') { - exists = false; - } else { - throw err; - } - }); - assert.equal(exists, false, 'Lockfile should not be created when no lockfile flags are set'); + const actual = await readLocalFile(lockfilePath); + assert.ok(actual.toString().trim().length > 0, 'Lockfile should have been created'); + const parsed = JSON.parse(actual.toString()); + assert.ok(parsed.features, 'Lockfile should contain features'); } finally { - // Restore from the known-good expected lockfile - await cpLocal(expectedPath, lockfilePath); + // Clean up by id-label so cleanup happens even if `up` failed before returning a containerId. + await shellExec(`docker rm -f $(docker ps -aq --filter label=${idLabel}) 2>/dev/null || true`, undefined, true, true); } }); + + it('read-only commands do not create a lockfile', async () => { + const readConfigTmpDir = await isolateFixture('lockfile-no-lockfile'); + const readConfigLockfilePath = path.join(readConfigTmpDir, '.devcontainer-lock.json'); + + // read-configuration should not create a lockfile + await shellExec(`${cli} read-configuration --workspace-folder ${readConfigTmpDir} --include-features-configuration`, undefined, true); + assert.equal(await lockfileExists(readConfigLockfilePath), false, 'read-configuration should not create a lockfile'); + + const resolveDepsTmpDir = await isolateFixture('lockfile-no-lockfile'); + const resolveDepsLockfilePath = path.join(resolveDepsTmpDir, '.devcontainer-lock.json'); + + await shellExec(`${cli} features resolve-dependencies --workspace-folder ${resolveDepsTmpDir}`, undefined, true); + assert.equal(await lockfileExists(resolveDepsLockfilePath), false, 'features resolve-dependencies should not create a lockfile'); + }); }); \ No newline at end of file