@warpgogol/forge 5.1.2 → 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 +14 -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/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/onboarding/create.d.ts.map +1 -1
- package/dist/src/onboarding/create.js +5 -2
- 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 +15 -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.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/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/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/onboarding/create.ts +5 -2
- package/src/onboarding/doctor-token-check.test.ts +124 -0
- package/src/onboarding/doctor.ts +17 -0
- package/src/onboarding/init.ts +1 -0
- 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/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/profile-schema.test.ts +1 -1
|
@@ -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,6 +49,7 @@ 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";
|
|
53
54
|
import fs from "node:fs";
|
|
54
55
|
import type { ProfilePrerequisite } from "../profiles/profile-schema.ts";
|
|
@@ -1272,6 +1273,22 @@ export async function runDoctor(
|
|
|
1272
1273
|
: "docs/adrs/ directory not found — run 'forge create' to create ADR directory",
|
|
1273
1274
|
});
|
|
1274
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
|
+
|
|
1275
1292
|
// Check git branch is 'main' (not 'master')
|
|
1276
1293
|
checks.push(checkGitBranch(workspaceRoot));
|
|
1277
1294
|
|
package/src/onboarding/init.ts
CHANGED
|
@@ -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) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Unit tests for the site-workshop stack profile (RFC-1125) — verifies the profile parses, scaffolds the workshop contract, and never scaffolds systems-cache inside the workshop root.</purpose>
|
|
4
|
+
</MODULE_CONTRACT>
|
|
5
|
+
<CHANGE_SUMMARY>
|
|
6
|
+
<item>RFC-1125: initial site-workshop profile tests (AC-1 evidence).</item>
|
|
7
|
+
</CHANGE_SUMMARY>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { test, expect } from "vitest";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { listStackProfiles } from "../profiles/stack-profile.ts";
|
|
13
|
+
|
|
14
|
+
// Resolve forge root from this test file's location
|
|
15
|
+
const FORGE_ROOT = join(import.meta.dirname, "..", "..");
|
|
16
|
+
|
|
17
|
+
function loadSiteWorkshop() {
|
|
18
|
+
const profiles = listStackProfiles(FORGE_ROOT);
|
|
19
|
+
const profile = profiles.find((p) => p.id === "site-workshop");
|
|
20
|
+
expect(profile, "site-workshop profile must exist in packages/forge/profiles/").toBeDefined();
|
|
21
|
+
return profile!;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("site-workshop profile parses and is discoverable", () => {
|
|
25
|
+
const profile = loadSiteWorkshop();
|
|
26
|
+
expect(profile.schema).toBe("forge/stack-profile@1");
|
|
27
|
+
expect(profile.displayName).toBeTruthy();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("scaffolds kernel wiring, missions/, and governance dirs — but never systems-cache", () => {
|
|
31
|
+
const profile = loadSiteWorkshop();
|
|
32
|
+
const dirs = profile.workspace.dirs;
|
|
33
|
+
const filePaths = profile.workspace.files.map((f) => f.path);
|
|
34
|
+
|
|
35
|
+
// AC-1: kernel wiring + missions + governance dirs present
|
|
36
|
+
expect(dirs).toContain("missions");
|
|
37
|
+
expect(dirs).toContain("docs");
|
|
38
|
+
expect(dirs).toContain("tools");
|
|
39
|
+
expect(filePaths).toContain("tools/kernel.config.ts");
|
|
40
|
+
expect(filePaths).toContain("missions/.gitkeep");
|
|
41
|
+
|
|
42
|
+
// AC-1: systems-cache is a sibling (../systems-cache), never scaffolded inside root
|
|
43
|
+
expect(
|
|
44
|
+
dirs,
|
|
45
|
+
"systems-cache must not be a scaffolded dir — sibling convention per RFC-1125",
|
|
46
|
+
).not.toContain("systems-cache");
|
|
47
|
+
expect(
|
|
48
|
+
filePaths.some((p) => p.startsWith("systems-cache")),
|
|
49
|
+
"no systems-cache files may be scaffolded — created by sternsystem.register on demand",
|
|
50
|
+
).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("scaffolded kernel.config.ts consumes the single-sourced WorkshopModuleMap", () => {
|
|
54
|
+
const profile = loadSiteWorkshop();
|
|
55
|
+
const kernelConfig = profile.workspace.files.find((f) => f.path === "tools/kernel.config.ts");
|
|
56
|
+
expect(kernelConfig, "profile must scaffold tools/kernel.config.ts").toBeDefined();
|
|
57
|
+
// AC-5: the scaffolded config spreads workshopModuleLoaders() — no hand-copied loader list
|
|
58
|
+
expect(kernelConfig!.content).toContain("workshopModuleLoaders");
|
|
59
|
+
expect(kernelConfig!.content).toContain("@warpgogol/werkstatt-engine/kernel/module-map");
|
|
60
|
+
expect(
|
|
61
|
+
kernelConfig!.content,
|
|
62
|
+
"kernel.config.ts must not embed per-module loader thunks — they live in WORKSHOP_MODULE_MAP",
|
|
63
|
+
).not.toContain("createMissionModule");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("install set includes engine, site plugin, and forge", () => {
|
|
67
|
+
const profile = loadSiteWorkshop();
|
|
68
|
+
const install = profile.install.join(" ");
|
|
69
|
+
expect(install).toContain("@warpgogol/werkstatt-engine");
|
|
70
|
+
expect(install).toContain("@warpgogol/werkstatt-site");
|
|
71
|
+
expect(install).toContain("@warpgogol/forge");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("ships .npmrc token template and site-workshop nextSteps guidance", () => {
|
|
75
|
+
const profile = loadSiteWorkshop();
|
|
76
|
+
const npmrc = profile.workspace.files.find((f) => f.path === ".npmrc");
|
|
77
|
+
expect(npmrc, "profile must scaffold .npmrc for the private @warpgogol scope").toBeDefined();
|
|
78
|
+
expect(npmrc!.content).toContain("@warpgogol:registry");
|
|
79
|
+
|
|
80
|
+
// RFC-1125: token lifecycle + identity.bootstrap guidance lives in NEXT_STEPS.md
|
|
81
|
+
expect(profile.nextSteps, "profile must provide nextSteps for token/identity onboarding").toBeTruthy();
|
|
82
|
+
expect(profile.nextSteps).toContain("NPM_TOKEN");
|
|
83
|
+
expect(profile.nextSteps).toContain("identity.bootstrap");
|
|
84
|
+
});
|
|
@@ -363,6 +363,7 @@ export const stackProfileDomainFieldsSchema = z.object({
|
|
|
363
363
|
templates: z.array(profileTemplateSchema).optional(),
|
|
364
364
|
scriptDir: z.string().min(1).optional(),
|
|
365
365
|
rootAgentsMdTemplate: z.string().optional(),
|
|
366
|
+
nextSteps: z.string().optional(),
|
|
366
367
|
compass: compassPolicyOverridesSchema.optional(),
|
|
367
368
|
});
|
|
368
369
|
|
|
@@ -380,5 +381,6 @@ export interface StackProfileDomainFields {
|
|
|
380
381
|
templates?: ProfileTemplate[];
|
|
381
382
|
scriptDir?: string;
|
|
382
383
|
rootAgentsMdTemplate?: string;
|
|
384
|
+
nextSteps?: string;
|
|
383
385
|
compass?: CompassPolicyOverrides;
|
|
384
386
|
}
|
|
@@ -80,6 +80,8 @@ export const stackProfileSchema = z.object({
|
|
|
80
80
|
scriptDir: stackProfileDomainFieldsSchema.shape.scriptDir,
|
|
81
81
|
// Profile-driven root AGENTS.md template
|
|
82
82
|
rootAgentsMdTemplate: stackProfileDomainFieldsSchema.shape.rootAgentsMdTemplate,
|
|
83
|
+
// RFC-1125: profile-driven NEXT_STEPS.md content override
|
|
84
|
+
nextSteps: stackProfileDomainFieldsSchema.shape.nextSteps,
|
|
83
85
|
// Profile-driven Compass file extensions
|
|
84
86
|
compass: stackProfileDomainFieldsSchema.shape.compass,
|
|
85
87
|
});
|
|
@@ -70,6 +70,48 @@ Test consequences.
|
|
|
70
70
|
Test evolution.
|
|
71
71
|
`;
|
|
72
72
|
|
|
73
|
+
const ADR_BODY_WITH_AC = (
|
|
74
|
+
id: string,
|
|
75
|
+
status: string,
|
|
76
|
+
createdAt: string,
|
|
77
|
+
acBlock: string,
|
|
78
|
+
): string => `---
|
|
79
|
+
id: ${id}
|
|
80
|
+
title: "Test ADR"
|
|
81
|
+
status: ${status}
|
|
82
|
+
scope: workspace
|
|
83
|
+
decider: human:test
|
|
84
|
+
createdAt: ${createdAt}
|
|
85
|
+
updatedAt: ${createdAt}
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
# ${id}: Test ADR
|
|
89
|
+
|
|
90
|
+
## Context
|
|
91
|
+
|
|
92
|
+
Test context.
|
|
93
|
+
|
|
94
|
+
## Decision
|
|
95
|
+
|
|
96
|
+
Test decision.
|
|
97
|
+
|
|
98
|
+
## Justification
|
|
99
|
+
|
|
100
|
+
Test justification.
|
|
101
|
+
|
|
102
|
+
## Consequences
|
|
103
|
+
|
|
104
|
+
Test consequences.
|
|
105
|
+
|
|
106
|
+
## Evolution
|
|
107
|
+
|
|
108
|
+
Test evolution.
|
|
109
|
+
|
|
110
|
+
## Acceptance criteria
|
|
111
|
+
|
|
112
|
+
${acBlock}
|
|
113
|
+
`;
|
|
114
|
+
|
|
73
115
|
async function makeGitRepo(dir: string): Promise<void> {
|
|
74
116
|
execFileSync("git", ["init"], { cwd: dir, timeout: 5000 });
|
|
75
117
|
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: dir, timeout: 5000 });
|
|
@@ -295,6 +337,114 @@ describe("adr.implement.stamp", () => {
|
|
|
295
337
|
expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-05")).toBe(true);
|
|
296
338
|
});
|
|
297
339
|
|
|
340
|
+
// ── ADR-IMP-02: acceptance criteria completeness (post-cutoff, mirrors AV-17) ──
|
|
341
|
+
|
|
342
|
+
it("rejects post-cutoff ADR with unchecked acceptance criteria", async () => {
|
|
343
|
+
await writeFile(
|
|
344
|
+
join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
|
|
345
|
+
ADR_BODY_WITH_AC(
|
|
346
|
+
"ADR-0001",
|
|
347
|
+
"accepted",
|
|
348
|
+
"2026-09-10",
|
|
349
|
+
"- [ ] AC-1: THE artifact SHALL exist (evidence: file: x.ts:1)\n- [x] AC-2: THE other SHALL pass (evidence: file: y.ts:2)",
|
|
350
|
+
),
|
|
351
|
+
);
|
|
352
|
+
const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
|
|
353
|
+
|
|
354
|
+
const result = await runAdrImplementStamp(
|
|
355
|
+
makeInput("ADR-0001", sha),
|
|
356
|
+
makeContext(workspaceRoot),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
expect(result.data?.status).toBe("fail");
|
|
360
|
+
expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-02")).toBe(true);
|
|
361
|
+
expect(result.exitCode).toBe(1);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("rejects post-cutoff ADR with checked criteria lacking evidence annotation", async () => {
|
|
365
|
+
await writeFile(
|
|
366
|
+
join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
|
|
367
|
+
ADR_BODY_WITH_AC(
|
|
368
|
+
"ADR-0001",
|
|
369
|
+
"accepted",
|
|
370
|
+
"2026-09-10",
|
|
371
|
+
"- [x] AC-1: THE artifact SHALL exist",
|
|
372
|
+
),
|
|
373
|
+
);
|
|
374
|
+
const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
|
|
375
|
+
|
|
376
|
+
const result = await runAdrImplementStamp(
|
|
377
|
+
makeInput("ADR-0001", sha),
|
|
378
|
+
makeContext(workspaceRoot),
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
expect(result.data?.status).toBe("fail");
|
|
382
|
+
expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-02")).toBe(true);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("stamps post-cutoff ADR when all criteria are checked with evidence", async () => {
|
|
386
|
+
const adrPath = join(workspaceRoot, "docs", "adrs", "adr-0001-test.md");
|
|
387
|
+
await writeFile(
|
|
388
|
+
adrPath,
|
|
389
|
+
ADR_BODY_WITH_AC(
|
|
390
|
+
"ADR-0001",
|
|
391
|
+
"accepted",
|
|
392
|
+
"2026-09-10",
|
|
393
|
+
"- [x] AC-1: THE artifact SHALL exist (evidence: file: x.ts:1)\n- [x] AC-2: THE other SHALL pass (evidence: file: y.ts:2)",
|
|
394
|
+
),
|
|
395
|
+
);
|
|
396
|
+
const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
|
|
397
|
+
|
|
398
|
+
const result = await runAdrImplementStamp(
|
|
399
|
+
makeInput("ADR-0001", sha),
|
|
400
|
+
makeContext(workspaceRoot),
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
expect(result.data?.status).toBe("pass");
|
|
404
|
+
expect(result.data?.data?.criteriaChecked).toBe(2);
|
|
405
|
+
|
|
406
|
+
const afterContent = await readFile(adrPath, "utf-8");
|
|
407
|
+
expect(afterContent).toContain("status: implemented");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("stamps pre-cutoff ADR with unchecked criteria (AV-17 exempt)", async () => {
|
|
411
|
+
await writeFile(
|
|
412
|
+
join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
|
|
413
|
+
ADR_BODY_WITH_AC(
|
|
414
|
+
"ADR-0001",
|
|
415
|
+
"accepted",
|
|
416
|
+
"2026-08-01",
|
|
417
|
+
"- [ ] AC-1: THE artifact SHALL exist (evidence: file: x.ts:1)",
|
|
418
|
+
),
|
|
419
|
+
);
|
|
420
|
+
const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
|
|
421
|
+
|
|
422
|
+
const result = await runAdrImplementStamp(
|
|
423
|
+
makeInput("ADR-0001", sha),
|
|
424
|
+
makeContext(workspaceRoot),
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
expect(result.data?.status).toBe("pass");
|
|
428
|
+
expect(result.data?.data?.criteriaChecked).toBeUndefined();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("stamps post-cutoff ADR without an acceptance criteria section", async () => {
|
|
432
|
+
const adrPath = join(workspaceRoot, "docs", "adrs", "adr-0001-test.md");
|
|
433
|
+
const body = ADR_BODY("ADR-0001", "accepted").replace(
|
|
434
|
+
"createdAt: 2026-08-01",
|
|
435
|
+
"createdAt: 2026-09-10",
|
|
436
|
+
);
|
|
437
|
+
await writeFile(adrPath, body);
|
|
438
|
+
const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
|
|
439
|
+
|
|
440
|
+
const result = await runAdrImplementStamp(
|
|
441
|
+
makeInput("ADR-0001", sha),
|
|
442
|
+
makeContext(workspaceRoot),
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
expect(result.data?.status).toBe("pass");
|
|
446
|
+
});
|
|
447
|
+
|
|
298
448
|
// ── Dry-run ──────────────────────────────────────────────────────────────────
|
|
299
449
|
|
|
300
450
|
it("dry-run checks preconditions without mutating file", async () => {
|
|
@@ -157,7 +157,7 @@ test("artifacts with missing required extensions field fails", () => {
|
|
|
157
157
|
|
|
158
158
|
test("all shipped profiles parse without changes", () => {
|
|
159
159
|
const profiles = listStackProfiles(FORGE_ROOT);
|
|
160
|
-
expect(profiles.length).toBe(
|
|
160
|
+
expect(profiles.length).toBe(6);
|
|
161
161
|
for (const profile of profiles) {
|
|
162
162
|
expect(profile.id).toBeDefined();
|
|
163
163
|
expect(profile.workspace.dirs.length).toBeGreaterThan(0);
|