@warpgogol/forge 5.1.1 → 5.1.3
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/AGENTS.md +21 -2
- package/dist/os/adr/handlers/implement-stamp.d.ts.map +1 -1
- package/dist/os/adr/handlers/implement-stamp.js +32 -2
- package/dist/os/adr/handlers/implement-stamp.js.map +1 -1
- package/dist/os/adr/types.d.ts +2 -1
- package/dist/os/adr/types.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-audit-handler.js +3 -3
- package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
- package/dist/os/compass/handlers/compass-change-summary-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-change-summary-handler.js +1 -1
- package/dist/os/compass/handlers/compass-change-summary-handler.js.map +1 -1
- package/dist/os/compass/handlers/compass-inventory-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-inventory-handler.js +1 -1
- package/dist/os/compass/handlers/compass-inventory-handler.js.map +1 -1
- package/dist/os/compass/handlers/compass-inventory.d.ts +1 -1
- package/dist/os/compass/handlers/compass-inventory.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-inventory.js +20 -5
- package/dist/os/compass/handlers/compass-inventory.js.map +1 -1
- package/dist/os/compass/handlers/compass-migrate-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-migrate-handler.js +1 -0
- package/dist/os/compass/handlers/compass-migrate-handler.js.map +1 -1
- package/dist/os/compass/handlers/compass-migrate.d.ts +2 -0
- package/dist/os/compass/handlers/compass-migrate.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-migrate.js +1 -1
- package/dist/os/compass/handlers/compass-migrate.js.map +1 -1
- package/dist/os/compass/handlers/resolve-scan-root.js +1 -1
- package/dist/os/core/handlers/pinned-validate.d.ts.map +1 -1
- package/dist/os/core/handlers/pinned-validate.js +26 -6
- package/dist/os/core/handlers/pinned-validate.js.map +1 -1
- package/dist/os/naming/naming-convention.d.ts.map +1 -1
- package/dist/os/naming/naming-convention.js +2 -0
- package/dist/os/naming/naming-convention.js.map +1 -1
- package/dist/os/rfc/frontmatter-io.d.ts.map +1 -1
- package/dist/os/rfc/frontmatter-io.js +1 -0
- package/dist/os/rfc/frontmatter-io.js.map +1 -1
- package/dist/os/rfc/handlers/validate-rules.d.ts.map +1 -1
- package/dist/os/rfc/handlers/validate-rules.js +6 -1
- package/dist/os/rfc/handlers/validate-rules.js.map +1 -1
- package/dist/os/werkstatt/handlers/workspace-deps.d.ts.map +1 -1
- package/dist/os/werkstatt/handlers/workspace-deps.js +4 -3
- package/dist/os/werkstatt/handlers/workspace-deps.js.map +1 -1
- package/dist/src/config/forge-config.d.ts +8 -1
- package/dist/src/config/forge-config.d.ts.map +1 -1
- package/dist/src/config/forge-config.js +52 -5
- package/dist/src/config/forge-config.js.map +1 -1
- package/dist/src/onboarding/create.d.ts.map +1 -1
- package/dist/src/onboarding/create.js +9 -10
- package/dist/src/onboarding/create.js.map +1 -1
- package/dist/src/onboarding/doctor.d.ts.map +1 -1
- package/dist/src/onboarding/doctor.js +80 -0
- package/dist/src/onboarding/doctor.js.map +1 -1
- package/dist/src/onboarding/init.d.ts +1 -0
- package/dist/src/onboarding/init.d.ts.map +1 -1
- package/dist/src/onboarding/init.js +11 -0
- package/dist/src/onboarding/init.js.map +1 -1
- package/dist/src/onboarding/npm-token-check.d.ts +40 -0
- package/dist/src/onboarding/npm-token-check.d.ts.map +1 -0
- package/dist/src/onboarding/npm-token-check.js +104 -0
- package/dist/src/onboarding/npm-token-check.js.map +1 -0
- package/dist/src/onboarding/scaffold-project.d.ts.map +1 -1
- package/dist/src/onboarding/scaffold-project.js +10 -0
- package/dist/src/onboarding/scaffold-project.js.map +1 -1
- package/dist/src/onboarding/upgrade.d.ts.map +1 -1
- package/dist/src/onboarding/upgrade.js +4 -2
- package/dist/src/onboarding/upgrade.js.map +1 -1
- package/dist/src/profiles/profile-schema.d.ts +2 -0
- package/dist/src/profiles/profile-schema.d.ts.map +1 -1
- package/dist/src/profiles/profile-schema.js +1 -0
- package/dist/src/profiles/profile-schema.js.map +1 -1
- package/dist/src/profiles/stack-profile.d.ts +1 -0
- package/dist/src/profiles/stack-profile.d.ts.map +1 -1
- package/dist/src/profiles/stack-profile.js +2 -0
- package/dist/src/profiles/stack-profile.js.map +1 -1
- package/os/adr/handlers/implement-stamp.ts +34 -2
- package/os/adr/types.ts +3 -1
- package/os/compass/handlers/compass-audit-handler.ts +3 -0
- package/os/compass/handlers/compass-change-summary-handler.ts +1 -0
- package/os/compass/handlers/compass-inventory-handler.ts +1 -0
- package/os/compass/handlers/compass-inventory.ts +20 -6
- package/os/compass/handlers/compass-migrate-handler.ts +1 -0
- package/os/compass/handlers/compass-migrate.ts +9 -2
- package/os/compass/handlers/resolve-scan-root.ts +1 -1
- package/os/core/handlers/pinned-validate.ts +33 -7
- package/os/naming/naming-convention.ts +2 -0
- package/os/rfc/frontmatter-io.ts +1 -0
- package/os/rfc/handlers/validate-rules.ts +8 -1
- package/os/werkstatt/handlers/workspace-deps.ts +4 -3
- package/package.json +1 -1
- package/profiles/site-workshop.yaml +654 -0
- package/skills/fo/fo-idea-create-rfc/acceptance-criteria-standard.md +1 -1
- package/src/config/forge-config.ts +59 -5
- package/src/onboarding/create.ts +9 -9
- package/src/onboarding/doctor-token-check.test.ts +124 -0
- package/src/onboarding/doctor.ts +87 -0
- package/src/onboarding/init.ts +20 -1
- package/src/onboarding/npm-token-check.ts +140 -0
- package/src/onboarding/scaffold-project.ts +11 -0
- package/src/onboarding/site-workshop-profile.test.ts +84 -0
- package/src/onboarding/upgrade.ts +4 -1
- package/src/profiles/profile-schema.ts +2 -0
- package/src/profiles/stack-profile.ts +2 -0
- package/src/tests/adr-implement-stamp.test.ts +150 -0
- package/src/tests/doctor-profile-id-known.test.ts +237 -0
- package/src/tests/pinned-validate.test.ts +134 -0
- package/src/tests/profile-schema.test.ts +1 -1
|
@@ -197,7 +197,10 @@ export const forgeConfigSchema = z.object({
|
|
|
197
197
|
})
|
|
198
198
|
.optional(),
|
|
199
199
|
/** RFC-0643: profile id — when present, loadForgeConfig loads the corresponding profiles/<id>.yaml */
|
|
200
|
-
|
|
200
|
+
// RFC-1118: accept the corrupted object form (whole StackProfile written by a
|
|
201
|
+
// previous buggy serialize) so loadForgeConfig can recover the declared id —
|
|
202
|
+
// a hard schema throw here would deadlock `forge upgrade`, the healing path.
|
|
203
|
+
profile: z.union([z.string(), z.object({ id: z.string().min(1) }).passthrough()]).optional(),
|
|
201
204
|
/** RFC-0704: packages with autonomous npm versions — ecosystem.commit skips platform bump when all staged files belong to these packages */
|
|
202
205
|
independentVersionPackages: z.array(z.string()).optional(),
|
|
203
206
|
});
|
|
@@ -230,6 +233,10 @@ export interface ForgeConfig {
|
|
|
230
233
|
forge?: { syncedVersion: string | null };
|
|
231
234
|
/** RFC-0643: loaded stack profile — present when forge.yaml has a `profile` field */
|
|
232
235
|
profile?: StackProfile;
|
|
236
|
+
/** RFC-1118: raw `profile:` id string from forge.yaml — preserved verbatim even when unresolvable */
|
|
237
|
+
profileDeclaredId?: string;
|
|
238
|
+
/** RFC-1118: resolution outcome for the declared `profile:` id */
|
|
239
|
+
profileResolution?: "resolved" | "unknown" | "absent" | "catalog-unavailable";
|
|
233
240
|
/** RFC-0704: packages with autonomous npm versions — ecosystem.commit skips platform bump when all staged files belong to these packages */
|
|
234
241
|
independentVersionPackages?: string[];
|
|
235
242
|
}
|
|
@@ -382,21 +389,45 @@ export function loadForgeConfig(workspaceRoot: string, forgeRootOverride?: strin
|
|
|
382
389
|
}
|
|
383
390
|
|
|
384
391
|
const rawData = result.data as Record<string, unknown>;
|
|
385
|
-
|
|
392
|
+
// RFC-1118: recover the declared id even when a previous buggy serialize wrote
|
|
393
|
+
// the whole StackProfile object into forge.yaml (object with an `id` field).
|
|
394
|
+
const rawProfile = rawData["profile"];
|
|
395
|
+
const profileId =
|
|
396
|
+
typeof rawProfile === "string"
|
|
397
|
+
? rawProfile
|
|
398
|
+
: rawProfile && typeof rawProfile === "object" && "id" in rawProfile
|
|
399
|
+
? String((rawProfile as { id: unknown }).id)
|
|
400
|
+
: undefined;
|
|
386
401
|
|
|
387
402
|
// RFC-0643: load stack profile when `profile` field is present in forge.yaml
|
|
388
403
|
let loadedProfile: StackProfile | undefined;
|
|
404
|
+
let catalogAvailable = false;
|
|
389
405
|
if (profileId) {
|
|
390
406
|
try {
|
|
391
407
|
const forgeRoot = forgeRootOverride ?? resolveForgeRoot(workspaceRoot);
|
|
392
|
-
|
|
393
|
-
|
|
408
|
+
// RFC-1118: listStackProfiles returns [] on a missing profiles/ dir — an
|
|
409
|
+
// unreadable catalog (forge not installed, fallback path) must surface as
|
|
410
|
+
// "catalog-unavailable", not "unknown" (which would fail doctor spuriously).
|
|
411
|
+
if (fs.existsSync(path.join(forgeRoot, "profiles"))) {
|
|
412
|
+
const profiles = listStackProfiles(forgeRoot);
|
|
413
|
+
catalogAvailable = true;
|
|
414
|
+
loadedProfile = profiles.find((p) => p.id === profileId);
|
|
415
|
+
}
|
|
394
416
|
} catch {
|
|
395
|
-
// forge root not resolvable —
|
|
417
|
+
// forge root not resolvable — catalog unavailable, resolvability undetermined
|
|
396
418
|
}
|
|
397
419
|
}
|
|
398
420
|
|
|
399
421
|
const config = { ...rawData } as unknown as ForgeConfig;
|
|
422
|
+
// RFC-1118: preserve the declared id and its resolution status — never silently drop it
|
|
423
|
+
config.profileDeclaredId = profileId;
|
|
424
|
+
config.profileResolution = !profileId
|
|
425
|
+
? "absent"
|
|
426
|
+
: loadedProfile
|
|
427
|
+
? "resolved"
|
|
428
|
+
: catalogAvailable
|
|
429
|
+
? "unknown"
|
|
430
|
+
: "catalog-unavailable";
|
|
400
431
|
if (loadedProfile) {
|
|
401
432
|
config.profile = loadedProfile;
|
|
402
433
|
} else {
|
|
@@ -406,6 +437,29 @@ export function loadForgeConfig(workspaceRoot: string, forgeRootOverride?: strin
|
|
|
406
437
|
return config;
|
|
407
438
|
}
|
|
408
439
|
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
// Serializer — RFC-1118: forge.yaml stores the declared profile id (string),
|
|
442
|
+
// never the resolved StackProfile object, and never drops a declared id.
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
export function serializeForgeConfig(config: ForgeConfig): Record<string, unknown> {
|
|
446
|
+
const out = { ...config } as unknown as Record<string, unknown>;
|
|
447
|
+
// Runtime-only fields must not be persisted
|
|
448
|
+
delete out["profileDeclaredId"];
|
|
449
|
+
delete out["profileResolution"];
|
|
450
|
+
// Prefer the preserved declared id; fall back to a raw string `profile`
|
|
451
|
+
// (hand-built configs that never went through loadForgeConfig).
|
|
452
|
+
const declared =
|
|
453
|
+
config.profileDeclaredId ??
|
|
454
|
+
(typeof config.profile === "string" ? (config.profile as unknown as string) : undefined);
|
|
455
|
+
if (declared) {
|
|
456
|
+
out["profile"] = declared;
|
|
457
|
+
} else {
|
|
458
|
+
delete out["profile"];
|
|
459
|
+
}
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
462
|
+
|
|
409
463
|
// ---------------------------------------------------------------------------
|
|
410
464
|
// Forge root resolver — the single place that decides monorepo vs npm-installed
|
|
411
465
|
// ---------------------------------------------------------------------------
|
package/src/onboarding/create.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { runAgentsGenerate } from "./agents-generate.ts";
|
|
|
32
32
|
import { scaffoldMemoryLayer } from "./memory-scaffold.ts";
|
|
33
33
|
import {
|
|
34
34
|
loadForgeConfig,
|
|
35
|
+
serializeForgeConfig,
|
|
35
36
|
resolveForgeRoot,
|
|
36
37
|
resolvePackageManager,
|
|
37
38
|
applyCliBindingDefaults,
|
|
@@ -93,6 +94,9 @@ function loadProfileDomainFields(profileId: string, forgeRoot: string): InitDoma
|
|
|
93
94
|
if (profile.compass) {
|
|
94
95
|
fields.compass = profile.compass;
|
|
95
96
|
}
|
|
97
|
+
if (profile.nextSteps) {
|
|
98
|
+
fields.nextSteps = profile.nextSteps;
|
|
99
|
+
}
|
|
96
100
|
return fields;
|
|
97
101
|
} catch {
|
|
98
102
|
return {};
|
|
@@ -348,13 +352,9 @@ export async function runCreate(
|
|
|
348
352
|
if (config.bindings) {
|
|
349
353
|
config.bindings.commands = applyCliBindingDefaults(pm);
|
|
350
354
|
}
|
|
351
|
-
// RFC-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const profileObj = configToSerialize["profile"] as { id?: string };
|
|
355
|
-
configToSerialize["profile"] = profileObj.id ?? configToSerialize["profile"];
|
|
356
|
-
}
|
|
357
|
-
fs.writeFileSync(forgeYamlPath, stringifyYaml(configToSerialize), "utf8");
|
|
355
|
+
// RFC-1118: serializeForgeConfig writes the declared profile id verbatim —
|
|
356
|
+
// never the resolved StackProfile object, never drops an unresolvable id.
|
|
357
|
+
fs.writeFileSync(forgeYamlPath, stringifyYaml(serializeForgeConfig(config)), "utf8");
|
|
358
358
|
} catch {
|
|
359
359
|
// Post-processing is best-effort — init already wrote a valid config
|
|
360
360
|
}
|
|
@@ -387,9 +387,9 @@ export async function runCreate(
|
|
|
387
387
|
}
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
-
// 13. Write NEXT_STEPS.md (RFC-0550)
|
|
390
|
+
// 13. Write NEXT_STEPS.md (RFC-0550); RFC-1125: profile may override via nextSteps field
|
|
391
391
|
const nextStepsPath = path.join(targetDir, "NEXT_STEPS.md");
|
|
392
|
-
const nextStepsContent = `# Next Steps
|
|
392
|
+
const nextStepsContent = initDomainFields.nextSteps ?? `# Next Steps
|
|
393
393
|
|
|
394
394
|
Your Forge project is ready. The next step is mandatory: run \`/forge-bootstrap\` to configure your project interactively.
|
|
395
395
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Unit tests for the npm-token probe (RFC-1125, AC-2) — .npmrc parsing, token presence detection, and the fail-fast fixHint contract used by forge create and forge.doctor.</purpose>
|
|
4
|
+
</MODULE_CONTRACT>
|
|
5
|
+
<CHANGE_SUMMARY>
|
|
6
|
+
<item>RFC-1125: initial npm-token probe tests (AC-2 evidence).</item>
|
|
7
|
+
</CHANGE_SUMMARY>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { test, expect, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
checkNpmToken,
|
|
16
|
+
readNpmrcTokenStatus,
|
|
17
|
+
workshopNeedsWarpgogolToken,
|
|
18
|
+
} from "./npm-token-check.ts";
|
|
19
|
+
|
|
20
|
+
let tempDir: string;
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
tempDir = await mkdtemp(join(tmpdir(), "npm-token-check-"));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const NPMRC_WITH_TOKEN = `@warpgogol:registry=https://registry.npmjs.org/
|
|
31
|
+
//registry.npmjs.org/:_authToken=npm_real_token_123
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
const NPMRC_PLACEHOLDER = `@warpgogol:registry=https://registry.npmjs.org/
|
|
35
|
+
//registry.npmjs.org/:_authToken=YOUR_NPM_TOKEN
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const NPMRC_ENV_REF = `@warpgogol:registry=https://registry.npmjs.org/
|
|
39
|
+
//registry.npmjs.org/:_authToken=\${NPM_TOKEN}
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
// --- readNpmrcTokenStatus ---
|
|
43
|
+
|
|
44
|
+
test("detects registry and literal token from .npmrc", async () => {
|
|
45
|
+
await writeFile(join(tempDir, ".npmrc"), NPMRC_WITH_TOKEN);
|
|
46
|
+
const status = readNpmrcTokenStatus(tempDir, {});
|
|
47
|
+
expect(status.registry).toBe("https://registry.npmjs.org/");
|
|
48
|
+
expect(status.tokenPresent).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("YOUR_NPM_TOKEN placeholder does not count as a token", async () => {
|
|
52
|
+
await writeFile(join(tempDir, ".npmrc"), NPMRC_PLACEHOLDER);
|
|
53
|
+
const status = readNpmrcTokenStatus(tempDir, {});
|
|
54
|
+
expect(status.tokenPresent).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("NPM_TOKEN env satisfies the probe even with placeholder .npmrc", async () => {
|
|
58
|
+
await writeFile(join(tempDir, ".npmrc"), NPMRC_PLACEHOLDER);
|
|
59
|
+
const status = readNpmrcTokenStatus(tempDir, { NPM_TOKEN: "npm_env_token" });
|
|
60
|
+
expect(status.tokenPresent).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("${NPM_TOKEN} reference resolves against env", async () => {
|
|
64
|
+
await writeFile(join(tempDir, ".npmrc"), NPMRC_ENV_REF);
|
|
65
|
+
expect(readNpmrcTokenStatus(tempDir, { NPM_TOKEN: "x" }).tokenPresent).toBe(true);
|
|
66
|
+
expect(readNpmrcTokenStatus(tempDir, {}).tokenPresent).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("missing .npmrc → no registry, no token", () => {
|
|
70
|
+
const status = readNpmrcTokenStatus(tempDir, {});
|
|
71
|
+
expect(status.registry).toBeNull();
|
|
72
|
+
expect(status.tokenPresent).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("detects _authToken when it is not the last line (scaffolded .npmrc shape)", async () => {
|
|
76
|
+
// Regression: scaffolded .npmrc appends dangerously-allow-all-builds and
|
|
77
|
+
// ignore-workspace-root-check AFTER the token line — the regex must match mid-file.
|
|
78
|
+
await writeFile(
|
|
79
|
+
join(tempDir, ".npmrc"),
|
|
80
|
+
`@warpgogol:registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=npm_real_token\ndangerously-allow-all-builds=true\nignore-workspace-root-check=true\n`,
|
|
81
|
+
);
|
|
82
|
+
const status = readNpmrcTokenStatus(tempDir, {});
|
|
83
|
+
expect(status.tokenPresent).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// --- workshopNeedsWarpgogolToken ---
|
|
87
|
+
|
|
88
|
+
test("needs token when .npmrc declares @warpgogol scope", async () => {
|
|
89
|
+
await writeFile(join(tempDir, ".npmrc"), NPMRC_PLACEHOLDER);
|
|
90
|
+
expect(workshopNeedsWarpgogolToken(tempDir, [])).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("needs token when install pulls @warpgogol packages", () => {
|
|
94
|
+
expect(workshopNeedsWarpgogolToken(tempDir, ["pnpm add -wD @warpgogol/forge"])).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("skips probe when no @warpgogol usage", () => {
|
|
98
|
+
expect(workshopNeedsWarpgogolToken(tempDir, ["pnpm add -wD prettier"])).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// --- checkNpmToken ---
|
|
102
|
+
|
|
103
|
+
test("canFetch=true → fixHint is null", async () => {
|
|
104
|
+
await writeFile(join(tempDir, ".npmrc"), NPMRC_WITH_TOKEN);
|
|
105
|
+
const result = await checkNpmToken(tempDir, { fetchVersion: () => true, env: {} });
|
|
106
|
+
expect(result.canFetch).toBe(true);
|
|
107
|
+
expect(result.fixHint).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("canFetch=false with token → fixHint mentions expiry/revocation", async () => {
|
|
111
|
+
await writeFile(join(tempDir, ".npmrc"), NPMRC_WITH_TOKEN);
|
|
112
|
+
const result = await checkNpmToken(tempDir, { fetchVersion: () => false, env: {} });
|
|
113
|
+
expect(result.canFetch).toBe(false);
|
|
114
|
+
expect(result.fixHint).toContain("expired or revoked");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("canFetch=false without token → fixHint names NPM_TOKEN and .npmrc", async () => {
|
|
118
|
+
await writeFile(join(tempDir, ".npmrc"), NPMRC_PLACEHOLDER);
|
|
119
|
+
const result = await checkNpmToken(tempDir, { fetchVersion: () => false, env: {} });
|
|
120
|
+
expect(result.canFetch).toBe(false);
|
|
121
|
+
// AC-2: the fixHint must be actionable — name the exact remediation path
|
|
122
|
+
expect(result.fixHint).toContain("NPM_TOKEN");
|
|
123
|
+
expect(result.fixHint).toContain(".npmrc");
|
|
124
|
+
});
|
package/src/onboarding/doctor.ts
CHANGED
|
@@ -49,7 +49,9 @@ import type { DuplicatePair } from "../knowledge/index.ts";
|
|
|
49
49
|
import { checkMemoryLayerHealth } from "./memory-scaffold.ts";
|
|
50
50
|
import { checkInvariants } from "./invariant-engine.ts";
|
|
51
51
|
import type { InvariantViolation } from "./invariant-engine.ts";
|
|
52
|
+
import { checkNpmToken, workshopNeedsWarpgogolToken } from "./npm-token-check.ts";
|
|
52
53
|
import { execSync } from "node:child_process";
|
|
54
|
+
import fs from "node:fs";
|
|
53
55
|
import type { ProfilePrerequisite } from "../profiles/profile-schema.ts";
|
|
54
56
|
|
|
55
57
|
interface DoctorCheck {
|
|
@@ -1023,6 +1025,72 @@ function checkDomainInfo(domainReport: DomainReport): DoctorCheck {
|
|
|
1023
1025
|
};
|
|
1024
1026
|
}
|
|
1025
1027
|
|
|
1028
|
+
// RFC-1118: fail-closed diagnostic for an unresolvable `profile:` id.
|
|
1029
|
+
// A declared id absent from the installed catalog is a hard failure —
|
|
1030
|
+
// never silently dropped. An unreadable catalog is a warning, not a fail.
|
|
1031
|
+
function checkProfileIdKnown(workspaceRoot: string, forgeRoot: string): DoctorCheck {
|
|
1032
|
+
const forgeYamlPath = join(workspaceRoot, "forge.yaml");
|
|
1033
|
+
if (!fs.existsSync(forgeYamlPath)) {
|
|
1034
|
+
return {
|
|
1035
|
+
name: "profile-id-known",
|
|
1036
|
+
status: "pass",
|
|
1037
|
+
message: "no forge.yaml — nothing to resolve",
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
let config: ReturnType<typeof loadForgeConfig>;
|
|
1041
|
+
try {
|
|
1042
|
+
config = loadForgeConfig(workspaceRoot, forgeRoot);
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
// forge.yaml exists but is unreadable/schema-invalid — a real defect, fail-closed
|
|
1045
|
+
return {
|
|
1046
|
+
name: "profile-id-known",
|
|
1047
|
+
status: "fail",
|
|
1048
|
+
message: `forge.yaml unreadable: ${(err as Error).message.split("\n")[0]}`,
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
switch (config.profileResolution) {
|
|
1053
|
+
case "unknown": {
|
|
1054
|
+
let version = "unknown";
|
|
1055
|
+
try {
|
|
1056
|
+
const pkg = JSON.parse(fs.readFileSync(join(forgeRoot, "package.json"), "utf8")) as {
|
|
1057
|
+
version?: string;
|
|
1058
|
+
};
|
|
1059
|
+
version = pkg.version ?? "unknown";
|
|
1060
|
+
} catch {
|
|
1061
|
+
// version unreadable — message still names the declared id
|
|
1062
|
+
}
|
|
1063
|
+
return {
|
|
1064
|
+
name: "profile-id-known",
|
|
1065
|
+
status: "fail",
|
|
1066
|
+
message:
|
|
1067
|
+
`profile "${config.profileDeclaredId}" not in installed @warpgogol/forge v${version} catalog — ` +
|
|
1068
|
+
`renamed/removed id or forge upgrade required`,
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
case "catalog-unavailable":
|
|
1072
|
+
return {
|
|
1073
|
+
name: "profile-id-known",
|
|
1074
|
+
status: "warn",
|
|
1075
|
+
message:
|
|
1076
|
+
`profile "${config.profileDeclaredId}" declared but the installed profile catalog cannot be read ` +
|
|
1077
|
+
`(forge root unresolvable) — resolvability undetermined`,
|
|
1078
|
+
};
|
|
1079
|
+
case "resolved":
|
|
1080
|
+
return {
|
|
1081
|
+
name: "profile-id-known",
|
|
1082
|
+
status: "pass",
|
|
1083
|
+
message: `profile "${config.profileDeclaredId}" resolved`,
|
|
1084
|
+
};
|
|
1085
|
+
default:
|
|
1086
|
+
return {
|
|
1087
|
+
name: "profile-id-known",
|
|
1088
|
+
status: "pass",
|
|
1089
|
+
message: "no profile declared",
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1026
1094
|
// RFC-0664: Memory layer health check
|
|
1027
1095
|
function checkMemoryLayer(workspaceRoot: string): DoctorCheck {
|
|
1028
1096
|
const health = checkMemoryLayerHealth(workspaceRoot);
|
|
@@ -1205,6 +1273,22 @@ export async function runDoctor(
|
|
|
1205
1273
|
: "docs/adrs/ directory not found — run 'forge create' to create ADR directory",
|
|
1206
1274
|
});
|
|
1207
1275
|
|
|
1276
|
+
// RFC-1125: re-verify the @warpgogol npm token post-install when the workshop
|
|
1277
|
+
// uses the private scope (.npmrc declares it or package.json has @warpgogol deps)
|
|
1278
|
+
const pkgJsonPath = join(workspaceRoot, "package.json");
|
|
1279
|
+
const pkgHasWarpgogol =
|
|
1280
|
+
fs.existsSync(pkgJsonPath) && fs.readFileSync(pkgJsonPath, "utf8").includes("@warpgogol/");
|
|
1281
|
+
if (workshopNeedsWarpgogolToken(workspaceRoot, []) || pkgHasWarpgogol) {
|
|
1282
|
+
const tokenCheck = await checkNpmToken(workspaceRoot);
|
|
1283
|
+
checks.push({
|
|
1284
|
+
name: "npm-token (@warpgogol)",
|
|
1285
|
+
status: tokenCheck.canFetch ? "pass" : "fail",
|
|
1286
|
+
message: tokenCheck.canFetch
|
|
1287
|
+
? `npm token fetches @warpgogol/werkstatt-engine (${tokenCheck.registry ?? "default registry"})`
|
|
1288
|
+
: tokenCheck.fixHint!,
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1208
1292
|
// Check git branch is 'main' (not 'master')
|
|
1209
1293
|
checks.push(checkGitBranch(workspaceRoot));
|
|
1210
1294
|
|
|
@@ -1222,6 +1306,9 @@ export async function runDoctor(
|
|
|
1222
1306
|
// RFC-0640: Domain info check
|
|
1223
1307
|
checks.push(checkDomainInfo(domainReport));
|
|
1224
1308
|
|
|
1309
|
+
// RFC-1118: profile identity check — fail-closed on unresolvable declared id
|
|
1310
|
+
checks.push(checkProfileIdKnown(workspaceRoot, forgeRoot));
|
|
1311
|
+
|
|
1225
1312
|
// RFC-0675: Enforce invariants using the invariant engine
|
|
1226
1313
|
if (domainReport.invariants.length > 0) {
|
|
1227
1314
|
const profiles = listStackProfiles(forgeRoot);
|
package/src/onboarding/init.ts
CHANGED
|
@@ -49,6 +49,7 @@ export interface InitDomainFields {
|
|
|
49
49
|
terminology?: Record<string, string>;
|
|
50
50
|
semanticBindings?: Record<string, string | null>;
|
|
51
51
|
profileId?: string;
|
|
52
|
+
nextSteps?: string;
|
|
52
53
|
compass?: {
|
|
53
54
|
fileExtensions?: string[];
|
|
54
55
|
testPatterns?: string[];
|
|
@@ -60,7 +61,11 @@ export function runInit(
|
|
|
60
61
|
context: unknown,
|
|
61
62
|
domainFields?: InitDomainFields,
|
|
62
63
|
): InitResult {
|
|
63
|
-
const ctx = context as {
|
|
64
|
+
const ctx = context as {
|
|
65
|
+
workspaceRoot?: string;
|
|
66
|
+
forgeRoot?: string;
|
|
67
|
+
logger?: { warn?: (msg: string) => void };
|
|
68
|
+
};
|
|
64
69
|
const workspaceRoot = ctx?.workspaceRoot ?? process.cwd();
|
|
65
70
|
const contextForgeRoot = ctx?.forgeRoot;
|
|
66
71
|
|
|
@@ -115,6 +120,20 @@ export function runInit(
|
|
|
115
120
|
} catch {
|
|
116
121
|
config = defaultForgeConfig(path.basename(workspaceRoot));
|
|
117
122
|
}
|
|
123
|
+
// RFC-1118: a declared `profile:` is never overwritten by re-detection.
|
|
124
|
+
// If --from detection disagrees with the declared id, surface a warning —
|
|
125
|
+
// the declared id stays verbatim in forge.yaml.
|
|
126
|
+
if (
|
|
127
|
+
detection?.profile &&
|
|
128
|
+
config.profileDeclaredId &&
|
|
129
|
+
detection.profile !== config.profileDeclaredId
|
|
130
|
+
) {
|
|
131
|
+
const msg =
|
|
132
|
+
`profile: declared id "${config.profileDeclaredId}" preserved — ` +
|
|
133
|
+
`detected "${detection.profile}" not written (forge.yaml already exists)`;
|
|
134
|
+
skipped.push(msg);
|
|
135
|
+
ctx?.logger?.warn?.(msg);
|
|
136
|
+
}
|
|
118
137
|
} else {
|
|
119
138
|
const projectName = path.basename(workspaceRoot);
|
|
120
139
|
config = defaultForgeConfig(projectName);
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Non-interactive npm-token probe for the private @warpgogol registry scope (RFC-1125) — parses .npmrc for the @warpgogol scope registry and auth token presence, probes the registry via `npm view @warpgogol/werkstatt-engine version` (injectable for tests) before pnpm install runs in forge create, re-verifies post-install in forge.doctor, and returns a structured NpmTokenCheck with an actionable fixHint on failure.</purpose>
|
|
4
|
+
<non-goals>
|
|
5
|
+
<item>Do not prompt the operator — the probe is non-interactive (create.ts forbids prompts).</item>
|
|
6
|
+
<item>Do not accept tokens via CLI flags — tokens must not appear in argv/history.</item>
|
|
7
|
+
</non-goals>
|
|
8
|
+
</MODULE_CONTRACT>
|
|
9
|
+
<CHANGE_SUMMARY>
|
|
10
|
+
<item>RFC-1125: initial npm-token probe — checkNpmToken + readNpmrcTokenStatus, wired into scaffold-project (pre-install fail-fast) and doctor (post-install re-verify).</item>
|
|
11
|
+
</CHANGE_SUMMARY>
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execSync } from "node:child_process";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
/** Result of the npm-token probe (RFC-1125). */
|
|
19
|
+
export interface NpmTokenCheck {
|
|
20
|
+
/** Registry URL from `.npmrc` `@warpgogol:registry=` (null when undeclared). */
|
|
21
|
+
registry: string | null;
|
|
22
|
+
/** A usable token is configured — NPM_TOKEN env or a real _authToken value. */
|
|
23
|
+
tokenPresent: boolean;
|
|
24
|
+
/** `npm view @warpgogol/werkstatt-engine version` succeeded from workspaceRoot. */
|
|
25
|
+
canFetch: boolean;
|
|
26
|
+
/** Actionable remediation when canFetch is false; null on success. */
|
|
27
|
+
fixHint: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Injectable dependencies for testing. */
|
|
31
|
+
export interface NpmTokenProbeDeps {
|
|
32
|
+
/** Override the registry probe — return true when the fetch succeeds. */
|
|
33
|
+
fetchVersion?: (cwd: string) => boolean;
|
|
34
|
+
/** Override the environment (defaults to process.env). */
|
|
35
|
+
env?: Record<string, string | undefined>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const WARPGOGOL_SCOPE = "@warpgogol";
|
|
39
|
+
const PROBE_PACKAGE = "@warpgogol/werkstatt-engine";
|
|
40
|
+
const PROBE_TIMEOUT_MS = 15_000;
|
|
41
|
+
const PLACEHOLDER_TOKENS = new Set(["YOUR_NPM_TOKEN", ""]);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse the workspace `.npmrc` for the @warpgogol scope: registry URL and
|
|
45
|
+
* whether a usable auth token is configured. A token counts as present when
|
|
46
|
+
* NPM_TOKEN is set in the env, or when `_authToken` holds a real value
|
|
47
|
+
* (not the YOUR_NPM_TOKEN placeholder, not an unset ${VAR} reference).
|
|
48
|
+
*/
|
|
49
|
+
export function readNpmrcTokenStatus(
|
|
50
|
+
workspaceRoot: string,
|
|
51
|
+
env: Record<string, string | undefined> = process.env,
|
|
52
|
+
): { registry: string | null; tokenPresent: boolean } {
|
|
53
|
+
const npmrcPath = path.join(workspaceRoot, ".npmrc");
|
|
54
|
+
const npmrc = fs.existsSync(npmrcPath) ? fs.readFileSync(npmrcPath, "utf8") : "";
|
|
55
|
+
|
|
56
|
+
const registryMatch = npmrc.match(/^@warpgogol:registry=(\S+)\s*$/m);
|
|
57
|
+
const registry = registryMatch?.[1] ?? null;
|
|
58
|
+
|
|
59
|
+
// Token sources, in precedence order:
|
|
60
|
+
// 1. NPM_TOKEN env (agent-driven installs; also satisfies ${NPM_TOKEN} refs)
|
|
61
|
+
if (env.NPM_TOKEN && env.NPM_TOKEN.trim().length > 0) {
|
|
62
|
+
return { registry, tokenPresent: true };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 2. Literal _authToken in .npmrc for the scope's registry host.
|
|
66
|
+
// `m` flag required — the token line is never last in a scaffolded .npmrc
|
|
67
|
+
// (dangerously-allow-all-builds + appended ignore-workspace-root-check follow it).
|
|
68
|
+
const tokenMatch = npmrc.match(/_authToken=(\S+)[ \t]*$/m);
|
|
69
|
+
if (tokenMatch) {
|
|
70
|
+
const value = tokenMatch[1];
|
|
71
|
+
// ${VAR} references resolve at install time — count as present only when set
|
|
72
|
+
const varRef = value.match(/^\$\{(\w+)\}$/);
|
|
73
|
+
if (varRef) {
|
|
74
|
+
const envValue = env[varRef[1]];
|
|
75
|
+
return { registry, tokenPresent: !!envValue && envValue.trim().length > 0 };
|
|
76
|
+
}
|
|
77
|
+
return { registry, tokenPresent: !PLACEHOLDER_TOKENS.has(value) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { registry, tokenPresent: false };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Whether the workspace needs the @warpgogol token at all — true when `.npmrc`
|
|
85
|
+
* declares the scope or any install command pulls `@warpgogol/` packages.
|
|
86
|
+
* Profiles without @warpgogol deps skip the probe entirely.
|
|
87
|
+
*/
|
|
88
|
+
export function workshopNeedsWarpgogolToken(workspaceRoot: string, installCmds: string[]): boolean {
|
|
89
|
+
const npmrcPath = path.join(workspaceRoot, ".npmrc");
|
|
90
|
+
if (fs.existsSync(npmrcPath)) {
|
|
91
|
+
const npmrc = fs.readFileSync(npmrcPath, "utf8");
|
|
92
|
+
if (npmrc.includes(`${WARPGOGOL_SCOPE}:registry`) || npmrc.includes(`${WARPGOGOL_SCOPE}/`)) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return installCmds.some((cmd) => cmd.includes(`${WARPGOGOL_SCOPE}/`));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function defaultFetchVersion(cwd: string): boolean {
|
|
100
|
+
try {
|
|
101
|
+
execSync(`npm view ${PROBE_PACKAGE} version`, {
|
|
102
|
+
cwd,
|
|
103
|
+
stdio: "pipe",
|
|
104
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
105
|
+
});
|
|
106
|
+
return true;
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildFixHint(tokenPresent: boolean): string {
|
|
113
|
+
const base =
|
|
114
|
+
`npm token for the @warpgogol scope cannot fetch ${PROBE_PACKAGE}. ` +
|
|
115
|
+
`Set NPM_TOKEN env or replace YOUR_NPM_TOKEN in .npmrc with a valid npm read token ` +
|
|
116
|
+
`(issued at workshop provisioning — see NEXT_STEPS.md §Token lifecycle).`;
|
|
117
|
+
return tokenPresent
|
|
118
|
+
? `${base} A token is configured but was rejected — it may be expired or revoked; request a fresh one.`
|
|
119
|
+
: base;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Run the npm-token probe: parse `.npmrc`, then verify the registry actually
|
|
124
|
+
* serves the package. Non-interactive — never prompts, never reads argv.
|
|
125
|
+
*/
|
|
126
|
+
export async function checkNpmToken(
|
|
127
|
+
workspaceRoot: string,
|
|
128
|
+
deps: NpmTokenProbeDeps = {},
|
|
129
|
+
): Promise<NpmTokenCheck> {
|
|
130
|
+
const env = deps.env ?? process.env;
|
|
131
|
+
const fetchVersion = deps.fetchVersion ?? defaultFetchVersion;
|
|
132
|
+
const { registry, tokenPresent } = readNpmrcTokenStatus(workspaceRoot, env);
|
|
133
|
+
const canFetch = fetchVersion(workspaceRoot);
|
|
134
|
+
return {
|
|
135
|
+
registry,
|
|
136
|
+
tokenPresent,
|
|
137
|
+
canFetch,
|
|
138
|
+
fixHint: canFetch ? null : buildFixHint(tokenPresent),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -22,6 +22,7 @@ import fs from "node:fs";
|
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import { execSync } from "node:child_process";
|
|
24
24
|
import { listStackProfiles, type StackProfile } from "../profiles/stack-profile.ts";
|
|
25
|
+
import { checkNpmToken, workshopNeedsWarpgogolToken } from "./npm-token-check.ts";
|
|
25
26
|
import { resolveForgeRoot } from "../config/forge-config.ts";
|
|
26
27
|
import type {
|
|
27
28
|
ForgeCommandInput,
|
|
@@ -166,6 +167,16 @@ export async function runScaffoldProject(
|
|
|
166
167
|
}
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
// RFC-1125: non-interactive npm-token probe — fail fast before pnpm install when
|
|
171
|
+
// the @warpgogol scope is needed but the token cannot fetch @warpgogol/werkstatt-engine.
|
|
172
|
+
// Runs after .npmrc is materialized so the probe sees the final token state.
|
|
173
|
+
if (profile.install.length > 0 && workshopNeedsWarpgogolToken(workspaceRoot, profile.install)) {
|
|
174
|
+
const tokenCheck = await checkNpmToken(workspaceRoot);
|
|
175
|
+
if (!tokenCheck.canFetch) {
|
|
176
|
+
return fail("forge.scaffold", profileId, tokenCheck.fixHint!, errors, outputFormat, logger);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
169
180
|
// Run ALL install commands AFTER all files are created (atomicity: no partial state if install fails)
|
|
170
181
|
// Root workspace install commands
|
|
171
182
|
if (outputFormat === "pretty" && profile.install.length > 0) {
|