@structor-dev/cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/README.md +131 -21
- package/ROADMAP.md +38 -0
- package/SECURITY.md +33 -0
- package/bin/structor.mjs +553 -29
- package/contrib/self-harness/files/README.md +32 -0
- package/contrib/self-harness/files/ai/AGENTS.md +35 -0
- package/contrib/self-harness/files/ai/ARCHITECTURE.md +38 -0
- package/contrib/self-harness/files/ai/HUB.md +59 -0
- package/contrib/self-harness/files/ai/PRODUCT.md +36 -0
- package/contrib/self-harness/files/ai/QUALITY.md +31 -0
- package/contrib/self-harness/files/ai/context.md +38 -0
- package/contrib/self-harness/files/scripts/check-workspace.mjs +72 -0
- package/contrib/self-harness/harness.config.json +37 -0
- package/docs/CONTRIBUTOR-SETUP.md +45 -0
- package/docs/INIT.md +55 -2
- package/docs/public-launch.md +150 -0
- package/examples/anthropic-only/harness.config.json +26 -0
- package/examples/frontend-backend/harness.config.json +8 -8
- package/examples/generated-harness-tree.md +432 -0
- package/examples/openai-and-anthropic/harness.config.json +7 -7
- package/examples/single-repo/harness.config.json +7 -7
- package/harness.config.example.json +1 -1
- package/package.json +12 -4
- package/schemas/contract-manifest.schema.json +0 -1
- package/schemas/harness-config.schema.json +5 -2
- package/scripts/check-config.mjs +20 -31
- package/scripts/check-examples.mjs +146 -0
- package/scripts/check-public-hygiene.mjs +249 -0
- package/scripts/check-schemas.mjs +42 -0
- package/scripts/check-template-files.mjs +15 -98
- package/scripts/generated-harness-contract.mjs +416 -0
- package/scripts/init-harness.mjs +227 -139
- package/scripts/lib.mjs +462 -12
- package/scripts/rendered-config.mjs +109 -0
- package/scripts/setup-contributor.mjs +125 -0
- package/scripts/smoke-template.mjs +260 -73
- package/template/AGENTS.md.tpl +4 -2
- package/template/README.md.tpl +5 -0
- package/template/ai/CODEX-HOOKS.md.tpl +1 -1
- package/template/ai/HARNESS-ENGINEERING.md.tpl +5 -2
- package/template/ai/HARNESS.md.tpl +4 -1
- package/template/ai/contracts/codex-hooks.contract.json.tpl +58 -1
- package/template/ai/contracts/codex-hooks.md.tpl +6 -0
- package/template/ai/contracts/release-flow.md.tpl +1 -1
- package/template/ai/templates/fixtures/issues/valid-ready.md.tpl +3 -1
- package/template/ai/templates/issue-template.md.tpl +3 -1
- package/template/ai/workspace/LOCAL-STACK.md.tpl +1 -1
- package/template/ai/workspace/SYSTEM-MAP.md.tpl +2 -2
- package/template/consumer/AGENTS.md.tpl +4 -4
- package/template/consumer/CLAUDE.md.tpl +4 -4
- package/template/scripts/bootstrap-workspace.mjs.tpl +11 -25
- package/template/scripts/check-claude-compatibility.mjs.tpl +62 -9
- package/template/scripts/check-codex-hooks.mjs.tpl +262 -20
- package/template/scripts/check-template-governance.mjs.tpl +2 -114
- package/template/scripts/check-workspace.mjs.tpl +27 -103
- package/template/scripts/check-worktree-bootstrap-fixtures.mjs.tpl +12 -0
- package/template/scripts/generate-html-views.mjs.tpl +357 -56
- package/template/scripts/generated-harness-contract.mjs.tpl +1 -0
- package/template/scripts/hooks/lib/codex-hooks-core.mjs.tpl +14 -3
- package/template/scripts/lib/path-safety.mjs.tpl +87 -0
- package/template/scripts/lib/worktree-bootstrap.mjs.tpl +16 -13
- package/template/scripts/validate-governance.mjs.tpl +52 -36
- package/schemas/task-brief.schema.json +0 -37
package/bin/structor.mjs
CHANGED
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
-
import { access, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { access, mkdir, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
5
5
|
import { constants as fsConstants } from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import process from "node:process";
|
|
8
8
|
import readline from "node:readline/promises";
|
|
9
|
-
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
10
|
+
import {
|
|
11
|
+
assertSafeConsumerPath,
|
|
12
|
+
hasConsumerRepositorySignal,
|
|
13
|
+
resolveHarnessConfig,
|
|
14
|
+
validateConfigShape,
|
|
15
|
+
} from "../scripts/lib.mjs";
|
|
16
|
+
import {
|
|
17
|
+
consumerEntrypointsForSettings,
|
|
18
|
+
requiredHarnessRepoFilesForWorkspaceCheck,
|
|
19
|
+
requiredWorkspaceFilesForWorkspaceCheck,
|
|
20
|
+
workspaceEntrypointsForSettings,
|
|
21
|
+
} from "../scripts/generated-harness-contract.mjs";
|
|
10
22
|
|
|
11
23
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
24
|
const generatorPath = path.join(packageRoot, "scripts/init-harness.mjs");
|
|
13
25
|
const configFileName = "harness.config.json";
|
|
26
|
+
const structorRepoUrlDefault = "https://github.com/nicolaycamacho/structor.git";
|
|
14
27
|
const reset = "\x1b[0m";
|
|
15
28
|
const styles = {
|
|
16
29
|
bold: "\x1b[1m",
|
|
@@ -83,7 +96,7 @@ async function maybeReadJson(filePath) {
|
|
|
83
96
|
}
|
|
84
97
|
}
|
|
85
98
|
|
|
86
|
-
function slugify(value) {
|
|
99
|
+
export function slugify(value) {
|
|
87
100
|
return value
|
|
88
101
|
.trim()
|
|
89
102
|
.toLowerCase()
|
|
@@ -92,12 +105,12 @@ function slugify(value) {
|
|
|
92
105
|
|| "project";
|
|
93
106
|
}
|
|
94
107
|
|
|
95
|
-
function relativeFrom(basePath, targetPath) {
|
|
108
|
+
export function relativeFrom(basePath, targetPath) {
|
|
96
109
|
const relative = path.relative(basePath, targetPath).replaceAll(path.sep, "/");
|
|
97
110
|
return relative === "" ? "." : relative.startsWith(".") ? relative : `./${relative}`;
|
|
98
111
|
}
|
|
99
112
|
|
|
100
|
-
function parseArgs(argv) {
|
|
113
|
+
export function parseArgs(argv) {
|
|
101
114
|
const [command = "help", ...rest] = argv;
|
|
102
115
|
const options = { _: [] };
|
|
103
116
|
for (let index = 0; index < rest.length; index += 1) {
|
|
@@ -110,15 +123,25 @@ function parseArgs(argv) {
|
|
|
110
123
|
else if (arg === "--help" || arg === "-h") options.help = true;
|
|
111
124
|
else if (arg === "--install-consumer-entrypoints") options.installConsumerEntrypoints = true;
|
|
112
125
|
else if (arg === "--force") options.force = true;
|
|
126
|
+
else if (arg === "--dry-run") options.dryRun = true;
|
|
113
127
|
else if (arg === "--workspace") options.workspace = rest[++index];
|
|
128
|
+
else if (arg === "--repo-url") options.repoUrl = rest[++index];
|
|
114
129
|
else if (arg === "--config") options.config = rest[++index];
|
|
115
130
|
else options._.push(arg);
|
|
116
131
|
}
|
|
117
132
|
return { command, options, rawArgs: rest };
|
|
118
133
|
}
|
|
119
134
|
|
|
135
|
+
function assertNoUnknownCommandFlags(command, options) {
|
|
136
|
+
const unknownFlags = options._.filter((arg) => arg.startsWith("--"));
|
|
137
|
+
if (unknownFlags.length === 0) return;
|
|
138
|
+
|
|
139
|
+
const noun = unknownFlags.length === 1 ? "argument" : "arguments";
|
|
140
|
+
throw new Error(`Unknown ${noun} for structor ${command}: ${unknownFlags.join(", ")}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
120
143
|
function printHelp() {
|
|
121
|
-
console.log(`Structor\n\nUsage:\n structor init [--workspace <path>] [--config <path>] [--yes]\n structor generate --config <path> [generator options]\n structor doctor\n\nCommands:\n init
|
|
144
|
+
console.log(`Structor\n\nUsage:\n structor init [--workspace <path>] [--config <path>] [--yes]\n structor generate --config <path> [generator options]\n structor contribute structor [--workspace <path>] [--repo-url <url-or-path>] [--yes] [--dry-run] [--force]\n structor doctor [--workspace <path>] [--config <path>]\n\nCommands:\n init Guided local setup for a Structor workspace.\n generate Render a generated harness from an existing config.\n contribute structor Create or refresh a local Structor contributor workspace.\n doctor Diagnose local Structor workspace drift without repairing files.\n`);
|
|
122
145
|
}
|
|
123
146
|
|
|
124
147
|
function runGenerator(args, cwd = process.cwd()) {
|
|
@@ -216,7 +239,52 @@ async function isDirectory(filePath) {
|
|
|
216
239
|
}
|
|
217
240
|
}
|
|
218
241
|
|
|
219
|
-
function
|
|
242
|
+
async function isEmptyDirectory(filePath) {
|
|
243
|
+
try {
|
|
244
|
+
return (await readdir(filePath)).length === 0;
|
|
245
|
+
} catch {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function isUsableStructorCheckout(repoRoot) {
|
|
251
|
+
if (!(await isDirectory(repoRoot))) return false;
|
|
252
|
+
const packageJson = await maybeReadJson(path.join(repoRoot, "package.json"));
|
|
253
|
+
return (
|
|
254
|
+
packageJson?.name === "@structor-dev/cli" &&
|
|
255
|
+
await exists(path.join(repoRoot, "bin/structor.mjs")) &&
|
|
256
|
+
await exists(path.join(repoRoot, "scripts/setup-contributor.mjs")) &&
|
|
257
|
+
await exists(path.join(repoRoot, "contrib/self-harness/harness.config.json"))
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function contributorWorkspacePlan(options = {}, cwd = process.cwd()) {
|
|
262
|
+
const workspaceRoot = path.resolve(cwd, options.workspace ?? ".");
|
|
263
|
+
const sourceRoot = path.join(workspaceRoot, "structor");
|
|
264
|
+
const selfHarnessRoot = path.join(workspaceRoot, "structor-self");
|
|
265
|
+
return {
|
|
266
|
+
workspaceRoot,
|
|
267
|
+
sourceRoot,
|
|
268
|
+
selfHarnessRoot,
|
|
269
|
+
repoUrl: options.repoUrl ?? structorRepoUrlDefault,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function runCommand(command, args, cwd, stdio = "inherit") {
|
|
274
|
+
const result = spawnSync(command, args, {
|
|
275
|
+
cwd,
|
|
276
|
+
stdio,
|
|
277
|
+
encoding: stdio === "pipe" ? "utf8" : undefined,
|
|
278
|
+
});
|
|
279
|
+
if (result.error) throw result.error;
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function commandText(command, args) {
|
|
284
|
+
return [command, ...args].join(" ");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function shouldExcludeCandidate(name) {
|
|
220
288
|
return (
|
|
221
289
|
name.startsWith(".") ||
|
|
222
290
|
name === "node_modules" ||
|
|
@@ -261,7 +329,7 @@ async function detectPackageManager(repoRoot) {
|
|
|
261
329
|
return null;
|
|
262
330
|
}
|
|
263
331
|
|
|
264
|
-
function packageCommand(packageManager, scriptName) {
|
|
332
|
+
export function packageCommand(packageManager, scriptName) {
|
|
265
333
|
if (packageManager === "yarn") return `yarn ${scriptName}`;
|
|
266
334
|
if (scriptName === "test") return `${packageManager} test`;
|
|
267
335
|
return `${packageManager} run ${scriptName}`;
|
|
@@ -297,7 +365,7 @@ async function inferValidation(repoRoot) {
|
|
|
297
365
|
return validation;
|
|
298
366
|
}
|
|
299
367
|
|
|
300
|
-
function compactValidation(validation) {
|
|
368
|
+
export function compactValidation(validation) {
|
|
301
369
|
return Object.fromEntries(Object.entries(validation).filter(([, value]) => value.trim() !== ""));
|
|
302
370
|
}
|
|
303
371
|
|
|
@@ -323,12 +391,24 @@ async function collectConsumerDetails(rl, workspaceRoot, selectedCandidates) {
|
|
|
323
391
|
return consumers;
|
|
324
392
|
}
|
|
325
393
|
|
|
326
|
-
async function promptManualConsumers(rl, workspaceRoot) {
|
|
394
|
+
async function promptManualConsumers(rl, workspaceRoot, outputPath) {
|
|
327
395
|
const consumers = [];
|
|
328
396
|
while (consumers.length === 0 || await askYesNo(rl, "Add another consumer repo?", false)) {
|
|
329
397
|
section(`Consumer ${consumers.length + 1}`);
|
|
330
398
|
const repoPath = await askLine(rl, "Path to consumer repo, relative to workspace", "./app");
|
|
331
399
|
const absolutePath = path.resolve(workspaceRoot, repoPath);
|
|
400
|
+
try {
|
|
401
|
+
assertSafeConsumerPath({
|
|
402
|
+
consumerName: slugify(path.basename(absolutePath)),
|
|
403
|
+
consumerPath: repoPath,
|
|
404
|
+
workspaceRoot,
|
|
405
|
+
outputRoot: path.resolve(workspaceRoot, outputPath),
|
|
406
|
+
repoRoot: packageRoot,
|
|
407
|
+
});
|
|
408
|
+
} catch (error) {
|
|
409
|
+
warn(error instanceof Error ? error.message : String(error));
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
332
412
|
if (!(await isDirectory(absolutePath))) {
|
|
333
413
|
warn(`Path does not exist yet: ${absolutePath}`);
|
|
334
414
|
if (!(await askYesNo(rl, "Use this path anyway?", false))) continue;
|
|
@@ -378,7 +458,7 @@ async function writeConfig(configPath, config) {
|
|
|
378
458
|
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
379
459
|
}
|
|
380
460
|
|
|
381
|
-
function nextValidationCommands(config) {
|
|
461
|
+
export function nextValidationCommands(config) {
|
|
382
462
|
const commands = [
|
|
383
463
|
`cd ${config.output.path}`,
|
|
384
464
|
"node scripts/validate-governance.mjs",
|
|
@@ -389,6 +469,325 @@ function nextValidationCommands(config) {
|
|
|
389
469
|
return commands;
|
|
390
470
|
}
|
|
391
471
|
|
|
472
|
+
function cleanHarnessReference(rawReference) {
|
|
473
|
+
return rawReference.trim().replace(/^[`'"]+/, "").replace(/[`'",;:.)\]}]+$/, "");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function extractHarnessReferences(content, harnessRepoName) {
|
|
477
|
+
const escapedName = harnessRepoName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
478
|
+
const pattern = new RegExp("(?:\\.\\.?/|/)[^`'\"\\s)<\\]}]*(?:" + escapedName + ")[^`'\"\\s)<\\]}]*", "g");
|
|
479
|
+
return [...new Set((content.match(pattern) ?? []).map(cleanHarnessReference).filter(Boolean))];
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function resolveHarnessReferenceTarget({ reference: rawReference, basePath, harnessRepoName }) {
|
|
483
|
+
const reference = cleanHarnessReference(rawReference);
|
|
484
|
+
const absoluteReference = path.isAbsolute(reference) ? path.resolve(reference) : path.resolve(basePath, reference);
|
|
485
|
+
const parts = absoluteReference.split(path.sep);
|
|
486
|
+
if (parts.lastIndexOf(harnessRepoName) === -1) return null;
|
|
487
|
+
return absoluteReference;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function resolveHarnessReferenceRoot({ reference: rawReference, basePath, harnessRepoName }) {
|
|
491
|
+
const target = resolveHarnessReferenceTarget({ reference: rawReference, basePath, harnessRepoName });
|
|
492
|
+
if (!target) return null;
|
|
493
|
+
const parts = target.split(path.sep);
|
|
494
|
+
const index = parts.lastIndexOf(harnessRepoName);
|
|
495
|
+
return parts.slice(0, index + 1).join(path.sep) || path.sep;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function isFileTarget(targetPath) {
|
|
499
|
+
try {
|
|
500
|
+
return (await stat(targetPath)).isFile();
|
|
501
|
+
} catch {
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function canonicalExistingPath(targetPath) {
|
|
507
|
+
try {
|
|
508
|
+
return await realpath(targetPath);
|
|
509
|
+
} catch {
|
|
510
|
+
return path.resolve(targetPath);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function isWithinRoot(root, candidate) {
|
|
515
|
+
if (candidate === root) return true;
|
|
516
|
+
return candidate.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function validateHarnessReferences({
|
|
520
|
+
pointerPath,
|
|
521
|
+
pointerContent,
|
|
522
|
+
basePath,
|
|
523
|
+
expectedHarnessRoot,
|
|
524
|
+
harnessRepoName,
|
|
525
|
+
models,
|
|
526
|
+
requireHarnessReference = true,
|
|
527
|
+
}) {
|
|
528
|
+
const references = extractHarnessReferences(pointerContent, harnessRepoName);
|
|
529
|
+
if (references.length === 0) {
|
|
530
|
+
return requireHarnessReference
|
|
531
|
+
? [`${pointerPath} does not contain a resolvable ${harnessRepoName} path.`]
|
|
532
|
+
: [];
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const issues = [];
|
|
536
|
+
for (const reference of references) {
|
|
537
|
+
const target = resolveHarnessReferenceTarget({ reference, basePath, harnessRepoName });
|
|
538
|
+
const referenceRoot = resolveHarnessReferenceRoot({ reference, basePath, harnessRepoName });
|
|
539
|
+
if (!target || !referenceRoot) {
|
|
540
|
+
issues.push(`${pointerPath} does not contain a resolvable ${harnessRepoName} path.`);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
const canonicalReferenceRoot = await canonicalExistingPath(referenceRoot);
|
|
544
|
+
const canonicalExpectedHarnessRoot = await canonicalExistingPath(expectedHarnessRoot);
|
|
545
|
+
if (canonicalReferenceRoot !== canonicalExpectedHarnessRoot) {
|
|
546
|
+
issues.push(`${pointerPath} points at ${referenceRoot} instead of ${expectedHarnessRoot}.`);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const relativeTarget = path.relative(referenceRoot, target).replaceAll(path.sep, "/");
|
|
551
|
+
if (!models.openai && relativeTarget === "AGENTS.md") {
|
|
552
|
+
issues.push(`${pointerPath} must not reference ${relativeTarget} when OpenAI support is disabled.`);
|
|
553
|
+
} else if (!models.anthropic && relativeTarget === "CLAUDE.md") {
|
|
554
|
+
issues.push(`${pointerPath} must not reference ${relativeTarget} when Anthropic support is disabled.`);
|
|
555
|
+
} else if (relativeTarget === "" || !(await isFileTarget(target))) {
|
|
556
|
+
issues.push(`${pointerPath} references missing generated-harness file ${relativeTarget || "."}.`);
|
|
557
|
+
} else {
|
|
558
|
+
// The lexical reference root can match while the target file resolves,
|
|
559
|
+
// via a symlink, to policy outside the harness. Require the canonical
|
|
560
|
+
// (realpath-resolved) target to stay under the canonical harness root.
|
|
561
|
+
const canonicalTarget = await canonicalExistingPath(target);
|
|
562
|
+
if (!isWithinRoot(canonicalExpectedHarnessRoot, canonicalTarget)) {
|
|
563
|
+
issues.push(`${pointerPath} resolves to ${canonicalTarget} outside the generated harness ${canonicalExpectedHarnessRoot}.`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return issues;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function readIfExists(filePath) {
|
|
571
|
+
if (!(await exists(filePath))) return null;
|
|
572
|
+
return readFile(filePath, "utf8");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function collectEntrypointRoutingIssues({
|
|
576
|
+
label,
|
|
577
|
+
basePath,
|
|
578
|
+
entrypoints,
|
|
579
|
+
expectedHarnessRoot,
|
|
580
|
+
harnessRepoName,
|
|
581
|
+
models,
|
|
582
|
+
}) {
|
|
583
|
+
const issues = [];
|
|
584
|
+
for (const entrypoint of entrypoints) {
|
|
585
|
+
const pointerPath = path.join(basePath, entrypoint.path);
|
|
586
|
+
const pointerContent = await readIfExists(pointerPath);
|
|
587
|
+
if (pointerContent === null) {
|
|
588
|
+
issues.push(`${label}:${entrypoint.path} missing.`);
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
if (entrypoint.routing === "claude-memory") {
|
|
592
|
+
if (!pointerContent.includes("../CLAUDE.md")) {
|
|
593
|
+
issues.push(`${label}:${entrypoint.path} must route through ../CLAUDE.md.`);
|
|
594
|
+
}
|
|
595
|
+
const referenceIssues = await validateHarnessReferences({
|
|
596
|
+
pointerPath: `${label}:${entrypoint.path}`,
|
|
597
|
+
pointerContent,
|
|
598
|
+
basePath,
|
|
599
|
+
expectedHarnessRoot,
|
|
600
|
+
harnessRepoName,
|
|
601
|
+
models,
|
|
602
|
+
requireHarnessReference: false,
|
|
603
|
+
});
|
|
604
|
+
issues.push(...referenceIssues);
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
const referenceIssues = await validateHarnessReferences({
|
|
608
|
+
pointerPath: `${label}:${entrypoint.path}`,
|
|
609
|
+
pointerContent,
|
|
610
|
+
basePath,
|
|
611
|
+
expectedHarnessRoot,
|
|
612
|
+
harnessRepoName,
|
|
613
|
+
models,
|
|
614
|
+
});
|
|
615
|
+
issues.push(...referenceIssues);
|
|
616
|
+
}
|
|
617
|
+
return issues;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function printDoctorCheck(results, status, label, detail = "") {
|
|
621
|
+
results.push({ status, label, detail });
|
|
622
|
+
const renderedStatus =
|
|
623
|
+
status === "OK" ? color("green", "OK") :
|
|
624
|
+
status === "WARN" ? color("yellow", "WARN") :
|
|
625
|
+
color("red", "FAIL");
|
|
626
|
+
console.log(`${renderedStatus} ${label}${detail ? ` - ${detail}` : ""}`);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function doctor(options) {
|
|
630
|
+
const results = [];
|
|
631
|
+
const workspaceRoot = path.resolve(options.workspace ?? process.cwd());
|
|
632
|
+
const configPath = path.resolve(workspaceRoot, options.config ?? configFileName);
|
|
633
|
+
section("Structor doctor");
|
|
634
|
+
note("Diagnosis only. No files will be repaired or written.");
|
|
635
|
+
|
|
636
|
+
let config = null;
|
|
637
|
+
if (await exists(configPath)) {
|
|
638
|
+
printDoctorCheck(results, "OK", "config file exists", configPath);
|
|
639
|
+
try {
|
|
640
|
+
config = await readJson(configPath);
|
|
641
|
+
printDoctorCheck(results, "OK", "config file parses");
|
|
642
|
+
} catch (error) {
|
|
643
|
+
printDoctorCheck(results, "FAIL", "config file parses", error instanceof Error ? error.message : String(error));
|
|
644
|
+
}
|
|
645
|
+
} else {
|
|
646
|
+
printDoctorCheck(results, "FAIL", "config file exists", configPath);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
let resolvedConfig = null;
|
|
650
|
+
if (config) {
|
|
651
|
+
const shapeErrors = await validateConfigShape(config, configPath);
|
|
652
|
+
if (shapeErrors.length === 0) {
|
|
653
|
+
printDoctorCheck(results, "OK", "config shape is valid");
|
|
654
|
+
} else {
|
|
655
|
+
for (const error of shapeErrors) printDoctorCheck(results, "FAIL", "config shape is valid", error);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (shapeErrors.length === 0) {
|
|
659
|
+
try {
|
|
660
|
+
resolvedConfig = await resolveHarnessConfig(config, {
|
|
661
|
+
label: configPath,
|
|
662
|
+
configPath,
|
|
663
|
+
outputPath: config.output.path,
|
|
664
|
+
});
|
|
665
|
+
printDoctorCheck(results, "OK", "output root is safe", resolvedConfig.outputRoot);
|
|
666
|
+
} catch (error) {
|
|
667
|
+
printDoctorCheck(results, "FAIL", "output root is safe", error instanceof Error ? error.message : String(error));
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (resolvedConfig) {
|
|
673
|
+
const { outputRoot, workspaceRoot: resolvedWorkspaceRoot, consumers, support } = resolvedConfig;
|
|
674
|
+
const settings = { models: config.models, clientSupport: support };
|
|
675
|
+
const harnessRepoName = config.project.harnessRepoName;
|
|
676
|
+
const repoRequiredFiles = requiredHarnessRepoFilesForWorkspaceCheck(settings);
|
|
677
|
+
const workspaceRequiredFiles = requiredWorkspaceFilesForWorkspaceCheck(settings);
|
|
678
|
+
const workspaceRoutingEntrypoints = workspaceEntrypointsForSettings(settings).filter(
|
|
679
|
+
(entrypoint) => entrypoint.routing !== "presence",
|
|
680
|
+
);
|
|
681
|
+
const consumerRoutingEntrypoints = consumerEntrypointsForSettings(settings);
|
|
682
|
+
|
|
683
|
+
if (path.basename(outputRoot) === harnessRepoName) {
|
|
684
|
+
printDoctorCheck(results, "OK", "generated harness folder name matches config", harnessRepoName);
|
|
685
|
+
} else {
|
|
686
|
+
printDoctorCheck(results, "FAIL", "generated harness folder name matches config", `expected ${harnessRepoName}, found ${path.basename(outputRoot)}`);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (await isDirectory(outputRoot)) {
|
|
690
|
+
printDoctorCheck(results, "OK", "generated harness output directory exists", outputRoot);
|
|
691
|
+
} else {
|
|
692
|
+
printDoctorCheck(results, "FAIL", "generated harness output directory exists", outputRoot);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const missingRepoFiles = [];
|
|
696
|
+
for (const relativePath of repoRequiredFiles) {
|
|
697
|
+
if (!(await exists(path.join(outputRoot, relativePath)))) missingRepoFiles.push(relativePath);
|
|
698
|
+
}
|
|
699
|
+
if (missingRepoFiles.length === 0) {
|
|
700
|
+
printDoctorCheck(results, "OK", "generated harness required files exist");
|
|
701
|
+
} else {
|
|
702
|
+
for (const relativePath of missingRepoFiles) {
|
|
703
|
+
printDoctorCheck(results, "FAIL", "generated harness required file exists", relativePath);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const missingWorkspaceFiles = [];
|
|
708
|
+
for (const relativePath of workspaceRequiredFiles) {
|
|
709
|
+
if (!(await exists(path.join(resolvedWorkspaceRoot, relativePath)))) missingWorkspaceFiles.push(relativePath);
|
|
710
|
+
}
|
|
711
|
+
if (missingWorkspaceFiles.length === 0) {
|
|
712
|
+
printDoctorCheck(results, "OK", "workspace entrypoint files exist");
|
|
713
|
+
} else {
|
|
714
|
+
for (const relativePath of missingWorkspaceFiles) {
|
|
715
|
+
printDoctorCheck(results, "FAIL", "workspace entrypoint file exists", relativePath);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const workspaceRoutingIssues = await collectEntrypointRoutingIssues({
|
|
720
|
+
label: "workspace",
|
|
721
|
+
basePath: resolvedWorkspaceRoot,
|
|
722
|
+
entrypoints: workspaceRoutingEntrypoints,
|
|
723
|
+
expectedHarnessRoot: outputRoot,
|
|
724
|
+
harnessRepoName,
|
|
725
|
+
models: config.models,
|
|
726
|
+
});
|
|
727
|
+
if (workspaceRoutingIssues.length === 0) {
|
|
728
|
+
printDoctorCheck(results, "OK", "workspace pointer files route to generated harness");
|
|
729
|
+
} else {
|
|
730
|
+
for (const issue of workspaceRoutingIssues) printDoctorCheck(results, "FAIL", "workspace pointer file routes to generated harness", issue);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
for (const consumer of consumers) {
|
|
734
|
+
const consumerRoot = consumer.root;
|
|
735
|
+
if (await isDirectory(consumerRoot)) {
|
|
736
|
+
if (await hasConsumerRepositorySignal(consumerRoot)) {
|
|
737
|
+
printDoctorCheck(results, "OK", `consumer repo exists: ${consumer.config.name}`, consumerRoot);
|
|
738
|
+
} else {
|
|
739
|
+
printDoctorCheck(results, "FAIL", `consumer repo exists: ${consumer.config.name}`, `missing repository signal at ${consumerRoot}`);
|
|
740
|
+
}
|
|
741
|
+
} else {
|
|
742
|
+
printDoctorCheck(results, "FAIL", `consumer repo exists: ${consumer.config.name}`, consumerRoot);
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (Object.values(consumer.config.validation ?? {}).some((value) => typeof value === "string" && value.trim() !== "")) {
|
|
747
|
+
printDoctorCheck(results, "OK", `consumer validation command documented: ${consumer.config.name}`);
|
|
748
|
+
} else {
|
|
749
|
+
printDoctorCheck(results, "WARN", `consumer validation command documented: ${consumer.config.name}`, "no validation commands configured");
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const consumerRoutingIssues = await collectEntrypointRoutingIssues({
|
|
753
|
+
label: `consumer:${consumer.config.name}`,
|
|
754
|
+
basePath: consumerRoot,
|
|
755
|
+
entrypoints: consumerRoutingEntrypoints,
|
|
756
|
+
expectedHarnessRoot: outputRoot,
|
|
757
|
+
harnessRepoName,
|
|
758
|
+
models: config.models,
|
|
759
|
+
});
|
|
760
|
+
if (consumerRoutingIssues.length === 0) {
|
|
761
|
+
printDoctorCheck(results, "OK", `consumer pointer files route to generated harness: ${consumer.config.name}`);
|
|
762
|
+
} else {
|
|
763
|
+
for (const issue of consumerRoutingIssues) {
|
|
764
|
+
printDoctorCheck(results, "FAIL", `consumer pointer file routes to generated harness: ${consumer.config.name}`, issue);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const manifestPath = path.join(outputRoot, ".structor/manifest.json");
|
|
770
|
+
if (await exists(manifestPath)) {
|
|
771
|
+
try {
|
|
772
|
+
await readJson(manifestPath);
|
|
773
|
+
printDoctorCheck(results, "OK", "manifest is present and parses", manifestPath);
|
|
774
|
+
} catch (error) {
|
|
775
|
+
printDoctorCheck(results, "WARN", "manifest is present but does not parse", error instanceof Error ? error.message : String(error));
|
|
776
|
+
}
|
|
777
|
+
} else {
|
|
778
|
+
printDoctorCheck(results, "WARN", "manifest is present", "optional in doctor v1");
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const failures = results.filter((result) => result.status === "FAIL").length;
|
|
783
|
+
const warnings = results.filter((result) => result.status === "WARN").length;
|
|
784
|
+
if (failures > 0) {
|
|
785
|
+
fail(`Structor doctor found ${failures} failure(s) and ${warnings} warning(s).`);
|
|
786
|
+
process.exit(1);
|
|
787
|
+
}
|
|
788
|
+
success(`Structor doctor passed with ${warnings} warning(s).`);
|
|
789
|
+
}
|
|
790
|
+
|
|
392
791
|
function printNextSteps(config) {
|
|
393
792
|
section("Next validation commands");
|
|
394
793
|
note("Run these from the workspace after generation to prove harness policy and workspace routing are healthy.");
|
|
@@ -397,6 +796,128 @@ function printNextSteps(config) {
|
|
|
397
796
|
}
|
|
398
797
|
}
|
|
399
798
|
|
|
799
|
+
async function printContributorPlan(plan, options, sourceReady) {
|
|
800
|
+
section(options.dryRun ? "Contributor workspace preview" : "Contributor workspace");
|
|
801
|
+
console.log(`Workspace: ${plan.workspaceRoot}`);
|
|
802
|
+
console.log(`Structor source: ${plan.sourceRoot}`);
|
|
803
|
+
console.log(`Structor self-harness: ${plan.selfHarnessRoot}`);
|
|
804
|
+
console.log(`Repo URL: ${plan.repoUrl}`);
|
|
805
|
+
console.log(`Source checkout: ${sourceReady ? "reuse existing local checkout" : "clone required"}`);
|
|
806
|
+
|
|
807
|
+
section("Network reads");
|
|
808
|
+
if (sourceReady) {
|
|
809
|
+
console.log(" - none; existing local Structor checkout will be reused");
|
|
810
|
+
} else {
|
|
811
|
+
console.log(` - ${commandText("git", ["clone", plan.repoUrl, plan.sourceRoot])}`);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
section("Local filesystem writes");
|
|
815
|
+
if (!sourceReady) console.log(` - create or use workspace folder ${plan.workspaceRoot}`);
|
|
816
|
+
if (!sourceReady) console.log(` - create Structor checkout ${plan.sourceRoot}`);
|
|
817
|
+
console.log(` - generate or refresh self-harness ${plan.selfHarnessRoot}`);
|
|
818
|
+
console.log(" - install missing source repo agent entrypoint pointers");
|
|
819
|
+
if (options.force) {
|
|
820
|
+
console.log(" - overwrite existing source repo agent entrypoint pointers because --force was provided");
|
|
821
|
+
} else {
|
|
822
|
+
console.log(" - skip existing source repo agent entrypoint pointers unless --force is provided");
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
section("Validation");
|
|
826
|
+
console.log(` - ${commandText(process.execPath, ["scripts/setup-contributor.mjs", ...(options.dryRun ? ["--dry-run"] : []), ...(options.force ? ["--force"] : [])])}`);
|
|
827
|
+
console.log(` - ${commandText(process.execPath, ["scripts/validate-governance.mjs"])} in ${plan.selfHarnessRoot}`);
|
|
828
|
+
console.log(` - ${commandText(process.execPath, ["scripts/check-workspace.mjs"])} in ${plan.selfHarnessRoot}`);
|
|
829
|
+
console.log(` - deferred source validation: cd ${plan.sourceRoot} && npm run validate`);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async function confirmContributorRun(options, plan, sourceReady) {
|
|
833
|
+
if (options.yes || options.dryRun) return true;
|
|
834
|
+
const rl = await createPrompt();
|
|
835
|
+
try {
|
|
836
|
+
await printContributorPlan(plan, options, sourceReady);
|
|
837
|
+
return await askYesNo(rl, "Continue with local writes and any required clone read?", false);
|
|
838
|
+
} finally {
|
|
839
|
+
rl.close();
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
async function ensureStructorCheckout(plan, options) {
|
|
844
|
+
const sourceReady = await isUsableStructorCheckout(plan.sourceRoot);
|
|
845
|
+
if (sourceReady) return { sourceReady: true, cloned: false };
|
|
846
|
+
|
|
847
|
+
if (await exists(plan.sourceRoot) && !(await isEmptyDirectory(plan.sourceRoot))) {
|
|
848
|
+
throw new Error(
|
|
849
|
+
`Existing ${plan.sourceRoot} is not a usable Structor checkout. Move it, choose a different --workspace, or provide a workspace with a valid structor checkout.`,
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (options.dryRun) return { sourceReady: false, cloned: false };
|
|
854
|
+
|
|
855
|
+
await mkdir(plan.workspaceRoot, { recursive: true });
|
|
856
|
+
section("Clone Structor source");
|
|
857
|
+
note("Network read only: this clones source code and does not authenticate, fork, push, open PRs, or mutate remotes.");
|
|
858
|
+
const clone = runCommand("git", ["clone", plan.repoUrl, plan.sourceRoot], process.cwd());
|
|
859
|
+
if (clone.status !== 0) throw new Error(`Clone failed: ${commandText("git", ["clone", plan.repoUrl, plan.sourceRoot])}`);
|
|
860
|
+
if (!(await isUsableStructorCheckout(plan.sourceRoot))) {
|
|
861
|
+
throw new Error(`Clone completed but ${plan.sourceRoot} is not a usable Structor checkout.`);
|
|
862
|
+
}
|
|
863
|
+
return { sourceReady: false, cloned: true };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function runContributorValidation(plan) {
|
|
867
|
+
section("Validate self-harness");
|
|
868
|
+
const validationCommands = [
|
|
869
|
+
[process.execPath, ["scripts/validate-governance.mjs"]],
|
|
870
|
+
[process.execPath, ["scripts/check-workspace.mjs"]],
|
|
871
|
+
];
|
|
872
|
+
for (const [command, args] of validationCommands) {
|
|
873
|
+
console.log(`$ ${commandText(command, args)}`);
|
|
874
|
+
const result = runCommand(command, args, plan.selfHarnessRoot);
|
|
875
|
+
if (result.status !== 0) {
|
|
876
|
+
throw new Error(`Validation failed in ${plan.selfHarnessRoot}: ${commandText(command, args)}`);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function printContributorSummary(plan, setupArgs, validationRan) {
|
|
882
|
+
section("Structor contributor workspace ready");
|
|
883
|
+
console.log(`Workspace: ${plan.workspaceRoot}`);
|
|
884
|
+
console.log(`Structor source: ${plan.sourceRoot}`);
|
|
885
|
+
console.log(`Structor self-harness: ${plan.selfHarnessRoot}`);
|
|
886
|
+
console.log(`Contributor setup: ${commandText(process.execPath, setupArgs)}`);
|
|
887
|
+
console.log(`Validation: ${validationRan ? "passed" : "preview only; no validation commands were run"}`);
|
|
888
|
+
|
|
889
|
+
section("Next agent prompt");
|
|
890
|
+
console.log(`Work in ${plan.sourceRoot} using the sibling self-harness at ${plan.selfHarnessRoot}. Read AGENTS.md, ai/HUB.md, and the relevant issue, then make the smallest Structor change with validation evidence.`);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
async function contributeStructor(options) {
|
|
894
|
+
if (options._.length !== 1) {
|
|
895
|
+
throw new Error("Usage: structor contribute structor [--workspace <path>] [--repo-url <url-or-path>] [--yes] [--dry-run] [--force]");
|
|
896
|
+
}
|
|
897
|
+
const plan = contributorWorkspacePlan(options);
|
|
898
|
+
const sourceReady = await isUsableStructorCheckout(plan.sourceRoot);
|
|
899
|
+
if (!(await confirmContributorRun(options, plan, sourceReady))) {
|
|
900
|
+
warn("Stopped before cloning, generating, or writing local files.");
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (options.dryRun) {
|
|
905
|
+
await printContributorPlan(plan, options, sourceReady);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
await ensureStructorCheckout(plan, options);
|
|
910
|
+
|
|
911
|
+
section("Generate Structor self-harness");
|
|
912
|
+
const setupArgs = ["scripts/setup-contributor.mjs"];
|
|
913
|
+
if (options.force) setupArgs.push("--force");
|
|
914
|
+
const setup = runCommand(process.execPath, setupArgs, plan.sourceRoot);
|
|
915
|
+
if (setup.status !== 0) throw new Error(`Contributor setup failed: ${commandText(process.execPath, setupArgs)}`);
|
|
916
|
+
|
|
917
|
+
runContributorValidation(plan);
|
|
918
|
+
printContributorSummary(plan, setupArgs, true);
|
|
919
|
+
}
|
|
920
|
+
|
|
400
921
|
async function init(options) {
|
|
401
922
|
const rl = await createPrompt();
|
|
402
923
|
try {
|
|
@@ -435,12 +956,8 @@ async function init(options) {
|
|
|
435
956
|
], defaultModelIndex);
|
|
436
957
|
|
|
437
958
|
section("Customization");
|
|
438
|
-
await askChoice(rl, "How much should Structor customize from consumer repos?", [
|
|
439
|
-
{ label: "Starter only", value: "starter", note: "available now" },
|
|
440
|
-
{ label: "Light scan", value: "starter", note: "coming soon" },
|
|
441
|
-
{ label: "Deep scan", value: "starter", note: "coming soon" },
|
|
442
|
-
]);
|
|
443
959
|
note("Starter only creates generic harness content. It does not infer real contracts or coding conventions.");
|
|
960
|
+
note("Light Scan and Deep Scan are planned future opt-in Consumer Repo Scan modes.");
|
|
444
961
|
|
|
445
962
|
section("Consumer repos");
|
|
446
963
|
note("For best results, run Structor from the workspace folder that contains your consumer repos as siblings.");
|
|
@@ -463,7 +980,7 @@ async function init(options) {
|
|
|
463
980
|
consumers = await collectConsumerDetails(rl, workspaceRoot, selected);
|
|
464
981
|
} else {
|
|
465
982
|
warn("No obvious sibling consumer repos found.");
|
|
466
|
-
consumers = await promptManualConsumers(rl, workspaceRoot);
|
|
983
|
+
consumers = await promptManualConsumers(rl, workspaceRoot, outputPath);
|
|
467
984
|
}
|
|
468
985
|
}
|
|
469
986
|
|
|
@@ -512,7 +1029,7 @@ async function init(options) {
|
|
|
512
1029
|
printCommandOutput(dryRun);
|
|
513
1030
|
if (dryRun.status !== 0) throw new Error("Generator dry-run failed.");
|
|
514
1031
|
|
|
515
|
-
const apply = options.yes || await askYesNo(rl, "Generate harness now?",
|
|
1032
|
+
const apply = options.yes || await askYesNo(rl, "Generate harness now?", true);
|
|
516
1033
|
if (!apply) {
|
|
517
1034
|
warn("Stopped after dry-run preview.");
|
|
518
1035
|
printNextSteps(config);
|
|
@@ -551,6 +1068,7 @@ async function main() {
|
|
|
551
1068
|
return;
|
|
552
1069
|
}
|
|
553
1070
|
if (command === "init") {
|
|
1071
|
+
assertNoUnknownCommandFlags(command, options);
|
|
554
1072
|
await init(options);
|
|
555
1073
|
return;
|
|
556
1074
|
}
|
|
@@ -558,19 +1076,25 @@ async function main() {
|
|
|
558
1076
|
passthroughGenerate(rawArgs);
|
|
559
1077
|
return;
|
|
560
1078
|
}
|
|
1079
|
+
if (command === "contribute") {
|
|
1080
|
+
const [target] = options._;
|
|
1081
|
+
if (target !== "structor") {
|
|
1082
|
+
throw new Error("Unknown contribute target. Supported target: structor");
|
|
1083
|
+
}
|
|
1084
|
+
await contributeStructor(options);
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
561
1087
|
if (command === "doctor") {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
note(" - moved harness or consumer folders");
|
|
566
|
-
note(" - unsafe output paths");
|
|
567
|
-
note("Track progress: docs/issues/0001-structor-doctor.md");
|
|
568
|
-
process.exit(1);
|
|
1088
|
+
assertNoUnknownCommandFlags(command, options);
|
|
1089
|
+
await doctor(options);
|
|
1090
|
+
return;
|
|
569
1091
|
}
|
|
570
1092
|
throw new Error(`Unknown command: ${command}`);
|
|
571
1093
|
}
|
|
572
1094
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
1095
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
1096
|
+
main().catch((error) => {
|
|
1097
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
1098
|
+
process.exit(1);
|
|
1099
|
+
});
|
|
1100
|
+
}
|