diff --git a/.gitignore b/.gitignore index 3c45586..57563d3 100644 --- a/.gitignore +++ b/.gitignore @@ -49,5 +49,6 @@ junit.xml .jsii tsconfig.json !/API.md +.tmp/ !/.github/workflows/assign-approver.yml !/.projenrc.ts diff --git a/.npmignore b/.npmignore index 98db8fc..b86f757 100644 --- a/.npmignore +++ b/.npmignore @@ -22,6 +22,7 @@ dist tsconfig.tsbuildinfo /.eslintrc.json !.jsii +.tmp/ /.gitattributes /.projenrc.ts /projenrc diff --git a/.projenrc.ts b/.projenrc.ts index 6035bb0..9d51dbd 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -40,6 +40,10 @@ const project = new awscdk.AwsCdkConstructLibrary({ // packageName: undefined, /* The "name" in package.json. */ }); +// Ignore the .tmp/ directory used by compute-version for version artifacts +project.gitignore.exclude('.tmp/'); +project.npmignore?.exclude('.tmp/'); + project.jest?.addSetupFileAfterEnv('/test/jest.setup.ts'); // Add CLI bin entry for compute-version diff --git a/README.md b/README.md index 6082ea7..12e3c20 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,25 @@ For detailed documentation, see [Metadata Guide](docs/METADATA.md). A CLI utility for computing versions based on git information: ```bash -npx compute-version --strategy git-tag --environment production +# Basic usage (writes to .tmp/version.json) +npx compute-version '{"format":"{commit-count}","components":{}}' + +# Custom output path +npx compute-version --output build/version.json '{"format":"{git-tag}","components":{}}' ``` +The version artifact is written to `.tmp/version.json` by default. This path is +gitignored and safe for all shell emulators (Yarn Berry, pnpm, Bun). + +You can override the output path with the `--output` (or `-o`) flag, or via the +`VERSION_OUTPUT_PATH` environment variable. + +> **Migration note:** Previous versions wrote to `~version.json`. That path +> caused tilde-expansion failures in non-bash shell emulators. The CLI still +> reads `~version.json` as a fallback (with a deprecation warning), but no +> longer writes to it. Update any scripts that reference `~version.json` to +> use `.tmp/version.json` instead. + ## API Reference For complete API documentation, see [API.md](docs/API.md). diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md index 2b80ede..bfa8480 100644 --- a/docs/VERSIONING.md +++ b/docs/VERSIONING.md @@ -256,19 +256,59 @@ const config = VersioningOutputsFactory.minimal(); ## CLI Usage -The `compute-version` CLI computes version information from git: +The `compute-version` CLI computes version information from git and writes it to +a JSON artifact file. ```bash -# Basic usage -npx compute-version --environment production +# Basic usage — writes to .tmp/version.json (default) +npx compute-version '{"format":"{commit-count}","components":{}}' -# With strategy -npx compute-version --strategy git-tag --environment staging +# Custom strategy +npx compute-version '{"format":"{git-tag}","components":{"commitCount":{"mode":"all"}}}' -# Output as JSON -npx compute-version --format json +# Override output path +npx compute-version --output build/version.json '{"format":"{commit-count}","components":{}}' + +# Or via environment variable +VERSION_OUTPUT_PATH=build/version.json npx compute-version '{"format":"{commit-count}","components":{}}' ``` +### Version Artifact + +| Setting | Value | +|---------|-------| +| Default path | `.tmp/version.json` | +| Override flag | `--output ` / `-o ` | +| Environment variable | `VERSION_OUTPUT_PATH` | +| Gitignored | Yes (`.tmp/` is excluded) | + +The artifact is a JSON file containing all computed version fields (version, +commitHash, shortCommitHash, branch, tag, commitCount, environment, etc.). + +**Path validation:** The CLI rejects output paths whose filename starts with +`~`, `-`, or `#` — characters that cause shell-parsing hazards (tilde expansion, +option-flag interpretation, comment stripping). + +### Reading the Version File (Programmatic) + +```typescript +import { readVersionFile } from 'cdk-devops'; + +// Reads from .tmp/version.json, falls back to ~version.json (deprecated) +const json = readVersionFile(); +``` + +### Migration from `~version.json` + +Previous versions wrote the artifact to `~version.json`. A leading `~` in a +filename triggers tilde expansion in shell emulators used by Yarn Berry, pnpm, +and Bun, causing the build step to abort with "Unsupported tilde expansion". + +The new default is `.tmp/version.json`. The `readVersionFile()` helper still +reads `~version.json` as a fallback and emits a deprecation warning. This +fallback will be removed in the next minor release. Update any CI scripts or +projen tasks that reference `~version.json` to use `.tmp/version.json`. + ## Environment Variables The module automatically extracts information from these environment variables: diff --git a/package-lock.json b/package-lock.json index 01adecc..c3bd71b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -125,7 +125,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2345,7 +2344,6 @@ "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2416,7 +2414,6 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -2894,7 +2891,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3817,7 +3813,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -4303,8 +4298,7 @@ "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.4.2.tgz", "integrity": "sha512-wsNxBlAott2qg8Zv87q3eYZYgheb9lchtBfjHzzLHtXbttwSrHPs1NNQbBrmbb1YZvYg2+Vh0Dor76w4mFxJkA==", "dev": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/conventional-changelog": { "version": "4.0.0", @@ -5208,7 +5202,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5379,7 +5372,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7342,7 +7334,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -9411,7 +9402,6 @@ "integrity": "sha512-BMsFDilBLpSzIEdK38kYY4x0w4U5qZeLqOTiZUiyOwe9GsHZSfLCHWJ7TvTAAeBF36nnOzxSySL6+/Hp0N7pTQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@jsii/check-node": "^1.121.0", "@jsii/spec": "^1.121.0", @@ -15532,7 +15522,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -15742,7 +15731,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15808,7 +15796,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, diff --git a/src/versioning/compute-version.ts b/src/versioning/compute-version.ts index f1ad09e..96b6e1d 100644 --- a/src/versioning/compute-version.ts +++ b/src/versioning/compute-version.ts @@ -1,14 +1,55 @@ #!/usr/bin/env node import * as cp from 'child_process'; import * as fs from 'fs'; +import * as path from 'path'; import { VersionComputer } from './computation'; import { GitInfo } from './git-info'; +import { DEFAULT_VERSION_OUTPUT_PATH, LEGACY_VERSION_OUTPUT_PATH, shellSafePath, validateOutputPath } from './output-path'; import { VersioningStrategy } from './strategy'; +/** + * Options for the compute-version CLI + */ +export interface ComputeVersionOptions { + /** + * Strategy configuration with format string and components + */ + readonly strategyConfig: { format: string; components: any }; + + /** + * Output file path for the version JSON artifact. + * @default '.tmp/version.json' + */ + readonly outputPath?: string; +} + +/** + * Resolve the output path from CLI args, environment, or default. + */ +function resolveOutputPath(cliOutput: string | undefined): string { + const outputPath = cliOutput || process.env.VERSION_OUTPUT_PATH || DEFAULT_VERSION_OUTPUT_PATH; + validateOutputPath(outputPath); + return outputPath; +} + +/** + * Ensure the parent directory of a file path exists. + */ +function ensureDirectory(filePath: string): void { + const dir = path.dirname(filePath); + if (dir && dir !== '.' && !fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + /** * Compute version information and write to file + * + * @param options - Configuration options */ -export async function computeVersion(strategyConfig: { format: string; components: any }): Promise { +export async function computeVersion(options: ComputeVersionOptions): Promise { + const outputPath = resolveOutputPath(options.outputPath); + try { // Gather git information const commitHash = cp.execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim(); @@ -60,12 +101,14 @@ export async function computeVersion(strategyConfig: { format: string; component }; // Create strategy and compute version - const strategy = VersioningStrategy.create(strategyConfig.format, strategyConfig.components); + const strategy = VersioningStrategy.create(options.strategyConfig.format, options.strategyConfig.components); const computer = new VersionComputer(strategy); const versionInfo = computer.compute(context); - fs.writeFileSync('~version.json', versionInfo.toJson()); - console.log('Version computed:', versionInfo.version, '(commit:', versionInfo.shortCommitHash + ')'); + ensureDirectory(outputPath); + fs.writeFileSync(outputPath, versionInfo.toJson()); + console.log(`Version computed: ${versionInfo.version} (commit: ${versionInfo.shortCommitHash})`); + console.log(`Written to: ${shellSafePath(outputPath)}`); } catch (error: any) { console.error('Error computing version:', error.message); const fallback = { @@ -79,12 +122,68 @@ export async function computeVersion(strategyConfig: { format: string; component deploymentUser: 'unknown', environment: 'unknown', }; - fs.writeFileSync('~version.json', JSON.stringify(fallback, null, 2)); + ensureDirectory(outputPath); + fs.writeFileSync(outputPath, JSON.stringify(fallback, null, 2)); + } +} + +/** + * Read version information from the output file. + * + * Checks the configured path first, then falls back to the legacy + * `~version.json` path with a deprecation warning. + * + * @param outputPath - Primary path to read from + * @returns The file contents as a string, or undefined if not found + */ +export function readVersionFile(outputPath?: string): string | undefined { + const primary = outputPath || DEFAULT_VERSION_OUTPUT_PATH; + + if (fs.existsSync(primary)) { + return fs.readFileSync(primary, 'utf8'); + } + + // Backwards-compat fallback: read from legacy path with deprecation warning + if (fs.existsSync(LEGACY_VERSION_OUTPUT_PATH)) { + console.warn( + `[DEPRECATED] Reading version from "${LEGACY_VERSION_OUTPUT_PATH}". ` + + 'This fallback will be removed in the next minor version. ' + + `Please update your workflow to use "${shellSafePath(DEFAULT_VERSION_OUTPUT_PATH)}" instead.`, + ); + return fs.readFileSync(LEGACY_VERSION_OUTPUT_PATH, 'utf8'); + } + + return undefined; +} + +/** + * Parse CLI arguments. + * + * Supports: + * compute-version [strategyJson] + * compute-version --output [strategyJson] + */ +function parseCLIArgs(argv: string[]): { strategyConfig: string; outputPath?: string } { + const args = argv.slice(2); + let outputPath: string | undefined; + let strategyConfig = '{"format":"{commit-count}","components":{}}'; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--output' || args[i] === '-o') { + outputPath = args[++i]; + } else if (!args[i].startsWith('-')) { + strategyConfig = args[i]; + } } + + return { strategyConfig, outputPath }; } // CLI entry point if (require.main === module) { - const strategyConfig = JSON.parse(process.argv[2] || '{"format":"{commit-count}","components":{}}'); - computeVersion(strategyConfig).catch(console.error); + const { strategyConfig, outputPath } = parseCLIArgs(process.argv); + computeVersion({ + strategyConfig: JSON.parse(strategyConfig), + outputPath, + }).catch(console.error); } diff --git a/src/versioning/index.ts b/src/versioning/index.ts index 41c5d7a..dcb8404 100644 --- a/src/versioning/index.ts +++ b/src/versioning/index.ts @@ -16,5 +16,8 @@ export * from './computation'; // CDK Constructs export * from './version-outputs'; +// Output path utilities +export { DEFAULT_VERSION_OUTPUT_PATH, LEGACY_VERSION_OUTPUT_PATH, validateOutputPath, shellSafePath } from './output-path'; + // CLI utilities export { computeVersion } from './compute-version'; diff --git a/src/versioning/output-path.ts b/src/versioning/output-path.ts new file mode 100644 index 0000000..b9e4325 --- /dev/null +++ b/src/versioning/output-path.ts @@ -0,0 +1,76 @@ +import * as path from 'path'; + +/** + * Default output path for the computed version artifact. + * + * Uses a `.tmp/` directory to avoid shell-parsing hazards (tilde expansion, + * glob characters, etc.) and to keep the working tree clean with a single + * `.gitignore` entry. + */ +export const DEFAULT_VERSION_OUTPUT_PATH = '.tmp/version.json'; + +/** + * Legacy output path used in versions <= 0.x. + * Kept only for backwards-compatible fallback reads during the deprecation window. + * + * @deprecated Will be removed in the next minor version. + */ +export const LEGACY_VERSION_OUTPUT_PATH = '~version.json'; + +/** + * Characters that are unsafe as the first character of a filename segment + * in shell contexts. A leading `~` triggers tilde expansion, `-` can be + * misinterpreted as an option flag, and `#` starts a comment in many shells. + */ +const UNSAFE_LEADING_CHARS = ['~', '-', '#']; + +/** + * Validate a version output file path. + * + * Rejects paths whose final segment begins with a character that is hazardous + * in shell command strings (`~`, `-`, `#`). + * + * @param filePath - The path to validate + * @throws Error if the path is unsafe for shell usage + */ +export function validateOutputPath(filePath: string): void { + if (!filePath || filePath.trim().length === 0) { + throw new Error('Version output path must not be empty.'); + } + + const basename = path.basename(filePath); + + for (const char of UNSAFE_LEADING_CHARS) { + if (basename.startsWith(char)) { + throw new Error( + `Version output path is unsafe for shell usage: the filename "${basename}" ` + + `begins with "${char}". Leading ~, -, and # characters cause shell-parsing ` + + 'hazards (tilde expansion, option flags, comments). ' + + 'Use a path like ".tmp/version.json" or ".version.tmp.json" instead.', + ); + } + } +} + +/** + * Render a relative file path safe for embedding in shell command strings. + * + * Ensures relative paths start with `./` to prevent misinterpretation by the + * shell (e.g., a file named `-rf` being parsed as flags to `rm`). + * + * @param filePath - The file path to make shell-safe + * @returns A path string safe for use in shell commands + */ +export function shellSafePath(filePath: string): string { + // Absolute paths are already unambiguous + if (path.isAbsolute(filePath)) { + return filePath; + } + + // Already prefixed with ./ or ../ + if (filePath.startsWith('./') || filePath.startsWith('../')) { + return filePath; + } + + return `./${filePath}`; +} diff --git a/test/versioning/compute-version.test.ts b/test/versioning/compute-version.test.ts new file mode 100644 index 0000000..ca51379 --- /dev/null +++ b/test/versioning/compute-version.test.ts @@ -0,0 +1,91 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { readVersionFile } from '../../src/versioning/compute-version'; +import { DEFAULT_VERSION_OUTPUT_PATH, LEGACY_VERSION_OUTPUT_PATH, validateOutputPath } from '../../src/versioning/output-path'; + +describe('compute-version', () => { + let tmpDir: string; + let originalCwd: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cv-test-')); + originalCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('readVersionFile', () => { + it('should read from the default path when it exists', () => { + const outputDir = path.dirname(DEFAULT_VERSION_OUTPUT_PATH); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(DEFAULT_VERSION_OUTPUT_PATH, '{"version":"1.0.0"}'); + + const result = readVersionFile(); + expect(result).toBe('{"version":"1.0.0"}'); + }); + + it('should read from a custom path when specified', () => { + fs.mkdirSync('custom', { recursive: true }); + fs.writeFileSync('custom/ver.json', '{"version":"2.0.0"}'); + + const result = readVersionFile('custom/ver.json'); + expect(result).toBe('{"version":"2.0.0"}'); + }); + + it('should fall back to legacy ~version.json with a deprecation warning', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + fs.writeFileSync(LEGACY_VERSION_OUTPUT_PATH, '{"version":"0.9.0"}'); + + const result = readVersionFile(); + expect(result).toBe('{"version":"0.9.0"}'); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[DEPRECATED]'), + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining(LEGACY_VERSION_OUTPUT_PATH), + ); + + warnSpy.mockRestore(); + }); + + it('should prefer default path over legacy path', () => { + const outputDir = path.dirname(DEFAULT_VERSION_OUTPUT_PATH); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(DEFAULT_VERSION_OUTPUT_PATH, '{"version":"1.0.0"}'); + fs.writeFileSync(LEGACY_VERSION_OUTPUT_PATH, '{"version":"0.9.0"}'); + + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = readVersionFile(); + expect(result).toBe('{"version":"1.0.0"}'); + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it('should return undefined when no version file exists', () => { + const result = readVersionFile(); + expect(result).toBeUndefined(); + }); + }); + + describe('output path validation in CLI context', () => { + it('should reject --output with a tilde-prefixed filename', () => { + expect(() => validateOutputPath('~version.json')).toThrow(/unsafe for shell usage/); + }); + + it('should reject --output with a dash-prefixed filename', () => { + expect(() => validateOutputPath('-output.json')).toThrow(/unsafe for shell usage/); + }); + + it('should reject --output with a hash-prefixed filename', () => { + expect(() => validateOutputPath('#output.json')).toThrow(/unsafe for shell usage/); + }); + }); +}); diff --git a/test/versioning/output-path.test.ts b/test/versioning/output-path.test.ts new file mode 100644 index 0000000..71a89af --- /dev/null +++ b/test/versioning/output-path.test.ts @@ -0,0 +1,115 @@ +import { + DEFAULT_VERSION_OUTPUT_PATH, + LEGACY_VERSION_OUTPUT_PATH, + validateOutputPath, + shellSafePath, +} from '../../src/versioning/output-path'; + +describe('output-path', () => { + describe('DEFAULT_VERSION_OUTPUT_PATH', () => { + it('should be .tmp/version.json', () => { + expect(DEFAULT_VERSION_OUTPUT_PATH).toBe('.tmp/version.json'); + }); + + it('should not start with a shell-hazardous character', () => { + const basename = DEFAULT_VERSION_OUTPUT_PATH.split('/').pop()!; + expect(basename).not.toMatch(/^[~\-#]/); + }); + }); + + describe('LEGACY_VERSION_OUTPUT_PATH', () => { + it('should be ~version.json', () => { + expect(LEGACY_VERSION_OUTPUT_PATH).toBe('~version.json'); + }); + }); + + describe('validateOutputPath', () => { + it('should accept safe paths', () => { + expect(() => validateOutputPath('.tmp/version.json')).not.toThrow(); + expect(() => validateOutputPath('build/version.json')).not.toThrow(); + expect(() => validateOutputPath('.version.tmp.json')).not.toThrow(); + expect(() => validateOutputPath('/absolute/path/version.json')).not.toThrow(); + expect(() => validateOutputPath('nested/deep/dir/out.json')).not.toThrow(); + }); + + it('should reject paths with leading tilde in filename', () => { + expect(() => validateOutputPath('~version.json')).toThrow(/unsafe for shell usage/); + expect(() => validateOutputPath('dir/~output.json')).toThrow(/unsafe for shell usage/); + expect(() => validateOutputPath('~')).toThrow(/unsafe for shell usage/); + }); + + it('should reject paths with leading dash in filename', () => { + expect(() => validateOutputPath('-version.json')).toThrow(/unsafe for shell usage/); + expect(() => validateOutputPath('dir/-output.json')).toThrow(/unsafe for shell usage/); + }); + + it('should reject paths with leading hash in filename', () => { + expect(() => validateOutputPath('#version.json')).toThrow(/unsafe for shell usage/); + expect(() => validateOutputPath('dir/#output.json')).toThrow(/unsafe for shell usage/); + }); + + it('should reject empty paths', () => { + expect(() => validateOutputPath('')).toThrow(/must not be empty/); + expect(() => validateOutputPath(' ')).toThrow(/must not be empty/); + }); + + it('should allow leading dot in filename (dotfiles are inert)', () => { + expect(() => validateOutputPath('.version.json')).not.toThrow(); + expect(() => validateOutputPath('dir/.output.json')).not.toThrow(); + }); + }); + + describe('shellSafePath', () => { + it('should prefix relative paths with ./', () => { + expect(shellSafePath('.tmp/version.json')).toBe('./.tmp/version.json'); + expect(shellSafePath('build/out.json')).toBe('./build/out.json'); + expect(shellSafePath('version.json')).toBe('./version.json'); + }); + + it('should not double-prefix paths already starting with ./', () => { + expect(shellSafePath('./build/out.json')).toBe('./build/out.json'); + }); + + it('should not prefix paths starting with ../', () => { + expect(shellSafePath('../build/out.json')).toBe('../build/out.json'); + }); + + it('should not prefix absolute paths', () => { + expect(shellSafePath('/tmp/version.json')).toBe('/tmp/version.json'); + expect(shellSafePath('/home/user/build/out.json')).toBe('/home/user/build/out.json'); + }); + }); + + describe('regression: no emitted path contains unquoted tilde token', () => { + /** + * Asserts that no shell command string produced by the path helpers + * contains a token matching /(^|\s)~[^/\s]/ — i.e., tilde followed by + * a non-slash, non-whitespace character, which triggers tilde expansion. + */ + it('shellSafePath of default output should not produce a tilde-expansion token', () => { + const rendered = shellSafePath(DEFAULT_VERSION_OUTPUT_PATH); + expect(rendered).not.toMatch(/(^|\s)~[^/\s]/); + }); + + it('no safe path through shellSafePath should contain a tilde-expansion token', () => { + const safePaths = [ + '.tmp/version.json', + 'build/version.json', + '.version.tmp.json', + '/tmp/version.json', + './output/version.json', + '../parent/version.json', + ]; + + for (const p of safePaths) { + const rendered = shellSafePath(p); + expect(rendered).not.toMatch(/(^|\s)~[^/\s]/); + } + }); + + it('cat command with default path should not contain a tilde-expansion token', () => { + const cmd = `cat ${shellSafePath(DEFAULT_VERSION_OUTPUT_PATH)}`; + expect(cmd).not.toMatch(/(^|\s)~[^/\s]/); + }); + }); +});