Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer-lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
"integrity": "sha256:ce078b7bf7d9ef3bcb9813b32103795d8d72172446890b64772cbe1dec6baafd"
}
}
}
}
3 changes: 2 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)").
Expand Down
8 changes: 8 additions & 0 deletions docs/contributing-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ node devcontainer.js run-user-commands --workspace-folder <path>

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-<version>.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
Expand Down
8 changes: 6 additions & 2 deletions src/spec-configuration/containerFeaturesConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ export interface ContainerFeatureInternalParams {
platform: NodeJS.Platform;
experimentalLockfile?: boolean;
experimentalFrozenLockfile?: boolean;
noLockfile?: boolean;
frozenLockfile?: boolean;
}

// TODO: Move to node layer.
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}

Expand Down
14 changes: 7 additions & 7 deletions src/spec-configuration/lockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ export async function generateLockfile(featuresConfig: FeaturesConfig): Promise<
});
}

export async function writeLockfile(params: ContainerFeatureInternalParams, config: DevContainerConfig, lockfile: Lockfile, forceInitLockfile?: boolean): Promise<string | undefined> {
export async function writeLockfile(params: ContainerFeatureInternalParams, config: DevContainerConfig, lockfile: Lockfile): Promise<string | undefined> {
if (params.noLockfile) {
return;
}

const lockfilePath = getLockfilePath(config);
const oldLockfileContent = await readLocalFile(lockfilePath)
.catch(err => {
Expand All @@ -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
Expand All @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/spec-node/containerFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion src/spec-node/devContainers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export interface ProvisionOptions {
};
experimentalLockfile?: boolean;
experimentalFrozenLockfile?: boolean;
noLockfile?: boolean;
frozenLockfile?: boolean;
secretsP?: Promise<Record<string, string>>;
omitSyntaxDirective?: boolean;
includeConfig?: boolean;
Expand Down Expand Up @@ -103,7 +105,7 @@ export async function launch(options: ProvisionOptions, providedIdLabels: string
}

export async function createDockerParams(options: ProvisionOptions, disposables: (() => Promise<unknown> | undefined)[]): Promise<DockerResolverParameters> {
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;
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 50 additions & 1 deletion src/spec-node/devContainersSpecCLI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.' },
Expand All @@ -161,6 +163,15 @@ function provisionOptions(y: Argv) {
if (remoteEnvs?.some(remoteEnv => !/.+=.*/.test(remoteEnv))) {
throw new Error('Unmatched argument format: remote-env must match <name>=<value>');
}
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;
});
}
Expand Down Expand Up @@ -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]) : [];
Expand Down Expand Up @@ -284,6 +305,8 @@ async function provision({
omitConfigRemotEnvFromMetadata,
experimentalLockfile,
experimentalFrozenLockfile,
noLockfile,
frozenLockfile: effectiveFrozenLockfile,
omitSyntaxDirective,
includeConfig,
includeMergedConfig,
Expand Down Expand Up @@ -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<ReturnType<typeof buildOptions>>;
Expand Down Expand Up @@ -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<unknown> | undefined)[] = [];
const dispose = async () => {
await Promise.all(disposables.map(d => d()));
Expand Down Expand Up @@ -619,6 +666,8 @@ async function doBuild({
dotfiles: {},
experimentalLockfile,
experimentalFrozenLockfile,
noLockfile,
frozenLockfile: effectiveFrozenLockfile,
omitSyntaxDirective,
}, disposables);

Expand Down
2 changes: 1 addition & 1 deletion src/spec-node/featureUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
2 changes: 1 addition & 1 deletion src/spec-node/upgradeCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
2 changes: 2 additions & 0 deletions src/spec-node/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ export interface DockerResolverParameters {
isTTY: boolean;
experimentalLockfile?: boolean;
experimentalFrozenLockfile?: boolean;
noLockfile?: boolean;
frozenLockfile?: boolean;
buildxPlatform: string | undefined;
buildxPush: boolean;
additionalLabels: string[];
Expand Down
Original file line number Diff line number Diff line change
@@ -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": {}
}
}
Loading