fix(windows): cross-platform path separators, JSON quoting, and dollar signs
- Add toPosixPath() helper to normalize output paths to forward slashes - Use string concatenation for relative base paths instead of path.join() - Apply toPosixPath() to all user-facing file paths in init.cjs output - Use array-based execFileSync in test helpers to bypass shell quoting issues with JSON args and dollar signs on Windows cmd.exe Fixes 7 test failures on Windows: frontmatter set/merge (3), init path assertions (2), and state dollar-amount corruption (2). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,13 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// ─── Path helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Normalize a relative path to always use forward slashes (cross-platform). */
|
||||
function toPosixPath(p) {
|
||||
return p.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
// ─── Model Profile Table ─────────────────────────────────────────────────────
|
||||
|
||||
const MODEL_PROFILES = {
|
||||
@@ -218,7 +225,7 @@ function searchPhaseInDir(baseDir, relBase, normalized) {
|
||||
|
||||
return {
|
||||
found: true,
|
||||
directory: path.join(relBase, match),
|
||||
directory: toPosixPath(path.join(relBase, match)),
|
||||
phase_number: phaseNumber,
|
||||
phase_name: phaseName,
|
||||
phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
|
||||
@@ -241,7 +248,7 @@ function findPhaseInternal(cwd, phase) {
|
||||
const normalized = normalizePhaseName(phase);
|
||||
|
||||
// Search current phases first
|
||||
const current = searchPhaseInDir(phasesDir, path.join('.planning', 'phases'), normalized);
|
||||
const current = searchPhaseInDir(phasesDir, '.planning/phases', normalized);
|
||||
if (current) return current;
|
||||
|
||||
// Search archived milestone phases (newest first)
|
||||
@@ -259,7 +266,7 @@ function findPhaseInternal(cwd, phase) {
|
||||
for (const archiveName of archiveDirs) {
|
||||
const version = archiveName.match(/^(v[\d.]+)-phases$/)[1];
|
||||
const archivePath = path.join(milestonesDir, archiveName);
|
||||
const relBase = path.join('.planning', 'milestones', archiveName);
|
||||
const relBase = '.planning/milestones/' + archiveName;
|
||||
const result = searchPhaseInDir(archivePath, relBase, normalized);
|
||||
if (result) {
|
||||
result.archived = version;
|
||||
@@ -419,4 +426,5 @@ module.exports = {
|
||||
pathExistsInternal,
|
||||
generateSlugInternal,
|
||||
getMilestoneInfo,
|
||||
toPosixPath,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, output, error } = require('./core.cjs');
|
||||
const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, toPosixPath, output, error } = require('./core.cjs');
|
||||
|
||||
function cmdInitExecutePhase(cwd, phase, raw) {
|
||||
if (!phase) {
|
||||
@@ -139,19 +139,19 @@ function cmdInitPlanPhase(cwd, phase, raw) {
|
||||
const files = fs.readdirSync(phaseDirFull);
|
||||
const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
|
||||
if (contextFile) {
|
||||
result.context_path = path.join(phaseInfo.directory, contextFile);
|
||||
result.context_path = toPosixPath(path.join(phaseInfo.directory, contextFile));
|
||||
}
|
||||
const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
|
||||
if (researchFile) {
|
||||
result.research_path = path.join(phaseInfo.directory, researchFile);
|
||||
result.research_path = toPosixPath(path.join(phaseInfo.directory, researchFile));
|
||||
}
|
||||
const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md');
|
||||
if (verificationFile) {
|
||||
result.verification_path = path.join(phaseInfo.directory, verificationFile);
|
||||
result.verification_path = toPosixPath(path.join(phaseInfo.directory, verificationFile));
|
||||
}
|
||||
const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md');
|
||||
if (uatFile) {
|
||||
result.uat_path = path.join(phaseInfo.directory, uatFile);
|
||||
result.uat_path = toPosixPath(path.join(phaseInfo.directory, uatFile));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -422,19 +422,19 @@ function cmdInitPhaseOp(cwd, phase, raw) {
|
||||
const files = fs.readdirSync(phaseDirFull);
|
||||
const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
|
||||
if (contextFile) {
|
||||
result.context_path = path.join(phaseInfo.directory, contextFile);
|
||||
result.context_path = toPosixPath(path.join(phaseInfo.directory, contextFile));
|
||||
}
|
||||
const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
|
||||
if (researchFile) {
|
||||
result.research_path = path.join(phaseInfo.directory, researchFile);
|
||||
result.research_path = toPosixPath(path.join(phaseInfo.directory, researchFile));
|
||||
}
|
||||
const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md');
|
||||
if (verificationFile) {
|
||||
result.verification_path = path.join(phaseInfo.directory, verificationFile);
|
||||
result.verification_path = toPosixPath(path.join(phaseInfo.directory, verificationFile));
|
||||
}
|
||||
const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md');
|
||||
if (uatFile) {
|
||||
result.uat_path = path.join(phaseInfo.directory, uatFile);
|
||||
result.uat_path = toPosixPath(path.join(phaseInfo.directory, uatFile));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -469,7 +469,7 @@ function cmdInitTodos(cwd, area, raw) {
|
||||
created: createdMatch ? createdMatch[1].trim() : 'unknown',
|
||||
title: titleMatch ? titleMatch[1].trim() : 'Untitled',
|
||||
area: todoArea,
|
||||
path: path.join('.planning', 'todos', 'pending', file),
|
||||
path: '.planning/todos/pending/' + file,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
@@ -629,7 +629,7 @@ function cmdInitProgress(cwd, raw) {
|
||||
const phaseInfo = {
|
||||
number: phaseNumber,
|
||||
name: phaseName,
|
||||
directory: path.join('.planning', 'phases', dir),
|
||||
directory: '.planning/phases/' + dir,
|
||||
status,
|
||||
plan_count: plans.length,
|
||||
summary_count: summaries.length,
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('frontmatter set', () => {
|
||||
|
||||
test('handles JSON array value', () => {
|
||||
const file = writeTempFile('---\nphase: 01\n---\nbody');
|
||||
const result = runGsdTools(`frontmatter set ${file} --field tags --value '["a","b"]'`);
|
||||
const result = runGsdTools(['frontmatter', 'set', file, '--field', 'tags', '--value', '["a","b"]']);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
@@ -140,7 +140,7 @@ describe('frontmatter set', () => {
|
||||
describe('frontmatter merge', () => {
|
||||
test('merges multiple fields into frontmatter', () => {
|
||||
const file = writeTempFile('---\nphase: 01\n---\nbody');
|
||||
const result = runGsdTools(`frontmatter merge ${file} --data '{"plan":"02","type":"tdd"}'`);
|
||||
const result = runGsdTools(['frontmatter', 'merge', file, '--data', '{"plan":"02","type":"tdd"}']);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
@@ -153,7 +153,7 @@ describe('frontmatter merge', () => {
|
||||
|
||||
test('overwrites existing fields on conflict', () => {
|
||||
const file = writeTempFile('---\nphase: 01\ntype: execute\n---\nbody');
|
||||
const result = runGsdTools(`frontmatter merge ${file} --data '{"phase":"02"}'`);
|
||||
const result = runGsdTools(['frontmatter', 'merge', file, '--data', '{"phase":"02"}']);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
|
||||
@@ -2,20 +2,35 @@
|
||||
* GSD Tools Test Helpers
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const { execSync, execFileSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const TOOLS_PATH = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
// Helper to run gsd-tools command
|
||||
/**
|
||||
* Run gsd-tools command.
|
||||
*
|
||||
* @param {string|string[]} args - Command string (shell-interpreted) or array
|
||||
* of arguments (shell-bypassed via execFileSync, safe for JSON and dollar signs).
|
||||
* @param {string} cwd - Working directory.
|
||||
*/
|
||||
function runGsdTools(args, cwd = process.cwd()) {
|
||||
try {
|
||||
const result = execSync(`node "${TOOLS_PATH}" ${args}`, {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
let result;
|
||||
if (Array.isArray(args)) {
|
||||
result = execFileSync(process.execPath, [TOOLS_PATH, ...args], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
} else {
|
||||
result = execSync(`node "${TOOLS_PATH}" ${args}`, {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
return { success: true, output: result.trim() };
|
||||
} catch (err) {
|
||||
return {
|
||||
|
||||
@@ -205,7 +205,7 @@ None
|
||||
);
|
||||
|
||||
const result = runGsdTools(
|
||||
"state add-decision --phase 11-01 --summary 'Benchmark prices moved from $0.50 to $2.00 to $5.00' --rationale 'track cost growth'",
|
||||
['state', 'add-decision', '--phase', '11-01', '--summary', 'Benchmark prices moved from $0.50 to $2.00 to $5.00', '--rationale', 'track cost growth'],
|
||||
tmpDir
|
||||
);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
@@ -233,7 +233,7 @@ None
|
||||
`
|
||||
);
|
||||
|
||||
const result = runGsdTools("state add-blocker --text 'Waiting on vendor quote $1.00 before approval'", tmpDir);
|
||||
const result = runGsdTools(['state', 'add-blocker', '--text', 'Waiting on vendor quote $1.00 before approval'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
|
||||
Reference in New Issue
Block a user