@gr8ful/spf 0.1.7 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -2
- package/assets/skill/cookbooks/authoring_chains.md +96 -84
- package/assets/skill/cookbooks/roster.md +3 -1
- package/assets/skill/references/config.md +52 -5
- package/assets/templates/ts.spf.config.yaml +12 -0
- package/dist/chains/context.d.ts +2 -0
- package/dist/chains/index.d.ts +21 -2
- package/dist/chains/index.js +73 -104
- package/dist/chains/{adw_simple_sdlc.d.ts → simple_sdlc.d.ts} +7 -1
- package/dist/chains/{adw_simple_sdlc.js → simple_sdlc.js} +19 -30
- package/dist/chains/steps.d.ts +117 -0
- package/dist/chains/steps.js +299 -0
- package/dist/cli/ask.d.ts +27 -0
- package/dist/cli/ask.js +125 -0
- package/dist/cli/commands/doctor.js +14 -24
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.js +96 -9
- package/dist/cli/commands/run.d.ts +1 -1
- package/dist/cli/commands/run.js +3 -1
- package/dist/cli/commands/watch.js +20 -3
- package/dist/cli/env_file.d.ts +18 -0
- package/dist/cli/env_file.js +99 -0
- package/dist/cli/index.js +6 -2
- package/dist/cli/interview.d.ts +26 -0
- package/dist/cli/interview.js +417 -0
- package/dist/core/agents.js +4 -1
- package/dist/core/console.d.ts +13 -1
- package/dist/core/console.js +51 -1
- package/dist/core/data_types.d.ts +55 -0
- package/dist/core/data_types.js +30 -0
- package/dist/core/notify/channel.d.ts +32 -0
- package/dist/core/notify/channel.js +14 -0
- package/dist/core/notify/notifier.d.ts +42 -0
- package/dist/core/notify/notifier.js +100 -0
- package/dist/core/notify/slack_channel.d.ts +13 -0
- package/dist/core/notify/slack_channel.js +30 -0
- package/dist/core/notify/teams_channel.d.ts +17 -0
- package/dist/core/notify/teams_channel.js +38 -0
- package/dist/core/notify/webhook_channel.d.ts +13 -0
- package/dist/core/notify/webhook_channel.js +19 -0
- package/dist/core/prompts.d.ts +2 -0
- package/dist/core/prompts.js +2 -0
- package/dist/core/providers.d.ts +12 -0
- package/dist/core/providers.js +24 -0
- package/dist/core/quality.d.ts +9 -0
- package/dist/core/quality.js +10 -0
- package/dist/core/runner.d.ts +7 -0
- package/dist/core/runner.js +4 -1
- package/dist/core/session.d.ts +6 -1
- package/dist/core/session.js +10 -3
- package/dist/core/tracer.js +1 -1
- package/dist/core/utils.d.ts +6 -2
- package/dist/core/utils.js +11 -2
- package/dist/core/watch.d.ts +10 -0
- package/dist/core/watch.js +67 -4
- package/dist/test/chains.test.d.ts +12 -0
- package/dist/test/chains.test.js +86 -0
- package/dist/test/data_types.test.js +34 -1
- package/dist/test/env_file.test.d.ts +1 -0
- package/dist/test/env_file.test.js +74 -0
- package/dist/test/fake_asker.d.ts +23 -0
- package/dist/test/fake_asker.js +30 -0
- package/dist/test/init_command.test.d.ts +1 -0
- package/dist/test/init_command.test.js +66 -0
- package/dist/test/interview.test.d.ts +1 -0
- package/dist/test/interview.test.js +297 -0
- package/dist/test/notify.test.d.ts +1 -0
- package/dist/test/notify.test.js +174 -0
- package/dist/test/ui_server.test.js +1 -1
- package/dist/test/watch.test.js +114 -1
- package/dist/ui/shared/types.d.ts +1 -1
- package/package.json +5 -2
- package/dist/chains/adw_build.d.ts +0 -12
- package/dist/chains/adw_build.js +0 -27
- package/dist/chains/adw_build_review.d.ts +0 -21
- package/dist/chains/adw_build_review.js +0 -55
- package/dist/chains/adw_build_test.d.ts +0 -21
- package/dist/chains/adw_build_test.js +0 -67
- package/dist/chains/adw_document.d.ts +0 -23
- package/dist/chains/adw_document.js +0 -59
- package/dist/chains/adw_plan.d.ts +0 -12
- package/dist/chains/adw_plan.js +0 -27
- package/dist/chains/adw_plan_build.d.ts +0 -12
- package/dist/chains/adw_plan_build.js +0 -30
- package/dist/chains/adw_plan_build_test.d.ts +0 -16
- package/dist/chains/adw_plan_build_test.js +0 -65
- package/dist/chains/adw_plan_build_test_quality.d.ts +0 -18
- package/dist/chains/adw_plan_build_test_quality.js +0 -66
- package/dist/chains/adw_prompt.d.ts +0 -12
- package/dist/chains/adw_prompt.js +0 -25
- package/dist/chains/adw_quality.d.ts +0 -12
- package/dist/chains/adw_quality.js +0 -32
- package/dist/chains/adw_scout.d.ts +0 -12
- package/dist/chains/adw_scout.js +0 -27
package/dist/core/watch.js
CHANGED
|
@@ -92,6 +92,13 @@ export async function reconcileOrphans(deps, state) {
|
|
|
92
92
|
}
|
|
93
93
|
else {
|
|
94
94
|
deps.log(`watch: ${issue.id} orphaned past ${MAX_ORPHAN_ATTEMPTS} attempts — blocked`);
|
|
95
|
+
deps.notify({
|
|
96
|
+
kind: "issue_blocked",
|
|
97
|
+
level: "error",
|
|
98
|
+
title: `issue ${issue.id} blocked`,
|
|
99
|
+
detail: `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned attempts.`,
|
|
100
|
+
fields: [["issue", issue.id], ["title", issue.title]],
|
|
101
|
+
});
|
|
95
102
|
if (!deps.dryRun) {
|
|
96
103
|
await deps.provider.transition(issue, "blocked", `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned attempts.`);
|
|
97
104
|
cleanupWorktree(deps, marker);
|
|
@@ -109,6 +116,13 @@ export async function finishReviews(deps) {
|
|
|
109
116
|
const status = await deps.codeHost.prStatus({ number: marker.pr, branch: marker.branch ?? "", url: "" });
|
|
110
117
|
if (status.merged) {
|
|
111
118
|
deps.log(`watch: ${issue.id}'s PR #${marker.pr} merged — done`);
|
|
119
|
+
deps.notify({
|
|
120
|
+
kind: "issue_done",
|
|
121
|
+
level: "info",
|
|
122
|
+
title: `issue ${issue.id} done`,
|
|
123
|
+
detail: `PR #${marker.pr} merged.`,
|
|
124
|
+
fields: [["issue", issue.id], ["title", issue.title], ["pr", `#${marker.pr}`]],
|
|
125
|
+
});
|
|
112
126
|
if (!deps.dryRun) {
|
|
113
127
|
await deps.provider.transition(issue, "done");
|
|
114
128
|
cleanupWorktree(deps, marker);
|
|
@@ -116,6 +130,13 @@ export async function finishReviews(deps) {
|
|
|
116
130
|
}
|
|
117
131
|
else if (status.state === "closed") {
|
|
118
132
|
deps.log(`watch: ${issue.id}'s PR #${marker.pr} closed without merging — blocked`);
|
|
133
|
+
deps.notify({
|
|
134
|
+
kind: "issue_blocked",
|
|
135
|
+
level: "error",
|
|
136
|
+
title: `issue ${issue.id} blocked`,
|
|
137
|
+
detail: `PR #${marker.pr} was closed without merging.`,
|
|
138
|
+
fields: [["issue", issue.id], ["title", issue.title], ["pr", `#${marker.pr}`]],
|
|
139
|
+
});
|
|
119
140
|
if (!deps.dryRun) {
|
|
120
141
|
await deps.provider.transition(issue, "blocked", `PR #${marker.pr} was closed without merging.`);
|
|
121
142
|
cleanupWorktree(deps, marker);
|
|
@@ -147,13 +168,28 @@ async function runIssue(deps, issue) {
|
|
|
147
168
|
const result = await deps.runChain({ prompt, cwd: worktreePath, adwId });
|
|
148
169
|
if (!result.accepted) {
|
|
149
170
|
deps.log(`watch: ${issue.id}: chain "${deps.chain}" did not succeed — blocked`);
|
|
150
|
-
|
|
171
|
+
const detail = result.detail || `Chain "${deps.chain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
|
|
172
|
+
deps.notify({
|
|
173
|
+
kind: "issue_blocked",
|
|
174
|
+
level: "error",
|
|
175
|
+
title: `issue ${issue.id} blocked`,
|
|
176
|
+
detail,
|
|
177
|
+
fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain], ["adw_id", adwId]],
|
|
178
|
+
});
|
|
179
|
+
await deps.provider.transition(issue, "blocked", detail);
|
|
151
180
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
152
181
|
return;
|
|
153
182
|
}
|
|
154
183
|
const wtGit = deps.worktreeGit(worktreePath);
|
|
155
184
|
if (wtGit.diffFiles(`origin/${deps.baseBranch}`).length === 0) {
|
|
156
185
|
deps.log(`watch: ${issue.id}: chain succeeded but committed nothing — blocked`);
|
|
186
|
+
deps.notify({
|
|
187
|
+
kind: "issue_blocked",
|
|
188
|
+
level: "error",
|
|
189
|
+
title: `issue ${issue.id} blocked`,
|
|
190
|
+
detail: `Chain "${deps.chain}" (adw_id ${adwId}) completed but left no committed changes.`,
|
|
191
|
+
fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain], ["adw_id", adwId]],
|
|
192
|
+
});
|
|
157
193
|
await deps.provider.transition(issue, "blocked", `Chain "${deps.chain}" (adw_id ${adwId}) completed but left no committed changes.`);
|
|
158
194
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
159
195
|
return;
|
|
@@ -173,10 +209,24 @@ async function runIssue(deps, issue) {
|
|
|
173
209
|
await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, pr: pr.number, attempt: 0 });
|
|
174
210
|
await deps.provider.transition(issue, "review");
|
|
175
211
|
deps.log(`watch: ${issue.id}: opened PR #${pr.number} — review`);
|
|
212
|
+
deps.notify({
|
|
213
|
+
kind: "pr_opened",
|
|
214
|
+
level: "info",
|
|
215
|
+
title: `PR #${pr.number} opened`,
|
|
216
|
+
fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain]],
|
|
217
|
+
url: pr.url || undefined,
|
|
218
|
+
});
|
|
176
219
|
}
|
|
177
220
|
catch (error) {
|
|
178
221
|
const message = error.message;
|
|
179
222
|
deps.log(`watch: ${issue.id}: error: ${message}`);
|
|
223
|
+
deps.notify({
|
|
224
|
+
kind: "watch_error",
|
|
225
|
+
level: "error",
|
|
226
|
+
title: `issue ${issue.id} errored`,
|
|
227
|
+
detail: message,
|
|
228
|
+
fields: [["issue", issue.id], ["title", issue.title]],
|
|
229
|
+
});
|
|
180
230
|
await deps.provider.transition(issue, "blocked", `spf watch error: ${message}`).catch(() => undefined);
|
|
181
231
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
182
232
|
}
|
|
@@ -201,13 +251,26 @@ export async function claimNewWork(deps, state) {
|
|
|
201
251
|
continue;
|
|
202
252
|
}
|
|
203
253
|
deps.log(`watch: claimed ${issue.id}: ${issue.title}`);
|
|
254
|
+
deps.notify({
|
|
255
|
+
kind: "issue_claimed",
|
|
256
|
+
level: "info",
|
|
257
|
+
title: `issue ${issue.id} claimed`,
|
|
258
|
+
fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain]],
|
|
259
|
+
});
|
|
204
260
|
state.inflight.add(issue.id);
|
|
205
261
|
runIssue(deps, issue).finally(() => state.inflight.delete(issue.id));
|
|
206
262
|
}
|
|
207
263
|
}
|
|
264
|
+
function tickErrorHandler(deps, stage) {
|
|
265
|
+
return (error) => {
|
|
266
|
+
const message = error.message;
|
|
267
|
+
deps.log(`watch: ${stage} error: ${message}`);
|
|
268
|
+
deps.notify({ kind: "watch_error", level: "error", title: `watch: ${stage} error`, detail: message, fields: [] });
|
|
269
|
+
};
|
|
270
|
+
}
|
|
208
271
|
/** One poll tick: reconcile, finish, claim — each independently caught, so one phase's error never blocks the rest. */
|
|
209
272
|
export async function tick(deps, state) {
|
|
210
|
-
await reconcileOrphans(deps, state).catch((
|
|
211
|
-
await finishReviews(deps).catch((
|
|
212
|
-
await claimNewWork(deps, state).catch((
|
|
273
|
+
await reconcileOrphans(deps, state).catch(tickErrorHandler(deps, "reconcileOrphans"));
|
|
274
|
+
await finishReviews(deps).catch(tickErrorHandler(deps, "finishReviews"));
|
|
275
|
+
await claimNewWork(deps, state).catch(tickErrorHandler(deps, "claimNewWork"));
|
|
213
276
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression net for the chain registry. Chains used to each be a hand-written
|
|
3
|
+
* module; now every one but `simple-sdlc` is a `steps` list whose
|
|
4
|
+
* `phases`/`requiredAgents`/`requiredSuites` are DERIVED (see
|
|
5
|
+
* `chains/steps.ts`'s `derivePhases`/`deriveRequiredAgents`/
|
|
6
|
+
* `deriveRequiredSuites`). These tests pin what that derivation produces
|
|
7
|
+
* today, so a step-factory change that silently alters a chain's shape (an
|
|
8
|
+
* agent dropped from `requiredAgents`, a suite no longer required, a phase
|
|
9
|
+
* missing from the display string) fails here instead of only showing up as
|
|
10
|
+
* a `spf list` diff nobody happened to read.
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression net for the chain registry. Chains used to each be a hand-written
|
|
3
|
+
* module; now every one but `simple-sdlc` is a `steps` list whose
|
|
4
|
+
* `phases`/`requiredAgents`/`requiredSuites` are DERIVED (see
|
|
5
|
+
* `chains/steps.ts`'s `derivePhases`/`deriveRequiredAgents`/
|
|
6
|
+
* `deriveRequiredSuites`). These tests pin what that derivation produces
|
|
7
|
+
* today, so a step-factory change that silently alters a chain's shape (an
|
|
8
|
+
* agent dropped from `requiredAgents`, a suite no longer required, a phase
|
|
9
|
+
* missing from the display string) fails here instead of only showing up as
|
|
10
|
+
* a `spf list` diff nobody happened to read.
|
|
11
|
+
*/
|
|
12
|
+
import { test } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { CHAINS, findChain, resolveRequiredAgents } from "../chains/index.js";
|
|
15
|
+
// name -> [phases, requiredAgents (with no options), requiredSuites]
|
|
16
|
+
const EXPECTED = {
|
|
17
|
+
prompt: { phases: "engineer(request) -> <agent>", agents: ["builder"], suites: [] },
|
|
18
|
+
scout: { phases: "engineer(request) -> scout", agents: ["scout"], suites: [] },
|
|
19
|
+
plan: { phases: "engineer(request) -> planner", agents: ["planner"], suites: [] },
|
|
20
|
+
build: { phases: "engineer(request) -> builder", agents: ["builder"], suites: [] },
|
|
21
|
+
"plan-build": { phases: "engineer(request) -> planner -> builder -> git(commit)", agents: ["planner", "builder"], suites: [] },
|
|
22
|
+
"build-test": {
|
|
23
|
+
phases: "engineer(request) -> builder -> code(test) [-> builder(fix) -> code(test) ...] bounded",
|
|
24
|
+
agents: ["builder"],
|
|
25
|
+
suites: ["test"],
|
|
26
|
+
},
|
|
27
|
+
"plan-build-test": {
|
|
28
|
+
phases: "engineer(request) -> planner -> builder -> code(test) [-> builder(fix) -> code(test) ...] bounded -> git(commit)",
|
|
29
|
+
agents: ["planner", "builder"],
|
|
30
|
+
suites: ["test"],
|
|
31
|
+
},
|
|
32
|
+
"plan-build-test-quality": {
|
|
33
|
+
phases: "engineer(request) -> planner -> builder -> code(verify) [-> builder(fix) -> code(verify) ...] bounded -> git(commit)",
|
|
34
|
+
agents: ["planner", "builder"],
|
|
35
|
+
suites: ["all"],
|
|
36
|
+
},
|
|
37
|
+
"build-review": {
|
|
38
|
+
phases: "engineer(request) -> builder -> reviewer [-> builder(revise) -> reviewer ...] bounded",
|
|
39
|
+
agents: ["builder", "reviewer"],
|
|
40
|
+
suites: [],
|
|
41
|
+
},
|
|
42
|
+
quality: { phases: "engineer(request) -> code(quality)", agents: [], suites: ["all"] },
|
|
43
|
+
document: { phases: "engineer(request) -> code(changes) -> documenter", agents: ["documenter"], suites: [] },
|
|
44
|
+
"simple-sdlc": {
|
|
45
|
+
phases: "engineer(request) -> planner -> git(commit_plan) -> builder -> code(test) [-> builder(fix) -> code(test) ...] " +
|
|
46
|
+
"-> reviewer [-> builder(revise) -> reviewer ...] -> code(retest, if revised) -> git(commit_build) " +
|
|
47
|
+
"-> code(changes) -> documenter -> git(commit_docs)",
|
|
48
|
+
agents: ["planner", "builder", "reviewer", "documenter"],
|
|
49
|
+
suites: ["test"],
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
test("every chain in the registry has an expectation pinned here", () => {
|
|
53
|
+
const names = CHAINS.map((c) => c.name).sort();
|
|
54
|
+
assert.deepEqual(names, Object.keys(EXPECTED).sort(), "a chain was added/removed/renamed without updating this test");
|
|
55
|
+
});
|
|
56
|
+
for (const chain of CHAINS) {
|
|
57
|
+
const expected = EXPECTED[chain.name];
|
|
58
|
+
test(`${chain.name}: derived phases/requiredAgents/requiredSuites match what was hand-verified against \`spf list\``, () => {
|
|
59
|
+
assert.equal(chain.phases, expected.phases);
|
|
60
|
+
assert.deepEqual(resolveRequiredAgents(chain, {}), expected.agents);
|
|
61
|
+
assert.deepEqual(chain.requiredSuites, expected.suites);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
test("prompt: requiredAgents depends on --agent, not a fixed list — the one dynamic case", () => {
|
|
65
|
+
const chain = findChain("prompt");
|
|
66
|
+
assert.deepEqual(resolveRequiredAgents(chain, {}), ["builder"], "no --agent -> falls back to builder");
|
|
67
|
+
assert.deepEqual(resolveRequiredAgents(chain, { agent: "planner" }), ["planner"], "--agent overrides the default");
|
|
68
|
+
});
|
|
69
|
+
test("every chain but simple-sdlc is a steps list; simple-sdlc alone uses the imperative run() escape hatch", () => {
|
|
70
|
+
for (const chain of CHAINS) {
|
|
71
|
+
if (chain.name === "simple-sdlc") {
|
|
72
|
+
assert.ok(chain.run, "simple-sdlc should still be the one hand-written chain");
|
|
73
|
+
assert.equal(chain.steps, undefined);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
assert.ok(chain.steps && chain.steps.length > 0, `${chain.name} should be a steps list`);
|
|
77
|
+
assert.equal(chain.run, undefined, `${chain.name} should not also define run()`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
test("findChain resolves every registered name and nothing else", () => {
|
|
82
|
+
for (const chain of CHAINS) {
|
|
83
|
+
assert.equal(findChain(chain.name)?.name, chain.name);
|
|
84
|
+
}
|
|
85
|
+
assert.equal(findChain("not-a-real-chain"), undefined);
|
|
86
|
+
});
|
|
@@ -9,9 +9,13 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { test } from "node:test";
|
|
11
11
|
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
12
15
|
import * as v from "valibot";
|
|
13
16
|
import { toJsonSchema } from "@valibot/to-json-schema";
|
|
14
|
-
import { AgentConfigSchema, BuildOutput, ChangesOutput, DocumentOutput, GenericOutput, PhaseParamsSchema, PlanOutput, ReviewOutput, ScoutOutput, VerifyOutput, makePhaseParams, } from "../core/data_types.js";
|
|
17
|
+
import { AgentConfigSchema, BuildOutput, ChangesOutput, DocumentOutput, GenericOutput, NotificationsConfigSchema, PhaseParamsSchema, PlanOutput, ReviewOutput, ScoutOutput, VerifyOutput, makePhaseParams, } from "../core/data_types.js";
|
|
18
|
+
import { loadConfig } from "../core/agents.js";
|
|
15
19
|
test("writes: three-state semantics — absent, null, and [] all mean something different", () => {
|
|
16
20
|
const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
|
|
17
21
|
const unrestricted = v.parse(AgentConfigSchema, base);
|
|
@@ -47,3 +51,32 @@ test("every envelope type still converts to JSON Schema (the sf_report tool wiri
|
|
|
47
51
|
test("PhaseParamsSchema itself (the one schema WITH a rawTransform) correctly refuses JSON Schema conversion", () => {
|
|
48
52
|
assert.throws(() => toJsonSchema(PhaseParamsSchema), /raw_transform/);
|
|
49
53
|
});
|
|
54
|
+
test("NotificationsConfigSchema defaults to off, no channels", () => {
|
|
55
|
+
const parsed = v.parse(NotificationsConfigSchema, {});
|
|
56
|
+
assert.equal(parsed.events, "off");
|
|
57
|
+
assert.equal(parsed.timeout_ms, 5_000);
|
|
58
|
+
assert.deepEqual(parsed.channels, []);
|
|
59
|
+
});
|
|
60
|
+
test("a channel's own `events` overrides the top-level scope; unset inherits it", () => {
|
|
61
|
+
const parsed = v.parse(NotificationsConfigSchema, {
|
|
62
|
+
events: "errors",
|
|
63
|
+
channels: [{ kind: "slack" }, { kind: "teams", events: "all" }],
|
|
64
|
+
});
|
|
65
|
+
assert.equal(parsed.channels[0].events, undefined, "unset per-channel scope stays undefined, not defaulted to the top-level value — the caller inherits at read time");
|
|
66
|
+
assert.equal(parsed.channels[1].events, "all");
|
|
67
|
+
});
|
|
68
|
+
test("notifications survives loadConfig's merge — key-by-key like observability/quality, channels replaced wholesale on override", () => {
|
|
69
|
+
const dir = mkdtempSync(join(tmpdir(), "spf-notify-merge-test-"));
|
|
70
|
+
try {
|
|
71
|
+
const base = join(dir, "base.yaml");
|
|
72
|
+
const override = join(dir, "override.yaml");
|
|
73
|
+
writeFileSync(base, "notifications:\n events: off\n channels:\n - {kind: webhook, webhook_url_env: BASE_HOOK}\n");
|
|
74
|
+
writeFileSync(override, "notifications:\n events: all\n channels:\n - {kind: slack, webhook_url_env: SLACK_WEBHOOK_URL}\n");
|
|
75
|
+
const cfg = loadConfig([base, override]);
|
|
76
|
+
assert.equal(cfg.notifications.events, "all", "events: key-by-key, override wins");
|
|
77
|
+
assert.deepEqual(cfg.notifications.channels.map((c) => c.kind), ["slack"], "channels: a whole-array replace, not an append — same semantics as quality.checks");
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
rmSync(dir, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.env` upsert (`cli/env_file.ts`) — the write-side counterpart of
|
|
3
|
+
* `process.loadEnvFile()` in `cli/index.ts`, which every command already
|
|
4
|
+
* uses to load `<repo_root>/.env`. Mirrors `ensureGitignore`'s idempotent
|
|
5
|
+
* shape: preserve everything unrelated, touch only the keys asked for.
|
|
6
|
+
*/
|
|
7
|
+
import { test, beforeEach, afterEach } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { maskSecret, readEnvFile, upsertEnvFile, writeEnvExample } from "../cli/env_file.js";
|
|
13
|
+
let dir;
|
|
14
|
+
// Each test gets its own scratch directory with no .env yet — cheap, and
|
|
15
|
+
// avoids one test's leftover .env leaking into the next.
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
dir = mkdtempSync(join(tmpdir(), "spf-env-file-test-"));
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
rmSync(dir, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
test("upsertEnvFile creates a fresh .env with the given keys", () => {
|
|
23
|
+
const result = upsertEnvFile(dir, { GITHUB_TOKEN: "ghp_abc" });
|
|
24
|
+
assert.deepEqual(result, { added: ["GITHUB_TOKEN"], updated: [], unchanged: [] });
|
|
25
|
+
assert.equal(readEnvFile(join(dir, ".env")).get("GITHUB_TOKEN"), "ghp_abc");
|
|
26
|
+
});
|
|
27
|
+
test("upsertEnvFile preserves unrelated lines and comments verbatim, appends new keys under its own header", () => {
|
|
28
|
+
writeFileSync(join(dir, ".env"), "# a hand-written comment\nFOO=bar\n\nBAZ=qux\n");
|
|
29
|
+
upsertEnvFile(dir, { GITHUB_TOKEN: "ghp_abc" });
|
|
30
|
+
const raw = readFileSync(join(dir, ".env"), "utf-8");
|
|
31
|
+
assert.match(raw, /# a hand-written comment/);
|
|
32
|
+
assert.match(raw, /FOO=bar/);
|
|
33
|
+
assert.match(raw, /BAZ=qux/);
|
|
34
|
+
assert.match(raw, /GITHUB_TOKEN=ghp_abc/);
|
|
35
|
+
});
|
|
36
|
+
test("upsertEnvFile rewrites an existing key in place — never a duplicate line", () => {
|
|
37
|
+
writeFileSync(join(dir, ".env"), "GITHUB_TOKEN=ghp_old\nOTHER=1\n");
|
|
38
|
+
const result = upsertEnvFile(dir, { GITHUB_TOKEN: "ghp_new" });
|
|
39
|
+
assert.deepEqual(result, { added: [], updated: ["GITHUB_TOKEN"], unchanged: [] });
|
|
40
|
+
const raw = readFileSync(join(dir, ".env"), "utf-8");
|
|
41
|
+
assert.equal((raw.match(/GITHUB_TOKEN=/g) || []).length, 1);
|
|
42
|
+
assert.match(raw, /GITHUB_TOKEN=ghp_new/);
|
|
43
|
+
assert.match(raw, /OTHER=1/);
|
|
44
|
+
});
|
|
45
|
+
test("upsertEnvFile is a no-op the second time with the same values", () => {
|
|
46
|
+
upsertEnvFile(dir, { GITHUB_TOKEN: "ghp_abc" });
|
|
47
|
+
const before = readFileSync(join(dir, ".env"), "utf-8");
|
|
48
|
+
const result = upsertEnvFile(dir, { GITHUB_TOKEN: "ghp_abc" });
|
|
49
|
+
assert.deepEqual(result, { added: [], updated: [], unchanged: ["GITHUB_TOKEN"] });
|
|
50
|
+
assert.equal(readFileSync(join(dir, ".env"), "utf-8"), before);
|
|
51
|
+
});
|
|
52
|
+
test("readEnvFile ignores blank lines and comments, and returns an empty map for a missing file", () => {
|
|
53
|
+
writeFileSync(join(dir, ".env"), "# comment\n\nA=1\n # indented comment\nB=2\n");
|
|
54
|
+
const values = readEnvFile(join(dir, ".env"));
|
|
55
|
+
assert.deepEqual([...values.entries()], [["A", "1"], ["B", "2"]]);
|
|
56
|
+
assert.equal(readEnvFile(join(dir, "nope.env")).size, 0);
|
|
57
|
+
});
|
|
58
|
+
test("maskSecret keeps only the last 4 characters; short values are fully masked", () => {
|
|
59
|
+
const value = "ghp_1234567890abcdef"; // 20 chars, last 4 = "cdef"
|
|
60
|
+
const masked = maskSecret(value);
|
|
61
|
+
assert.equal(masked.length, value.length);
|
|
62
|
+
assert.match(masked, /^•+cdef$/);
|
|
63
|
+
assert.equal(maskSecret("abcd"), "••••");
|
|
64
|
+
assert.equal(maskSecret("ab"), "••");
|
|
65
|
+
});
|
|
66
|
+
test("writeEnvExample writes key names with empty values, sorted and de-duplicated", () => {
|
|
67
|
+
writeEnvExample(dir, ["GITHUB_TOKEN", "JIRA_EMAIL", "GITHUB_TOKEN"]);
|
|
68
|
+
const raw = readFileSync(join(dir, ".env.example"), "utf-8");
|
|
69
|
+
assert.equal(raw, "# spf — keys required by this repo's spf.config.yaml; fill in real values in .env (gitignored)\nGITHUB_TOKEN=\nJIRA_EMAIL=\n");
|
|
70
|
+
});
|
|
71
|
+
test("writeEnvExample writes nothing when there are no keys", () => {
|
|
72
|
+
writeEnvExample(dir, []);
|
|
73
|
+
assert.equal(existsSync(join(dir, ".env.example")), false);
|
|
74
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared test double for `cli/ask.ts`'s `Asker` interface — the seam
|
|
3
|
+
* `runInterview` (`cli/interview.ts`) exists to be driven through, the same
|
|
4
|
+
* way `FakeProvider`/`FakeCodeHost` (`watch.test.ts`) stand in for
|
|
5
|
+
* `IssueProvider`/`CodeHostProvider`.
|
|
6
|
+
*
|
|
7
|
+
* Answers are keyed by a SUBSTRING of the question's label, not by call
|
|
8
|
+
* order — `runInterview` asks a different number of questions depending on
|
|
9
|
+
* earlier answers (skip a quality check, skip watch entirely, ...), so a
|
|
10
|
+
* positional queue would silently misalign the moment a test changes one
|
|
11
|
+
* answer. Substring matching keeps each scenario's script self-documenting
|
|
12
|
+
* and immune to that.
|
|
13
|
+
*/
|
|
14
|
+
import type { Asker } from "../cli/ask.js";
|
|
15
|
+
export interface AskerScript {
|
|
16
|
+
text?: Record<string, string>;
|
|
17
|
+
select?: Record<string, string>;
|
|
18
|
+
confirm?: Record<string, boolean>;
|
|
19
|
+
secret?: Record<string, string>;
|
|
20
|
+
/** Used when a confirm's label matches nothing in `confirm` above. Defaults to the caller's own default. */
|
|
21
|
+
defaultConfirm?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare function createFakeAsker(script: AskerScript): Asker;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
function firstMatch(map, label) {
|
|
2
|
+
if (!map)
|
|
3
|
+
return undefined;
|
|
4
|
+
const key = Object.keys(map).find((k) => label.includes(k));
|
|
5
|
+
return key === undefined ? undefined : map[key];
|
|
6
|
+
}
|
|
7
|
+
export function createFakeAsker(script) {
|
|
8
|
+
return {
|
|
9
|
+
async text(label, opts) {
|
|
10
|
+
const answer = firstMatch(script.text, label);
|
|
11
|
+
return answer !== undefined ? answer : (opts?.default ?? "");
|
|
12
|
+
},
|
|
13
|
+
async select(label, _choices, dflt) {
|
|
14
|
+
const answer = firstMatch(script.select, label);
|
|
15
|
+
return (answer !== undefined ? answer : dflt);
|
|
16
|
+
},
|
|
17
|
+
async confirm(label, dflt) {
|
|
18
|
+
const answer = firstMatch(script.confirm, label);
|
|
19
|
+
if (answer !== undefined)
|
|
20
|
+
return answer;
|
|
21
|
+
return script.defaultConfirm ?? dflt;
|
|
22
|
+
},
|
|
23
|
+
async secret(label, _opts) {
|
|
24
|
+
return firstMatch(script.secret, label) ?? "";
|
|
25
|
+
},
|
|
26
|
+
note() { },
|
|
27
|
+
heading() { },
|
|
28
|
+
close() { },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression guard for `spf init`'s non-interactive paths — `--yes`,
|
|
3
|
+
* `--template <name>`, and (implicitly, since the test runner's stdin/stdout
|
|
4
|
+
* are never a TTY) plain `spf init` with no flags. All three must keep
|
|
5
|
+
* writing the exact same content they always have, byte for byte, and must
|
|
6
|
+
* never touch stdin — a scripted `spf init` in CI must not hang.
|
|
7
|
+
*
|
|
8
|
+
* The interactive interview itself is covered directly against `Asker`
|
|
9
|
+
* in `interview.test.ts`; `initCommand`'s interactive branch always calls
|
|
10
|
+
* the real `createAsker()` (backed by `node:readline` on `process.stdin`),
|
|
11
|
+
* which has nothing to read in a test process — exercising it here would
|
|
12
|
+
* just hang, so it isn't.
|
|
13
|
+
*/
|
|
14
|
+
import { test, beforeEach, afterEach } from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { execFileSync } from "node:child_process";
|
|
20
|
+
import { initCommand } from "../cli/commands/init.js";
|
|
21
|
+
let dir;
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
dir = mkdtempSync(join(tmpdir(), "spf-init-test-"));
|
|
24
|
+
execFileSync("git", ["init", "-q"], { cwd: dir });
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
rmSync(dir, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
test("--yes writes the plain starter config, all commented out, with no interview", async () => {
|
|
30
|
+
const code = await initCommand(["--cwd", dir, "--yes"]);
|
|
31
|
+
assert.equal(code, 0);
|
|
32
|
+
const content = readFileSync(join(dir, ".spf", "spf.config.yaml"), "utf-8");
|
|
33
|
+
assert.match(content, /merged ON TOP of spf's packaged built-in defaults/);
|
|
34
|
+
assert.match(content, /^# quality:/m); // every real section stays commented out
|
|
35
|
+
assert.doesNotMatch(content, /^defaults:/m);
|
|
36
|
+
});
|
|
37
|
+
test("plain `spf init` (no TTY in a test process) falls through to the same starter config as --yes", async () => {
|
|
38
|
+
const code = await initCommand(["--cwd", dir]);
|
|
39
|
+
assert.equal(code, 0);
|
|
40
|
+
const content = readFileSync(join(dir, ".spf", "spf.config.yaml"), "utf-8");
|
|
41
|
+
assert.match(content, /merged ON TOP of spf's packaged built-in defaults/);
|
|
42
|
+
});
|
|
43
|
+
test("--template writes the named template as-is, skipping the interview even without --yes", async () => {
|
|
44
|
+
const code = await initCommand(["--cwd", dir, "--template", "ts-cc"]);
|
|
45
|
+
assert.equal(code, 0);
|
|
46
|
+
const content = readFileSync(join(dir, ".spf", "spf.config.yaml"), "utf-8");
|
|
47
|
+
assert.match(content, /Claude Code backend, pointed at Ollama/);
|
|
48
|
+
});
|
|
49
|
+
test("an existing config is left alone without --force, even for --yes", async () => {
|
|
50
|
+
await initCommand(["--cwd", dir, "--yes"]);
|
|
51
|
+
const before = readFileSync(join(dir, ".spf", "spf.config.yaml"), "utf-8");
|
|
52
|
+
const code = await initCommand(["--cwd", dir, "--template", "ts-cc"]); // no --force
|
|
53
|
+
assert.equal(code, 0);
|
|
54
|
+
assert.equal(readFileSync(join(dir, ".spf", "spf.config.yaml"), "utf-8"), before);
|
|
55
|
+
});
|
|
56
|
+
test("--force overwrites an existing config non-interactively", async () => {
|
|
57
|
+
await initCommand(["--cwd", dir, "--yes"]);
|
|
58
|
+
await initCommand(["--cwd", dir, "--template", "ts-cc", "--force"]);
|
|
59
|
+
const content = readFileSync(join(dir, ".spf", "spf.config.yaml"), "utf-8");
|
|
60
|
+
assert.match(content, /Claude Code backend, pointed at Ollama/);
|
|
61
|
+
});
|
|
62
|
+
test("never writes .env or .env.example on the non-interactive paths", async () => {
|
|
63
|
+
await initCommand(["--cwd", dir, "--yes"]);
|
|
64
|
+
assert.equal(existsSync(join(dir, ".env")), false);
|
|
65
|
+
assert.equal(existsSync(join(dir, ".env.example")), false);
|
|
66
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|