@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.
Files changed (94) hide show
  1. package/README.md +57 -2
  2. package/assets/skill/cookbooks/authoring_chains.md +96 -84
  3. package/assets/skill/cookbooks/roster.md +3 -1
  4. package/assets/skill/references/config.md +52 -5
  5. package/assets/templates/ts.spf.config.yaml +12 -0
  6. package/dist/chains/context.d.ts +2 -0
  7. package/dist/chains/index.d.ts +21 -2
  8. package/dist/chains/index.js +73 -104
  9. package/dist/chains/{adw_simple_sdlc.d.ts → simple_sdlc.d.ts} +7 -1
  10. package/dist/chains/{adw_simple_sdlc.js → simple_sdlc.js} +19 -30
  11. package/dist/chains/steps.d.ts +117 -0
  12. package/dist/chains/steps.js +299 -0
  13. package/dist/cli/ask.d.ts +27 -0
  14. package/dist/cli/ask.js +125 -0
  15. package/dist/cli/commands/doctor.js +14 -24
  16. package/dist/cli/commands/init.d.ts +1 -1
  17. package/dist/cli/commands/init.js +96 -9
  18. package/dist/cli/commands/run.d.ts +1 -1
  19. package/dist/cli/commands/run.js +3 -1
  20. package/dist/cli/commands/watch.js +20 -3
  21. package/dist/cli/env_file.d.ts +18 -0
  22. package/dist/cli/env_file.js +99 -0
  23. package/dist/cli/index.js +6 -2
  24. package/dist/cli/interview.d.ts +26 -0
  25. package/dist/cli/interview.js +417 -0
  26. package/dist/core/agents.js +4 -1
  27. package/dist/core/console.d.ts +13 -1
  28. package/dist/core/console.js +51 -1
  29. package/dist/core/data_types.d.ts +55 -0
  30. package/dist/core/data_types.js +30 -0
  31. package/dist/core/notify/channel.d.ts +32 -0
  32. package/dist/core/notify/channel.js +14 -0
  33. package/dist/core/notify/notifier.d.ts +42 -0
  34. package/dist/core/notify/notifier.js +100 -0
  35. package/dist/core/notify/slack_channel.d.ts +13 -0
  36. package/dist/core/notify/slack_channel.js +30 -0
  37. package/dist/core/notify/teams_channel.d.ts +17 -0
  38. package/dist/core/notify/teams_channel.js +38 -0
  39. package/dist/core/notify/webhook_channel.d.ts +13 -0
  40. package/dist/core/notify/webhook_channel.js +19 -0
  41. package/dist/core/prompts.d.ts +2 -0
  42. package/dist/core/prompts.js +2 -0
  43. package/dist/core/providers.d.ts +12 -0
  44. package/dist/core/providers.js +24 -0
  45. package/dist/core/quality.d.ts +9 -0
  46. package/dist/core/quality.js +10 -0
  47. package/dist/core/runner.d.ts +7 -0
  48. package/dist/core/runner.js +4 -1
  49. package/dist/core/session.d.ts +6 -1
  50. package/dist/core/session.js +10 -3
  51. package/dist/core/tracer.js +1 -1
  52. package/dist/core/utils.d.ts +6 -2
  53. package/dist/core/utils.js +11 -2
  54. package/dist/core/watch.d.ts +10 -0
  55. package/dist/core/watch.js +67 -4
  56. package/dist/test/chains.test.d.ts +12 -0
  57. package/dist/test/chains.test.js +86 -0
  58. package/dist/test/data_types.test.js +34 -1
  59. package/dist/test/env_file.test.d.ts +1 -0
  60. package/dist/test/env_file.test.js +74 -0
  61. package/dist/test/fake_asker.d.ts +23 -0
  62. package/dist/test/fake_asker.js +30 -0
  63. package/dist/test/init_command.test.d.ts +1 -0
  64. package/dist/test/init_command.test.js +66 -0
  65. package/dist/test/interview.test.d.ts +1 -0
  66. package/dist/test/interview.test.js +297 -0
  67. package/dist/test/notify.test.d.ts +1 -0
  68. package/dist/test/notify.test.js +174 -0
  69. package/dist/test/ui_server.test.js +1 -1
  70. package/dist/test/watch.test.js +114 -1
  71. package/dist/ui/shared/types.d.ts +1 -1
  72. package/package.json +5 -2
  73. package/dist/chains/adw_build.d.ts +0 -12
  74. package/dist/chains/adw_build.js +0 -27
  75. package/dist/chains/adw_build_review.d.ts +0 -21
  76. package/dist/chains/adw_build_review.js +0 -55
  77. package/dist/chains/adw_build_test.d.ts +0 -21
  78. package/dist/chains/adw_build_test.js +0 -67
  79. package/dist/chains/adw_document.d.ts +0 -23
  80. package/dist/chains/adw_document.js +0 -59
  81. package/dist/chains/adw_plan.d.ts +0 -12
  82. package/dist/chains/adw_plan.js +0 -27
  83. package/dist/chains/adw_plan_build.d.ts +0 -12
  84. package/dist/chains/adw_plan_build.js +0 -30
  85. package/dist/chains/adw_plan_build_test.d.ts +0 -16
  86. package/dist/chains/adw_plan_build_test.js +0 -65
  87. package/dist/chains/adw_plan_build_test_quality.d.ts +0 -18
  88. package/dist/chains/adw_plan_build_test_quality.js +0 -66
  89. package/dist/chains/adw_prompt.d.ts +0 -12
  90. package/dist/chains/adw_prompt.js +0 -25
  91. package/dist/chains/adw_quality.d.ts +0 -12
  92. package/dist/chains/adw_quality.js +0 -32
  93. package/dist/chains/adw_scout.d.ts +0 -12
  94. package/dist/chains/adw_scout.js +0 -27
@@ -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
- await deps.provider.transition(issue, "blocked", result.detail || `Chain "${deps.chain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`);
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((error) => deps.log(`watch: reconcileOrphans error: ${error.message}`));
211
- await finishReviews(deps).catch((error) => deps.log(`watch: finishReviews error: ${error.message}`));
212
- await claimNewWork(deps, state).catch((error) => deps.log(`watch: claimNewWork error: ${error.message}`));
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 {};