Files
OpenSpec/scripts/pack-version-check.mjs
1d35e90880 fix(windows): preserve a file's existing line endings on rewrite (#1958)
* fix(windows): preserve a file's existing line endings on rewrite

The parsers normalize CRLF to LF on read, but nothing restored it on
write. On a Windows checkout (core.autocrlf=true) that turned every
rewrite into a whole-file change: applying a delta that added one
requirement produced a diff of 21 insertions and 14 deletions, burying
the real change. Archiving the same spec now writes 7 insertions and 0
deletions.

- specs-apply: write an updated spec back with the convention the file
  already used; a spec that does not exist yet stays LF.
- file-system: same fix for updateFileWithMarkers, so installing shell
  completions into a CRLF .bashrc/.zshrc no longer leaves mixed endings,
  which bash reports as "$'\r': command not found".
- pack-version-check: spawn npm through cross-spawn, since execFile
  cannot resolve npm.cmd on Windows.

Adds src/utils/line-endings.ts for the detect/restore pair, plus tests
pinning the CRLF round trip through the real write paths. Also adds
regression tests for path containment under Windows case variance:
path.win32.relative already folds case, and those tests pin both halves
of the contract so a future "case-insensitive" change cannot quietly
loosen the traversal guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(windows): keep removeMarkerBlock on the file's own newline

Addresses the two review points and one more instance of the same bug.

`removeMarkerBlock` collapses a run of blank lines, and rebuilt the
separator as a bare '\n' regardless of the file it came from. Removing a
managed block from a CRLF CLAUDE.md or rc file therefore left a lone LF
behind - the mixed ending this PR exists to prevent. It now uses the
newline it already detects for the trailing ending.

Test fixes:

- `marker-updates.test.ts`: close `describe('line endings')` so
  `removeMarkerBlock` is no longer nested inside `updateFileWithMarkers`.
- `path-containment.test.ts`: exercise `FileSystemUtils.assertPathWithin`
  and `resolveProjectArtifactPath` instead of a private copy of the
  containment logic, which passed whatever the production guard did. The
  guard had no coverage at all; a prefix-comparison regression now fails
  the sibling case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(windows): read the file's convention consistently, and only ENOENT as absent

Three follow-ups from CodeRabbit's pass on the superseding PR.

`writeUpdatedSpec` turned every read error into "no previous file", so an
existing but unreadable spec was treated as absent and rewritten as LF.
Only ENOENT means absent now; everything else propagates.

`removeMarkerBlock` chose CRLF whenever the content held one anywhere, so
a single stray CRLF in an otherwise-LF file pulled the whole rewrite to
CRLF. It now uses detectLineEnding, the same dominant-ending reading
matchLineEnding uses, so both write paths agree.

Added the alias-path case the containment suite was missing: a directory
link inside the root that resolves outside it. That exercises the
canonicalization half of the guard, which a lexical check cannot do - the
link's own path looks contained. Skipped where creating a directory link
needs a privilege the runner lacks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Travis James <travis@tribehealthsolutions.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 14:30:24 +00:00

128 lines
4.0 KiB
JavaScript

#!/usr/bin/env node
// Guard: Ensure the packed tarball's CLI `--version` matches package.json.
//
// Notes:
// - We intentionally use `npm pack` (not pnpm) because `npm pack --json` is
// consistently supported and returns the tarball metadata we need. The
// project uses pnpm for install/publish, but this guard only needs to pack
// locally and verify the installed CLI output.
// - `npm pack` triggers the package's `prepare` script (build), and
// `changeset publish` triggers `prepublishOnly` (also builds here). This
// means an explicit build is not strictly necessary for the guard.
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import path from 'path';
import spawn from 'cross-spawn';
function log(msg) {
if (process.env.CI) return; // keep CI logs quiet by default
console.log(msg);
}
// cross-spawn, not execFileSync: on Windows `npm` is npm.cmd, which execFile
// cannot resolve without a shell. Keeps the argv form, so no shell is involved.
function run(cmd, args, opts = {}) {
const result = spawn.sync(cmd, args, {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
...opts,
});
if (result.error) throw result.error;
if (result.status !== 0) {
const stderr = (result.stderr || '').trim();
throw new Error(
`${cmd} ${args.join(' ')} exited with ${result.status}${stderr ? `: ${stderr}` : ''}`
);
}
return result.stdout;
}
function npmPack() {
try {
const jsonOut = run('npm', ['pack', '--json', '--silent']);
const arr = JSON.parse(jsonOut);
if (Array.isArray(arr) && arr.length > 0) {
const last = arr[arr.length - 1];
const file = (last && typeof last === 'object' && last.filename) || (typeof last === 'string' ? last : null);
if (file) return String(file).trim();
}
// Unexpected JSON shape or empty array; fallback to plain output
const out = run('npm', ['pack', '--silent']).trim();
const lines = out.split(/\r?\n/);
return lines[lines.length - 1].trim();
} catch (e) {
// Fallback for environments not supporting --json
const out = run('npm', ['pack', '--silent']).trim();
const lines = out.split(/\r?\n/);
return lines[lines.length - 1].trim();
}
}
function main() {
const pkg = JSON.parse(readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'));
const expected = pkg.version;
let work;
let tgzPath;
try {
log(`Packing @fission-ai/openspec@${expected}...`);
const filename = npmPack();
tgzPath = path.resolve(filename);
log(`Created: ${tgzPath}`);
work = mkdtempSync(path.join(tmpdir(), 'openspec-pack-check-'));
log(`Temp dir: ${work}`);
// Make a tiny project
writeFileSync(
path.join(work, 'package.json'),
JSON.stringify({ name: 'pack-check', private: true }, null, 2)
);
// Try to avoid noisy output and speed up
const env = {
...process.env,
npm_config_loglevel: 'silent',
npm_config_audit: 'false',
npm_config_fund: 'false',
npm_config_progress: 'false',
};
// Install the tarball
run('npm', ['install', tgzPath, '--silent', '--no-audit', '--no-fund'], { cwd: work, env });
// Run the installed CLI via Node to avoid bin resolution/platform issues
const binRel = path.join('node_modules', '@fission-ai', 'openspec', 'bin', 'openspec.js');
const actual = run(process.execPath, [binRel, '--version'], { cwd: work }).trim();
if (actual !== expected) {
throw new Error(
`Packed CLI version mismatch: expected ${expected}, got ${actual}. ` +
'Ensure the dist is built and the CLI reads version from package.json.'
);
}
log('Version check passed.');
} finally {
// Always attempt cleanup
if (work) {
try { rmSync(work, { recursive: true, force: true }); } catch {}
}
if (tgzPath) {
try { rmSync(tgzPath, { force: true }); } catch {}
}
}
}
try {
main();
console.log('✅ pack-version-check: OK');
} catch (err) {
console.error(`❌ pack-version-check: ${err.message}`);
process.exit(1);
}