From 0cfd6f07b55b5f08c99af87352ee349bd0b1c563 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Mon, 22 Sep 2025 11:14:23 +0200 Subject: [PATCH] tmp --- .../cli-commands-options.instructions.md | 149 +++++++++++++++ .../project-overview.instructions.md | 178 ++++++++++++++++++ .../prompts/extract-instructions.prompt.md | 28 +++ src/spec-node/devContainersSpecCLI.ts | 41 ++-- .../featuresCLI/resolveDependencies.ts | 7 +- src/spec-node/templatesCLI/apply.ts | 8 +- src/spec-node/upgradeCommand.ts | 5 +- src/test/cli.build.test.ts | 37 ++++ src/test/cli.exec.base.ts | 52 +++++ src/test/cli.test.ts | 73 +++++++ src/test/cli.up.test.ts | 43 +++++ src/test/container-features/lockfile.test.ts | 17 ++ 12 files changed, 609 insertions(+), 29 deletions(-) create mode 100644 .github/instructions/cli-commands-options.instructions.md create mode 100644 .github/instructions/project-overview.instructions.md create mode 100644 .github/prompts/extract-instructions.prompt.md diff --git a/.github/instructions/cli-commands-options.instructions.md b/.github/instructions/cli-commands-options.instructions.md new file mode 100644 index 000000000..a62999dc3 --- /dev/null +++ b/.github/instructions/cli-commands-options.instructions.md @@ -0,0 +1,149 @@ +--- +description: "Discussion of the CLI commands and options part of the codebase" +--- + +# CLI Commands and Options + +This document covers the command-line interface implementation, including command structure, argument parsing, validation, and extension patterns. + +## Architecture Overview + +The CLI is built using a modular command structure where each command is implemented as a separate module with standardized interfaces for argument parsing, validation, and execution. + +### Key Components + +- **Command Registration**: Commands are registered through a central registry system +- **Argument Parsing**: Uses a consistent parsing framework for options, flags, and positional arguments +- **Validation Pipeline**: Multi-stage validation including syntax, semantic, and context validation +- **Error Handling**: Standardized error reporting with user-friendly messages and exit codes + +## Command Structure Patterns + +### Command Definition +Commands follow a consistent structure with: +- Command metadata (name, description, aliases) +- Argument/option definitions with types and validation rules +- Handler function for command execution +- Help text generation + +### Option Types +- **Boolean flags**: Simple on/off switches +- **String options**: Text input with optional validation patterns +- **Enum options**: Predefined value sets with validation +- **Array options**: Multiple values of the same type +- **File/path options**: Special handling for filesystem references + +## Development Conventions + +### Adding New Commands +1. Create command module in appropriate subdirectory +2. Define command schema with full type information +3. Implement validation logic (both sync and async where needed) +4. Add comprehensive error handling with specific error codes +5. Include help text and examples +6. Register command in central registry +7. Add integration tests covering common usage patterns + +### Option Naming Conventions +- Use kebab-case for multi-word options (`--config-file`) +- Provide short aliases for frequently used options (`-c` for `--config`) +- Boolean flags should be positive by default (`--enable-feature` not `--disable-feature`) +- Use consistent naming across related commands + +### Validation Patterns +- **Input Validation**: Check argument types, ranges, and format requirements +- **Context Validation**: Verify prerequisites, file existence, permissions +- **Cross-option Validation**: Ensure option combinations are valid +- **Async Validation**: Handle network-dependent or filesystem validation + +## Integration Points + +### Configuration System +Commands integrate with the configuration system to: +- Load default values from config files +- Override config with command-line arguments +- Validate configuration consistency + +### Logging and Output +- Use structured logging for debugging and audit trails +- Implement consistent output formatting (JSON, table, plain text) +- Handle progress reporting for long-running operations + +### Error Handling +- Map internal errors to user-friendly messages +- Use specific exit codes for different error categories +- Provide actionable error messages with suggested fixes + +## Common Patterns + +### Async Command Execution +Most commands involve async operations (file I/O, network requests). Follow patterns for: +- Proper async/await usage +- Timeout handling +- Graceful cancellation +- Progress reporting + +### File System Operations +- Always validate paths before operations +- Handle relative vs absolute path resolution +- Implement proper error handling for permissions, missing files +- Consider cross-platform path handling + +### Configuration Merging +Commands often need to merge configuration from multiple sources: +1. Default values +2. Configuration files +3. Environment variables +4. Command-line arguments + +## Testing Patterns + +### Unit Tests +- Test command parsing in isolation +- Validate option validation logic +- Mock external dependencies +- Test error conditions and edge cases + +### Integration Tests +- Test complete command execution flows +- Verify file system interactions +- Test configuration loading and merging +- Validate output formatting + +## Common Pitfalls + +### Argument Parsing +- Be careful with optional vs required arguments +- Handle edge cases in string parsing (quotes, escaping) +- Validate mutually exclusive options +- Consider default value precedence + +### Error Messages +- Avoid technical jargon in user-facing messages +- Provide specific error locations (line numbers, file paths) +- Include suggested fixes when possible +- Use consistent error formatting + +### Performance Considerations +- Lazy-load command modules to improve startup time +- Cache validation results when appropriate +- Optimize for common usage patterns +- Handle large input sets efficiently + +## Extension Points + +### Custom Validators +The validation system supports custom validators for domain-specific requirements. + +### Output Formatters +New output formats can be added through the formatter registry. + +### Command Plugins +External commands can be registered through the plugin system. + +## Key Files and Directories + +- `/src/spec-node/devContainersSpecCLI.ts` - Main CLI entry point +- `/src/spec-configuration/` - Configuration parsing and validation +- `/src/spec-utils/` - Shared utilities for command implementation +- Tests in `/src/test/` following command structure diff --git a/.github/instructions/project-overview.instructions.md b/.github/instructions/project-overview.instructions.md new file mode 100644 index 000000000..68f956234 --- /dev/null +++ b/.github/instructions/project-overview.instructions.md @@ -0,0 +1,178 @@ +--- +description: "Discussion of the devcontainers CLI project architecture, conventions, and development patterns" +--- + +# DevContainers CLI Project Instructions + +## Overview + +The DevContainers CLI (`@devcontainers/cli`) is a TypeScript-based Node.js project that implements the [Development Containers specification](https://containers.dev). It provides tooling for building, running, and managing development containers across different container runtimes and orchestrators. + +## Architecture + +### Core Components + +- **`src/spec-configuration/`** - Configuration parsing and validation for devcontainer.json +- **`src/spec-node/`** - Node.js-specific implementations of the specification +- **`src/spec-utils/`** - Shared utilities for specification handling +- **`src/test/`** - Comprehensive test suites including container tests + +### Key Design Principles + +1. **Specification Compliance**: All features must align with the official devcontainer specification +2. **Multi-Runtime Support**: Support for Docker, Podman, and other container runtimes +3. **Cross-Platform**: Works on Windows, macOS, and Linux +4. **Extensibility**: Plugin architecture for features and lifecycle hooks + +## Development Conventions + +### TypeScript Patterns + +- Use strict TypeScript configuration with `noImplicitAny` and `strictNullChecks` +- Prefer interfaces over type aliases for object shapes +- Use proper async/await patterns, avoid callback-style APIs +- Export types and interfaces from dedicated `types.ts` files + +### Error Handling + +- Use custom error classes that extend base `Error` +- Provide meaningful error messages with context +- Include error codes for programmatic handling +- Log errors at appropriate levels using the project's logging system + +### Testing Strategy + +- Unit tests in `src/test/` with `.test.ts` suffix +- Container integration tests that actually build and run containers +- Mock external dependencies (Docker API, file system operations) +- Use descriptive test names that explain the scenario being tested + +## Key Integration Points + +### Container Runtime Integration + +- **Docker**: Primary runtime support via Docker API +- **Podman**: Alternative runtime with compatibility layer +- **BuildKit**: For advanced build features and caching + +### File System Operations + +- Configuration discovery and parsing from workspace roots +- Template and feature resolution from local and remote sources +- Volume mounting and bind mount handling across platforms + +### External Dependencies + +- **Container registries**: For pulling base images and publishing +- **Git repositories**: For fetching features and templates +- **Package managers**: npm, pip, apt for installing tools in containers + +## Common Development Patterns + +### Adding New CLI Commands + +1. Define command in `src/spec-node/devContainersSpecCLI.ts` +2. Implement handler function with proper argument parsing +3. Add comprehensive error handling and logging +4. Include unit and integration tests +5. Update CLI help text and documentation + +### Configuration Processing + +- Use `src/spec-configuration/` utilities for parsing devcontainer.json +- Validate configuration against JSON schema +- Handle inheritance and composition (extends, merging) +- Support both local and remote configuration sources + +### Feature Implementation + +- Follow the specification's feature model +- Support installation scripts and lifecycle hooks +- Handle dependency resolution and ordering +- Provide proper cleanup and error recovery + +## Common Pitfalls + +### Platform-Specific Issues + +- **Path handling**: Use `path.posix` for container paths, `path` for host paths +- **Line endings**: Handle CRLF/LF differences in scripts and configs +- **File permissions**: Different behavior on Windows vs Unix systems +- **Container mounting**: Volume vs bind mount differences across platforms + +### Container Runtime Differences + +- Docker Desktop vs Docker Engine behavior variations +- Podman compatibility quirks (networking, volumes, security contexts) +- Image building differences between runtimes +- Registry authentication handling + +### Performance Considerations + +- **Image caching**: Leverage BuildKit and registry caching +- **Parallel operations**: Use proper concurrency for multi-container scenarios +- **File watching**: Efficient change detection for rebuild scenarios +- **Network optimization**: Minimize registry pulls and DNS lookups + +## Development Workflow + +### Setup and Building + +```bash +# Install dependencies +npm install + +# Build TypeScript +npm run build + +# Run tests +npm test + +# Run container tests (requires Docker) +npm run test:container +``` + +### Testing Guidelines + +- Always test with actual containers, not just mocks +- Test cross-platform scenarios when possible +- Include negative test cases for error conditions +- Verify cleanup behavior (containers, volumes, networks) + +### Debugging + +- Use `--log-level trace` for detailed operation logging +- Container logs are available via runtime APIs +- File system operations are logged at debug level +- Network issues often manifest as timeout errors + +## Extension Points + +### Custom Features + +- Implement in separate npm packages +- Follow feature specification format +- Provide proper metadata and documentation +- Test with multiple base images and scenarios + +### Lifecycle Hooks + +- `onCreateCommand`, `postCreateCommand`, `postStartCommand` +- Handle both synchronous and asynchronous operations +- Provide proper error propagation +- Support both shell commands and executable scripts + +## Related Documentation + +- [Contributing Guidelines](../../CONTRIBUTING.md) +- [Development Container Specification](https://containers.dev) +- [Feature and Template specifications](https://containers.dev/implementors/features/) +- [JSON Schema definitions](src/spec-configuration/schemas/) + +## Key Files and Directories + +- `src/spec-node/devContainersSpecCLI.ts` - Main CLI entry point +- `src/spec-configuration/configuration.ts` - Configuration parsing logic +- `src/spec-utils/` - Shared utilities and helpers +- `src/test/container-features/` - Feature integration tests +- `.devcontainer/` - Project's own development container configuration diff --git a/.github/prompts/extract-instructions.prompt.md b/.github/prompts/extract-instructions.prompt.md new file mode 100644 index 000000000..93d4012f9 --- /dev/null +++ b/.github/prompts/extract-instructions.prompt.md @@ -0,0 +1,28 @@ +--- +mode: edit +--- +Analyze the user requested part of the codebase (use a suitable ) to generate or update `.github/instructions/.instructions.md` for guiding AI coding agents. + +Focus on discovering the essential knowledge that would help an AI agents be immediately productive in the part of the codebase. Consider aspects like: +- The design and how it fits into the overall architecture +- Component-specific conventions and patterns that differ from common practices +- Integration points, external dependencies, and cross-component communication patterns +- Common pitfalls, edge cases, and non-obvious behaviors specific to this part of the codebase +- What are common ways to add to this part of the codebase? + +Source existing conventions from `.github/instructions/*.instructions.md,CONTRIBUTING.md,README.md}` and cross reference any of these files where relevant. + +Guidelines (read more at https://aka.ms/vscode-instructions-docs): +- If `.github/instructions/.instructions.md` exists, merge intelligently - preserve valuable content while updating outdated sections +- Write concise instructions using markdown structure +- Document only discoverable patterns, not aspirational practices +- Reference key files/directories that exemplify important patterns + +Your audience is other developers working on this project who know less about this feature area or other agents who come into this area to make changes. + +Update `.github/instructions/.instructions.md` for the user. Include an instructions header: +``` +--- +description: "Discussion of the part of the codebase" +--- +``` diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 59136695d..b9c0fdf1b 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -148,12 +148,6 @@ 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'); - } const mounts = (argv.mount && (Array.isArray(argv.mount) ? argv.mount : [argv.mount])) as string[] | undefined; if (mounts?.some(mount => !mountRegex.test(mount))) { throw new Error('Unmatched argument format: mount must match type=,source=,target=[,external=]'); @@ -218,7 +212,9 @@ async function provision({ 'include-merged-configuration': includeMergedConfig, }: ProvisionArgs) { - const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : undefined; + // Use cwd as workspaceFolder when not provided and no id-label or override-config are present + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : + (!idLabel && !overrideConfig ? process.cwd() : undefined); const addRemoteEnvs = addRemoteEnv ? (Array.isArray(addRemoteEnv) ? addRemoteEnv as string[] : [addRemoteEnv]) : []; const addCacheFroms = addCacheFrom ? (Array.isArray(addCacheFrom) ? addCacheFrom as string[] : [addCacheFrom]) : []; const additionalFeatures = additionalFeaturesJson ? jsonc.parse(additionalFeaturesJson) as Record> : {}; @@ -507,7 +503,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.' }, '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 +570,8 @@ async function doBuild({ await Promise.all(disposables.map(d => d())); }; try { - const workspaceFolder = path.resolve(process.cwd(), workspaceFolderArg); + // Use cwd as workspaceFolder when not provided + 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]) : []; @@ -784,9 +781,6 @@ function runUserCommandsOptions(y: Argv) { if (remoteEnvs?.some(remoteEnv => !/.+=.*/.test(remoteEnv))) { 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.'); - } return true; }); } @@ -840,7 +834,9 @@ async function doRunUserCommands({ await Promise.all(disposables.map(d => d())); }; try { - const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : undefined; + // Use cwd as workspaceFolder when not provided and no container-id or id-label are present + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : + (!containerId && !idLabel ? process.cwd() : undefined); const providedIdLabels = idLabel ? Array.isArray(idLabel) ? idLabel as string[] : [idLabel] : undefined; const addRemoteEnvs = addRemoteEnv ? (Array.isArray(addRemoteEnv) ? addRemoteEnv as string[] : [addRemoteEnv]) : []; const configFile = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; @@ -974,9 +970,6 @@ function readConfigurationOptions(y: Argv) { if (idLabels?.some(idLabel => !/.+=.+/.test(idLabel))) { 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.'); - } return true; }); } @@ -1012,7 +1005,9 @@ async function readConfiguration({ }; let output: Log | undefined; try { - const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : undefined; + // Use cwd as workspaceFolder when not provided and no container-id or id-label are present + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : + (!containerId && !idLabel ? process.cwd() : undefined); const providedIdLabels = idLabel ? Array.isArray(idLabel) ? idLabel as string[] : [idLabel] : undefined; const configFile = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; const overrideConfigFile = overrideConfig ? URI.file(path.resolve(process.cwd(), overrideConfig)) : undefined; @@ -1107,7 +1102,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.' }, '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 +1134,8 @@ async function outdated({ }; let output: Log | undefined; try { - const workspaceFolder = path.resolve(process.cwd(), workspaceFolderArg); + // Use cwd as workspaceFolder when not provided + 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, '..', '..'); @@ -1242,9 +1238,6 @@ function execOptions(y: Argv) { if (remoteEnvs?.some(remoteEnv => !/.+=.*/.test(remoteEnv))) { 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.'); - } return true; }); } @@ -1292,7 +1285,9 @@ export async function doExec({ let output: Log | undefined; const isTTY = process.stdin.isTTY && process.stdout.isTTY || logFormat === 'json'; // If stdin or stdout is a pipe, we don't want to use a PTY. try { - const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : undefined; + // Use cwd as workspaceFolder when not provided and no container-id or id-label are present + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : + (!containerId && !idLabel ? process.cwd() : undefined); const providedIdLabels = idLabel ? Array.isArray(idLabel) ? idLabel as string[] : [idLabel] : undefined; const addRemoteEnvs = addRemoteEnv ? (Array.isArray(addRemoteEnv) ? addRemoteEnv as string[] : [addRemoteEnv]) : []; const configFile = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; diff --git a/src/spec-node/featuresCLI/resolveDependencies.ts b/src/spec-node/featuresCLI/resolveDependencies.ts index 93e569ff6..3c5c71f85 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,9 @@ async function featuresResolveDependencies({ let jsonOutput: JsonOutput = {}; + // Use cwd as workspaceFolder when not provided + 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..c726f13ae 100644 --- a/src/spec-node/templatesCLI/apply.ts +++ b/src/spec-node/templatesCLI/apply.ts @@ -1,3 +1,4 @@ +import path from 'path'; import { Argv } from 'yargs'; import { Log, LogLevel, mapLogLevel } from '../../spec-utils/log'; import { getPackageConfig } from '../../spec-utils/product'; @@ -10,7 +11,7 @@ import { runAsyncHandler } from '../utils'; 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, @@ -43,6 +44,9 @@ async function templateApply({ await Promise.all(disposables.map(d => d())); }; + // Use cwd as workspaceFolder when not provided + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : process.cwd(); + const pkg = getPackageConfig(); const output = createLog({ diff --git a/src/spec-node/upgradeCommand.ts b/src/spec-node/upgradeCommand.ts index 3336087c7..d25874db5 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.' }, '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.' }, @@ -70,7 +70,8 @@ async function featuresUpgrade({ }; let output: Log | undefined; try { - const workspaceFolder = path.resolve(process.cwd(), workspaceFolderArg); + // Use cwd as workspaceFolder when not provided + 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..db84dcab6 100644 --- a/src/test/cli.build.test.ts +++ b/src/test/cli.build.test.ts @@ -433,5 +433,42 @@ 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 () => { + const testFolder = `${__dirname}/configs/example`; + const originalCwd = process.cwd(); + try { + process.chdir(testFolder); + const res = await shellExec(`${cli} 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 () => { + const tempDir = path.join(os.tmpdir(), 'devcontainer-build-test-' + Date.now()); + await shellExec(`mkdir -p ${tempDir}`); + const originalCwd = process.cwd(); + try { + process.chdir(tempDir); + let success = false; + try { + await shellExec(`${cli} 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..265b567e2 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'); @@ -406,6 +407,57 @@ export function describeTests2({ text, options }: BuildKitOption) { await shellExec(`docker rm -f ${response.containerId}`); }); + + describe('Command exec with default workspace', () => { + describe('with valid config in current directory', () => { + let containerId: string | null = null; + const testFolder = `${__dirname}/configs/image`; + + beforeEach(async () => { + const originalCwd = process.cwd(); + try { + process.chdir(testFolder); + containerId = (await devContainerUp(cli, '.')).containerId; + } finally { + process.chdir(originalCwd); + } + }); + + afterEach(async () => await devContainerDown({ containerId })); + + it('should execute command successfully when using current directory', async () => { + const originalCwd = process.cwd(); + try { + process.chdir(testFolder); + const res = await shellExec(`${cli} exec echo "hello world"`); + assert.match(res.stdout, /hello world/); + } finally { + process.chdir(originalCwd); + } + }); + }); + + 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(); + try { + process.chdir(tempDir); + let success = false; + try { + await shellExec(`${cli} exec echo "test"`); + success = true; + } catch (error) { + // 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}`); + } + }); + }); }); }); } diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index 522c9073d..63255e706 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,42 @@ describe('Dev Containers CLI', function () { await shellExec(`docker rm -f ${upResponse.containerId}`); }); + + it('should execute successfully when using current directory', async () => { + const testFolder = `${__dirname}/configs/image`; + const originalCwd = process.cwd(); + try { + process.chdir(testFolder); + const res = await shellExec(`${cli} run-user-commands`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + } finally { + process.chdir(originalCwd); + } + }); + + it('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 originalCwd = process.cwd(); + try { + process.chdir(tempDir); + let success = false; + try { + await shellExec(`${cli} run-user-commands`); + 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}`); + } + }); }); describe('Command read-configuration', () => { @@ -124,5 +161,41 @@ 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 originalCwd = process.cwd(); + try { + process.chdir(testFolder); + const res = await shellExec(`${cli} 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 originalCwd = process.cwd(); + try { + process.chdir(tempDir); + let success = false; + try { + await shellExec(`${cli} read-configuration`); + 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/cli.up.test.ts b/src/test/cli.up.test.ts index 94515e89a..d2d932e8e 100644 --- a/src/test/cli.up.test.ts +++ b/src/test/cli.up.test.ts @@ -310,4 +310,47 @@ 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 originalCwd = process.cwd(); + let containerId: string | null = null; + try { + process.chdir(testFolder); + const upResult = await devContainerUp(cli, '.'); + containerId = upResult.containerId; + assert.equal(upResult.outcome, 'success'); + assert.ok(containerId); + } finally { + process.chdir(originalCwd); + if (containerId) { + await devContainerDown({ 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 originalCwd = process.cwd(); + try { + process.chdir(tempDir); + let success = false; + try { + await shellExec(`${cli} 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/lockfile.test.ts b/src/test/container-features/lockfile.test.ts index 57034e6f2..9fb6685c2 100644 --- a/src/test/container-features/lockfile.test.ts +++ b/src/test/container-features/lockfile.test.ts @@ -258,4 +258,21 @@ 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 originalCwd = process.cwd(); + try { + process.chdir(workspaceFolder); + const res = await shellExec(`${cli} 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.1.3']); + assert.strictEqual(response.features['ghcr.io/devcontainers/features/git@1.1.3'].current, '1.1.3'); + } finally { + process.chdir(originalCwd); + } + }); }); \ No newline at end of file