Skip to content

Commit 5b33031

Browse files
authored
fix: harden repo-local helper launcher resolution
Harden repo-local helper generation, update repair behavior, ROADMAP lifecycle status reconciliation, and npx-first runtime guidance.
1 parent de3c780 commit 5b33031

41 files changed

Lines changed: 2060 additions & 634 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 71 additions & 58 deletions
Large diffs are not rendered by default.

bin/gsdd.mjs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { cmdFindPhase, cmdVerify, cmdScaffold, cmdPhaseStatus } from './lib/phas
2020
import { cmdFileOp } from './lib/file-ops.mjs';
2121
import { createCmdHealth } from './lib/health.mjs';
2222
import { cmdLifecyclePreflight } from './lib/lifecycle-preflight.mjs';
23+
import { resolveWorkspaceContext } from './lib/workspace-root.mjs';
2324

2425
const __filename = fileURLToPath(import.meta.url);
2526
const __dirname = dirname(__filename);
@@ -87,9 +88,18 @@ function createCliContext(cwd = process.cwd()) {
8788
const INIT_CONTEXT = createCliContext(CWD);
8889

8990
const cmdInit = createCmdInit(INIT_CONTEXT);
90-
const cmdUpdate = createCmdUpdate(INIT_CONTEXT);
9191
const cmdHealth = createCmdHealth(INIT_CONTEXT);
9292

93+
const cmdUpdate = (...updateArgs) => {
94+
const { args: normalizedArgs, workspaceRoot, invalid, error } = resolveWorkspaceContext(updateArgs, { cwd: INIT_CONTEXT.cwd });
95+
if (invalid) {
96+
console.error(error);
97+
process.exitCode = 1;
98+
return;
99+
}
100+
return createCmdUpdate(createCliContext(workspaceRoot))(...normalizedArgs);
101+
};
102+
93103
const COMMANDS = {
94104
init: cmdInit,
95105
update: cmdUpdate,

bin/lib/file-ops.mjs

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { cpSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'fs';
1+
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from 'fs';
22
import { dirname, isAbsolute, relative, resolve } from 'path';
33
import { output, parseFlagValue } from './cli-utils.mjs';
4+
import { resolveWorkspaceContext } from './workspace-root.mjs';
45

56
class FileOpError extends Error {}
67

@@ -21,6 +22,36 @@ function resolveWorkspacePath(cwd, target) {
2122
fail(`Path must stay inside the workspace: ${target}`);
2223
}
2324

25+
function ensureRealPathInsideWorkspace(workspaceRoot, candidate, label) {
26+
const realWorkspaceRoot = realpathSync(workspaceRoot);
27+
const realCandidate = realpathSync(candidate);
28+
const rel = relative(realWorkspaceRoot, realCandidate);
29+
if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) {
30+
return realCandidate;
31+
}
32+
fail(`${label} must stay inside the workspace: ${candidate}`);
33+
}
34+
35+
function ensureExistingFilePathInsideWorkspace(workspaceRoot, candidate, label) {
36+
const stats = lstatSync(candidate);
37+
if (stats.isSymbolicLink()) {
38+
fail(`${label} cannot be a symlink: ${candidate}`);
39+
}
40+
return ensureRealPathInsideWorkspace(workspaceRoot, candidate, label);
41+
}
42+
43+
function ensureParentPathInsideWorkspace(workspaceRoot, candidate, label) {
44+
let current = dirname(candidate);
45+
while (!existsSync(current)) {
46+
const parent = dirname(current);
47+
if (parent === current) {
48+
fail(`${label} must stay inside the workspace: ${candidate}`);
49+
}
50+
current = parent;
51+
}
52+
ensureExistingFilePathInsideWorkspace(workspaceRoot, current, label);
53+
}
54+
2455
function getMissingBehavior(args) {
2556
const parsed = parseFlagValue(args, '--missing');
2657
if (!parsed.present) return 'error';
@@ -36,6 +67,7 @@ function cmdCopy(cwd, args) {
3667
}
3768

3869
const missingBehavior = getMissingBehavior(flags);
70+
const workspaceRoot = resolve(cwd);
3971
const source = resolveWorkspacePath(cwd, sourceArg);
4072
const destination = resolveWorkspacePath(cwd, destinationArg);
4173

@@ -51,6 +83,12 @@ function cmdCopy(cwd, args) {
5183
fail(`Copy only supports files in this phase: ${sourceArg}`);
5284
}
5385

86+
ensureExistingFilePathInsideWorkspace(workspaceRoot, source, 'Source path');
87+
if (existsSync(destination)) {
88+
ensureExistingFilePathInsideWorkspace(workspaceRoot, destination, 'Destination path');
89+
}
90+
ensureParentPathInsideWorkspace(workspaceRoot, destination, 'Destination path');
91+
5492
mkdirSync(dirname(destination), { recursive: true });
5593
cpSync(source, destination, { force: true });
5694
output({ operation: 'copy', source: sourceArg, destination: destinationArg, changed: true });
@@ -63,6 +101,7 @@ function cmdDelete(cwd, args) {
63101
}
64102

65103
const missingBehavior = getMissingBehavior(flags);
104+
const workspaceRoot = resolve(cwd);
66105
const target = resolveWorkspacePath(cwd, targetArg);
67106

68107
if (!existsSync(target)) {
@@ -77,6 +116,7 @@ function cmdDelete(cwd, args) {
77116
fail(`Delete only supports files in this phase: ${targetArg}`);
78117
}
79118

119+
ensureExistingFilePathInsideWorkspace(workspaceRoot, target, 'Target path');
80120
unlinkSync(target);
81121
output({ operation: 'delete', target: targetArg, changed: true });
82122
}
@@ -93,6 +133,7 @@ function cmdRegexSub(cwd, args) {
93133
}
94134

95135
const missingBehavior = getMissingBehavior(flags);
136+
const workspaceRoot = resolve(cwd);
96137
const target = resolveWorkspacePath(cwd, targetArg);
97138

98139
if (!existsSync(target)) {
@@ -107,6 +148,8 @@ function cmdRegexSub(cwd, args) {
107148
fail(`regex-sub only supports files in this phase: ${targetArg}`);
108149
}
109150

151+
ensureExistingFilePathInsideWorkspace(workspaceRoot, target, 'Target path');
152+
110153
let regex;
111154
try {
112155
regex = new RegExp(pattern, regexFlags.value || 'g');
@@ -134,19 +177,24 @@ function cmdRegexSub(cwd, args) {
134177
}
135178

136179
export function cmdFileOp(...args) {
137-
const cwd = process.cwd();
138-
const [operation, ...rest] = args;
180+
const { args: normalizedArgs, workspaceRoot, invalid, error } = resolveWorkspaceContext(args);
181+
if (invalid) {
182+
console.error(error);
183+
process.exitCode = 1;
184+
return;
185+
}
186+
const [operation, ...rest] = normalizedArgs;
139187

140188
try {
141189
switch (operation) {
142190
case 'copy':
143-
cmdCopy(cwd, rest);
191+
cmdCopy(workspaceRoot, rest);
144192
return;
145193
case 'delete':
146-
cmdDelete(cwd, rest);
194+
cmdDelete(workspaceRoot, rest);
147195
return;
148196
case 'regex-sub':
149-
cmdRegexSub(cwd, rest);
197+
cmdRegexSub(workspaceRoot, rest);
150198
return;
151199
default:
152200
fail('Usage: gsdd file-op <copy|delete|regex-sub> ...');

bin/lib/health-truth.mjs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,11 @@ export function runTruthChecks(planningDir, frameworkDir, actualCheckIds, option
7575
}
7676
}
7777

78-
if (existsSync(specPath) && existsSync(roadmapPath)) {
79-
const mismatches = lifecycle.requirementAlignment.mismatches;
78+
if (existsSync(roadmapPath)) {
79+
const mismatches = [
80+
...(existsSync(specPath) ? lifecycle.requirementAlignment.mismatches : []),
81+
...lifecycle.phaseStatusAlignment.mismatches,
82+
];
8083
if (mismatches.length > 0) {
8184
warnings.push({
8285
id: 'W10',

bin/lib/health.mjs

Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { output } from './cli-utils.mjs';
1010
import { runTruthChecks, TRUTH_CHECK_IDS } from './health-truth.mjs';
1111
import { evaluateLifecycleState } from './lifecycle-state.mjs';
1212
import { evaluateRuntimeFreshness } from './runtime-freshness.mjs';
13+
import { resolveWorkspaceContext } from './workspace-root.mjs';
1314

1415
/**
1516
* Factory function returning the health command.
@@ -18,17 +19,26 @@ import { evaluateRuntimeFreshness } from './runtime-freshness.mjs';
1819
export function createCmdHealth(ctx) {
1920
return async function cmdHealth(...healthArgs) {
2021
const jsonMode = healthArgs.includes('--json');
21-
const cwd = process.cwd();
22-
const planningDir = join(cwd, '.planning');
22+
const { planningDir, workspaceRoot, invalid, error } = resolveWorkspaceContext(healthArgs);
23+
if (invalid) {
24+
if (jsonMode) {
25+
output({ status: 'broken', errors: [{ id: 'E1', severity: 'ERROR', message: error, fix: 'Pass --workspace-root with a real path or remove the flag.' }], warnings: [], info: [] });
26+
} else {
27+
console.log(error);
28+
}
29+
process.exitCode = 1;
30+
return;
31+
}
32+
const cwd = workspaceRoot;
2333
const frameworkSourceMode = isFrameworkSourceRepo(cwd);
2434
const healthCheckIds = ['E1', 'E2', 'E3', 'E4', 'E5', 'E6', 'E7', 'E8', 'W1', 'W2', 'W3', 'W4', 'W5', 'W6', ...TRUTH_CHECK_IDS, 'I1', 'I2', 'I3'];
2535

2636
// Pre-init guard
2737
if (!existsSync(join(planningDir, 'config.json'))) {
2838
if (jsonMode) {
29-
output({ status: 'broken', errors: [{ id: 'E1', severity: 'ERROR', message: '.planning/config.json missing', fix: 'Run `gsdd init`' }], warnings: [], info: [] });
39+
output({ status: 'broken', errors: [{ id: 'E1', severity: 'ERROR', message: '.planning/config.json missing', fix: 'Run `npx -y gsdd-cli init`' }], warnings: [], info: [] });
3040
} else {
31-
console.log('Not initialized. Run `gsdd init`.');
41+
console.log('Not initialized. Run `npx -y gsdd-cli init`. If `gsdd` is installed globally, `gsdd init` is also fine.');
3242
}
3343
process.exitCode = 1;
3444
return;
@@ -50,71 +60,73 @@ export function createCmdHealth(ctx) {
5060
const requiredFields = ['researchDepth', 'modelProfile', 'initVersion'];
5161
const missing = requiredFields.filter((f) => !(f in config));
5262
if (missing.length > 0) {
53-
errors.push({ id: 'E2', severity: 'ERROR', message: `config.json missing required fields: ${missing.join(', ')}`, fix: 'Run `gsdd init` to regenerate' });
63+
errors.push({ id: 'E2', severity: 'ERROR', message: `config.json missing required fields: ${missing.join(', ')}`, fix: 'Run `npx -y gsdd-cli init` to regenerate' });
5464
}
5565
} catch {
56-
errors.push({ id: 'E1', severity: 'ERROR', message: '.planning/config.json is unparseable', fix: 'Run `gsdd init`' });
66+
errors.push({ id: 'E1', severity: 'ERROR', message: '.planning/config.json is unparseable', fix: 'Run `npx -y gsdd-cli init`' });
5767
}
5868

5969
// E3: templates/ missing
6070
const templatesDir = join(planningDir, 'templates');
71+
const runtimeHelpersDir = join(planningDir, 'bin');
6172
const hasTemplatesDir = existsSync(templatesDir);
73+
const hasRuntimeHelpersDir = existsSync(runtimeHelpersDir);
6274
const rolesDir = join(templatesDir, 'roles');
6375
const delegatesDir = join(templatesDir, 'delegates');
6476
const hasRolesDir = hasTemplatesDir && existsSync(rolesDir);
6577
const hasDelegatesDir = hasTemplatesDir && existsSync(delegatesDir);
6678
const skipInstalledTemplateChecks = !hasTemplatesDir && frameworkSourceMode;
6779

6880
if (!hasTemplatesDir && !skipInstalledTemplateChecks) {
69-
errors.push({ id: 'E3', severity: 'ERROR', message: '.planning/templates/ missing', fix: 'Run `gsdd update --templates`' });
81+
errors.push({ id: 'E3', severity: 'ERROR', message: '.planning/templates/ missing', fix: 'Run `npx -y gsdd-cli update --templates`' });
7082
} else if (hasTemplatesDir) {
7183
// E4: roles/ missing or empty
7284
if (!hasRolesDir) {
73-
errors.push({ id: 'E4', severity: 'ERROR', message: '.planning/templates/roles/ missing', fix: 'Run `gsdd update --templates`' });
85+
errors.push({ id: 'E4', severity: 'ERROR', message: '.planning/templates/roles/ missing', fix: 'Run `npx -y gsdd-cli update --templates`' });
7486
} else {
7587
const roleFiles = readdirSync(rolesDir).filter((f) => f.endsWith('.md'));
7688
if (roleFiles.length === 0) {
77-
errors.push({ id: 'E4', severity: 'ERROR', message: '.planning/templates/roles/ has 0 role files', fix: 'Run `gsdd update --templates`' });
89+
errors.push({ id: 'E4', severity: 'ERROR', message: '.planning/templates/roles/ has 0 role files', fix: 'Run `npx -y gsdd-cli update --templates`' });
7890
}
7991
}
8092

8193
// E5: delegates/ missing or empty
8294
if (!hasDelegatesDir) {
83-
errors.push({ id: 'E5', severity: 'ERROR', message: '.planning/templates/delegates/ missing', fix: 'Run `gsdd update --templates`' });
95+
errors.push({ id: 'E5', severity: 'ERROR', message: '.planning/templates/delegates/ missing', fix: 'Run `npx -y gsdd-cli update --templates`' });
8496
} else {
8597
const delegateFiles = readdirSync(delegatesDir).filter((f) => f.endsWith('.md'));
8698
if (delegateFiles.length === 0) {
87-
errors.push({ id: 'E5', severity: 'ERROR', message: '.planning/templates/delegates/ has 0 delegate files', fix: 'Run `gsdd update --templates`' });
99+
errors.push({ id: 'E5', severity: 'ERROR', message: '.planning/templates/delegates/ has 0 delegate files', fix: 'Run `npx -y gsdd-cli update --templates`' });
88100
}
89101
}
90102

91103
// E6: research/ missing or empty
92104
const researchDir = join(templatesDir, 'research');
93105
if (!existsSync(researchDir)) {
94-
errors.push({ id: 'E6', severity: 'ERROR', message: '.planning/templates/research/ missing', fix: 'Run `gsdd update --templates`' });
106+
errors.push({ id: 'E6', severity: 'ERROR', message: '.planning/templates/research/ missing', fix: 'Run `npx -y gsdd-cli update --templates`' });
95107
} else {
96108
const researchFiles = readdirSync(researchDir).filter((f) => f.endsWith('.md'));
97109
if (researchFiles.length === 0) {
98-
errors.push({ id: 'E6', severity: 'ERROR', message: '.planning/templates/research/ has 0 template files', fix: 'Run `gsdd update --templates`' });
110+
errors.push({ id: 'E6', severity: 'ERROR', message: '.planning/templates/research/ has 0 template files', fix: 'Run `npx -y gsdd-cli update --templates`' });
99111
}
100112
}
101113

102114
// E7: codebase/ missing or empty
103115
const codebaseDir = join(templatesDir, 'codebase');
104116
if (!existsSync(codebaseDir)) {
105-
errors.push({ id: 'E7', severity: 'ERROR', message: '.planning/templates/codebase/ missing', fix: 'Run `gsdd update --templates`' });
117+
errors.push({ id: 'E7', severity: 'ERROR', message: '.planning/templates/codebase/ missing', fix: 'Run `npx -y gsdd-cli update --templates`' });
106118
} else {
107119
const codebaseFiles = readdirSync(codebaseDir).filter((f) => f.endsWith('.md'));
108120
if (codebaseFiles.length === 0) {
109-
errors.push({ id: 'E7', severity: 'ERROR', message: '.planning/templates/codebase/ has 0 template files', fix: 'Run `gsdd update --templates`' });
121+
errors.push({ id: 'E7', severity: 'ERROR', message: '.planning/templates/codebase/ has 0 template files', fix: 'Run `npx -y gsdd-cli update --templates`' });
110122
}
111123
}
112124

113125
// E8: critical root template files missing
114126
const requiredRootFiles = ['spec.md', 'roadmap.md', 'auth-matrix.md'];
115127
const missingRoot = requiredRootFiles.filter((f) => !existsSync(join(templatesDir, f)));
116128
if (missingRoot.length > 0) {
117-
errors.push({ id: 'E8', severity: 'ERROR', message: `.planning/templates/ missing critical root files: ${missingRoot.join(', ')}`, fix: 'Run `gsdd update --templates`' });
129+
errors.push({ id: 'E8', severity: 'ERROR', message: `.planning/templates/ missing critical root files: ${missingRoot.join(', ')}`, fix: 'Run `npx -y gsdd-cli update --templates`' });
118130
}
119131
}
120132

@@ -123,27 +135,28 @@ export function createCmdHealth(ctx) {
123135
// W1: generation-manifest.json missing
124136
const manifest = skipInstalledTemplateChecks ? null : readManifest(planningDir);
125137
if (!manifest && !skipInstalledTemplateChecks) {
126-
warnings.push({ id: 'W1', severity: 'WARN', message: 'generation-manifest.json missing', fix: 'Run `gsdd update --templates` to create' });
138+
warnings.push({ id: 'W1', severity: 'WARN', message: 'generation-manifest.json missing', fix: 'Run `npx -y gsdd-cli update` to create' });
127139
}
128140

129141
// W2 + W3: template/role hash mismatches and missing files
130142
if (manifest && hasTemplatesDir) {
131143
const allCategories = [
132-
{ name: 'delegates', dir: delegatesDir, hashes: hasDelegatesDir ? manifest.templates?.delegates : null },
133-
{ name: 'research', dir: join(templatesDir, 'research'), hashes: manifest.templates?.research },
134-
{ name: 'codebase', dir: join(templatesDir, 'codebase'), hashes: manifest.templates?.codebase },
135-
{ name: 'root templates', dir: templatesDir, hashes: manifest.templates?.root },
136-
{ name: 'roles', dir: rolesDir, hashes: hasRolesDir ? manifest.roles : null },
144+
{ name: 'delegates', dir: delegatesDir, hashes: hasDelegatesDir ? manifest.templates?.delegates : null, fixCommand: 'npx -y gsdd-cli update --templates' },
145+
{ name: 'research', dir: join(templatesDir, 'research'), hashes: manifest.templates?.research, fixCommand: 'npx -y gsdd-cli update --templates' },
146+
{ name: 'codebase', dir: join(templatesDir, 'codebase'), hashes: manifest.templates?.codebase, fixCommand: 'npx -y gsdd-cli update --templates' },
147+
{ name: 'root templates', dir: templatesDir, hashes: manifest.templates?.root, fixCommand: 'npx -y gsdd-cli update --templates' },
148+
{ name: 'roles', dir: rolesDir, hashes: hasRolesDir ? manifest.roles : null, fixCommand: 'npx -y gsdd-cli update --templates' },
149+
{ name: 'runtime helpers', dir: planningDir, hashes: hasRuntimeHelpersDir ? manifest.runtimeHelpers : null, fixCommand: 'npx -y gsdd-cli update' },
137150
];
138151

139152
for (const cat of allCategories) {
140153
if (!cat.hashes) continue;
141154
const result = detectModifications(cat.dir, cat.hashes);
142155
if (result.modified.length > 0) {
143-
warnings.push({ id: 'W2', severity: 'WARN', message: `${cat.name}: ${result.modified.length} file(s) modified locally (${result.modified.join(', ')})`, fix: 'Intentional? Run `gsdd update --templates` to reset' });
156+
warnings.push({ id: 'W2', severity: 'WARN', message: `${cat.name}: ${result.modified.length} file(s) modified locally (${result.modified.join(', ')})`, fix: `Intentional? Run \`${cat.fixCommand}\` to reset` });
144157
}
145158
if (result.missing.length > 0) {
146-
warnings.push({ id: 'W3', severity: 'WARN', message: `${cat.name}: ${result.missing.length} file(s) missing from disk (${result.missing.join(', ')})`, fix: 'Run `gsdd update --templates` to restore' });
159+
warnings.push({ id: 'W3', severity: 'WARN', message: `${cat.name}: ${result.missing.length} file(s) missing from disk (${result.missing.join(', ')})`, fix: `Run \`${cat.fixCommand}\` to restore` });
147160
}
148161
}
149162
}
@@ -186,20 +199,30 @@ export function createCmdHealth(ctx) {
186199
];
187200
const hasAnyAdapter = adapterPaths.some((p) => existsSync(p));
188201
if (!hasAnyAdapter) {
189-
warnings.push({ id: 'W6', severity: 'WARN', message: 'No adapter surfaces detected', fix: 'Run `gsdd init --tools <platform>`' });
202+
warnings.push({ id: 'W6', severity: 'WARN', message: 'No adapter surfaces detected', fix: 'Run `npx -y gsdd-cli init --tools <platform>`' });
190203
}
191204

192205
const runtimeFreshnessReport = configOk && Array.isArray(ctx.workflows)
193206
? evaluateRuntimeFreshness({ cwd, workflows: ctx.workflows })
194207
: null;
195208

196-
warnings.push(...runTruthChecks(planningDir, cwd, healthCheckIds, { runtimeFreshnessReport }));
209+
warnings.push(...runTruthChecks(planningDir, cwd, healthCheckIds, { runtimeFreshnessReport }).map((warning) => {
210+
if (warning.id !== 'W10') return warning;
211+
return {
212+
...warning,
213+
message: warning.message.replace(
214+
/^ROADMAP\/SPEC requirement status drift/,
215+
'ROADMAP lifecycle status drift (requirement checkbox and/or overview/detail phase status mismatch)'
216+
),
217+
fix: 'Reconcile .planning/ROADMAP.md overview/detail phase markers and .planning/SPEC.md requirement checkboxes',
218+
};
219+
}));
197220

198221
// --- INFO checks ---
199222

200223
// I1: generation manifest was produced by a different framework version
201224
if (manifest && manifest.frameworkVersion && manifest.frameworkVersion !== ctx.frameworkVersion) {
202-
info.push({ id: 'I1', severity: 'INFO', message: `Generation manifest frameworkVersion (${manifest.frameworkVersion}) differs from current framework version (${ctx.frameworkVersion})`, fix: 'Run `gsdd update --templates`' });
225+
info.push({ id: 'I1', severity: 'INFO', message: `Generation manifest frameworkVersion (${manifest.frameworkVersion}) differs from current framework version (${ctx.frameworkVersion})`, fix: 'Run `npx -y gsdd-cli update --templates`' });
203226
}
204227

205228
// I2: Phase completion count
@@ -250,4 +273,3 @@ export function createCmdHealth(ctx) {
250273
function isFrameworkSourceRepo(cwd) {
251274
return existsSync(join(cwd, 'distilled', 'templates')) && existsSync(join(cwd, 'distilled', 'workflows'));
252275
}
253-

0 commit comments

Comments
 (0)