@structor-dev/cli 0.1.0 → 0.2.1
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 +561 -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-placeholders.mjs +2 -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/scripts/lib.mjs
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
import { access, readdir, readFile, stat } from "node:fs/promises";
|
|
1
|
+
import { access, lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
2
2
|
import { constants as fsConstants } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
6
|
export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
7
7
|
|
|
8
|
+
export const consumerRepoSignals = [
|
|
9
|
+
".git",
|
|
10
|
+
"package.json",
|
|
11
|
+
"pyproject.toml",
|
|
12
|
+
"go.mod",
|
|
13
|
+
"Cargo.toml",
|
|
14
|
+
"pom.xml",
|
|
15
|
+
"build.gradle",
|
|
16
|
+
"Gemfile",
|
|
17
|
+
"composer.json",
|
|
18
|
+
];
|
|
19
|
+
|
|
8
20
|
export async function exists(filePath) {
|
|
9
21
|
try {
|
|
10
22
|
await access(filePath, fsConstants.F_OK);
|
|
@@ -56,7 +68,200 @@ export function isSameOrInsidePath(candidate, root) {
|
|
|
56
68
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
57
69
|
}
|
|
58
70
|
|
|
59
|
-
export function
|
|
71
|
+
export function isAbsolutePathString(candidate) {
|
|
72
|
+
return (
|
|
73
|
+
path.isAbsolute(candidate) ||
|
|
74
|
+
candidate.startsWith("/") ||
|
|
75
|
+
candidate.startsWith("\\") ||
|
|
76
|
+
/^[A-Za-z]:/.test(candidate)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function pathHasTraversal(candidate) {
|
|
81
|
+
return candidate.split(/[\\/]+/).includes("..");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function pathSegments(candidate) {
|
|
85
|
+
return candidate.split(/[\\/]+/).filter(Boolean);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function lstatIfExists(targetPath) {
|
|
89
|
+
try {
|
|
90
|
+
return await lstat(targetPath);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error?.code === "ENOENT") return null;
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function workspaceRootForConfig(configDir, templateRepoRoot = repoRoot) {
|
|
98
|
+
const resolvedConfigDir = path.resolve(configDir);
|
|
99
|
+
const resolvedTemplateRepoRoot = path.resolve(templateRepoRoot);
|
|
100
|
+
return isSameOrInsidePath(resolvedConfigDir, resolvedTemplateRepoRoot)
|
|
101
|
+
? path.dirname(resolvedTemplateRepoRoot)
|
|
102
|
+
: resolvedConfigDir;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function canonicalPathForWrite(targetPath) {
|
|
106
|
+
let currentPath = path.resolve(targetPath);
|
|
107
|
+
const missingSegments = [];
|
|
108
|
+
|
|
109
|
+
while (true) {
|
|
110
|
+
if (await exists(currentPath)) {
|
|
111
|
+
return path.join(await realpath(currentPath), ...missingSegments);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const parentPath = path.dirname(currentPath);
|
|
115
|
+
if (parentPath === currentPath) {
|
|
116
|
+
return path.join(currentPath, ...missingSegments);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
missingSegments.unshift(path.basename(currentPath));
|
|
120
|
+
currentPath = parentPath;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function assertSafeConsumerPath({
|
|
125
|
+
consumerName,
|
|
126
|
+
consumerPath,
|
|
127
|
+
workspaceRoot,
|
|
128
|
+
outputRoot = null,
|
|
129
|
+
repoRoot: templateRepoRoot = repoRoot,
|
|
130
|
+
allowTemplateRepoConsumer = false,
|
|
131
|
+
}) {
|
|
132
|
+
const label = `Consumer path for ${consumerName}`;
|
|
133
|
+
const rejectedPath = path.resolve(workspaceRoot, consumerPath);
|
|
134
|
+
const segments = pathSegments(consumerPath);
|
|
135
|
+
|
|
136
|
+
if (isAbsolutePathString(consumerPath)) {
|
|
137
|
+
throw new Error(`${label} is unsafe: absolute consumer paths are not allowed.`);
|
|
138
|
+
}
|
|
139
|
+
if (pathHasTraversal(consumerPath)) {
|
|
140
|
+
throw new Error(`${label} is unsafe: relative traversal is not allowed.`);
|
|
141
|
+
}
|
|
142
|
+
if (segments.filter((segment) => segment !== ".").length === 0) {
|
|
143
|
+
throw new Error(`${label} is unsafe: path must name a consumer repository folder, not the workspace root.`);
|
|
144
|
+
}
|
|
145
|
+
if (segments.includes(".git") || pathContainsSegment(rejectedPath, ".git")) {
|
|
146
|
+
throw new Error(`${label} is unsafe: consumer paths must not contain a .git path segment.`);
|
|
147
|
+
}
|
|
148
|
+
if (!isSameOrInsidePath(rejectedPath, workspaceRoot)) {
|
|
149
|
+
throw new Error(`${label} is unsafe: path must stay inside the workspace ${workspaceRoot}.`);
|
|
150
|
+
}
|
|
151
|
+
if (path.resolve(rejectedPath) === path.resolve(workspaceRoot)) {
|
|
152
|
+
throw new Error(`${label} is unsafe: path must not equal the workspace root ${workspaceRoot}.`);
|
|
153
|
+
}
|
|
154
|
+
if (!allowTemplateRepoConsumer && isSameOrInsidePath(rejectedPath, templateRepoRoot)) {
|
|
155
|
+
throw new Error(`${label} is unsafe: path must not equal or be inside the Structor template repo ${templateRepoRoot}.`);
|
|
156
|
+
}
|
|
157
|
+
if (outputRoot && isSameOrInsidePath(rejectedPath, outputRoot)) {
|
|
158
|
+
throw new Error(`${label} is unsafe: path must not equal or be inside the generated harness output ${outputRoot}.`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return rejectedPath;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function hasConsumerRepositorySignal(consumerRoot) {
|
|
165
|
+
for (const signal of consumerRepoSignals) {
|
|
166
|
+
if (await exists(path.join(consumerRoot, signal))) return true;
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function assertConfirmedConsumerRepository({
|
|
172
|
+
consumerName,
|
|
173
|
+
consumerRoot,
|
|
174
|
+
workspaceRoot,
|
|
175
|
+
outputRoot = null,
|
|
176
|
+
repoRoot: templateRepoRoot = repoRoot,
|
|
177
|
+
allowTemplateRepoConsumer = false,
|
|
178
|
+
}) {
|
|
179
|
+
const label = `Consumer path for ${consumerName}`;
|
|
180
|
+
const info = await lstatIfExists(consumerRoot);
|
|
181
|
+
if (info === null) {
|
|
182
|
+
throw new Error(`Consumer repo path for ${consumerName} does not exist: ${consumerRoot}`);
|
|
183
|
+
}
|
|
184
|
+
if (info.isSymbolicLink()) {
|
|
185
|
+
throw new Error(`${label} is unsafe: symlinked consumer paths are not allowed: ${consumerRoot}.`);
|
|
186
|
+
}
|
|
187
|
+
if (!info.isDirectory()) {
|
|
188
|
+
throw new Error(`${label} is not a directory: ${consumerRoot}.`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const canonicalWorkspaceRoot = await canonicalPathForWrite(workspaceRoot);
|
|
192
|
+
const canonicalConsumerRoot = await canonicalPathForWrite(consumerRoot);
|
|
193
|
+
if (!isSameOrInsidePath(canonicalConsumerRoot, canonicalWorkspaceRoot)) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`${label} is unsafe: resolved path escapes workspace ${canonicalWorkspaceRoot}: ${canonicalConsumerRoot}.`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const canonicalTemplateRepoRoot = await canonicalPathForWrite(templateRepoRoot);
|
|
200
|
+
if (!allowTemplateRepoConsumer && isSameOrInsidePath(canonicalConsumerRoot, canonicalTemplateRepoRoot)) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`${label} is unsafe: resolved path must not equal or be inside the Structor template repo ${canonicalTemplateRepoRoot}.`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (outputRoot) {
|
|
207
|
+
const canonicalOutputRoot = await canonicalPathForWrite(outputRoot);
|
|
208
|
+
if (isSameOrInsidePath(canonicalConsumerRoot, canonicalOutputRoot)) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`${label} is unsafe: resolved path must not equal or be inside the generated harness output ${canonicalOutputRoot}.`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!(await hasConsumerRepositorySignal(canonicalConsumerRoot))) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`${label} is not a confirmed consumer repository: ${consumerRoot} (expected one of ${consumerRepoSignals.join(", ")}).`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return canonicalConsumerRoot;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function firstSymlinkUnderRoot(targetPath, rootPath) {
|
|
225
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
226
|
+
const resolvedRoot = path.resolve(rootPath);
|
|
227
|
+
if (!isSameOrInsidePath(resolvedTarget, resolvedRoot)) return null;
|
|
228
|
+
|
|
229
|
+
const relative = path.relative(resolvedRoot, resolvedTarget);
|
|
230
|
+
if (relative === "") return null;
|
|
231
|
+
|
|
232
|
+
let currentPath = resolvedRoot;
|
|
233
|
+
for (const segment of relative.split(path.sep)) {
|
|
234
|
+
currentPath = path.join(currentPath, segment);
|
|
235
|
+
const info = await lstatIfExists(currentPath);
|
|
236
|
+
if (info === null) return null;
|
|
237
|
+
if (info.isSymbolicLink()) return currentPath;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function assertSafeWriteTarget({ targetPath, rootPath, label = "Write target" }) {
|
|
244
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
245
|
+
const resolvedRoot = path.resolve(rootPath);
|
|
246
|
+
if (!isSameOrInsidePath(resolvedTarget, resolvedRoot)) {
|
|
247
|
+
throw new Error(`${label} is unsafe: target ${resolvedTarget} must stay inside ${resolvedRoot}.`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const symlinkPath = await firstSymlinkUnderRoot(resolvedTarget, resolvedRoot);
|
|
251
|
+
if (symlinkPath !== null) {
|
|
252
|
+
throw new Error(`${label} is unsafe: symlinked write targets are not allowed (${symlinkPath}).`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const canonicalRoot = await canonicalPathForWrite(resolvedRoot);
|
|
256
|
+
const canonicalTarget = await canonicalPathForWrite(resolvedTarget);
|
|
257
|
+
if (!isSameOrInsidePath(canonicalTarget, canonicalRoot)) {
|
|
258
|
+
throw new Error(`${label} is unsafe: resolved target escapes ${canonicalRoot}: ${canonicalTarget}.`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return canonicalTarget;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function assertSafeOutputRoot({
|
|
60
265
|
outputPath,
|
|
61
266
|
outputRoot,
|
|
62
267
|
repoRoot: templateRepoRoot,
|
|
@@ -68,22 +273,190 @@ export function assertSafeOutputRoot({
|
|
|
68
273
|
if (path.isAbsolute(outputPath) && !allowAbsoluteOutput) {
|
|
69
274
|
throw new Error(`Unsafe output path ${rejectedPath}: absolute output paths require --allow-absolute-output.`);
|
|
70
275
|
}
|
|
71
|
-
if (
|
|
72
|
-
throw new Error(`Unsafe output path ${rejectedPath}: output must not
|
|
276
|
+
if (pathContainsSegment(rejectedPath, ".git")) {
|
|
277
|
+
throw new Error(`Unsafe output path ${rejectedPath}: output path must not contain a .git path segment.`);
|
|
73
278
|
}
|
|
74
|
-
|
|
75
|
-
|
|
279
|
+
|
|
280
|
+
const symlinkPath = await firstSymlinkUnderRoot(outputRoot, workspaceRoot);
|
|
281
|
+
if (symlinkPath !== null) {
|
|
282
|
+
throw new Error(`Unsafe output path ${rejectedPath}: output path must not use symlinked output directories (${symlinkPath}).`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const outputInfo = await lstatIfExists(outputRoot);
|
|
286
|
+
if (outputInfo?.isSymbolicLink()) {
|
|
287
|
+
throw new Error(`Unsafe output path ${rejectedPath}: output path must not use symlinked output directories (${rejectedPath}).`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const canonicalOutputRoot = await canonicalPathForWrite(outputRoot);
|
|
291
|
+
const canonicalTemplateRepoRoot = await canonicalPathForWrite(templateRepoRoot);
|
|
292
|
+
const canonicalWorkspaceRoot = await canonicalPathForWrite(workspaceRoot);
|
|
293
|
+
|
|
294
|
+
if (!path.isAbsolute(outputPath) && !isSameOrInsidePath(canonicalOutputRoot, canonicalWorkspaceRoot)) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`Unsafe output path ${rejectedPath}: relative output paths must remain inside the workspace boundary ${canonicalWorkspaceRoot}.`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (isSameOrInsidePath(canonicalOutputRoot, canonicalTemplateRepoRoot)) {
|
|
300
|
+
throw new Error(`Unsafe output path ${rejectedPath}: output must not equal or be inside the template repo ${canonicalTemplateRepoRoot}.`);
|
|
301
|
+
}
|
|
302
|
+
if (canonicalOutputRoot === canonicalWorkspaceRoot) {
|
|
303
|
+
throw new Error(`Unsafe output path ${rejectedPath}: output must not equal the workspace root ${canonicalWorkspaceRoot}.`);
|
|
76
304
|
}
|
|
77
305
|
for (const consumerRoot of consumerRepos) {
|
|
78
|
-
|
|
306
|
+
const canonicalConsumerRoot = await canonicalPathForWrite(consumerRoot);
|
|
307
|
+
if (isSameOrInsidePath(canonicalOutputRoot, canonicalConsumerRoot)) {
|
|
79
308
|
throw new Error(
|
|
80
|
-
`Unsafe output path ${rejectedPath}: output must not equal or be inside configured consumer repo ${
|
|
309
|
+
`Unsafe output path ${rejectedPath}: output must not equal or be inside configured consumer repo ${canonicalConsumerRoot}.`,
|
|
81
310
|
);
|
|
82
311
|
}
|
|
83
312
|
}
|
|
84
|
-
if (pathContainsSegment(
|
|
313
|
+
if (pathContainsSegment(canonicalOutputRoot, ".git")) {
|
|
85
314
|
throw new Error(`Unsafe output path ${rejectedPath}: output path must not contain a .git path segment.`);
|
|
86
315
|
}
|
|
316
|
+
|
|
317
|
+
return canonicalOutputRoot;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export class ConfigResolutionError extends Error {
|
|
321
|
+
constructor(errors) {
|
|
322
|
+
super(errors.join("\n"));
|
|
323
|
+
this.name = "ConfigResolutionError";
|
|
324
|
+
this.errors = errors;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function resolveClientSupport(config) {
|
|
329
|
+
return {
|
|
330
|
+
codexHooks: config.models.openai && (config.clientSupport?.codex?.hooks ?? true),
|
|
331
|
+
claudeRules: config.models.anthropic && (config.clientSupport?.claude?.rules ?? true),
|
|
332
|
+
claudeHooks: config.models.anthropic && (config.clientSupport?.claude?.hooks ?? false),
|
|
333
|
+
claudeSkills: config.models.anthropic && (config.clientSupport?.claude?.skills ?? false),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function configResolutionMessage(label, error) {
|
|
338
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
339
|
+
return `${label}: ${message}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function resolveHarnessConfig(config, {
|
|
343
|
+
label = "harness config",
|
|
344
|
+
configPath = null,
|
|
345
|
+
configDir = configPath ? path.dirname(path.resolve(configPath)) : process.cwd(),
|
|
346
|
+
outputPath = config?.output?.path,
|
|
347
|
+
repoRoot: templateRepoRoot = repoRoot,
|
|
348
|
+
allowAbsoluteOutput = false,
|
|
349
|
+
requireExistingConsumers = false,
|
|
350
|
+
allowTemplateRepoConsumer = false,
|
|
351
|
+
} = {}) {
|
|
352
|
+
const errors = await validateConfigShape(config, label);
|
|
353
|
+
if (errors.length > 0) {
|
|
354
|
+
throw new ConfigResolutionError(errors);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const resolvedConfigDir = path.resolve(configDir);
|
|
358
|
+
const workspaceRoot = workspaceRootForConfig(resolvedConfigDir, templateRepoRoot);
|
|
359
|
+
const requestedOutputRoot = path.resolve(resolvedConfigDir, outputPath);
|
|
360
|
+
const consumerRoots = [];
|
|
361
|
+
|
|
362
|
+
for (const consumer of config.consumers) {
|
|
363
|
+
try {
|
|
364
|
+
consumerRoots.push(assertSafeConsumerPath({
|
|
365
|
+
consumerName: consumer.name,
|
|
366
|
+
consumerPath: consumer.path,
|
|
367
|
+
workspaceRoot,
|
|
368
|
+
repoRoot: templateRepoRoot,
|
|
369
|
+
allowTemplateRepoConsumer,
|
|
370
|
+
}));
|
|
371
|
+
} catch (error) {
|
|
372
|
+
errors.push(configResolutionMessage(label, error));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
let outputRoot = requestedOutputRoot;
|
|
377
|
+
try {
|
|
378
|
+
outputRoot = await assertSafeOutputRoot({
|
|
379
|
+
outputPath,
|
|
380
|
+
outputRoot: requestedOutputRoot,
|
|
381
|
+
repoRoot: templateRepoRoot,
|
|
382
|
+
workspaceRoot,
|
|
383
|
+
consumerRepos: consumerRoots,
|
|
384
|
+
allowAbsoluteOutput,
|
|
385
|
+
});
|
|
386
|
+
} catch (error) {
|
|
387
|
+
errors.push(configResolutionMessage(label, error));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (errors.length > 0) {
|
|
391
|
+
throw new ConfigResolutionError(errors);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const canonicalWorkspaceRoot = await canonicalPathForWrite(workspaceRoot);
|
|
395
|
+
const canonicalTemplateRepoRoot = await canonicalPathForWrite(templateRepoRoot);
|
|
396
|
+
const consumers = [];
|
|
397
|
+
for (const consumer of config.consumers) {
|
|
398
|
+
try {
|
|
399
|
+
const consumerRoot = assertSafeConsumerPath({
|
|
400
|
+
consumerName: consumer.name,
|
|
401
|
+
consumerPath: consumer.path,
|
|
402
|
+
workspaceRoot,
|
|
403
|
+
outputRoot,
|
|
404
|
+
repoRoot: templateRepoRoot,
|
|
405
|
+
allowTemplateRepoConsumer,
|
|
406
|
+
});
|
|
407
|
+
const confirmedRoot = requireExistingConsumers
|
|
408
|
+
? await assertConfirmedConsumerRepository({
|
|
409
|
+
consumerName: consumer.name,
|
|
410
|
+
consumerRoot,
|
|
411
|
+
workspaceRoot,
|
|
412
|
+
outputRoot,
|
|
413
|
+
repoRoot: templateRepoRoot,
|
|
414
|
+
allowTemplateRepoConsumer,
|
|
415
|
+
})
|
|
416
|
+
: null;
|
|
417
|
+
const canonicalConsumerRoot = confirmedRoot ?? await canonicalPathForWrite(consumerRoot);
|
|
418
|
+
if (!confirmedRoot) {
|
|
419
|
+
if (!isSameOrInsidePath(canonicalConsumerRoot, canonicalWorkspaceRoot)) {
|
|
420
|
+
throw new Error(
|
|
421
|
+
`Consumer path for ${consumer.name} is unsafe: resolved path escapes workspace ${canonicalWorkspaceRoot}: ${canonicalConsumerRoot}.`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
if (!allowTemplateRepoConsumer && isSameOrInsidePath(canonicalConsumerRoot, canonicalTemplateRepoRoot)) {
|
|
425
|
+
throw new Error(
|
|
426
|
+
`Consumer path for ${consumer.name} is unsafe: resolved path must not equal or be inside the Structor template repo ${canonicalTemplateRepoRoot}.`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
if (isSameOrInsidePath(canonicalConsumerRoot, outputRoot)) {
|
|
430
|
+
throw new Error(
|
|
431
|
+
`Consumer path for ${consumer.name} is unsafe: resolved path must not equal or be inside the generated harness output ${outputRoot}.`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
consumers.push({
|
|
436
|
+
config: consumer,
|
|
437
|
+
requestedRoot: consumerRoot,
|
|
438
|
+
root: canonicalConsumerRoot,
|
|
439
|
+
confirmedRoot,
|
|
440
|
+
});
|
|
441
|
+
} catch (error) {
|
|
442
|
+
errors.push(configResolutionMessage(label, error));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (errors.length > 0) {
|
|
447
|
+
throw new ConfigResolutionError(errors);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
config,
|
|
452
|
+
configDir: resolvedConfigDir,
|
|
453
|
+
workspaceRoot,
|
|
454
|
+
outputPath,
|
|
455
|
+
requestedOutputRoot,
|
|
456
|
+
outputRoot,
|
|
457
|
+
support: resolveClientSupport(config),
|
|
458
|
+
consumers,
|
|
459
|
+
};
|
|
87
460
|
}
|
|
88
461
|
|
|
89
462
|
export function failIfErrors(title, errors) {
|
|
@@ -109,7 +482,63 @@ function typeName(value) {
|
|
|
109
482
|
return typeof value;
|
|
110
483
|
}
|
|
111
484
|
|
|
112
|
-
|
|
485
|
+
const SUPPORTED_SCHEMA_KEYWORDS = new Set([
|
|
486
|
+
"$id",
|
|
487
|
+
"$schema",
|
|
488
|
+
"additionalProperties",
|
|
489
|
+
"const",
|
|
490
|
+
"description",
|
|
491
|
+
"enum",
|
|
492
|
+
"items",
|
|
493
|
+
"minItems",
|
|
494
|
+
"minLength",
|
|
495
|
+
"pattern",
|
|
496
|
+
"properties",
|
|
497
|
+
"required",
|
|
498
|
+
"title",
|
|
499
|
+
"type",
|
|
500
|
+
]);
|
|
501
|
+
|
|
502
|
+
function collectUnsupportedSchemaKeywords(schema, label, errors) {
|
|
503
|
+
if (!isPlainObject(schema)) return;
|
|
504
|
+
|
|
505
|
+
for (const key of Object.keys(schema)) {
|
|
506
|
+
if (!SUPPORTED_SCHEMA_KEYWORDS.has(key)) {
|
|
507
|
+
errors.push(`${label} schema uses unsupported keyword ${key}.`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (Object.hasOwn(schema, "items") && !isPlainObject(schema.items)) {
|
|
512
|
+
errors.push(`${label}.items must be a schema object; tuple or boolean items are not supported.`);
|
|
513
|
+
} else if (isPlainObject(schema.items)) {
|
|
514
|
+
collectUnsupportedSchemaKeywords(schema.items, `${label}[]`, errors);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (
|
|
518
|
+
Object.hasOwn(schema, "additionalProperties") &&
|
|
519
|
+
typeof schema.additionalProperties !== "boolean"
|
|
520
|
+
) {
|
|
521
|
+
errors.push(`${label}.additionalProperties must be a boolean; schema-valued additionalProperties is not supported.`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (Object.hasOwn(schema, "properties") && !isPlainObject(schema.properties)) {
|
|
525
|
+
errors.push(`${label}.properties must be an object of schema objects.`);
|
|
526
|
+
} else if (isPlainObject(schema.properties)) {
|
|
527
|
+
for (const [key, propertySchema] of Object.entries(schema.properties)) {
|
|
528
|
+
if (!isPlainObject(propertySchema)) {
|
|
529
|
+
errors.push(`${label}.${key} schema must be an object.`);
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
collectUnsupportedSchemaKeywords(propertySchema, `${label}.${key}`, errors);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function jsonSchemaValueEquals(actual, expected) {
|
|
538
|
+
return Object.is(actual, expected);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function validateJsonSchemaValue(value, schema, label, errors) {
|
|
113
542
|
const expectedType = schema.type;
|
|
114
543
|
if (expectedType) {
|
|
115
544
|
const validType =
|
|
@@ -126,6 +555,10 @@ function validateJsonSchema(value, schema, label, errors) {
|
|
|
126
555
|
errors.push(`${label} must be ${JSON.stringify(schema.const)}.`);
|
|
127
556
|
}
|
|
128
557
|
|
|
558
|
+
if (Array.isArray(schema.enum) && !schema.enum.some((allowed) => jsonSchemaValueEquals(value, allowed))) {
|
|
559
|
+
errors.push(`${label} must be one of ${schema.enum.map((allowed) => JSON.stringify(allowed)).join(", ")}.`);
|
|
560
|
+
}
|
|
561
|
+
|
|
129
562
|
if (typeof value === "string") {
|
|
130
563
|
if (schema.minLength !== undefined && value.length < schema.minLength) {
|
|
131
564
|
errors.push(`${label} must be at least ${schema.minLength} character(s).`);
|
|
@@ -141,7 +574,7 @@ function validateJsonSchema(value, schema, label, errors) {
|
|
|
141
574
|
}
|
|
142
575
|
if (schema.items) {
|
|
143
576
|
for (const [index, item] of value.entries()) {
|
|
144
|
-
|
|
577
|
+
validateJsonSchemaValue(item, schema.items, `${label}[${index}]`, errors);
|
|
145
578
|
}
|
|
146
579
|
}
|
|
147
580
|
}
|
|
@@ -162,12 +595,17 @@ function validateJsonSchema(value, schema, label, errors) {
|
|
|
162
595
|
}
|
|
163
596
|
for (const [key, propertySchema] of Object.entries(properties)) {
|
|
164
597
|
if (Object.hasOwn(value, key)) {
|
|
165
|
-
|
|
598
|
+
validateJsonSchemaValue(value[key], propertySchema, `${label}.${key}`, errors);
|
|
166
599
|
}
|
|
167
600
|
}
|
|
168
601
|
}
|
|
169
602
|
}
|
|
170
603
|
|
|
604
|
+
export function validateJsonSchema(value, schema, label, errors) {
|
|
605
|
+
collectUnsupportedSchemaKeywords(schema, label, errors);
|
|
606
|
+
validateJsonSchemaValue(value, schema, label, errors);
|
|
607
|
+
}
|
|
608
|
+
|
|
171
609
|
export async function validateConfigShape(config, label) {
|
|
172
610
|
const errors = [];
|
|
173
611
|
const schema = await readJson("schemas/harness-config.schema.json");
|
|
@@ -180,9 +618,21 @@ export async function validateConfigShape(config, label) {
|
|
|
180
618
|
const names = new Set();
|
|
181
619
|
if (Array.isArray(config.consumers)) {
|
|
182
620
|
for (const [index, consumer] of config.consumers.entries()) {
|
|
621
|
+
if (!isPlainObject(consumer)) continue;
|
|
183
622
|
const prefix = `${label}.consumers[${index}]`;
|
|
184
623
|
if (names.has(consumer.name)) errors.push(`${prefix}.name is duplicated.`);
|
|
185
624
|
names.add(consumer.name);
|
|
625
|
+
if (typeof consumer.path === "string") {
|
|
626
|
+
if (isAbsolutePathString(consumer.path)) {
|
|
627
|
+
errors.push(`${prefix}.path must be relative to the workspace; absolute paths are not allowed.`);
|
|
628
|
+
}
|
|
629
|
+
if (pathHasTraversal(consumer.path)) {
|
|
630
|
+
errors.push(`${prefix}.path must not contain relative traversal segments.`);
|
|
631
|
+
}
|
|
632
|
+
if (pathSegments(consumer.path).filter((segment) => segment !== ".").length === 0) {
|
|
633
|
+
errors.push(`${prefix}.path must name a consumer repository folder, not the workspace root.`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
186
636
|
}
|
|
187
637
|
}
|
|
188
638
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
const rawSlugPattern = /^[a-z0-9][a-z0-9-]*$/;
|
|
4
|
+
|
|
5
|
+
export function markdownText(value) {
|
|
6
|
+
const normalized = String(value).replace(/\s+/g, " ").trim();
|
|
7
|
+
const escaped = normalized.replace(/[\\`*_{}\[\]<>()#+!|>~]/g, "\\$&");
|
|
8
|
+
return escaped.replace(/^([-+]) /, "\\$1 ").replace(/^(\d+)([.)]) /, "$1\\$2 ");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function markdownCodeSpan(value) {
|
|
12
|
+
const text = String(value)
|
|
13
|
+
.replace(/\r/g, "\\r")
|
|
14
|
+
.replace(/\n/g, "\\n")
|
|
15
|
+
.replace(/\t/g, "\\t");
|
|
16
|
+
const longestBacktickRun = Math.max(0, ...Array.from(text.matchAll(/`+/g), (match) => match[0].length));
|
|
17
|
+
const delimiter = "`".repeat(longestBacktickRun + 1);
|
|
18
|
+
const padding = text.startsWith("`") || text.endsWith("`") || text.startsWith(" ") || text.endsWith(" ") ? " " : "";
|
|
19
|
+
return `${delimiter}${padding}${text}${padding}${delimiter}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function javascriptLiteral(value) {
|
|
23
|
+
return JSON.stringify(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function jsonLiteral(value) {
|
|
27
|
+
return JSON.stringify(value, null, 2);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function javascriptBoolean(value) {
|
|
31
|
+
return value ? "true" : "false";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function rawSlug(value, label) {
|
|
35
|
+
const text = String(value);
|
|
36
|
+
if (!rawSlugPattern.test(text)) {
|
|
37
|
+
throw new Error(`${label} must be a safe slug before raw template rendering.`);
|
|
38
|
+
}
|
|
39
|
+
return text;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function markdownPathCodeSpan(value) {
|
|
43
|
+
return markdownCodeSpan(String(value).replaceAll(path.sep, "/"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function consumerList(consumers) {
|
|
47
|
+
return consumers.map((consumer) => `- ${markdownCodeSpan(consumer.name)}: ${markdownText(consumer.purpose)}`).join("\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function validationList(validation) {
|
|
51
|
+
const entries = Object.entries(validation ?? {});
|
|
52
|
+
if (entries.length === 0) return "- No local validation commands documented yet.";
|
|
53
|
+
return entries.map(([name, command]) => `- ${markdownText(name)}: ${markdownCodeSpan(command)}`).join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function consumerNames(consumers) {
|
|
57
|
+
return consumers.map((consumer) => rawSlug(consumer.name, "consumer.name"));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function consumerConfig(resolvedConsumers, outputRoot) {
|
|
61
|
+
const generatedWorkspaceRoot = path.dirname(outputRoot);
|
|
62
|
+
return resolvedConsumers.map(({ config: consumer, root: consumerRoot }) => {
|
|
63
|
+
return {
|
|
64
|
+
...consumer,
|
|
65
|
+
name: rawSlug(consumer.name, "consumer.name"),
|
|
66
|
+
workspacePath: path.relative(generatedWorkspaceRoot, consumerRoot).replaceAll(path.sep, "/") || ".",
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function renderedGeneratedScriptHashes(hashes) {
|
|
72
|
+
return jsonLiteral(hashes);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function harnessTemplateValues(config, support, resolvedConsumers, outputRoot) {
|
|
76
|
+
return {
|
|
77
|
+
PROJECT_NAME: markdownText(config.project.name),
|
|
78
|
+
PROJECT_NAME_CODE: markdownCodeSpan(config.project.name),
|
|
79
|
+
PROJECT_NAME_JSON: javascriptLiteral(config.project.name),
|
|
80
|
+
PROJECT_SLUG: rawSlug(config.project.slug, "project.slug"),
|
|
81
|
+
HARNESS_REPO_NAME: rawSlug(config.project.harnessRepoName, "project.harnessRepoName"),
|
|
82
|
+
CONSUMER_REPOS_LIST: consumerList(config.consumers),
|
|
83
|
+
CONSUMER_REPO_NAMES_JSON: javascriptLiteral(consumerNames(config.consumers)),
|
|
84
|
+
CONSUMER_CONFIG_JSON: jsonLiteral(consumerConfig(resolvedConsumers, outputRoot)),
|
|
85
|
+
PRIMARY_CONSUMER_NAME: rawSlug(config.consumers[0].name, "consumer.name"),
|
|
86
|
+
MODEL_OPENAI_ENABLED: javascriptBoolean(config.models.openai),
|
|
87
|
+
MODEL_ANTHROPIC_ENABLED: javascriptBoolean(config.models.anthropic),
|
|
88
|
+
CLIENT_CODEX_HOOKS_ENABLED: javascriptBoolean(support.codexHooks),
|
|
89
|
+
CLIENT_CLAUDE_RULES_ENABLED: javascriptBoolean(support.claudeRules),
|
|
90
|
+
CLIENT_CLAUDE_HOOKS_ENABLED: javascriptBoolean(support.claudeHooks),
|
|
91
|
+
CLIENT_CLAUDE_SKILLS_ENABLED: javascriptBoolean(support.claudeSkills),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function consumerEntrypointValues(config, consumer, harnessRelativePath) {
|
|
96
|
+
const harnessPath = (relativePath) => markdownPathCodeSpan(`${harnessRelativePath}/${relativePath}`);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
PROJECT_NAME: markdownText(config.project.name),
|
|
100
|
+
CONSUMER_NAME: markdownText(consumer.name),
|
|
101
|
+
CONSUMER_PURPOSE: markdownText(consumer.purpose),
|
|
102
|
+
CONSUMER_VALIDATION_LIST: validationList(consumer.validation),
|
|
103
|
+
HARNESS_AGENTS_PATH: harnessPath("AGENTS.md"),
|
|
104
|
+
HARNESS_CLAUDE_PATH: harnessPath("CLAUDE.md"),
|
|
105
|
+
HARNESS_AI_AGENTS_PATH: harnessPath("ai/AGENTS.md"),
|
|
106
|
+
HARNESS_AI_HUB_PATH: harnessPath("ai/HUB.md"),
|
|
107
|
+
HARNESS_AI_CONTEXT_PATH: harnessPath("ai/context.md"),
|
|
108
|
+
};
|
|
109
|
+
}
|