autonomous-sdlc-harness 0.1.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/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +24 -0
- package/dist/cli.js +194 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/config.js +561 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/daemon.js +791 -0
- package/dist/commands/daemon.js.map +1 -0
- package/dist/commands/doctor.js +336 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/init.js +2023 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/registry.js +42 -0
- package/dist/commands/registry.js.map +1 -0
- package/dist/config/check.js +505 -0
- package/dist/config/check.js.map +1 -0
- package/dist/config/io.js +177 -0
- package/dist/config/io.js.map +1 -0
- package/dist/config/model.js +406 -0
- package/dist/config/model.js.map +1 -0
- package/dist/core/errors.js +71 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/git.js +537 -0
- package/dist/core/git.js.map +1 -0
- package/dist/core/json.js +125 -0
- package/dist/core/json.js.map +1 -0
- package/dist/core/layerCoverage.js +141 -0
- package/dist/core/layerCoverage.js.map +1 -0
- package/dist/core/layerGapRemedy.js +62 -0
- package/dist/core/layerGapRemedy.js.map +1 -0
- package/dist/core/nameList.js +23 -0
- package/dist/core/nameList.js.map +1 -0
- package/dist/core/paths.js +153 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/prompt.js +206 -0
- package/dist/core/prompt.js.map +1 -0
- package/dist/core/repoPaths.js +55 -0
- package/dist/core/repoPaths.js.map +1 -0
- package/dist/core/report.js +150 -0
- package/dist/core/report.js.map +1 -0
- package/dist/core/templating.js +88 -0
- package/dist/core/templating.js.map +1 -0
- package/dist/core/writer.js +479 -0
- package/dist/core/writer.js.map +1 -0
- package/dist/daemon/backend.js +180 -0
- package/dist/daemon/backend.js.map +1 -0
- package/dist/daemon/units.js +380 -0
- package/dist/daemon/units.js.map +1 -0
- package/dist/detect/nestedApplication.js +79 -0
- package/dist/detect/nestedApplication.js.map +1 -0
- package/dist/detect/presets.js +2033 -0
- package/dist/detect/presets.js.map +1 -0
- package/dist/detect/signals.js +1368 -0
- package/dist/detect/signals.js.map +1 -0
- package/dist/doctor/checks.js +3530 -0
- package/dist/doctor/checks.js.map +1 -0
- package/dist/generators/claudeContext.js +588 -0
- package/dist/generators/claudeContext.js.map +1 -0
- package/dist/generators/githooks.js +446 -0
- package/dist/generators/githooks.js.map +1 -0
- package/dist/generators/harnessConfig.js +632 -0
- package/dist/generators/harnessConfig.js.map +1 -0
- package/dist/generators/notifications.js +191 -0
- package/dist/generators/notifications.js.map +1 -0
- package/dist/generators/outerLoopScripts.js +165 -0
- package/dist/generators/outerLoopScripts.js.map +1 -0
- package/dist/generators/permissionProfile.js +1172 -0
- package/dist/generators/permissionProfile.js.map +1 -0
- package/dist/generators/projectSettings.js +322 -0
- package/dist/generators/projectSettings.js.map +1 -0
- package/dist/generators/repoRoot.js +417 -0
- package/dist/generators/repoRoot.js.map +1 -0
- package/dist/generators/scripts.js +557 -0
- package/dist/generators/scripts.js.map +1 -0
- package/dist/generators/stateDir.js +221 -0
- package/dist/generators/stateDir.js.map +1 -0
- package/dist/machine/paths.js +111 -0
- package/dist/machine/paths.js.map +1 -0
- package/dist/machine/plugins.js +224 -0
- package/dist/machine/plugins.js.map +1 -0
- package/dist/machine/registry.js +330 -0
- package/dist/machine/registry.js.map +1 -0
- package/package.json +23 -0
- package/scripts/README.md +13 -0
- package/scripts/daemon/launchd.plist.template +59 -0
- package/scripts/daemon/systemd.service.template +58 -0
- package/templates/README.md +15 -0
- package/templates/claude/CLAUDE.md +54 -0
- package/templates/claude/README.md +5 -0
- package/templates/claude/context/api.md +29 -0
- package/templates/claude/context/conventions.md +23 -0
- package/templates/claude/context/data-layer.md +28 -0
- package/templates/claude/context/data-storage.md +29 -0
- package/templates/claude/context/docs-catalog.md +29 -0
- package/templates/claude/context/domain.md +28 -0
- package/templates/claude/context/layer.md +20 -0
- package/templates/claude/context/module.md +30 -0
- package/templates/claude/context/package.md +29 -0
- package/templates/claude/context/presentation.md +32 -0
- package/templates/claude/context/state-slices.md +28 -0
- package/templates/claude/context/tests.md +28 -0
- package/templates/claude/harness-task-offer.md +58 -0
- package/templates/claude/push-notify.env.example +21 -0
- package/templates/claude/qa-accounts.env.example +38 -0
- package/templates/claude/qa_test_scenarios.md +110 -0
- package/templates/claude/settings.autonomous.json +93 -0
- package/templates/claude/settings.autonomous.qa.json +36 -0
- package/templates/githooks/README.md +3 -0
- package/templates/githooks/pre-push +72 -0
- package/templates/repo/README.md +3 -0
- package/templates/repo/gitattributes +16 -0
- package/templates/repo/gitignore +61 -0
- package/templates/repo/gitignore.qa +25 -0
- package/templates/repo/mcp.json +17 -0
- package/templates/scripts/README.md +5 -0
- package/templates/scripts/autonomous-format-stream.sh +95 -0
- package/templates/scripts/autonomous-notify.sh +337 -0
- package/templates/scripts/autonomous-watcher.sh +3087 -0
- package/templates/scripts/cleanup-merged-worktrees.sh +327 -0
- package/templates/scripts/commit-on-branch.sh +288 -0
- package/templates/scripts/create-worktree.sh +360 -0
- package/templates/scripts/deploy.sh +47 -0
- package/templates/scripts/lib/harness-run-lib.sh +1481 -0
- package/templates/scripts/push-branch.sh +140 -0
- package/templates/scripts/refresh-branch.sh +244 -0
- package/templates/scripts/restart-watcher.sh +401 -0
- package/templates/scripts/scratch-run.sh +302 -0
- package/templates/scripts/setup-worktree.sh +262 -0
- package/templates/scripts/start-dev-server.sh +99 -0
- package/templates/scripts/test.sh +50 -0
- package/templates/scripts/typecheck.sh +50 -0
- package/templates/state-dir/README-root.md +13 -0
- package/templates/state-dir/README.md +9 -0
- package/templates/state-dir/architecture_branch_review_point_reviews/README.md +9 -0
- package/templates/state-dir/architecture_branch_reviews/README.md +9 -0
- package/templates/state-dir/architecture_reviews/README.md +9 -0
- package/templates/state-dir/architecture_user_review_reviews/README.md +9 -0
- package/templates/state-dir/autonomous_inbox/README.md +9 -0
- package/templates/state-dir/autonomous_logs/README.md +9 -0
- package/templates/state-dir/branch_statistics/README.md +9 -0
- package/templates/state-dir/business_parity_branch_review_point_reviews/README.md +9 -0
- package/templates/state-dir/business_parity_branch_reviews/README.md +9 -0
- package/templates/state-dir/business_parity_reviews/README.md +9 -0
- package/templates/state-dir/business_parity_user_review_reviews/README.md +9 -0
- package/templates/state-dir/clarification_digests/README.md +9 -0
- package/templates/state-dir/clarifications/README.md +9 -0
- package/templates/state-dir/code_reviews/README.md +9 -0
- package/templates/state-dir/dispatch_additions/README.md +19 -0
- package/templates/state-dir/docs_catalog/README.md +9 -0
- package/templates/state-dir/flow_progress/README.md +9 -0
- package/templates/state-dir/improvement_observations/README.md +19 -0
- package/templates/state-dir/improvement_suggestions.md +29 -0
- package/templates/state-dir/lessons.md +23 -0
- package/templates/state-dir/qa_review_point_reviews/README.md +9 -0
- package/templates/state-dir/qa_reviews/README.md +9 -0
- package/templates/state-dir/review_plan_point_reviews/README.md +9 -0
- package/templates/state-dir/review_plan_reviews/README.md +9 -0
- package/templates/state-dir/scratch/README.md +11 -0
- package/templates/state-dir/skeptic_review_plan_reviews/README.md +9 -0
- package/templates/state-dir/skeptic_review_point_reviews/README.md +9 -0
- package/templates/state-dir/skeptic_reviews/README.md +9 -0
- package/templates/state-dir/story_plans/README.md +9 -0
- package/templates/state-dir/task_plan_point_reviews/README.md +9 -0
- package/templates/state-dir/task_plan_reviews/README.md +9 -0
- package/templates/state-dir/task_plans/README.md +9 -0
- package/templates/state-dir/task_prompts/README.md +9 -0
- package/templates/state-dir/ui_test_plan_reviews/README.md +9 -0
- package/templates/state-dir/ui_test_plans/README.md +9 -0
- package/templates/state-dir/user_review_fix_plan_point_reviews/README.md +9 -0
- package/templates/state-dir/user_reviews/README.md +9 -0
|
@@ -0,0 +1,2023 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command: `init` — wire an adopting repository, once, deterministically.
|
|
3
|
+
*
|
|
4
|
+
* **The rule this module exists to enforce: `init` orders and reports; it decides nothing.** Every
|
|
5
|
+
* value written here comes from stack detection or from a generator, and every byte reaches the
|
|
6
|
+
* filesystem through one {@link WritePlan} applied once at the end. That is what makes the command
|
|
7
|
+
* reproducible: a second `init` over the same repository produces the same result, and an adopter can
|
|
8
|
+
* re-derive the recorded preset from the detection table by hand. It is also why judgement-dependent
|
|
9
|
+
* work — reading real code to refine a layer profile, filling a conventions stub — belongs to
|
|
10
|
+
* `/harness-analyze` and is deliberately absent from this file. The offer to run it
|
|
11
|
+
* ({@link resolveAnalyzeOffer}) adds no model call and no external process here: the answer's whole
|
|
12
|
+
* material effect is which banner wording the generated always-loaded file carries and which closing
|
|
13
|
+
* pointer this run prints.
|
|
14
|
+
*
|
|
15
|
+
* ## Three non-obvious choices, and where each comes from
|
|
16
|
+
*
|
|
17
|
+
* 1. **The whole run is one plan, applied after every generator has spoken.** A generator enqueues
|
|
18
|
+
* and returns; no generator touches the filesystem. So `--dry-run` cannot leak a write no matter
|
|
19
|
+
* what a generator enqueued, and a refusal raised against the last request cannot leave the first
|
|
20
|
+
* ten on disk — the write engine checks the whole plan before committing any of it
|
|
21
|
+
* (`core/writer.ts`). **Three mutations sit outside that plan**, and each is confined by a
|
|
22
|
+
* condition of its own rather than by a shared one: `initRepository` before the plan is built
|
|
23
|
+
* runs only where there is no repository yet ({@link resolveTargetRepository}); `pointHooksPath`
|
|
24
|
+
* after it has been applied writes `core.hooksPath` only where that setting is unset
|
|
25
|
+
* (`generators/githooks.ts`); and `commitAll`, after it as well, runs only in a repository with
|
|
26
|
+
* no commit ({@link commitGeneratedFiles}) — so none of them can reach an established history or
|
|
27
|
+
* an adopter's own hooks directory. So `--dry-run`'s "cannot leak a write" guarantee no longer
|
|
28
|
+
* rests on the plan's structure alone — it rests on that structure **plus one explicit `dryRun`
|
|
29
|
+
* guard at each of those three calls**, which is three guards to check rather than a structural
|
|
30
|
+
* argument that covers everything. The commit's position is load-bearing for a second reason: the
|
|
31
|
+
* managed `.gitignore` block arrives with the plan, and it is what keeps the commit's `git add -A`
|
|
32
|
+
* from staging the run-control artifacts — so a commit made before `plan.apply` would commit
|
|
33
|
+
* exactly the files that must never be committed (`generators/repoRoot.ts`).
|
|
34
|
+
* 2. **The config generator runs first and hands back the config *in effect*, and every later
|
|
35
|
+
* generator reads that object rather than the flags.** Whenever a config file is there it is
|
|
36
|
+
* kept and read, on every run and `--force` included, so the config in effect is the adopter's
|
|
37
|
+
* own — which is the only way `init` writes the state tree, the profile and the hook against the
|
|
38
|
+
* values the adopter edited rather than against the ones `init` would have chosen again. Only
|
|
39
|
+
* `--reset-config` rebuilds it, and it says so (`generators/harnessConfig.ts`).
|
|
40
|
+
* 3. **The scripts are written immediately after the config and before the profile** — the wrappers
|
|
41
|
+
* and then the outer-loop set, both into the configured `scriptsDir`. The permission profile
|
|
42
|
+
* allow-lists their literal paths, so a run that wrote the profile without them would allow-list
|
|
43
|
+
* files that do not exist, leaving the configured verification commands matching neither `allow`
|
|
44
|
+
* nor `deny` — which in an unattended run is a stall rather than a refusal
|
|
45
|
+
* (`generators/scripts.ts`, `generators/outerLoopScripts.ts`, `generators/permissionProfile.ts`).
|
|
46
|
+
*
|
|
47
|
+
* ## The refusals, and why they come before the plan is built
|
|
48
|
+
*
|
|
49
|
+
* `init` is a writer aimed at a repository, so a mis-scoped run is destructive rather than merely
|
|
50
|
+
* wrong. All three preconditions are **settled** before a single generator is called — two by a
|
|
51
|
+
* check the flags alone answer, the third by an answer (the flag, a prompt on a terminal, or the
|
|
52
|
+
* non-interactive default) — and that is the property the ordering exists for: a plan that is never
|
|
53
|
+
* built cannot be half-applied.
|
|
54
|
+
*
|
|
55
|
+
* They are ordered by **what each one needs**, and the git gate is what makes that ordering
|
|
56
|
+
* load-bearing rather than cosmetic: its accept path creates a repository, which is one of the three
|
|
57
|
+
* mutations outside the plan. So every refusal answerable from the parsed flags is raised ahead of
|
|
58
|
+
* it, and a refused run leaves the adopter's directory exactly as it found it. The same reasoning
|
|
59
|
+
* puts {@link parseQaDriver} — and the refusal of a command line giving both spellings of the
|
|
60
|
+
* analyze offer — inside the parser, one step earlier still.
|
|
61
|
+
*
|
|
62
|
+
* - **`--state-dir` names a dot-directory** — an unattended run may not be permitted to write
|
|
63
|
+
* beneath one, so the tree would be created and then silently lose every artifact produced under
|
|
64
|
+
* it (`docs/config.md` §3).
|
|
65
|
+
* Answerable from the flag, so it is raised before the git gate — as is `--preset`'s closed-set
|
|
66
|
+
* check (`detect/signals.ts`), the flag surface's other pure-argument refusal;
|
|
67
|
+
* - **not inside a git repository, and the run was not told to create one** — the root every write
|
|
68
|
+
* is confined to cannot be resolved (`core/git.ts`), and the worktree-based flow this wires cannot
|
|
69
|
+
* prepare a checkout without one. Git stays a hard gate and nothing is ever created silently:
|
|
70
|
+
* `--git-init` creates the repository, a terminal without the flag is asked, and a run that cannot
|
|
71
|
+
* be asked refuses — which is the behaviour every earlier release had (`core/prompt.ts`,
|
|
72
|
+
* `docs/cli.md` §2);
|
|
73
|
+
* - **the resolved root is the harness's own repository** — `init` at this repository's root would
|
|
74
|
+
* wire the harness itself, which is the trap that makes `docs/development.md` §5's gate 2 unsafe
|
|
75
|
+
* the moment this command works. Last of the three because it is the one check that needs the root
|
|
76
|
+
* the gate above resolved.
|
|
77
|
+
*
|
|
78
|
+
* ## What this command deliberately does not do
|
|
79
|
+
*
|
|
80
|
+
* - **It reads no template and formats no generated content.** Every artifact's shape, re-run policy
|
|
81
|
+
* and warning text belongs to the generator that owns it; duplicating any of it here would be a
|
|
82
|
+
* second source for a decision that already has one.
|
|
83
|
+
* - **It writes no guard hooks into the adopter's settings**, and takes no browser wiring decision:
|
|
84
|
+
* the plugin's own hooks compose with the adopter's, and `.mcp.json` is the repo-root generator's
|
|
85
|
+
* to gate on `browserWiringApplies` (`config/model.ts`) — the one predicate that generator and the
|
|
86
|
+
* permission profile both read, so the condition is not restated here to go stale. This command
|
|
87
|
+
* *calls* that predicate, once, to decide whether the closing report owes the registry sentence
|
|
88
|
+
* ({@link reportNextSteps}); calling it is not restating it.
|
|
89
|
+
*/
|
|
90
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
91
|
+
import { join, relative, resolve } from 'node:path';
|
|
92
|
+
import { formatProblem } from '../config/check.js';
|
|
93
|
+
import { configExists, loadConfig } from '../config/io.js';
|
|
94
|
+
import { answersNone, asQaDriver, browserWiringApplies, CONFIG_FILENAME, DEFAULTS, isPlaceholder, qaDriverChoices, STATE_DIR_DOT_PATTERN, } from '../config/model.js';
|
|
95
|
+
import { EXIT, HarnessError } from '../core/errors.js';
|
|
96
|
+
import { commitAll, hasCommits, initRepository, probeRepoRoot } from '../core/git.js';
|
|
97
|
+
import { readJsonFile } from '../core/json.js';
|
|
98
|
+
import { layerCoverage } from '../core/layerCoverage.js';
|
|
99
|
+
import { layerGapRemedy, recordedVerdictClause } from '../core/layerGapRemedy.js';
|
|
100
|
+
import { nameList } from '../core/nameList.js';
|
|
101
|
+
import { insideRepo, packageRoot } from '../core/paths.js';
|
|
102
|
+
import { askLine, askYesNo, canPrompt } from '../core/prompt.js';
|
|
103
|
+
import { normalizeRepoDir, normalizeRepoPathStrict } from '../core/repoPaths.js';
|
|
104
|
+
import { WritePlan } from '../core/writer.js';
|
|
105
|
+
import { findNestedApplicationDir } from '../detect/nestedApplication.js';
|
|
106
|
+
import { buildPreset, commandSourceClaim, searchedRoots, SHARED_CONVENTIONS_PATH, } from '../detect/presets.js';
|
|
107
|
+
import { detectPreset, parsePresetName, FLAT_FALLBACK_SIGNAL_ID, FORCED_SIGNAL_ID, } from '../detect/signals.js';
|
|
108
|
+
import { writeClaudeContext, CLAUDE_MD_PATH, RESERVED_ANALYZE_TARGETS, SETUP_PENDING_OPEN, isUntouchedSkeletonText, } from '../generators/claudeContext.js';
|
|
109
|
+
import { pointHooksPath, writeGitHooks } from '../generators/githooks.js';
|
|
110
|
+
import { writeHarnessConfig } from '../generators/harnessConfig.js';
|
|
111
|
+
import { writeNotifications, GUIDED_ENDPOINT_EXAMPLE } from '../generators/notifications.js';
|
|
112
|
+
import { writeOuterLoopScripts } from '../generators/outerLoopScripts.js';
|
|
113
|
+
import { writePermissionProfile } from '../generators/permissionProfile.js';
|
|
114
|
+
import { writeProjectSettings, MARKETPLACE_FLAG, MARKETPLACE_NAME, MARKETPLACES_KEY, PLUGIN_NAME, SETTINGS_PATH, SLUG_SHAPE, } from '../generators/projectSettings.js';
|
|
115
|
+
import { writeRepoRootFiles, MCP_PATH } from '../generators/repoRoot.js';
|
|
116
|
+
import { configKeyPath, configuredCommand, wrapperCommandLine, wrapperPath, writeWrapperScripts, WRAPPER_SCRIPTS, } from '../generators/scripts.js';
|
|
117
|
+
import { writeStateDir } from '../generators/stateDir.js';
|
|
118
|
+
/** The command's one-line summary, in the usage block and at the head of its own `--help`. */
|
|
119
|
+
const SUMMARY = 'Wire a repository: config, scripts, state tree, permission profile, project settings, conventions stubs';
|
|
120
|
+
/** The roadmap item this command belongs to, as the registry reports it. */
|
|
121
|
+
const ROADMAP_ITEM = 13;
|
|
122
|
+
/** The analyze command's own name, without the leading `/` and without the plugin prefix. */
|
|
123
|
+
const ANALYZE_COMMAND_NAME = 'harness-analyze';
|
|
124
|
+
/** The analyze command as a line addressing the adopter names it — step C of the install story. */
|
|
125
|
+
const ANALYZE_COMMAND = `/${ANALYZE_COMMAND_NAME}`;
|
|
126
|
+
/**
|
|
127
|
+
* The same command, spelled as something **executed** rather than read.
|
|
128
|
+
*
|
|
129
|
+
* A session's `/` picker lists every command of this plugin under the plugin's own prefix and
|
|
130
|
+
* fuzzy-matches a bare name onto it, so {@link ANALYZE_COMMAND} reaches the command wherever a
|
|
131
|
+
* person types it into a session. A command passed as a session's **first message** — which is
|
|
132
|
+
* what {@link ANALYZE_INVOCATION} does — meets no picker and is matched exactly, so the bare
|
|
133
|
+
* form fails there with `Unknown command`. That the **prefixed** form succeeds where the bare
|
|
134
|
+
* one fails is not established: the only measurement in this tree is headless and negative on
|
|
135
|
+
* both spellings (root `README.md`, `### Measured while building that evidence, and not fixed
|
|
136
|
+
* here`), and the interactive first-message form waits on the hand-run gate — if that comes back
|
|
137
|
+
* negative the printed line is dropped rather than respelled (`docs/analyze.md` §9). The prefix
|
|
138
|
+
* is taken from {@link PLUGIN_NAME}, which mirrors the plugin manifest, rather than written out
|
|
139
|
+
* here.
|
|
140
|
+
*/
|
|
141
|
+
const ANALYZE_COMMAND_QUALIFIED = `/${PLUGIN_NAME}:${ANALYZE_COMMAND_NAME}`;
|
|
142
|
+
/**
|
|
143
|
+
* How this CLI is typed, for every line that tells an adopter to run something — stated here once
|
|
144
|
+
* for the whole command surface.
|
|
145
|
+
*
|
|
146
|
+
* **The rule, one line: an occurrence an adopter is told to *run* carries the `npx` prefix; an
|
|
147
|
+
* occurrence that is the tool's *name* does not.** The bare `autonomous-sdlc-harness …` form
|
|
148
|
+
* resolves only where something installed a global binary, and no documented adoption path does:
|
|
149
|
+
* root `README.md`'s quick start is `npx autonomous-sdlc-harness …` throughout and
|
|
150
|
+
* `docs/development.md`'s contributor loop is `node cli/dist/cli.js …`. `npx` is the safe superset
|
|
151
|
+
* — it runs an already-installed global binary as well as fetching a published one — so it is the
|
|
152
|
+
* one adopter-facing spelling rather than a second special case. Under the name half of the rule
|
|
153
|
+
* the prefix is wrong and is not added: the `autonomous-sdlc-harness: <message>` error prefix and
|
|
154
|
+
* the `--version` banner in `cli.ts`, {@link MARKETPLACE_NAME}, `MACHINE_DIR_NAME`,
|
|
155
|
+
* `LAUNCHD_LABEL_PREFIX`, and the managed `.gitignore` block header in `generators/repoRoot.ts`.
|
|
156
|
+
* There is no third category.
|
|
157
|
+
*/
|
|
158
|
+
const CLI = 'npx autonomous-sdlc-harness';
|
|
159
|
+
/**
|
|
160
|
+
* This verb, written in full wherever a line addresses the adopter.
|
|
161
|
+
*
|
|
162
|
+
* An adopter about to be offered {@link ANALYZE_COMMAND} meets two initializers — this one and the
|
|
163
|
+
* host tool's own, which also writes an always-loaded project file — so a bare `init` in an
|
|
164
|
+
* adopter-facing line names neither of them unambiguously (`docs/analyze.md` §9).
|
|
165
|
+
*/
|
|
166
|
+
const INIT_VERB = `${CLI} init`;
|
|
167
|
+
/** The command that verifies what this one wired, named in the closing pointer. */
|
|
168
|
+
const DOCTOR_COMMAND = `${CLI} doctor`;
|
|
169
|
+
/**
|
|
170
|
+
* The command that arms the inbox, named in the closing pointer's last step while `phases.qa` is on.
|
|
171
|
+
*
|
|
172
|
+
* Spelled here rather than left to prose because it is one of the two operator steps that step names,
|
|
173
|
+
* and the other one — the `permissions.allow` entries — deliberately has no constant: its lines carry
|
|
174
|
+
* a machine-local path this command cannot see, and `doctor`'s `plugin-permissions` check prints them
|
|
175
|
+
* (`docs/cli.md` §7).
|
|
176
|
+
*/
|
|
177
|
+
const DAEMON_INSTALL_COMMAND = `${CLI} daemon install`;
|
|
178
|
+
/**
|
|
179
|
+
* The command that answers, before the phase ever runs, whether the pinned MCP server packages can be
|
|
180
|
+
* fetched from this machine's registry — named in the registry sentence step 4 adds while
|
|
181
|
+
* `browserWiringApplies` holds (`doctor/checks.ts`'s browser-wiring check, `commands/doctor.ts`).
|
|
182
|
+
*/
|
|
183
|
+
const DOCTOR_CHECK_REGISTRY_COMMAND = `${DOCTOR_COMMAND} --check-registry`;
|
|
184
|
+
/**
|
|
185
|
+
* How `.mcp.json` launches those servers, as the sentence names it.
|
|
186
|
+
*
|
|
187
|
+
* The launcher, not the specs: the two package names and their pinned versions live in
|
|
188
|
+
* `templates/repo/mcp.json` and are enumerated by the `doctor` check above, so a third copy here
|
|
189
|
+
* would go stale on the next pin bump.
|
|
190
|
+
*/
|
|
191
|
+
const MCP_LAUNCHER = 'npx -y';
|
|
192
|
+
/**
|
|
193
|
+
* The agent runner as it is typed at a shell, and the two lines of the closing report that name it.
|
|
194
|
+
*
|
|
195
|
+
* Both are strings a person retypes: this CLI executes neither, and {@link INIT_VERB} starts no
|
|
196
|
+
* process and opens no window. {@link MARKETPLACE_ADD_COMMAND} is spelled as `docs/development.md`
|
|
197
|
+
* §1 attests the host tool's own verb — the adopter form of `claude plugin marketplace add`, over
|
|
198
|
+
* the slug shape the flag beside it takes — and no other host-tool verb is invented here.
|
|
199
|
+
*/
|
|
200
|
+
const AGENT_CLI = 'claude';
|
|
201
|
+
const MARKETPLACE_ADD_COMMAND = `${AGENT_CLI} plugin marketplace add ${SLUG_SHAPE}`;
|
|
202
|
+
const ANALYZE_INVOCATION = `${AGENT_CLI} "${ANALYZE_COMMAND_QUALIFIED}"`;
|
|
203
|
+
/** The verb that applies a layer-profile revision — the one writer of `layers[]` (`docs/analyze.md` §3). */
|
|
204
|
+
const CONFIG_SET_LAYERS_COMMAND = `${CLI} config set layers`;
|
|
205
|
+
/** The one-key form of the same verb, named in step 3 for the two run settings (`commands/config.ts`). */
|
|
206
|
+
const CONFIG_SET_COMMAND = `${CLI} config set <key> <value>`;
|
|
207
|
+
/**
|
|
208
|
+
* The marketplace manifest at a repository root, and the CLI package inside it.
|
|
209
|
+
*
|
|
210
|
+
* Together with the package **name** below they are the whole of how `init` recognises the harness's
|
|
211
|
+
* own repository. Two signals rather than one because either alone is ordinary: a repository may
|
|
212
|
+
* publish its own marketplace, and a repository may have a `cli/` package — only a repository with
|
|
213
|
+
* both, whose CLI package is *this* package, is the one `init` must not be aimed at.
|
|
214
|
+
*/
|
|
215
|
+
const MARKETPLACE_MANIFEST = join('.claude-plugin', 'marketplace.json');
|
|
216
|
+
const CLI_MANIFEST = join('cli', 'package.json');
|
|
217
|
+
/** The flag whose value the dot-directory refusal is raised against. */
|
|
218
|
+
const STATE_DIR_FLAG = '--state-dir';
|
|
219
|
+
/** The flag that answers the git gate — named in the prompt, in the refusal, and in the note. */
|
|
220
|
+
const GIT_INIT_FLAG = '--git-init';
|
|
221
|
+
/**
|
|
222
|
+
* The flag that overrides the configured application directory — named in the rung-2 note and in the
|
|
223
|
+
* warning a configured value that could not be used raises ({@link resolveDetectionAppDir}).
|
|
224
|
+
*/
|
|
225
|
+
const APP_DIR_FLAG = '--app-dir';
|
|
226
|
+
/** The flag that answers the QA-driver question — named in the prompt, the refusal and the note. */
|
|
227
|
+
const QA_DRIVER_FLAG = '--qa-driver';
|
|
228
|
+
/**
|
|
229
|
+
* The two spellings that answer the analyze offer — one accept, one decline, and neither given is
|
|
230
|
+
* the state {@link resolveAnalyzeOffer} answers with the documented default.
|
|
231
|
+
*
|
|
232
|
+
* The one `--no-*` pair in this flag surface, and the precedent it sets: **two flags, two keys, one
|
|
233
|
+
* tri-state**. A single key cannot tell an absent flag from an explicit decline, and the flag table's
|
|
234
|
+
* discriminated union admits no negated-switch kind — a row pairs a switch with a boolean key or it
|
|
235
|
+
* is not a switch row at all.
|
|
236
|
+
*/
|
|
237
|
+
const ANALYZE_FLAG = '--analyze';
|
|
238
|
+
const NO_ANALYZE_FLAG = '--no-analyze';
|
|
239
|
+
/** The flag that answers the notification opt-in — named in the prompt and in the no-terminal note. */
|
|
240
|
+
const NOTIFICATIONS_FLAG = '--notifications';
|
|
241
|
+
/** The flag that supplies the endpoint the opt-in needs, and without which nothing is written. */
|
|
242
|
+
const PUSH_URL_FLAG = '--push-url';
|
|
243
|
+
/**
|
|
244
|
+
* The subject of the first commit, which `init` makes in any repository it wires that has no commit
|
|
245
|
+
* yet — the one this run created, and the one it found in that state.
|
|
246
|
+
*
|
|
247
|
+
* A fixed sentence with nothing of this machine in it: no absolute path, no branch name, no
|
|
248
|
+
* operator's label. The commit lands in a repository that is about to be shared, and everything a
|
|
249
|
+
* reader of its history needs is what happened, not where it was run.
|
|
250
|
+
*/
|
|
251
|
+
const FIRST_COMMIT_MESSAGE = 'chore: adopt the autonomous SDLC harness';
|
|
252
|
+
/**
|
|
253
|
+
* Freeze the flag table, and check it covers the whole flag surface.
|
|
254
|
+
*
|
|
255
|
+
* `rows` infers its literal key union; the second parameter is empty — and the call therefore
|
|
256
|
+
* complete — only when that union covers every {@link InitFlags} key. A key left without a row makes
|
|
257
|
+
* this call a missing-argument error whose expected type is that key, which is what turns "extends
|
|
258
|
+
* the generator flag interfaces" into an enforced table rather than a convention.
|
|
259
|
+
*/
|
|
260
|
+
function initOptions(rows, ..._rowsMissingFor) {
|
|
261
|
+
return Object.freeze(rows);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The flag surface, in the order `--help` prints it: the gate that decides whether there is a
|
|
265
|
+
* repository to wire at all, then the one that decides whether the config in it is read or rebuilt,
|
|
266
|
+
* then the two that decide what is detected, then the values written into the config, then the three
|
|
267
|
+
* phase toggles with their own inputs beside them, then the onboarding slug, then the pair that
|
|
268
|
+
* answers the offer to analyze this repository — which decides the wording the generated
|
|
269
|
+
* always-loaded file carries — and last the pair that decides whether this account gets told when an
|
|
270
|
+
* unattended run finishes, which is the one pair that writes nothing into the repository at all.
|
|
271
|
+
*
|
|
272
|
+
* The first two sit together, and ahead of everything else, because they are the rows whose subject
|
|
273
|
+
* is the **shape of the run** rather than a value in the generated file: one settles what `init` is
|
|
274
|
+
* aimed at, the other settles whether it generates that file at all or reads the one already there.
|
|
275
|
+
* `--reset-config` would otherwise be filed among the config values it re-derives, which is the one
|
|
276
|
+
* thing it is not — it writes none of them itself.
|
|
277
|
+
*
|
|
278
|
+
* Every row is optional and every one has a documented default — for the config values, in the
|
|
279
|
+
* generator that reads it; for {@link GIT_INIT_FLAG}, in {@link resolveTargetRepository}, whose
|
|
280
|
+
* default is the refusal every earlier release raised; and for `--reset-config`, in
|
|
281
|
+
* `generators/harnessConfig.ts`, whose default is to read the file that is already there. `init`
|
|
282
|
+
* supplies no default of its own beyond that, so an absent flag and a flag never added are the same
|
|
283
|
+
* thing to everything downstream.
|
|
284
|
+
*/
|
|
285
|
+
const INIT_OPTIONS = initOptions([
|
|
286
|
+
{
|
|
287
|
+
key: 'gitInit',
|
|
288
|
+
flag: GIT_INIT_FLAG,
|
|
289
|
+
kind: 'switch',
|
|
290
|
+
summary: 'Create a git repository when the target directory is not in one (otherwise init refuses)',
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
key: 'resetConfig',
|
|
294
|
+
flag: '--reset-config',
|
|
295
|
+
kind: 'switch',
|
|
296
|
+
summary: `Rebuild ${CONFIG_FILENAME} from detection and the flags, after a .bak (init otherwise reads it)`,
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
key: 'preset',
|
|
300
|
+
flag: '--preset',
|
|
301
|
+
kind: 'value',
|
|
302
|
+
placeholder: '<name>',
|
|
303
|
+
summary: 'Force a layer preset instead of detecting one',
|
|
304
|
+
configValue: 'detection.preset',
|
|
305
|
+
steersDetection: true,
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
key: 'appDir',
|
|
309
|
+
flag: APP_DIR_FLAG,
|
|
310
|
+
kind: 'value',
|
|
311
|
+
placeholder: '<dir>',
|
|
312
|
+
summary: 'Repo-relative directory of the application, when the repository nests it',
|
|
313
|
+
configValue: 'appDir',
|
|
314
|
+
steersDetection: true,
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
key: 'projectName',
|
|
318
|
+
flag: '--project-name',
|
|
319
|
+
kind: 'value',
|
|
320
|
+
placeholder: '<name>',
|
|
321
|
+
summary: 'Short project identifier (default: the repository directory name)',
|
|
322
|
+
configValue: 'projectName',
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
key: 'defaultBranch',
|
|
326
|
+
flag: '--default-branch',
|
|
327
|
+
kind: 'value',
|
|
328
|
+
placeholder: '<branch>',
|
|
329
|
+
summary: 'The branch work merges back into, and the one a run may never push to',
|
|
330
|
+
configValue: 'defaultBranch',
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
key: 'stateDir',
|
|
334
|
+
flag: STATE_DIR_FLAG,
|
|
335
|
+
kind: 'value',
|
|
336
|
+
placeholder: '<dir>',
|
|
337
|
+
summary: 'Repo-relative run-artifact directory; must not be dot-named',
|
|
338
|
+
configValue: 'stateDir',
|
|
339
|
+
},
|
|
340
|
+
{ key: 'qa', flag: '--qa', kind: 'switch', summary: 'Turn the interactive test phase on', configValue: 'phases.qa' },
|
|
341
|
+
{
|
|
342
|
+
key: 'qaDriver',
|
|
343
|
+
flag: QA_DRIVER_FLAG,
|
|
344
|
+
kind: 'value',
|
|
345
|
+
placeholder: '<driver>',
|
|
346
|
+
summary: 'Which interactive-test driver the QA phase runs (with --qa)',
|
|
347
|
+
configValue: 'qa.driver',
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
key: 'docs',
|
|
351
|
+
flag: '--docs',
|
|
352
|
+
kind: 'switch',
|
|
353
|
+
summary: 'Turn the documentation phase on',
|
|
354
|
+
configValue: 'phases.docs',
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
key: 'docsRoot',
|
|
358
|
+
flag: '--docs-root',
|
|
359
|
+
kind: 'value',
|
|
360
|
+
placeholder: '<dir>',
|
|
361
|
+
summary: 'Documentation root the docs phase keeps current (with --docs)',
|
|
362
|
+
configValue: 'docs.root',
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
key: 'parity',
|
|
366
|
+
flag: '--parity',
|
|
367
|
+
kind: 'switch',
|
|
368
|
+
summary: 'Turn the reference-parity phase on',
|
|
369
|
+
configValue: 'phases.parity',
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
key: 'referenceImpl',
|
|
373
|
+
flag: '--reference-impl',
|
|
374
|
+
kind: 'value',
|
|
375
|
+
placeholder: '<path>',
|
|
376
|
+
summary: 'Checkout of the reference implementation to compare against (with --parity)',
|
|
377
|
+
configValue: 'parity.referenceImplPath',
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
key: 'referenceToolchainPath',
|
|
381
|
+
flag: '--reference-toolchain-path',
|
|
382
|
+
kind: 'value',
|
|
383
|
+
placeholder: '<abs>',
|
|
384
|
+
summary: "Absolute directory of the reference implementation's own toolchain (with --parity)",
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
key: 'marketplace',
|
|
388
|
+
flag: '--marketplace',
|
|
389
|
+
kind: 'value',
|
|
390
|
+
placeholder: '<owner>/<repo>',
|
|
391
|
+
summary: 'Marketplace repository written into the committed .claude/settings.json',
|
|
392
|
+
},
|
|
393
|
+
// Two rows, two keys, one tri-state — the reasoning is at {@link ANALYZE_FLAG}, and
|
|
394
|
+
// {@link resolveAnalyzeOffer} is what folds the pair into a single answer.
|
|
395
|
+
{
|
|
396
|
+
key: 'analyze',
|
|
397
|
+
flag: ANALYZE_FLAG,
|
|
398
|
+
kind: 'switch',
|
|
399
|
+
summary: `Offer to run ${ANALYZE_COMMAND} after wiring (the default; ${NO_ANALYZE_FLAG} declines)`,
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
key: 'noAnalyze',
|
|
403
|
+
flag: NO_ANALYZE_FLAG,
|
|
404
|
+
kind: 'switch',
|
|
405
|
+
summary: 'Decline the analyze offer, and record the declined wording',
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
key: 'notifications',
|
|
409
|
+
flag: NOTIFICATIONS_FLAG,
|
|
410
|
+
kind: 'switch',
|
|
411
|
+
summary: 'Set up push notifications for unattended runs (default: off)',
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
key: 'pushUrl',
|
|
415
|
+
flag: PUSH_URL_FLAG,
|
|
416
|
+
kind: 'value',
|
|
417
|
+
placeholder: '<url>',
|
|
418
|
+
summary: 'Endpoint unattended-run notifications are posted to (with --notifications)',
|
|
419
|
+
},
|
|
420
|
+
]);
|
|
421
|
+
/**
|
|
422
|
+
* A flag value as a reader can retype it: quoted only where a shell would otherwise split or expand
|
|
423
|
+
* it, so the remedy below stays copy-pasteable for an ordinary branch name and stays correct for a
|
|
424
|
+
* path with a space in it.
|
|
425
|
+
*/
|
|
426
|
+
function retypable(value) {
|
|
427
|
+
return /^[A-Za-z0-9._/@-]+$/.test(value) ? value : JSON.stringify(value);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* The warning a kept-config run owes for the flags whose values it resolved and threw away, or
|
|
431
|
+
* `undefined` when this command line carried none.
|
|
432
|
+
*
|
|
433
|
+
* Derived from {@link INIT_OPTIONS} and the parsed flags rather than from a list of its own — a row
|
|
434
|
+
* is in the discarded class exactly when it carries {@link InitOption.configValue} (the membership
|
|
435
|
+
* test is in that type's header), so a flag added to the table is classified there and here at once.
|
|
436
|
+
* *Given* is `true` for a switch rather than merely present, because {@link parseInitFlags} names
|
|
437
|
+
* every switch key on every run and presence alone would name flags nobody passed.
|
|
438
|
+
*
|
|
439
|
+
* A **warning** rather than a note: it survives `--quiet` (`core/report.ts`), and an unattended run
|
|
440
|
+
* that passed a flag which did nothing is exactly the reader who needs to have been told. It names
|
|
441
|
+
* both routes because they are not the same repair — `config set` changes the one key it is given
|
|
442
|
+
* and re-derives nothing else, while `--reset-config` rebuilds the whole file from detection and
|
|
443
|
+
* this command line, which is the only route that also re-derives what a value implies.
|
|
444
|
+
*
|
|
445
|
+
* **It claims the unwritten value, never that the flag did nothing.** A dropped row carrying
|
|
446
|
+
* {@link InitOption.steersDetection} did steer this run's stack detection, and a kept run with
|
|
447
|
+
* `--force` re-renders the wrapper bodies from that detection (`generators/scripts.ts`,
|
|
448
|
+
* `resolveBody` precedence 2) — so the clause naming that is added when, and only when, such a row
|
|
449
|
+
* is among the dropped. A reader told a flag reached nothing reaches for `--reset-config`, which
|
|
450
|
+
* rebuilds the whole file and discards every hand-set value, to get an effect a plain re-run had
|
|
451
|
+
* already had half of.
|
|
452
|
+
*/
|
|
453
|
+
function discardedConfigFlagsWarning(flags) {
|
|
454
|
+
const dropped = [];
|
|
455
|
+
for (const option of INIT_OPTIONS) {
|
|
456
|
+
const configKey = option.configValue;
|
|
457
|
+
if (configKey === undefined)
|
|
458
|
+
continue;
|
|
459
|
+
const steersDetection = option.steersDetection === true;
|
|
460
|
+
if (option.kind === 'switch') {
|
|
461
|
+
if (flags[option.key] === true) {
|
|
462
|
+
dropped.push({ flag: option.flag, configKey, invocation: option.flag, steersDetection });
|
|
463
|
+
}
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
const value = flags[option.key];
|
|
467
|
+
if (value === undefined)
|
|
468
|
+
continue;
|
|
469
|
+
dropped.push({ flag: option.flag, configKey, invocation: `${option.flag} ${retypable(value)}`, steersDetection });
|
|
470
|
+
}
|
|
471
|
+
if (dropped.length === 0)
|
|
472
|
+
return undefined;
|
|
473
|
+
const one = dropped.length === 1;
|
|
474
|
+
const keyRemedies = dropped.map((entry) => `\`${CLI} config set ${entry.configKey} <value>\``).join(', ');
|
|
475
|
+
const steering = dropped.filter((entry) => entry.steersDetection).map((entry) => entry.flag);
|
|
476
|
+
const steeringClause = steering.length === 0
|
|
477
|
+
? ''
|
|
478
|
+
: ` ${steering.join(', ')} also steer${steering.length === 1 ? 's' : ''} stack detection, and still did on this run: what \`--force\` then re-renders from that detection is the wrapper scripts, not this file.`;
|
|
479
|
+
return `${dropped.map((entry) => entry.flag).join(', ')} ${one ? 'was' : 'were'} given, and ${CONFIG_FILENAME} was read rather than written on this run: ${one ? 'that flag supplies a value' : 'each of those flags supplies a value'} for a key of that file, so no such value was written and the keys in the file are the ones every generator after it read.${steeringClause} Apply ${one ? 'it' : 'them'} by rebuilding the file from detection and this command line with \`${INIT_VERB} --reset-config ${dropped.map((entry) => entry.invocation).join(' ')}\`, which copies the file that is there to a .bak first and is the only route that also re-derives what a value implies — or change ${one ? 'the key' : 'the keys'} alone with ${keyRemedies}`;
|
|
480
|
+
}
|
|
481
|
+
/** The command's own `Options:` rows, invocation-aligned, derived from {@link INIT_OPTIONS}. */
|
|
482
|
+
function initOptionLines() {
|
|
483
|
+
const invocations = INIT_OPTIONS.map((option) => `${option.flag}${option.kind === 'switch' ? '' : ` ${option.placeholder}`}`);
|
|
484
|
+
const width = Math.max(...invocations.map((invocation) => invocation.length));
|
|
485
|
+
return INIT_OPTIONS.map((option, index) => ` ${invocations[index].padEnd(width)} ${option.summary}`);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* The lines the registry renders under this command's synopsis. The entry point appends the global
|
|
489
|
+
* options after them, so `init --help` prints both sets and exits 0 without reaching {@link run} —
|
|
490
|
+
* which is what keeps `--help` safe to invoke against a command that writes files.
|
|
491
|
+
*/
|
|
492
|
+
const INIT_USAGE = Object.freeze([
|
|
493
|
+
'Init options (every one optional):',
|
|
494
|
+
...initOptionLines(),
|
|
495
|
+
]);
|
|
496
|
+
/**
|
|
497
|
+
* Parse the arguments left after the entry point consumed the global flags.
|
|
498
|
+
*
|
|
499
|
+
* **An unrecognised flag is a refusal, never a silent ignore.** A mistyped flag that is quietly
|
|
500
|
+
* dropped produces a repository wired differently from what was asked for, and the difference is
|
|
501
|
+
* only visible later, in a generated file nobody re-reads. A positional argument is refused for the
|
|
502
|
+
* same reason: `init` takes none, so one is a mistyped flag or a command that does not exist.
|
|
503
|
+
*/
|
|
504
|
+
/**
|
|
505
|
+
* The {@link QA_DRIVER_FLAG} value, checked against the schema's own enum — the flag surface's twin
|
|
506
|
+
* of `parsePresetName`, and the only value flag whose legal values are a closed set.
|
|
507
|
+
*
|
|
508
|
+
* It is settled at the flag rather than left to the config check for two reasons. The generated
|
|
509
|
+
* config would be refused by {@link writeHarnessConfig}'s guard as {@link EXIT.INTERNAL}, which
|
|
510
|
+
* blames this CLI for a value the adopter typed; and the two mobile values name agent variants
|
|
511
|
+
* closely enough (`mobile-maestro`, `mobile-mcp`) that reaching for the wrong one is a plausible
|
|
512
|
+
* mistake rather than a contrived one, so the refusal lists all three — from `qaDriverChoices`, the
|
|
513
|
+
* same renderer {@link askQaDriver} puts the values through, so the two surfaces cannot disagree
|
|
514
|
+
* about which of them this release implements. This is also the only one of the two a subprocess can
|
|
515
|
+
* reach, and so the one `test/init.test.mjs` asserts the marking on behaviourally.
|
|
516
|
+
*/
|
|
517
|
+
function parseQaDriver(value) {
|
|
518
|
+
const driver = asQaDriver(value);
|
|
519
|
+
if (driver === undefined) {
|
|
520
|
+
throw new HarnessError(`init: unknown ${QA_DRIVER_FLAG} ${JSON.stringify(value)}: expected one of ${qaDriverChoices()}`);
|
|
521
|
+
}
|
|
522
|
+
return driver;
|
|
523
|
+
}
|
|
524
|
+
function parseInitFlags(argv) {
|
|
525
|
+
const values = new Map();
|
|
526
|
+
const switches = new Set();
|
|
527
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
528
|
+
const token = argv[index];
|
|
529
|
+
const separator = token.startsWith('--') ? token.indexOf('=') : -1;
|
|
530
|
+
const name = separator > 0 ? token.slice(0, separator) : token;
|
|
531
|
+
const inlineValue = separator > 0 ? token.slice(separator + 1) : undefined;
|
|
532
|
+
const option = INIT_OPTIONS.find((candidate) => candidate.flag === name);
|
|
533
|
+
if (option === undefined) {
|
|
534
|
+
throw new HarnessError(token.startsWith('-')
|
|
535
|
+
? `init: unknown option ${JSON.stringify(name)} — run \`${INIT_VERB} --help\` for the options it takes`
|
|
536
|
+
: `init: unexpected argument ${JSON.stringify(token)} — ${INIT_VERB} takes options only, and writes into the repository containing the current directory (or --cwd)`);
|
|
537
|
+
}
|
|
538
|
+
if (option.kind === 'switch') {
|
|
539
|
+
if (inlineValue !== undefined)
|
|
540
|
+
throw new HarnessError(`init: ${name} does not take a value`);
|
|
541
|
+
switches.add(option.key);
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
let value = inlineValue;
|
|
545
|
+
if (value === undefined) {
|
|
546
|
+
index += 1;
|
|
547
|
+
value = argv[index];
|
|
548
|
+
}
|
|
549
|
+
if (value === undefined || value === '')
|
|
550
|
+
throw new HarnessError(`init: ${name} requires ${option.placeholder}`);
|
|
551
|
+
values.set(option.key, value);
|
|
552
|
+
}
|
|
553
|
+
// Sound by construction rather than by inspection: {@link InitOption} admits a string only under a
|
|
554
|
+
// `ValueFlagKey` and a switch only under a `SwitchFlagKey`, so every entry assembled here holds
|
|
555
|
+
// the type the interface declares for its key. The switches are named one by one rather than
|
|
556
|
+
// collected because the list below is meant to be the whole of {@link SwitchFlagKey} — that is
|
|
557
|
+
// what makes each of them `false` when absent rather than missing, and a key added to that type
|
|
558
|
+
// and not here is a flag the parser accepts and every generator then reads as `undefined`.
|
|
559
|
+
//
|
|
560
|
+
// `qaDriver` is the one exception and is re-set from {@link parseQaDriver} below, because its
|
|
561
|
+
// declared type is the enum rather than `string`: the collected raw value would satisfy the cast
|
|
562
|
+
// and not the type. Checking it here — inside the parser, before the git gate and every other
|
|
563
|
+
// precondition — is also what keeps a mistyped driver from costing an adopter a repository this
|
|
564
|
+
// run created and then refused to wire.
|
|
565
|
+
const qaDriver = values.get('qaDriver');
|
|
566
|
+
// The analyze pair is refused here for that same reason and at that same point: a contradictory
|
|
567
|
+
// command line must not cost an adopter a repository this run created and then declined to wire.
|
|
568
|
+
if (switches.has('analyze') && switches.has('noAnalyze')) {
|
|
569
|
+
throw new HarnessError(`init: ${ANALYZE_FLAG} and ${NO_ANALYZE_FLAG} answer the same question opposite ways and both were given: pass one, or neither — with neither, the documented default accepts the offer`);
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
...Object.fromEntries([...values]),
|
|
573
|
+
...(qaDriver === undefined ? {} : { qaDriver: parseQaDriver(qaDriver) }),
|
|
574
|
+
gitInit: switches.has('gitInit'),
|
|
575
|
+
resetConfig: switches.has('resetConfig'),
|
|
576
|
+
analyze: switches.has('analyze'),
|
|
577
|
+
noAnalyze: switches.has('noAnalyze'),
|
|
578
|
+
notifications: switches.has('notifications'),
|
|
579
|
+
qa: switches.has('qa'),
|
|
580
|
+
docs: switches.has('docs'),
|
|
581
|
+
parity: switches.has('parity'),
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
/** The `name` of a JSON manifest, or `undefined` when there is no readable one with a name. */
|
|
585
|
+
function packageNameAt(manifestPath) {
|
|
586
|
+
let parsed;
|
|
587
|
+
try {
|
|
588
|
+
parsed = readJsonFile(manifestPath);
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
// A manifest that does not parse belongs to a repository this command has no opinion about:
|
|
592
|
+
// the question here is only "is this the harness's own repository", and an unreadable answer
|
|
593
|
+
// is a no.
|
|
594
|
+
return undefined;
|
|
595
|
+
}
|
|
596
|
+
if (parsed === undefined || parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
597
|
+
return undefined;
|
|
598
|
+
const name = parsed['name'];
|
|
599
|
+
return typeof name === 'string' && name !== '' ? name : undefined;
|
|
600
|
+
}
|
|
601
|
+
/** This package's own `name`, read from the manifest beside the compiled tree. */
|
|
602
|
+
function ownPackageName() {
|
|
603
|
+
const manifestPath = join(packageRoot(), 'package.json');
|
|
604
|
+
const name = packageNameAt(manifestPath);
|
|
605
|
+
if (name === undefined) {
|
|
606
|
+
throw new HarnessError(`this CLI's own manifest could not be read at ${manifestPath}, so init could not check whether it was aimed at the harness's own repository: this is a fault in this CLI rather than in the repository it was run against`, EXIT.INTERNAL);
|
|
607
|
+
}
|
|
608
|
+
return name;
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Refuse a run aimed at the repository this CLI itself lives in.
|
|
612
|
+
*
|
|
613
|
+
* Wiring the harness's own repository is never what was meant, and it is a plausible accident:
|
|
614
|
+
* `docs/development.md` §5's gate 2 documents running the built CLI from this root, which was a
|
|
615
|
+
* refusal check for as long as `init` refused everything. Once it works, that invocation would
|
|
616
|
+
* generate a config, a state tree and a permission profile into the harness's own tree.
|
|
617
|
+
*/
|
|
618
|
+
function assertNotHarnessOwnRepository(repoRoot) {
|
|
619
|
+
if (!existsSync(join(repoRoot, MARKETPLACE_MANIFEST)))
|
|
620
|
+
return;
|
|
621
|
+
if (packageNameAt(join(repoRoot, CLI_MANIFEST)) !== ownPackageName())
|
|
622
|
+
return;
|
|
623
|
+
throw new HarnessError(`refusing to wire the harness's own repository; run \`${INIT_VERB}\` in the repository you want to adopt it. ${repoRoot} carries ${MARKETPLACE_MANIFEST} and a ${CLI_MANIFEST} naming this package, so it is the harness itself rather than an adopting project: wiring it would generate a ${CONFIG_FILENAME}, a run-artifact tree and a permission profile into the tree that ships them. Run ${INIT_VERB} from the adopting repository, or point it at one with --cwd <path>`);
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Refuse a `--state-dir` that names, or reaches through, a dot-directory — **before** anything is
|
|
627
|
+
* assembled from it.
|
|
628
|
+
*
|
|
629
|
+
* The reason is the one `docs/config.md` §3 gives and the one the tree generator repeats: the
|
|
630
|
+
* run-artifact tree has to be writable by an unattended run, and a dot-path is where a host reserves
|
|
631
|
+
* directories an unattended run may not write to. The failure is therefore silent — the tree is
|
|
632
|
+
* created, the run reports success, and every artifact it produced is gone. Refusing at the flag is what makes it an adopter-fixable usage error with the ordinary exit
|
|
633
|
+
* code, rather than the internal-fault exit the config generator's backstop would raise on a config
|
|
634
|
+
* this CLI had already assembled.
|
|
635
|
+
*/
|
|
636
|
+
function assertUsableStateDir(value) {
|
|
637
|
+
if (value === undefined)
|
|
638
|
+
return;
|
|
639
|
+
if (!STATE_DIR_DOT_PATTERN.test(value.trim()))
|
|
640
|
+
return;
|
|
641
|
+
throw new HarnessError(`${STATE_DIR_FLAG} ${JSON.stringify(value)} has a path segment starting with '.', and the run-artifact tree must not be dot-named or reach through a dot segment: it has to be writable by an unattended run, and a dot-path is where a host reserves directories an unattended run may not write to — measured for .claude/**, where such a run completes with exit 0 having written nothing. Pass a name without a leading dot; do not "fix" it back to a dot-name`);
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* What the run says about a directory that was **already** inside a repository — what
|
|
645
|
+
* {@link GIT_INIT_FLAG} meant there, and what this run does about that repository's own history. The
|
|
646
|
+
* case was silent until Task 8, and the silence is what let a run leave the exact state
|
|
647
|
+
* {@link commitGeneratedFiles} exists to prevent without anything saying so.
|
|
648
|
+
*
|
|
649
|
+
* **Notes, never warnings.** A provisioning script that passes the flag whether or not the directory
|
|
650
|
+
* is fresh is a *correct* run, and a warning that fires on every correct run is how a warning list
|
|
651
|
+
* stops being read (`generators/harnessConfig.ts`).
|
|
652
|
+
*
|
|
653
|
+
* **Two gates, because the two facts are about different things.** What the flag did here is a
|
|
654
|
+
* statement *about the flag*, so a run that never asked for a repository is told none of it. Whether
|
|
655
|
+
* the first commit happens is a statement about the repository's **state**:
|
|
656
|
+
* {@link commitGeneratedFiles} stopped consulting the flag once the commit became a fix for an unborn
|
|
657
|
+
* `HEAD` rather than a courtesy to a repository this run created — so a preview gated on the flag
|
|
658
|
+
* would leave the ordinary no-flag adoption (an adopter's own `git init`, never committed into)
|
|
659
|
+
* silent about the one mutation outside the plan a real run makes there. That is `docs/cli.md` §1's
|
|
660
|
+
* "a dry run cannot report a success a real run would not have" contract read in its other
|
|
661
|
+
* direction: a dry run may not omit a mutation a real run makes either.
|
|
662
|
+
*
|
|
663
|
+
* Four texts — three gated on the flag, one on {@link hasCommits} alone:
|
|
664
|
+
*
|
|
665
|
+
* - **Flag given, the repository has history.** Nothing was created and nothing is committed —
|
|
666
|
+
* {@link commitGeneratedFiles} short-circuits on {@link hasCommits}, so the *commit* half reads the
|
|
667
|
+
* same in both modes: it is the repository's state that decides it, not the run's. The *wiring*
|
|
668
|
+
* half is the run's own work, so it splits — "this run only wires it" on a real run, "a real run
|
|
669
|
+
* would only wire it" under `--dry-run`, which wires nothing.
|
|
670
|
+
* - **Flag given, no commit yet.** Nothing was created, said on its own: the commit half of this
|
|
671
|
+
* state is one of the two lines below, neither of which is about the flag.
|
|
672
|
+
* - **No commit yet, real run — flag gated.** This run *does* make the first commit, for the reason
|
|
673
|
+
* that guard exists: `git worktree add` cannot prepare a checkout from a repository with no commit.
|
|
674
|
+
* A real run's account of the commit it actually made is {@link commitGeneratedFiles}' `ok` line,
|
|
675
|
+
* which every commit-less real run already gets; this line is the promise made before the plan is
|
|
676
|
+
* applied, and it stays flag-gated so a no-flag run is not told the same thing twice.
|
|
677
|
+
* - **No commit yet, `--dry-run` — gated on the state alone.** The same two facts in the conditional,
|
|
678
|
+
* printed whether or not the flag was given, because that is the gate the commit itself is under
|
|
679
|
+
* and a dry run is the only run with nothing after the fact to say it.
|
|
680
|
+
*
|
|
681
|
+
* Asking {@link hasCommits} here costs a second probe and is safe: nothing commits between this call
|
|
682
|
+
* and {@link commitGeneratedFiles}, so the note and the commit cannot disagree.
|
|
683
|
+
*/
|
|
684
|
+
function alreadyARepositoryNotes(ctx, flags, root) {
|
|
685
|
+
const commits = hasCommits(root);
|
|
686
|
+
const notes = [];
|
|
687
|
+
if (flags.gitInit === true) {
|
|
688
|
+
const nothingCreated = `${GIT_INIT_FLAG} was given and nothing was created: this directory is already inside a git repository at ${root}`;
|
|
689
|
+
notes.push(commits
|
|
690
|
+
? `${nothingCreated}, and that repository has history — init never commits into an established history, so ${ctx.flags.dryRun ? 'a real run would only wire it' : 'this run only wires it'}`
|
|
691
|
+
: nothingCreated);
|
|
692
|
+
}
|
|
693
|
+
const worktreeReason = 'because `git worktree add` cannot prepare a checkout from a repository with no commit';
|
|
694
|
+
if (!commits && ctx.flags.dryRun) {
|
|
695
|
+
notes.push(`this repository has no commit yet, so a real run would make the first commit here — \`git add -A\` would stage everything in the working tree, what init generated and whatever was already here, with the managed .gitignore block deciding what is left out — ${worktreeReason}. This was a dry run, so nothing was written and nothing was committed`);
|
|
696
|
+
}
|
|
697
|
+
else if (!commits && flags.gitInit === true) {
|
|
698
|
+
notes.push(`this repository has no commit yet, so init makes the first commit from what is in the working tree once the plan has been applied, ${worktreeReason}`);
|
|
699
|
+
}
|
|
700
|
+
return notes;
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Settle the one precondition that has an *answer* rather than only a verdict: is there a repository
|
|
704
|
+
* to wire, and — when there is not — may this run create one.
|
|
705
|
+
*
|
|
706
|
+
* **Git stays a hard gate; nothing is ever created silently.** The repository appears only because
|
|
707
|
+
* {@link GIT_INIT_FLAG} said so, or because someone at a terminal answered yes. A run that cannot be
|
|
708
|
+
* asked takes the documented default and refuses, which is exactly what every release before the
|
|
709
|
+
* offer did — so an unattended run's outcome is unchanged (`core/prompt.ts`).
|
|
710
|
+
*
|
|
711
|
+
* **And every outcome of the flag is reported, including the one where it did nothing.** Passing
|
|
712
|
+
* {@link GIT_INIT_FLAG} into a directory that is already a repository creates nothing, which is
|
|
713
|
+
* correct and used to be silent; it now carries a note saying so and saying what this run does about
|
|
714
|
+
* that repository's own history ({@link alreadyARepositoryNotes}).
|
|
715
|
+
*
|
|
716
|
+
* It is called where `resolveRepoRoot` used to be — before the harness-own-repository check and
|
|
717
|
+
* before the first generator, so the accept path still runs before a single write is enqueued and
|
|
718
|
+
* the refusal path still leaves a plan that was never built. **After** the two refusals the flags
|
|
719
|
+
* alone answer, though, and that half of the ordering is this function's doing: its accept path
|
|
720
|
+
* creates a repository, so a refusal raised later would leave one behind in a directory the run then
|
|
721
|
+
* declined to wire — a repository the adopter never asked for and was never told about. The
|
|
722
|
+
* corrected re-run would then find it simply *there*, take it as the root every write is confined to
|
|
723
|
+
* without putting the question this gate exists to put, and — since it has no commit —
|
|
724
|
+
* {@link commitGeneratedFiles} would commit the whole of that directory's working tree into it.
|
|
725
|
+
*
|
|
726
|
+
* **Only git's own "not a git repository" is an offer.** A `git` that is not on PATH, and a `git`
|
|
727
|
+
* that refused to answer (`detected dubious ownership`, an unreadable object store), each throw the
|
|
728
|
+
* probe's own message unchanged: the first is a missing prerequisite whose accept path would fail
|
|
729
|
+
* for the same reason, and the second may well be a refusal *inside* an existing repository, where
|
|
730
|
+
* creating one is the wrong move and git's own sentence is the remedy.
|
|
731
|
+
*/
|
|
732
|
+
function resolveTargetRepository(ctx, flags) {
|
|
733
|
+
const probe = probeRepoRoot(ctx.cwd);
|
|
734
|
+
if (probe.kind === 'repository') {
|
|
735
|
+
// Nothing to decide — but a run that passed the flag anyway gets told what that meant here, and
|
|
736
|
+
// a dry run over a commit-less repository gets the first commit previewed whether or not it did,
|
|
737
|
+
// rather than the silence that let a commit-less repository be wired and left uncommitted with
|
|
738
|
+
// nothing said ({@link alreadyARepositoryNotes}).
|
|
739
|
+
return { root: probe.root, created: false, notes: alreadyARepositoryNotes(ctx, flags, probe.root) };
|
|
740
|
+
}
|
|
741
|
+
if (probe.kind !== 'not-a-repository')
|
|
742
|
+
throw new HarnessError(probe.message);
|
|
743
|
+
const accepted = flags.gitInit === true ||
|
|
744
|
+
askYesNo({
|
|
745
|
+
question: `No git repository at ${ctx.cwd}. Create one here and wire it?`,
|
|
746
|
+
defaultAnswer: false,
|
|
747
|
+
flag: GIT_INIT_FLAG,
|
|
748
|
+
flagHint: 'to create one without being asked',
|
|
749
|
+
}, { flags: ctx.flags, report: ctx.report });
|
|
750
|
+
if (!accepted) {
|
|
751
|
+
throw new HarnessError(`${probe.message}. Every path init writes is confined to a repository root, and the worktree-based flow it wires cannot prepare a checkout without one, so this is a gate rather than something init works around. Pass ${GIT_INIT_FLAG} to create a repository here, run ${INIT_VERB} from inside an existing one, or point it at one with --cwd <path>`);
|
|
752
|
+
}
|
|
753
|
+
if (ctx.flags.dryRun) {
|
|
754
|
+
// The preview reports the decision without making it, and the rest of the plan is previewed
|
|
755
|
+
// against `cwd` as the root. Resolved through `realpathSync` rather than taken as spelled, for
|
|
756
|
+
// the reason the real path below re-probes: a caller's directory may reach its target through a
|
|
757
|
+
// symlink (`/tmp` on macOS is one), and a preview whose absolute paths differ from the ones a
|
|
758
|
+
// real run writes is not the faithful preview `docs/cli.md` §1 promises. This is an explicit
|
|
759
|
+
// `dryRun` guard rather than a consequence of the plan's structure, because `initRepository`
|
|
760
|
+
// writes outside the plan: a dry run may not report a success a real run would not have, and it
|
|
761
|
+
// may not create a repository in order to say so.
|
|
762
|
+
const root = realpathSync(ctx.cwd);
|
|
763
|
+
ctx.report.info(`would create a git repository at ${root}`);
|
|
764
|
+
return {
|
|
765
|
+
root,
|
|
766
|
+
created: false,
|
|
767
|
+
notes: [
|
|
768
|
+
`no git repository was created — this was a dry run; with ${GIT_INIT_FLAG} a real run creates one at ${root}, wires it, and makes the first commit from everything in this directory's working tree — what it generated and whatever was already here — with the managed .gitignore block deciding what is left out`,
|
|
769
|
+
],
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
initRepository(ctx.cwd);
|
|
773
|
+
// Re-probed rather than assumed: `rev-parse --show-toplevel` answers with the physical path, and
|
|
774
|
+
// the caller's directory may reach it through a symlink (`/tmp` on macOS is one). Taking `cwd`
|
|
775
|
+
// here would spell every absolute path the generators write differently from the root git reports,
|
|
776
|
+
// and nothing downstream would notice.
|
|
777
|
+
const created = probeRepoRoot(ctx.cwd);
|
|
778
|
+
if (created.kind !== 'repository')
|
|
779
|
+
throw new HarnessError(created.message);
|
|
780
|
+
ctx.report.ok(`created a git repository at ${created.root}`);
|
|
781
|
+
return {
|
|
782
|
+
root: created.root,
|
|
783
|
+
created: true,
|
|
784
|
+
notes: [
|
|
785
|
+
`the git repository at ${created.root} was created by this run — init makes the first commit from everything in this directory's working tree, what it generated and whatever was already here, with the managed .gitignore block deciding what is left out, because \`git worktree add\` cannot prepare a checkout from a repository with no commit`,
|
|
786
|
+
],
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Make the first commit — in any repository being wired that has no commit yet, and only from what
|
|
791
|
+
* is on disk once the whole plan has been applied.
|
|
792
|
+
*
|
|
793
|
+
* **Why it exists at all:** a repository with an unborn `HEAD` is one `git worktree add` away from
|
|
794
|
+
* failing, and that is a fact about the repository's **state** rather than about who created it. So
|
|
795
|
+
* an adopter who accepted the offer — or who ran `init` inside a repository they had made and not
|
|
796
|
+
* yet committed into — would have a correctly wired repository from which the worktree-based flow
|
|
797
|
+
* this command exists to enable cannot prepare a single checkout: one non-obvious step away from
|
|
798
|
+
* working, with nothing in the run saying so. `doctor` reports it, but `doctor` cannot be relied on
|
|
799
|
+
* to have run.
|
|
800
|
+
*
|
|
801
|
+
* Two conditions, each answering a different way this could be wrong:
|
|
802
|
+
*
|
|
803
|
+
* - **`dryRun`** — the third of the three explicit guards named in choice 1 of the module header. A
|
|
804
|
+
* dry run has applied no plan, so there would be nothing of its own to commit anyway; the guard
|
|
805
|
+
* is written all the same, because the guarantee it protects is "a dry run mutates nothing" and
|
|
806
|
+
* that must not rest on what some other function did elsewhere in the file.
|
|
807
|
+
* - **{@link hasCommits}** — a repository with history is never committed into. This one check is
|
|
808
|
+
* the whole of that property and it holds by construction rather than by inspection: it
|
|
809
|
+
* short-circuits before `commitAll` is reached, so no path from here reaches a commit on top of
|
|
810
|
+
* somebody's work.
|
|
811
|
+
*
|
|
812
|
+
* **Two consequences, both said out loud rather than left implicit.** First, an established
|
|
813
|
+
* repository is untouched, whoever wired it and whether or not this run created anything — the
|
|
814
|
+
* check above is the whole reason, and there is no second one to keep in step with it. Second,
|
|
815
|
+
* `git add -A` stages the **whole working tree** — what `init` generated and whatever was already in
|
|
816
|
+
* the directory — in either arm, with the managed `.gitignore` block the plan just wrote deciding
|
|
817
|
+
* what is left out (`generators/repoRoot.ts`). A repository `--git-init` created is not an
|
|
818
|
+
* exception: detection read that directory's manifests before the plan was built, so there is
|
|
819
|
+
* content there to stage. That single-commit shape is deliberate — one shape, one message — so the
|
|
820
|
+
* run **reports which arm it was in**, because *who* left the repository without a commit is what
|
|
821
|
+
* differs, not what got staged.
|
|
822
|
+
*
|
|
823
|
+
* **A refusal is a warning, not a failure.** Everything the run was asked to wire is on disk and
|
|
824
|
+
* correct by the time this runs, so exiting non-zero would report a failed adoption because of a
|
|
825
|
+
* step the adopter can finish with two commands. The overwhelmingly common cause is a machine with
|
|
826
|
+
* no commit identity — which `init` deliberately does not set, because an identity is the
|
|
827
|
+
* operator's and inventing one authors commits as somebody who does not exist — so the warning
|
|
828
|
+
* carries the exact commands rather than only git's account of the refusal.
|
|
829
|
+
*/
|
|
830
|
+
function commitGeneratedFiles(ctx, target) {
|
|
831
|
+
if (ctx.flags.dryRun || hasCommits(target.root))
|
|
832
|
+
return [];
|
|
833
|
+
const result = commitAll(target.root, FIRST_COMMIT_MESSAGE);
|
|
834
|
+
if (result.committed) {
|
|
835
|
+
ctx.report.ok(target.created
|
|
836
|
+
? `committed this directory as the repository's first commit: ${FIRST_COMMIT_MESSAGE}. \`git add -A\` staged everything in the working tree — what init generated and whatever was already here — with the managed .gitignore block deciding what was left out`
|
|
837
|
+
: `this repository was already here and had no commit yet, so init made its first one: ${FIRST_COMMIT_MESSAGE}. \`git add -A\` staged everything in the working tree — what init generated and whatever was already here — with the managed .gitignore block deciding what was left out, because \`git worktree add\` cannot prepare a checkout from a repository with no commit`);
|
|
838
|
+
return [];
|
|
839
|
+
}
|
|
840
|
+
return [
|
|
841
|
+
`this repository has no commit yet: git declined to make one — ${result.reason}. Everything init generated is on disk and correct; only the commit is missing, and \`git worktree add\` fails against a repository with no commit, so the worktree-based flow cannot prepare a checkout until there is one. Most often the machine has no commit identity, which init does not set for you: run \`git config user.email "you@example.com"\` and \`git config user.name "Your Name"\`, then \`git add -A && git commit -m "${FIRST_COMMIT_MESSAGE}"\``,
|
|
842
|
+
];
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Ask which driver the interactive test phase should run — or answer `undefined` on every run that
|
|
846
|
+
* cannot be asked, which is what tells the config generator to take the documented default and to
|
|
847
|
+
* say so rather than pretending a choice was made.
|
|
848
|
+
*
|
|
849
|
+
* **`undefined` rather than the default itself** is the whole reason this wrapper exists: `askLine`
|
|
850
|
+
* answers a no-terminal run with `defaultValue`, so a caller taking its return alone cannot tell an
|
|
851
|
+
* adopter who chose `web-playwright` from a CI run that was never asked — and the two want opposite
|
|
852
|
+
* notes. {@link canPrompt} is the same predicate `askLine` itself uses, so `--non-interactive` and
|
|
853
|
+
* `--quiet` are honoured here on exactly the terms they are honoured everywhere.
|
|
854
|
+
*
|
|
855
|
+
* The question **back-references** the detected preset instead of naming it. It used to open on
|
|
856
|
+
* `` detected the `<preset>` layer preset ``, which is verbatim the opening clause of the detection
|
|
857
|
+
* line printed immediately above it: the two read as one narration line printed twice, so the eye
|
|
858
|
+
* skips the second and the question with it. What the naming was for still holds — the detection
|
|
859
|
+
* table is file-existence only and has no mobile row (`docs/cli.md` §4), so what `init` found says
|
|
860
|
+
* nothing about how the application is reached — and the back-reference carries it without
|
|
861
|
+
* re-printing the line the adopter has just read. The preset is therefore no longer an input, and
|
|
862
|
+
* the call site's ordering (after the detection line) is what the back-reference depends on.
|
|
863
|
+
*
|
|
864
|
+
* The values come from `qaDriverChoices`, so the two that ship declared-not-implemented are marked
|
|
865
|
+
* **where the choice is made** rather than only in documents a reader opens after choosing.
|
|
866
|
+
*/
|
|
867
|
+
function askQaDriver(ctx) {
|
|
868
|
+
const promptCtx = { flags: ctx.flags, report: ctx.report };
|
|
869
|
+
if (!canPrompt(promptCtx))
|
|
870
|
+
return undefined;
|
|
871
|
+
return askLine({
|
|
872
|
+
question: `the detected preset says nothing about how this project's application is reached: which driver should the interactive test phase run? (${qaDriverChoices()})`,
|
|
873
|
+
flag: QA_DRIVER_FLAG,
|
|
874
|
+
defaultValue: DEFAULTS.qa.driver,
|
|
875
|
+
}, promptCtx);
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* The conventions documents this repository has: every distinct `layers[].conventions` value, plus
|
|
879
|
+
* the shared cross-layer document whether or not a layer points at it.
|
|
880
|
+
*
|
|
881
|
+
* **The walk is deliberately duplicated and bounded to the walk.** `generators/claudeContext.ts`
|
|
882
|
+
* applies the same rule when it collects the stubs, and it is module-private on this branch — so
|
|
883
|
+
* each consumer of the rule walks `layers[]` itself, while all of them take the shared document's
|
|
884
|
+
* path from {@link SHARED_CONVENTIONS_PATH}, the one exported constant, rather than re-spelling a
|
|
885
|
+
* context path. `doctor`'s `setup-analysis` check states the same derivation in its own comment.
|
|
886
|
+
*/
|
|
887
|
+
function conventionsDocuments(config) {
|
|
888
|
+
return [
|
|
889
|
+
...new Set([SHARED_CONVENTIONS_PATH, ...config.layers.map((layer) => layer.conventions)].map(normalizeRepoPathStrict)),
|
|
890
|
+
];
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* The **untouched-skeleton test** applied to one document — {@link isUntouchedSkeletonText}, which
|
|
894
|
+
* is the predicate itself and lives beside the two markers it is built from. `doctor` and the
|
|
895
|
+
* analyze command apply the same test, so the three cannot disagree about what "unfilled" means,
|
|
896
|
+
* and an adopter who wrote a document by hand is never treated as having left a skeleton.
|
|
897
|
+
*
|
|
898
|
+
* What this adds is the reading, and the answer for a document that cannot be read: **untouched**,
|
|
899
|
+
* because here that means a document `init` is about to create for the first time — which is what
|
|
900
|
+
* keeps a fresh adoption from taking the skip branch below.
|
|
901
|
+
*/
|
|
902
|
+
function isUntouchedSkeleton(repoRoot, repoRelative) {
|
|
903
|
+
let content;
|
|
904
|
+
try {
|
|
905
|
+
content = readFileSync(join(repoRoot, repoRelative), 'utf8');
|
|
906
|
+
}
|
|
907
|
+
catch {
|
|
908
|
+
return true;
|
|
909
|
+
}
|
|
910
|
+
return isUntouchedSkeletonText(content);
|
|
911
|
+
}
|
|
912
|
+
/** True when the always-loaded file is there and still carries an opened setup-pending banner. */
|
|
913
|
+
function hasOpenSetupBanner(repoRoot) {
|
|
914
|
+
try {
|
|
915
|
+
return readFileSync(join(repoRoot, CLAUDE_MD_PATH), 'utf8').includes(SETUP_PENDING_OPEN);
|
|
916
|
+
}
|
|
917
|
+
catch {
|
|
918
|
+
return false;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Settle the offer to run {@link ANALYZE_COMMAND} in this repository's first session.
|
|
923
|
+
*
|
|
924
|
+
* **The third of the four questions this command puts**, and — like all four — settled before
|
|
925
|
+
* `plan.apply`, so no answer is taken after anything has been written: the repository itself
|
|
926
|
+
* ({@link resolveTargetRepository}), which driver the interactive test phase runs
|
|
927
|
+
* ({@link askQaDriver}, asked lazily inside the config generator), this offer, and push
|
|
928
|
+
* notifications ({@link resolveNotifications}). Moving the call site breaks that order and this
|
|
929
|
+
* sentence with it.
|
|
930
|
+
*
|
|
931
|
+
* **The documented default is yes, and the interaction rule's continuity clause holds in two halves
|
|
932
|
+
* rather than one** (`docs/cli.md` §2). The *pointer* half is exactly the behaviour this release
|
|
933
|
+
* already had: every earlier release ended by telling every adopter to run this command
|
|
934
|
+
* ({@link reportNextSteps}), so taking the default leaves the run saying what it has always said and
|
|
935
|
+
* *declining* is the new option — which is why the default is yes. The *recorded-intent* half is
|
|
936
|
+
* **new in this release**: no earlier release wrote any banner into the generated always-loaded file,
|
|
937
|
+
* and the declined answer writes one too.
|
|
938
|
+
*
|
|
939
|
+
* There is deliberately **no** size threshold, duration estimate or any other condition that
|
|
940
|
+
* silently declines — the adopter consented to this specific work — and the answer costs this
|
|
941
|
+
* command no model call and no external process.
|
|
942
|
+
*/
|
|
943
|
+
function resolveAnalyzeOffer(ctx, flags, repoRoot, config) {
|
|
944
|
+
// Read from the filesystem **before** the plan is built, because the generator enqueues the banner
|
|
945
|
+
// while the plan is being assembled: by the time it has spoken, "was this file already there" and
|
|
946
|
+
// "was this document still a skeleton" are no longer answerable, and the answer is an *input* to
|
|
947
|
+
// that generator rather than a reading of what it produced.
|
|
948
|
+
const documents = conventionsDocuments(config);
|
|
949
|
+
const filledConventions = documents.filter((path) => !isUntouchedSkeleton(repoRoot, path));
|
|
950
|
+
const facts = { claudeMdExisted: existsSync(join(repoRoot, CLAUDE_MD_PATH)), filledConventions };
|
|
951
|
+
// (1) The flag pair folded into the tri-state, with no prompt either way. Both-given was refused in
|
|
952
|
+
// the parser, so at most one is true here — and neither being true is the "not answered" state the
|
|
953
|
+
// rest of this function handles.
|
|
954
|
+
if (flags.noAnalyze === true)
|
|
955
|
+
return { offer: 'declined', nothingToFill: false, notes: [], ...facts };
|
|
956
|
+
if (flags.analyze === true)
|
|
957
|
+
return { offer: 'accepted', nothingToFill: false, notes: [], ...facts };
|
|
958
|
+
// (2) The skip branch: nothing is left to fill, and nothing this run does will unfill it. All
|
|
959
|
+
// three conditions have to hold, and each is load-bearing:
|
|
960
|
+
//
|
|
961
|
+
// (2a) **the run is not forced.** `--force` upgrades every `create-if-absent` request without its
|
|
962
|
+
// own `forceOverride` to overwrite-after-backup (`core/writer.ts`), and the conventions stubs
|
|
963
|
+
// are enqueued that way for every document that is not already a skeleton
|
|
964
|
+
// (`generators/claudeContext.ts` fixes `forceOverride: 'never'` on that one case, where the
|
|
965
|
+
// `.bak` holds the only surviving analysis) — so a forced run over an analyzed repository
|
|
966
|
+
// rewrites every filled document back to its skeleton and regenerates the always-loaded file
|
|
967
|
+
// from the template. "Nothing is left to fill" is then true of the tree this run *found* and
|
|
968
|
+
// false of the tree it is *about to produce*, and this branch reads the found one.
|
|
969
|
+
// (2b) **no document is still an untouched skeleton** ({@link isUntouchedSkeleton}, both halves).
|
|
970
|
+
// (2c) **the always-loaded file exists and carries no open banner.** It exists, so an unforced run
|
|
971
|
+
// keeps it and no banner can be written into it whatever the answer; and no open banner,
|
|
972
|
+
// because one still there is a pass that never completed — precisely the case that must not go
|
|
973
|
+
// unasked. Deliberately not "does not exist or has no open banner": a repository whose
|
|
974
|
+
// documents are filled but whose always-loaded file was deleted is one this run *creates* that
|
|
975
|
+
// file for, so a banner will be written and its wording has to be chosen by an answer.
|
|
976
|
+
if (!ctx.flags.force &&
|
|
977
|
+
filledConventions.length === documents.length &&
|
|
978
|
+
facts.claudeMdExisted &&
|
|
979
|
+
!hasOpenSetupBanner(repoRoot)) {
|
|
980
|
+
// `offer` is a placeholder on this path — no answer was taken. (2a) + (2c) keep it out of the
|
|
981
|
+
// generator: the file is kept, so no banner is written whatever it says. It is `nothingToFill`
|
|
982
|
+
// that keeps the closing wording from reading it as a declined answer.
|
|
983
|
+
return {
|
|
984
|
+
offer: 'declined',
|
|
985
|
+
nothingToFill: true,
|
|
986
|
+
notes: [
|
|
987
|
+
`the conventions documents are already filled, so ${ANALYZE_COMMAND} was not offered again — run it at any time to revise them, and \`${DOCTOR_COMMAND}\` for what is still unfilled`,
|
|
988
|
+
],
|
|
989
|
+
...facts,
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
const promptCtx = { flags: ctx.flags, report: ctx.report };
|
|
993
|
+
// (3) A run that cannot be asked takes the documented default and says so **here**, rather than
|
|
994
|
+
// leaving the note to {@link askYesNo}: that function prints only on its `'no-terminal'` path and
|
|
995
|
+
// returns the default silently under `--non-interactive` and `--quiet` (`core/prompt.ts`), so the
|
|
996
|
+
// note would be missing from exactly the unattended run that most needs it. One line, one
|
|
997
|
+
// producer, both reasons — and through the reporter's `info` like every other narration, so
|
|
998
|
+
// `--quiet` still suppresses it with the rest.
|
|
999
|
+
if (!canPrompt(promptCtx)) {
|
|
1000
|
+
return {
|
|
1001
|
+
offer: 'accepted',
|
|
1002
|
+
nothingToFill: false,
|
|
1003
|
+
notes: [
|
|
1004
|
+
`the analyze offer was not put — no terminal, --non-interactive or --quiet — so it took its documented default and was accepted: pass ${NO_ANALYZE_FLAG} to decline it without being asked`,
|
|
1005
|
+
],
|
|
1006
|
+
...facts,
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
// (4) A terminal, and neither flag given: the one question this offer puts. The bracketed default
|
|
1010
|
+
// is yes, and the question says what is about to happen and where it happens.
|
|
1011
|
+
const accepted = askYesNo({
|
|
1012
|
+
question: `Run ${ANALYZE_COMMAND} in this repository's first session? It reads this repository's code and writes its conventions documents from what it finds — it runs in a session rather than here, so ${INIT_VERB} analyzes nothing itself.`,
|
|
1013
|
+
defaultAnswer: true,
|
|
1014
|
+
flag: NO_ANALYZE_FLAG,
|
|
1015
|
+
flagHint: 'to decline it without being asked',
|
|
1016
|
+
}, promptCtx);
|
|
1017
|
+
return { offer: accepted ? 'accepted' : 'declined', nothingToFill: false, notes: [], ...facts };
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Settle whether this account gets told when an unattended run finishes, and where.
|
|
1021
|
+
*
|
|
1022
|
+
* **The documented default is off**, which is exactly what every release before this one did:
|
|
1023
|
+
* delivery has shipped since the outer-loop scripts did, and nobody was ever asked, so an operator
|
|
1024
|
+
* who never read `docs/watcher.md` §6 got a desktop banner where one was available and nothing else.
|
|
1025
|
+
* The default therefore changes no existing behaviour — it is the *asking* that is new.
|
|
1026
|
+
*
|
|
1027
|
+
* The two questions are asked in order and the second only inside the first's yes, because an
|
|
1028
|
+
* endpoint is meaningless without the opt-in and the opt-in writes nothing without an endpoint. The
|
|
1029
|
+
* endpoint question carries no default: `askLine` answers `undefined` with none, and `undefined` is
|
|
1030
|
+
* what routes the run to the generator's guided-setup note rather than to a file — writing an empty
|
|
1031
|
+
* machine-local file would shadow a repository-side one that already has values
|
|
1032
|
+
* (`generators/notifications.ts`, choice 1).
|
|
1033
|
+
*
|
|
1034
|
+
* A `--push-url` passed **without** the opt-in is left in place rather than dropped here: the
|
|
1035
|
+
* generator owns what that means and warns about it, as it owns every other line about the artifact
|
|
1036
|
+
* it writes.
|
|
1037
|
+
*/
|
|
1038
|
+
function resolveNotifications(ctx, flags) {
|
|
1039
|
+
const promptCtx = { flags: ctx.flags, report: ctx.report };
|
|
1040
|
+
const enabled = flags.notifications === true ||
|
|
1041
|
+
askYesNo({
|
|
1042
|
+
question: 'Set up push notifications for unattended runs?',
|
|
1043
|
+
defaultAnswer: false,
|
|
1044
|
+
flag: NOTIFICATIONS_FLAG,
|
|
1045
|
+
flagHint: 'to set them up without being asked',
|
|
1046
|
+
}, promptCtx);
|
|
1047
|
+
const pushUrl = flags.pushUrl ??
|
|
1048
|
+
(enabled
|
|
1049
|
+
? askLine({
|
|
1050
|
+
question: `Where should notifications be posted? (any endpoint that accepts a POST, e.g. ${GUIDED_ENDPOINT_EXAMPLE})`,
|
|
1051
|
+
flag: PUSH_URL_FLAG,
|
|
1052
|
+
}, promptCtx)
|
|
1053
|
+
: undefined);
|
|
1054
|
+
return pushUrl === undefined ? { enabled } : { enabled, pushUrl };
|
|
1055
|
+
}
|
|
1056
|
+
/** What detection concluded, as one line the summary can carry. */
|
|
1057
|
+
function detectionLine(detection) {
|
|
1058
|
+
if (detection.matchedSignal === FORCED_SIGNAL_ID) {
|
|
1059
|
+
return `--preset selected the \`${detection.preset}\` layer preset, so no detection signal was evaluated`;
|
|
1060
|
+
}
|
|
1061
|
+
const evidence = detection.evidence === undefined ? '' : ` — ${detection.evidence}`;
|
|
1062
|
+
return `detected the \`${detection.preset}\` layer preset (signal ${detection.matchedSignal}${evidence})`;
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* What answered for the **commands**, as the second line of the same step — the half
|
|
1066
|
+
* {@link detectionLine} says nothing about, because that line is a verdict on the layer preset alone.
|
|
1067
|
+
*
|
|
1068
|
+
* The two halves are decided independently (`detect/presets.ts`, `COMMAND_FAMILIES`), so a run whose
|
|
1069
|
+
* layer table fell to `flat:fallback` while a family answered off the root manifest reported only the
|
|
1070
|
+
* pessimistic half: a grep of a whole init capture for any manifest or family name returned nothing.
|
|
1071
|
+
*
|
|
1072
|
+
* **It reads {@link PresetProfile}'s recorded fields and derives none of them.** The pair naming the
|
|
1073
|
+
* source is {@link PresetProfile.commandManifest}'s, and which required keys that source actually
|
|
1074
|
+
* supplied is {@link PresetProfile.resolvedRequired}'s — both composed by `commandSourceClaim`, the
|
|
1075
|
+
* same composer the two detection notes use, so one run's output cannot name that source two ways
|
|
1076
|
+
* nor claim a key the same run warns is a placeholder. Never an entry picked out of
|
|
1077
|
+
* {@link PresetProfile.readManifests} by position: those two lists are ordered by different tables.
|
|
1078
|
+
* `readManifests` is read here for a **count** and nothing else.
|
|
1079
|
+
*
|
|
1080
|
+
* It states no rule about the search and claims nothing about whether the other manifests were
|
|
1081
|
+
* consulted: the collision's detail is the tiebreak note's, and the clause pointing at it is gated on
|
|
1082
|
+
* that note being **published** as well as raised. Raising it is `buildPreset`'s gate
|
|
1083
|
+
* (`readManifests.length > 1`); publishing it is the `config.kept` gate at the call site, which drops
|
|
1084
|
+
* `profile.notes` wholesale on a kept re-run (`generatedConfigNotes`). Reproducing the raise gate
|
|
1085
|
+
* alone is what let this clause point at a note the run never printed, so `kept` suppresses it.
|
|
1086
|
+
*
|
|
1087
|
+
* `kept` also decides the **verb**. Every other line of this shape asserts what a key *was written
|
|
1088
|
+
* as*, and a kept re-run wrote no key — the standing local rule `generators/harnessConfig.ts` gates
|
|
1089
|
+
* `notesIfWritten` on. So on a kept re-run this line reports what **detection resolved**, never what
|
|
1090
|
+
* is in effect; what is in effect is the kept file's, and `writeHarnessConfig`'s replacement note
|
|
1091
|
+
* says so.
|
|
1092
|
+
*
|
|
1093
|
+
* @param options.kept whether this run keeps an existing config rather than writing one — the same
|
|
1094
|
+
* fact `writeHarnessConfig` computes as `kept`, derived at the call site because this line prints
|
|
1095
|
+
* before that generator runs.
|
|
1096
|
+
*/
|
|
1097
|
+
function commandSourceLine(profile, detection, options) {
|
|
1098
|
+
const resolvedNoLine = options.kept
|
|
1099
|
+
? 'this run resolved no line for commands.typecheck or commands.test'
|
|
1100
|
+
: 'commands.typecheck and commands.test carry placeholders';
|
|
1101
|
+
if (profile.commandFamily !== undefined) {
|
|
1102
|
+
const claim = commandSourceClaim({
|
|
1103
|
+
family: profile.commandFamily,
|
|
1104
|
+
manifest: profile.commandManifest,
|
|
1105
|
+
resolvedRequired: profile.resolvedRequired,
|
|
1106
|
+
}, options.kept ? 'detection-resolved' : 'came-from');
|
|
1107
|
+
const others = !options.kept && profile.readManifests.length > 1
|
|
1108
|
+
? profile.readManifests.filter((entry) => entry !== profile.commandManifest).length
|
|
1109
|
+
: 0;
|
|
1110
|
+
const collision = others === 0
|
|
1111
|
+
? ''
|
|
1112
|
+
: ` — ${others} other manifest${others === 1 ? '' : 's'} init reads ${others === 1 ? 'is' : 'are'} present too, and the tiebreak note names ${others === 1 ? 'it' : 'them'} and what became of ${others === 1 ? 'it' : 'them'}`;
|
|
1113
|
+
return `${claim}${collision}`;
|
|
1114
|
+
}
|
|
1115
|
+
// The one positional read of this list, and it is in the arm where **no family answered**: there is
|
|
1116
|
+
// nothing to pair a manifest with here, only a present file to name so the placeholder pair is not
|
|
1117
|
+
// reported against a repository that looks empty. The prohibition is on pairing an entry with a
|
|
1118
|
+
// family by position ({@link PresetProfile.commandManifest}), and no arm above does that.
|
|
1119
|
+
const [first] = profile.readManifests;
|
|
1120
|
+
if (first === undefined) {
|
|
1121
|
+
return `no command family answered: no manifest init reads is present in ${searchedRoots(detection.context)}, so ${resolvedNoLine}`;
|
|
1122
|
+
}
|
|
1123
|
+
const rest = profile.readManifests.length - 1;
|
|
1124
|
+
const alongside = rest === 0 ? '' : ` (with ${rest} other manifest${rest === 1 ? '' : 's'} init reads)`;
|
|
1125
|
+
return `no command family answered, so ${resolvedNoLine} — \`${first}\` is present${alongside} and supplied neither line`;
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* The command keys the `commands` step walks, in the fixed order it prints them: the two verifiers
|
|
1129
|
+
* first, then the dev server, then the two unwrapped lines, then the deploy command on its own
|
|
1130
|
+
* object.
|
|
1131
|
+
*
|
|
1132
|
+
* Order is stated here rather than taken from `WRAPPER_SCRIPTS`, because the report covers **more**
|
|
1133
|
+
* than the wrapper set — `build` and `depInstall` are deliberately never wrapped
|
|
1134
|
+
* (`generators/scripts.ts`) and are still commands this run put into effect.
|
|
1135
|
+
*/
|
|
1136
|
+
const COMMAND_REPORT_ORDER = Object.freeze([
|
|
1137
|
+
'typecheck',
|
|
1138
|
+
'test',
|
|
1139
|
+
'devServer',
|
|
1140
|
+
'build',
|
|
1141
|
+
'depInstall',
|
|
1142
|
+
'deploy',
|
|
1143
|
+
]);
|
|
1144
|
+
/** The same key as a {@link WrapperKey}, or `undefined` for one of the two unwrapped keys. */
|
|
1145
|
+
function asWrapperKey(key) {
|
|
1146
|
+
return WRAPPER_SCRIPTS.find((script) => script.key === key)?.key;
|
|
1147
|
+
}
|
|
1148
|
+
/** What a wrapped row prints in place of a command line for {@link WrapperBody}'s `unresolved` body. */
|
|
1149
|
+
const RUNS_NO_COMMAND = 'runs no command; the wrapper reports what to fix';
|
|
1150
|
+
/**
|
|
1151
|
+
* The body the wrapper **on disk** carries, or `undefined` when this run is the one that writes it.
|
|
1152
|
+
*
|
|
1153
|
+
* The path is composed from the generator's own `scriptsDir` and `file`, never parsed back out of a
|
|
1154
|
+
* `commands.*` value — `configuredWrapperFile`'s standing prohibition (`generators/scripts.ts`).
|
|
1155
|
+
* This runs while the plan is still being built, so `existsSync` answers about the repository as this
|
|
1156
|
+
* run found it. `--force` upgrades `create-if-absent` to overwrite-after-`.bak` (`core/writer.ts`), so
|
|
1157
|
+
* a forced run writes its own body and the file on disk has nothing to say.
|
|
1158
|
+
*
|
|
1159
|
+
* A read that throws is answered `unrecognised`, the same state an adopter's unreadable edit reports:
|
|
1160
|
+
* both mean *this command cannot say what that file runs*, and neither is a licence to guess.
|
|
1161
|
+
*/
|
|
1162
|
+
function keptWrapperBody(options) {
|
|
1163
|
+
const path = join(options.repoRoot, options.scriptsDir, options.file);
|
|
1164
|
+
if (options.force || !existsSync(path))
|
|
1165
|
+
return undefined;
|
|
1166
|
+
try {
|
|
1167
|
+
return wrapperCommandLine(readFileSync(path, 'utf8'));
|
|
1168
|
+
}
|
|
1169
|
+
catch {
|
|
1170
|
+
return { kind: 'unrecognised' };
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* One wrapped key's row: what that wrapper will run, and where that line came from.
|
|
1175
|
+
*
|
|
1176
|
+
* **The kept line and the resolved line stay two facts rather than one.** Where they differ the row
|
|
1177
|
+
* leads with the kept line, because that is what executes, and names the resolved one after it — an
|
|
1178
|
+
* adopter who edited a wrapper to correct its command and re-ran `init` is exactly the reader who
|
|
1179
|
+
* needs to see both, and that difference is the state `--force` exists for.
|
|
1180
|
+
*/
|
|
1181
|
+
function wrappedRowLine(options) {
|
|
1182
|
+
const { keyPath, wrapper, kept, dryRun } = options;
|
|
1183
|
+
const head = `${keyPath} -> ${wrapper.invocation} ->`;
|
|
1184
|
+
const resolved = wrapper.unresolved ? RUNS_NO_COMMAND : wrapper.command;
|
|
1185
|
+
if (kept === undefined) {
|
|
1186
|
+
const writes = dryRun ? 'a real run would write it' : 'this run writes it';
|
|
1187
|
+
return `${head} ${resolved} — the line this run resolved for ${wrapper.file}; ${writes}`;
|
|
1188
|
+
}
|
|
1189
|
+
if (kept.kind === 'unrecognised') {
|
|
1190
|
+
return `${head} ${resolved} — the line this run resolved for ${wrapper.file}; the wrapper on disk could not be read, so what that file runs is not reported here`;
|
|
1191
|
+
}
|
|
1192
|
+
const runs = kept.kind === 'unresolved' ? RUNS_NO_COMMAND : kept.command;
|
|
1193
|
+
const difference = runs === resolved ? '' : `; this run resolved ${resolved}, and --force is what replaces the file with it`;
|
|
1194
|
+
return `${head} ${runs} — the line ${wrapper.file} already carries${difference}`;
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* The residue an answered key leaves behind, as a clause, or `''` when there is none.
|
|
1198
|
+
*
|
|
1199
|
+
* The answered row says what **this run** did; this says what an earlier one left. Wrappers are
|
|
1200
|
+
* `create-if-absent` and no generator deletes, so a repository that had a real command — whose `init`
|
|
1201
|
+
* wrote the wrapper and its allow entries — and later answers the key keeps that file. The clause is
|
|
1202
|
+
* printed only when the file is actually there, so the row never names a residue this repository does
|
|
1203
|
+
* not have.
|
|
1204
|
+
*
|
|
1205
|
+
* The permission half is split on `force` because that is the flag that regenerates the profile: an
|
|
1206
|
+
* unforced run leaves the entries naming the file exactly where they are, and a forced one is itself
|
|
1207
|
+
* the run that drops them. `existsSync` answers about the repository as this run found it, so a dry
|
|
1208
|
+
* run reports the same residue a real one would.
|
|
1209
|
+
*/
|
|
1210
|
+
function answeredNoneResidue(options) {
|
|
1211
|
+
const { repoRoot, scriptsDir, key, force, dryRun } = options;
|
|
1212
|
+
if (key === undefined)
|
|
1213
|
+
return '';
|
|
1214
|
+
const file = WRAPPER_SCRIPTS.find((script) => script.key === key)?.file;
|
|
1215
|
+
if (file === undefined || !existsSync(join(repoRoot, scriptsDir, file)))
|
|
1216
|
+
return '';
|
|
1217
|
+
const entries = force
|
|
1218
|
+
? `this run ${dryRun ? 'would regenerate' : 'regenerated'} the profile without its permission entries`
|
|
1219
|
+
: 'its permission entries are still in the profile, and init --force is what clears them';
|
|
1220
|
+
return `; ${wrapperPath(scriptsDir, file)} an earlier run wrote is still on disk — nothing here deletes it — and ${entries}`;
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Every command line this run put in effect, one line per key — what the `commands` step prints,
|
|
1224
|
+
* identically in a real run and under `--dry-run`, save for the write provenance a dry run states in
|
|
1225
|
+
* the conditional.
|
|
1226
|
+
*
|
|
1227
|
+
* **A wrapped row's third field is the line that will *run*, which is not always the line this run
|
|
1228
|
+
* resolved.** Wrappers are `create-if-absent` (`generators/scripts.ts`), so over a wrapper already on
|
|
1229
|
+
* disk the body resolved here is discarded and the kept file keeps running its own line —
|
|
1230
|
+
* {@link WrittenWrapper.command} carries that caveat, and {@link keptWrapperBody} is what reads the
|
|
1231
|
+
* file so the row does not inherit it. Each row therefore says which of the two it printed.
|
|
1232
|
+
*
|
|
1233
|
+
* Composed from the config **in effect** plus the generator's own answer, never from detection: on a
|
|
1234
|
+
* kept re-run the adopter's file is what every later generator read, and it is what an unattended run
|
|
1235
|
+
* will execute.
|
|
1236
|
+
*/
|
|
1237
|
+
function commandReportLines(options) {
|
|
1238
|
+
const { repoRoot, config, wrappers, force, dryRun } = options;
|
|
1239
|
+
const lines = [];
|
|
1240
|
+
for (const key of COMMAND_REPORT_ORDER) {
|
|
1241
|
+
const wrapperKey = asWrapperKey(key);
|
|
1242
|
+
const keyPath = wrapperKey === undefined ? `commands.${key}` : configKeyPath(wrapperKey);
|
|
1243
|
+
const configured = configuredCommand(config, key);
|
|
1244
|
+
const wrapper = wrappers.written.find((entry) => entry.key === key);
|
|
1245
|
+
// Both of the next two arms are read before the wrapper lookup, for the same reason by two
|
|
1246
|
+
// different routes: `selectWrapper` writes no file for either value, so there is no line to
|
|
1247
|
+
// print behind it.
|
|
1248
|
+
// The placeholder first, because it is the one value that is neither a command nor a wrapper
|
|
1249
|
+
// invocation: `selectWrapper` writes no file for it, so there is no line to print behind it.
|
|
1250
|
+
if (configured !== undefined && isPlaceholder(configured)) {
|
|
1251
|
+
lines.push(`${keyPath} -> ${configured} — the placeholder init writes for a command it could not detect${wrapperKey === undefined ? '' : ', so no wrapper was written for it'}; nothing runs until it is set`);
|
|
1252
|
+
continue;
|
|
1253
|
+
}
|
|
1254
|
+
// The sentinel next, gated on the key and not on the value's shape: `answersNone` answers for
|
|
1255
|
+
// `typecheck` alone, so none of the other five keys prints this row for a line this run did in
|
|
1256
|
+
// fact wrap and allow-list. `init` never writes the sentinel, so this row is reachable only on
|
|
1257
|
+
// a re-run over a config an adopter has already answered.
|
|
1258
|
+
if (answersNone(key, configured)) {
|
|
1259
|
+
lines.push(`${keyPath} -> ${configured} — this repository states it has no such command${wrapperKey === undefined ? '' : ', so no wrapper was written for it'}; this run allow-listed none, and nothing runs for this key${answeredNoneResidue({
|
|
1260
|
+
repoRoot,
|
|
1261
|
+
scriptsDir: wrappers.scriptsDir,
|
|
1262
|
+
key: wrapperKey,
|
|
1263
|
+
force,
|
|
1264
|
+
dryRun,
|
|
1265
|
+
})}`);
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
if (wrapper !== undefined) {
|
|
1269
|
+
const kept = keptWrapperBody({ repoRoot, scriptsDir: wrappers.scriptsDir, file: wrapper.file, force });
|
|
1270
|
+
if (wrapper.unresolved || configured === wrapper.invocation || configured === undefined) {
|
|
1271
|
+
lines.push(wrappedRowLine({ keyPath, wrapper, kept, dryRun }));
|
|
1272
|
+
}
|
|
1273
|
+
else {
|
|
1274
|
+
// Precedence 1 of `resolveBody`: the key holds a raw line, and this run inlines it into the
|
|
1275
|
+
// wrapper the permission profile allow-lists — but only where that file is not already there,
|
|
1276
|
+
// which is `create-if-absent`'s whole contract.
|
|
1277
|
+
const inlined = kept === undefined
|
|
1278
|
+
? `, which this run ${dryRun ? 'would inline' : 'inlined'} into ${wrapper.file}: the wrapper is the form the permission profile allow-lists`
|
|
1279
|
+
: kept.kind === 'unrecognised'
|
|
1280
|
+
? `; ${wrapper.file} is already on disk and could not be read, so what that file runs is not reported here`
|
|
1281
|
+
: `; ${wrapper.file} is already on disk and carries ${kept.kind === 'unresolved' ? RUNS_NO_COMMAND : kept.command}, and --force is what replaces the file with this line`;
|
|
1282
|
+
lines.push(`${keyPath} -> ${configured} — a raw command line${inlined}`);
|
|
1283
|
+
}
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
// No wrapper: either one of the two keys that are never wrapped, or an optional wrapped key this
|
|
1287
|
+
// repository set no line for — which prints nothing at all.
|
|
1288
|
+
if (configured === undefined)
|
|
1289
|
+
continue;
|
|
1290
|
+
lines.push(`${keyPath} -> ${configured} — run as configured; this key is not wrapped and is not allow-listed`);
|
|
1291
|
+
}
|
|
1292
|
+
return lines;
|
|
1293
|
+
}
|
|
1294
|
+
/** True when `path` names a directory that is there — the second half of the rung-2 screen. */
|
|
1295
|
+
function isDirectory(path) {
|
|
1296
|
+
return statSync(path, { throwIfNoEntry: false })?.isDirectory() === true;
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* The `appDir` **stack detection probes**, in one settled order:
|
|
1300
|
+
*
|
|
1301
|
+
* 1. {@link APP_DIR_FLAG}, this command line's own statement;
|
|
1302
|
+
* 2. {@link CONFIG_FILENAME}'s `appDir` — the tree the repository already declares;
|
|
1303
|
+
* 3. `.`, the repository as its own application.
|
|
1304
|
+
*
|
|
1305
|
+
* Rung 2 is what this function exists for. The sole `detectPreset` call read the flag or `.` and
|
|
1306
|
+
* never the file, which both JSDoc blocks on the receiving end already documented it as reading
|
|
1307
|
+
* (`detect/signals.ts`, `DetectContext`'s constructor and `detectPreset`). Without it a plain re-run on a
|
|
1308
|
+
* nested-app repository detects the *root's* layout and reports it beside layers read from the kept
|
|
1309
|
+
* config, and `init --reset-config` — the path this CLI prescribes for rebuilding a configuration —
|
|
1310
|
+
* re-derives every layer scope under `.`, forgetting an application directory that was `init`'s own
|
|
1311
|
+
* output, with no line of output naming what was lost.
|
|
1312
|
+
*
|
|
1313
|
+
* **The two bad-value arms are deliberately asymmetric, and that asymmetry is the point.** A
|
|
1314
|
+
* flag-sourced value outside the repository keeps `DetectContext`'s hard refusal — it is a typo
|
|
1315
|
+
* or a mis-scoped invocation, and this function does not screen it. Anything **config**-sourced —
|
|
1316
|
+
* a value that cannot be used, and equally a file that cannot be read at all — falls back to `.`
|
|
1317
|
+
* with a warning instead: `--reset-config` is the documented route to repair a broken configuration,
|
|
1318
|
+
* so a refusal sourced from the very file being rebuilt would make a repository with a bad `appDir`
|
|
1319
|
+
* unrepairable by the one command documented to repair it. Silence is the arm that is not available
|
|
1320
|
+
* on any of them: `.` re-derives every layer scope under the repository root.
|
|
1321
|
+
*
|
|
1322
|
+
* Modelled on `resolveDefaultBranch` (`generators/harnessConfig.ts`), the shipped precedent for a
|
|
1323
|
+
* per-rung resolver that reports **which rung answered** rather than only the value.
|
|
1324
|
+
*/
|
|
1325
|
+
function resolveDetectionAppDir(repoRoot, flags) {
|
|
1326
|
+
// Rung 1, and silent: the flag is on the adopter's own command line, so a note restating it tells
|
|
1327
|
+
// them nothing they cannot already see.
|
|
1328
|
+
if (flags.appDir !== undefined)
|
|
1329
|
+
return { appDir: flags.appDir, source: 'flag' };
|
|
1330
|
+
// Rung 2. `loadConfig` never throws — an absent or malformed file is reported as a problem and
|
|
1331
|
+
// answers `config: undefined` (`config/io.ts`). The type check is not redundant with that: the
|
|
1332
|
+
// returned type is an assertion about the parsed value rather than a guarantee.
|
|
1333
|
+
const loaded = loadConfig(repoRoot);
|
|
1334
|
+
const configured = loaded.config?.appDir;
|
|
1335
|
+
if (typeof configured !== 'string' || configured === '') {
|
|
1336
|
+
// A file that is there but did not parse is not the same answer as no file at all, and the two
|
|
1337
|
+
// are told apart here because `--reset-config` — the documented repair for exactly that file —
|
|
1338
|
+
// is the run that reaches this branch: a silent `.` re-derives every layer scope under the root
|
|
1339
|
+
// while the rebuild note says the application directory survived. Third arm of the same screen
|
|
1340
|
+
// the two bad-value rejections below carry, and it warns for the same reason they do.
|
|
1341
|
+
if (loaded.config === undefined && configExists(repoRoot)) {
|
|
1342
|
+
return {
|
|
1343
|
+
appDir: '.',
|
|
1344
|
+
source: 'default',
|
|
1345
|
+
warning: `${CONFIG_FILENAME} is present at ${loaded.path} but could not be read, so no configured appDir answered and stack detection probed the repository root instead: ${loaded.problems.map(formatProblem).join('; ')}. Every layer scope this run derives is under the repository root: re-run with ${APP_DIR_FLAG} <dir> to name the application directory this repository actually has`,
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
return { appDir: '.', source: 'default' };
|
|
1349
|
+
}
|
|
1350
|
+
const absolute = resolve(repoRoot, configured);
|
|
1351
|
+
const rejection = !insideRepo(repoRoot, absolute)
|
|
1352
|
+
? 'resolves outside the repository'
|
|
1353
|
+
: isDirectory(absolute)
|
|
1354
|
+
? undefined
|
|
1355
|
+
: 'names no directory that is there';
|
|
1356
|
+
if (rejection !== undefined) {
|
|
1357
|
+
// `source` is `'default'` rather than `'config'`: the file was consulted, and `.` is what
|
|
1358
|
+
// answered.
|
|
1359
|
+
return {
|
|
1360
|
+
appDir: '.',
|
|
1361
|
+
source: 'default',
|
|
1362
|
+
warning: `${CONFIG_FILENAME} sets appDir to ${JSON.stringify(configured)}, which ${rejection}, so stack detection probed the repository root instead — refusing here would leave a repository with a bad appDir unrepairable by \`${INIT_VERB} --reset-config\`, which is the documented way to rebuild that file. Every layer scope this run derives is under the repository root: re-run with ${APP_DIR_FLAG} <dir> to name the application directory this repository actually has`,
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
return {
|
|
1366
|
+
appDir: configured,
|
|
1367
|
+
source: 'config',
|
|
1368
|
+
// Worded to stay true of the `.` an already-wired flat repository carries: it says which rung
|
|
1369
|
+
// the value came from, not that the value differs from the root.
|
|
1370
|
+
note: `stack detection probed ${JSON.stringify(configured)} — the appDir ${CONFIG_FILENAME} already declares — rather than a directory given on this command line; ${APP_DIR_FLAG} <dir> overrides it, and \`${APP_DIR_FLAG} .\` points detection at the repository root`,
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
/**
|
|
1374
|
+
* The directory this run was **invoked in**, repo-relative — and `undefined` when that is the
|
|
1375
|
+
* repository root itself, which is the ordinary case and has nothing to report.
|
|
1376
|
+
*
|
|
1377
|
+
* `realpathSync` rather than `ctx.cwd` as it was spelled, for the reason
|
|
1378
|
+
* {@link resolveTargetRepository}'s `--dry-run` arm resolves the same value: git answers with the
|
|
1379
|
+
* physical path, and a caller's directory may reach it through a symlink (`os.tmpdir()` and `/var` on
|
|
1380
|
+
* macOS are two). A lexical comparison against an unresolved `cwd` would call an ordinary root-level
|
|
1381
|
+
* run nested and print the lines below on every one of them.
|
|
1382
|
+
*
|
|
1383
|
+
* Outside the root is `undefined` too, and unreachable rather than handled: the root came from a
|
|
1384
|
+
* probe of this very directory ({@link resolveTargetRepository}), so a `cwd` outside it is a state no
|
|
1385
|
+
* invocation produces.
|
|
1386
|
+
*/
|
|
1387
|
+
function invocationBelowRoot(ctx, repoRoot) {
|
|
1388
|
+
const invokedIn = realpathSync(ctx.cwd);
|
|
1389
|
+
if (!insideRepo(repoRoot, invokedIn))
|
|
1390
|
+
return undefined;
|
|
1391
|
+
const rel = normalizeRepoDir(relative(repoRoot, invokedIn));
|
|
1392
|
+
return rel === '.' ? undefined : rel;
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* The source directories the config **in effect** covers with no layer, as one note — or `undefined`
|
|
1396
|
+
* where there are none to name.
|
|
1397
|
+
*
|
|
1398
|
+
* **The set and the remedy are both somebody else's**: `core/layerCoverage.ts` derives the
|
|
1399
|
+
* directories, `core/nameList.ts` renders them and `core/layerGapRemedy.ts` composes what to do
|
|
1400
|
+
* about them, all three shared with `doctor`'s `layer-drift` check. This function decides only that
|
|
1401
|
+
* `init` says it, and the two commands are then one statement rather than two findings — which is
|
|
1402
|
+
* the whole point: before this line, a gap in a detected profile appeared in `doctor` alone, so an
|
|
1403
|
+
* adopter who read `init` and never ran `doctor` learned nothing about it.
|
|
1404
|
+
*
|
|
1405
|
+
* **A note, never a warning.** A freshly detected repository routing one directory to the catch-all
|
|
1406
|
+
* is an ordinary adoption outcome, and this command's warnings are reserved for what needs fixing.
|
|
1407
|
+
*
|
|
1408
|
+
* **Nothing is said where `layerCoverage` does not grade**: that state is the catch-all-only profile,
|
|
1409
|
+
* which `buildPreset` already warns about and `LAYER_PROFILE_CHECK` reports as itself, and a second
|
|
1410
|
+
* line here would report it twice.
|
|
1411
|
+
*/
|
|
1412
|
+
function layerGapNote(repoRoot, config) {
|
|
1413
|
+
const { graded, appDir, candidates, uncovered } = layerCoverage({ repoRoot, config });
|
|
1414
|
+
if (!graded || uncovered.length === 0)
|
|
1415
|
+
return undefined;
|
|
1416
|
+
const one = uncovered.length === 1;
|
|
1417
|
+
const clause = recordedVerdictClause(config.detection?.review);
|
|
1418
|
+
const remedy = layerGapRemedy({ review: config.detection?.review, analyzeCommand: ANALYZE_COMMAND, cli: CLI });
|
|
1419
|
+
return (`${uncovered.length} of the ${candidates.length} source directories under \`${appDir}\` ${one ? 'is' : 'are'} covered by no layer and ${one ? 'routes' : 'route'} to the catch-all: ${nameList(uncovered)} — an implementer and a reviewer working there are handed the shared cross-layer document rather than that layer's own rules.` +
|
|
1420
|
+
`${clause === undefined ? '' : ` ${clause}.`} ${remedy}. \`${DOCTOR_COMMAND}\`'s \`layer-drift\` check reports this same set, so the two commands are one statement rather than two findings`);
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* Wire one repository: detect, build the whole write plan bottom-up, apply it once, report what
|
|
1424
|
+
* happened, and end by pointing at the analyze command.
|
|
1425
|
+
*
|
|
1426
|
+
* The generator order below is load-bearing and is stated in one place, here: the config first
|
|
1427
|
+
* because everything after it reads the config **in effect**; the wrapper scripts and then the
|
|
1428
|
+
* outer-loop scripts, because the profile allow-lists their literal paths and both land in the
|
|
1429
|
+
* configured `scriptsDir`; the state tree and the conventions stubs; the profile;
|
|
1430
|
+
* the committable project settings; the repository-root files; the account's own push-notification
|
|
1431
|
+
* settings, which are the one target outside the repository and are ordered here only because their
|
|
1432
|
+
* question is the last one the run puts; and the git hook last, with
|
|
1433
|
+
* `core.hooksPath` pointed at it only **after** the plan has been applied and the hook it enables
|
|
1434
|
+
* therefore exists — and after that, in a repository that has no commit yet, the first commit
|
|
1435
|
+
* ({@link commitGeneratedFiles}).
|
|
1436
|
+
*/
|
|
1437
|
+
async function run(ctx) {
|
|
1438
|
+
const flags = parseInitFlags(ctx.argv);
|
|
1439
|
+
// Settled from the flags alone, and therefore ahead of the git gate: `resolveTargetRepository` may
|
|
1440
|
+
// create a repository, and a refusal raised after it would leave one behind in a directory this run
|
|
1441
|
+
// then declined to wire — the harm {@link parseQaDriver} is checked inside the parser to avoid.
|
|
1442
|
+
assertUsableStateDir(flags.stateDir);
|
|
1443
|
+
const forcedPreset = flags.preset === undefined ? undefined : parsePresetName(flags.preset);
|
|
1444
|
+
// Every precondition that needs the repository is settled here, before a generator is called and
|
|
1445
|
+
// therefore before anything is enqueued: a plan that is never built cannot be half-applied. The
|
|
1446
|
+
// first is settled by an answer — the flag, a prompt, or the non-interactive default — the second
|
|
1447
|
+
// by a check against the root that answer resolved.
|
|
1448
|
+
const target = resolveTargetRepository(ctx, flags);
|
|
1449
|
+
const repoRoot = target.root;
|
|
1450
|
+
assertNotHarnessOwnRepository(repoRoot);
|
|
1451
|
+
// The one fact the rest of this run is relative to, named before any of it: every path below is
|
|
1452
|
+
// under this root, and until this line an adopter standing in a subdirectory had ninety lines of
|
|
1453
|
+
// output and nothing saying where they landed. A **step** rather than a note, and the same shape
|
|
1454
|
+
// `doctor` opens with (`commands/doctor.ts`'s `checks (<root>)`), so the two commands name a
|
|
1455
|
+
// repository the same way — a note would print at the end, after the reader has already read the
|
|
1456
|
+
// run relative to an unnamed root. `--quiet` suppresses it with the rest of the narration, which is
|
|
1457
|
+
// correct: a quiet run has no reader to orient.
|
|
1458
|
+
ctx.report.step(`wiring (${repoRoot})`);
|
|
1459
|
+
// Settled before anything is reported, and after the refusals above so nothing is enqueued if this
|
|
1460
|
+
// one refuses too — a flag-sourced value outside the repository still throws out of
|
|
1461
|
+
// `DetectContext` ({@link resolveDetectionAppDir}). The whole answer stays in scope rather than
|
|
1462
|
+
// only its value: `appDir.source` is what a later reader gates on, and it is the only carrier of
|
|
1463
|
+
// which rung produced the directory detection probed.
|
|
1464
|
+
const appDir = resolveDetectionAppDir(repoRoot, flags);
|
|
1465
|
+
const detection = detectPreset(repoRoot, appDir.appDir, forcedPreset);
|
|
1466
|
+
const profile = buildPreset(detection);
|
|
1467
|
+
// Collected rather than printed as they arrive, so the run reports its actions first and its
|
|
1468
|
+
// caveats after them — a warning raised by the first generator is otherwise scrolled off by the
|
|
1469
|
+
// hundred action lines that follow it.
|
|
1470
|
+
const warnings = [];
|
|
1471
|
+
// `target.notes` leads: it is a repository fact, true whichever config this run ends up with, and
|
|
1472
|
+
// it stays unconditional.
|
|
1473
|
+
const notes = [...target.notes];
|
|
1474
|
+
// The four detection lists, held back rather than seeded: every line in them describes the config
|
|
1475
|
+
// this run **generated**, which `writeHarnessConfig` discards on a kept re-run — a placeholder
|
|
1476
|
+
// warning naming a key the kept file resolved, a note naming the manifest a `commands.*` value the
|
|
1477
|
+
// kept file does not hold was derived from. Warnings and notes move behind one `config.kept` gate
|
|
1478
|
+
// below for that one reason, and neither may be pinned here as unconditional.
|
|
1479
|
+
const generatedConfigWarnings = [...detection.warnings, ...profile.warnings];
|
|
1480
|
+
const generatedConfigNotes = [...detection.notes, ...profile.notes];
|
|
1481
|
+
// The resolver's own two lines are statements about **this invocation** — which rung supplied the
|
|
1482
|
+
// directory detection probed, and what a configured value that could not be used cost — so they are
|
|
1483
|
+
// unconditional, like `target.notes` and unlike the four lists above them: both stay true of a
|
|
1484
|
+
// re-run that keeps the very file rung 2 read. Same rule as the sink split in
|
|
1485
|
+
// `generators/harnessConfig.ts`'s `buildConfig`, one level up.
|
|
1486
|
+
if (appDir.warning !== undefined)
|
|
1487
|
+
warnings.push(appDir.warning);
|
|
1488
|
+
if (appDir.note !== undefined)
|
|
1489
|
+
notes.push(appDir.note);
|
|
1490
|
+
// Two more statements about **this invocation**, and unconditional for the same reason those two
|
|
1491
|
+
// are. Both are gated on `appDir.source` — the field, never the absence of the two message
|
|
1492
|
+
// fields ({@link DetectionAppDir}) — so an adopter who has already answered the application-directory
|
|
1493
|
+
// question, on this command line or in the file, is not told to answer it again.
|
|
1494
|
+
//
|
|
1495
|
+
// The first is a **note**: wiring the root is what `init` is supposed to do from anywhere inside the
|
|
1496
|
+
// repository, and a warning that fires on every correct run is how a warning list stops being read
|
|
1497
|
+
// (`generators/harnessConfig.ts`). The second is a warning, and only where detection recognised
|
|
1498
|
+
// nothing — in a repository that nests its application that is the one case where the silent `.`
|
|
1499
|
+
// is the likeliest *cause* rather than an incidental fact. It is pushed as a second line rather than
|
|
1500
|
+
// folded into `FLAT_FALLBACK_WARNING`, which is detection's and knows nothing about the directory
|
|
1501
|
+
// this command was invoked in (`detect/signals.ts`).
|
|
1502
|
+
const invokedIn = invocationBelowRoot(ctx, repoRoot);
|
|
1503
|
+
if (invokedIn !== undefined && appDir.source === 'default') {
|
|
1504
|
+
notes.push(`init was run in ${invokedIn} and wired the repository at ${repoRoot}, which is where every file it writes lands; appDir is ${JSON.stringify(appDir.appDir)}, because no application directory this run could use was named. If ${invokedIn} is the application, re-run with ${APP_DIR_FLAG} ${invokedIn}`);
|
|
1505
|
+
if (detection.matchedSignal === FLAT_FALLBACK_SIGNAL_ID) {
|
|
1506
|
+
warnings.push(`no detection signal matched, so the \`flat\` preset caught this run — and stack detection probed the repository root while init was run in ${invokedIn}. Where a repository nests its application, an application that was never named is the likeliest reason nothing is recognised: re-run with ${APP_DIR_FLAG} ${invokedIn} to detect that tree instead, before reaching for ${ANALYZE_COMMAND}`);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
// The third statement about **this invocation**, and the one that reaches the repository the
|
|
1510
|
+
// finding is actually about: an application that sits entirely in one subdirectory, adopted from
|
|
1511
|
+
// the root with a `flat` fallback and a placeholder for both required commands, where nothing in
|
|
1512
|
+
// the output named the subdirectory or the flag that would detect it.
|
|
1513
|
+
//
|
|
1514
|
+
// Gated on the same `appDir.source === 'default'` the two lines above are — an adopter who has
|
|
1515
|
+
// already named an application directory is not asked again — plus the fallback, because a
|
|
1516
|
+
// recognised layout is not a repository that failed to see its application. The third condition is
|
|
1517
|
+
// the block above: where the run was started **at or below** the directory this probe would name,
|
|
1518
|
+
// that block already points into that tree, and two `--app-dir` remedies for one condition is worse
|
|
1519
|
+
// than the duplication this condition exists to prevent.
|
|
1520
|
+
//
|
|
1521
|
+
// A **note**, never a warning: a repository is not at fault for nesting its application, and this
|
|
1522
|
+
// run is otherwise correct. Printed on `--dry-run` as on a real run, like the two lines above it,
|
|
1523
|
+
// because it is a statement about what this invocation detected rather than about what it wrote.
|
|
1524
|
+
if (detection.matchedSignal === FLAT_FALLBACK_SIGNAL_ID && appDir.source === 'default') {
|
|
1525
|
+
const nested = findNestedApplicationDir(repoRoot);
|
|
1526
|
+
// `findNestedApplicationDir` only ever names a direct child of the root, so the equality leg
|
|
1527
|
+
// covers every directory it can return; the `startsWith` leg is for the *invocation* side — a
|
|
1528
|
+
// run started deeper than that child (`service/api` under a probed `service`).
|
|
1529
|
+
const startedInside = invokedIn !== undefined &&
|
|
1530
|
+
nested !== undefined &&
|
|
1531
|
+
(invokedIn === nested.dir || invokedIn.startsWith(`${nested.dir}/`));
|
|
1532
|
+
if (nested !== undefined && !startedInside) {
|
|
1533
|
+
notes.push(`no detection signal matched at the repository root, and \`${nested.dir}\` holds \`${nested.manifest}\` — a manifest init reads — so the application may be that directory rather than the repository. Nothing here adopts it: which directory is the application is your statement, not a detection result. Re-run with \`${APP_DIR_FLAG} ${nested.dir}\` to point detection at that tree instead`);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
// Both halves of detection, in one step: the layer preset, then what answered for the commands.
|
|
1537
|
+
// The second line is a statement about what **this invocation** detected rather than about what it
|
|
1538
|
+
// wrote, so it is printed on `--dry-run` exactly as on a real run — and `--quiet` suppresses it with
|
|
1539
|
+
// the rest of the narration, which needs no code here (`core/report.ts`).
|
|
1540
|
+
//
|
|
1541
|
+
// `writeHarnessConfig` computes the same fact as `kept` (`generators/harnessConfig.ts`), and it is
|
|
1542
|
+
// derived a second time here rather than read off `config` because this step prints **before** that
|
|
1543
|
+
// generator runs. Moving the line below the generator would separate it from its own step heading.
|
|
1544
|
+
// It governs both of the second line's claims: what the two command keys were resolved *as*, and
|
|
1545
|
+
// whether the tiebreak note it points at is published at all.
|
|
1546
|
+
const configKept = !flags.resetConfig && configExists(repoRoot);
|
|
1547
|
+
ctx.report.step('stack detection');
|
|
1548
|
+
ctx.report.info(detectionLine(detection));
|
|
1549
|
+
ctx.report.info(commandSourceLine(profile, detection, { kept: configKept }));
|
|
1550
|
+
const plan = new WritePlan();
|
|
1551
|
+
const config = writeHarnessConfig({
|
|
1552
|
+
repoRoot,
|
|
1553
|
+
detection,
|
|
1554
|
+
preset: profile,
|
|
1555
|
+
flags,
|
|
1556
|
+
plan,
|
|
1557
|
+
// No `force:` — the run's `--force` is not an input to this generator (`harnessConfig.ts`).
|
|
1558
|
+
// `plan.apply` below still passes it, so it governs every other create-if-absent artifact.
|
|
1559
|
+
resetConfig: flags.resetConfig,
|
|
1560
|
+
// Wording only — the decision above is mode-independent (`harnessConfig.ts`).
|
|
1561
|
+
dryRun: ctx.flags.dryRun,
|
|
1562
|
+
// Wording only too, and the rung rather than the value: the start-over note may claim `appDir`
|
|
1563
|
+
// was re-read from the file being rebuilt only where rung 2 answered.
|
|
1564
|
+
appDirSource: appDir.source,
|
|
1565
|
+
// Lazy on purpose: the generator calls it only while the interactive test phase is on and only
|
|
1566
|
+
// when `--qa-driver` did not already answer, so no run that did not need the question is asked
|
|
1567
|
+
// it. It is put here, after the detection line has been printed, because the question
|
|
1568
|
+
// back-references it ({@link askQaDriver}) — "the detected preset" names nothing an adopter who
|
|
1569
|
+
// has not read that line can resolve.
|
|
1570
|
+
askDriver: () => askQaDriver(ctx),
|
|
1571
|
+
});
|
|
1572
|
+
// `kept` is known only now, so this is where the four held-back lists are published or dropped —
|
|
1573
|
+
// ahead of `config.warnings`, which is the order they printed in before they were gated. A kept
|
|
1574
|
+
// re-run gets one note in their place instead of silence, and the kept file's own warnings come
|
|
1575
|
+
// from `writeHarnessConfig`, which reports them from the config check.
|
|
1576
|
+
//
|
|
1577
|
+
// This note is about **this run's detection**: the preset and command lines it produced went
|
|
1578
|
+
// unwritten. What the kept file puts in force is `writeHarnessConfig`'s own replacement note to
|
|
1579
|
+
// say, in the same block, and the two must stay distinct rather than converge on one sentence.
|
|
1580
|
+
//
|
|
1581
|
+
// The warning beside it is about **this command line**: which flags of it were dropped. It does
|
|
1582
|
+
// not repeat the note — one is about a detection nobody asked for, the other about values somebody
|
|
1583
|
+
// typed — and it is a warning rather than a third note for the reason its own header gives.
|
|
1584
|
+
if (config.kept) {
|
|
1585
|
+
notes.push(`stack detection ran to answer what ${CONFIG_FILENAME} already answers, so the preset and command lines it detected were not written and the values in that file are the ones in effect; re-run with --reset-config to rebuild the file from this run's detection`);
|
|
1586
|
+
const discarded = discardedConfigFlagsWarning(flags);
|
|
1587
|
+
if (discarded !== undefined)
|
|
1588
|
+
warnings.push(discarded);
|
|
1589
|
+
}
|
|
1590
|
+
else {
|
|
1591
|
+
warnings.push(...generatedConfigWarnings);
|
|
1592
|
+
notes.push(...generatedConfigNotes);
|
|
1593
|
+
}
|
|
1594
|
+
warnings.push(...config.warnings);
|
|
1595
|
+
notes.push(...config.notes);
|
|
1596
|
+
// The config **in effect** from here on: freshly built, or the adopter's own when a re-run kept
|
|
1597
|
+
// the file that was already there.
|
|
1598
|
+
const effective = config.config;
|
|
1599
|
+
// Unconditional on `config.kept`, unlike the four detection lists above: this is derived from the
|
|
1600
|
+
// config **in effect**, so it stays true of a re-run that kept the adopter's own file — and that
|
|
1601
|
+
// population is the one that can carry a recorded review, which the note's remedy is fitted to.
|
|
1602
|
+
// Printed on `--dry-run` as on a real run, because it describes what this invocation detected.
|
|
1603
|
+
const layerGap = layerGapNote(repoRoot, effective);
|
|
1604
|
+
if (layerGap !== undefined)
|
|
1605
|
+
notes.push(layerGap);
|
|
1606
|
+
const wrappers = writeWrapperScripts({ repoRoot, config: effective, rawCommands: profile.rawCommands, plan });
|
|
1607
|
+
warnings.push(...wrappers.warnings);
|
|
1608
|
+
// What this run put in effect, before the file log rather than after it, so the reader has the
|
|
1609
|
+
// command lines in hand while reading which wrappers were written. Narration — `step` and `info`,
|
|
1610
|
+
// suppressed by `--quiet` like the rest of the action log (`core/report.ts`) — and no line of it
|
|
1611
|
+
// reaches `notes` or `warnings`, which stay the caveat channels. It is composed while the plan is
|
|
1612
|
+
// being built and writes nothing, so `--dry-run` prints the same rows over the same repository
|
|
1613
|
+
// state; the one thing it says differently is the write provenance, in the conditional.
|
|
1614
|
+
const commandLines = commandReportLines({
|
|
1615
|
+
repoRoot,
|
|
1616
|
+
config: effective,
|
|
1617
|
+
wrappers,
|
|
1618
|
+
force: ctx.flags.force,
|
|
1619
|
+
dryRun: ctx.flags.dryRun,
|
|
1620
|
+
});
|
|
1621
|
+
if (commandLines.length > 0) {
|
|
1622
|
+
ctx.report.step('commands');
|
|
1623
|
+
for (const line of commandLines)
|
|
1624
|
+
ctx.report.info(line);
|
|
1625
|
+
}
|
|
1626
|
+
// Immediately after the wrappers, into the same directory: the outer-loop scripts and the library
|
|
1627
|
+
// they source. Unlike the wrappers these are **not** configurable command lines — they carry no
|
|
1628
|
+
// token at all and read `harness.config.json` at run time — so a re-run leaves an adopter's edited
|
|
1629
|
+
// script byte-identical and `--force` regenerates it only after a `.bak`. They are written before
|
|
1630
|
+
// the permission profile for the same reason the wrappers are: the profile allow-lists the literal
|
|
1631
|
+
// paths of the agent-invocable ones, and a profile written first would name files that do not
|
|
1632
|
+
// exist (`generators/outerLoopScripts.ts`).
|
|
1633
|
+
const outerLoop = writeOuterLoopScripts({ repoRoot, config: effective, plan });
|
|
1634
|
+
notes.push(...outerLoop.notes);
|
|
1635
|
+
const state = writeStateDir({ repoRoot, config: effective, plan });
|
|
1636
|
+
notes.push(...state.notes);
|
|
1637
|
+
// The third of the four questions this run puts, and settled here because its answer is an
|
|
1638
|
+
// *input* to the generator below: that generator enqueues the banner while the plan is being
|
|
1639
|
+
// built, so a resolution taken afterwards would be reading a decision it was supposed to make.
|
|
1640
|
+
const analyze = resolveAnalyzeOffer(ctx, flags, repoRoot, effective);
|
|
1641
|
+
notes.push(...analyze.notes);
|
|
1642
|
+
const context = writeClaudeContext({
|
|
1643
|
+
repoRoot,
|
|
1644
|
+
config: effective,
|
|
1645
|
+
plan,
|
|
1646
|
+
// The offer's whole material effect on what is written: which of the two banner wordings the
|
|
1647
|
+
// generated always-loaded file carries, each ending in its own disposal sentence.
|
|
1648
|
+
analyzeOffer: analyze.offer,
|
|
1649
|
+
// The run's `--force`, because it is half of what decides whether the setup-pending banner
|
|
1650
|
+
// reached an always-loaded project file that was already there: an unforced run keeps that file
|
|
1651
|
+
// and the generator notes that no banner was written into it, a forced one regenerates it from
|
|
1652
|
+
// the template after a `.bak`.
|
|
1653
|
+
force: ctx.flags.force,
|
|
1654
|
+
// The run's `--dry-run`, which changes that note's tense and nothing else, the same contract
|
|
1655
|
+
// `analyzeRecordSentence` keeps for the closing pointer: both sentences print on one run, so a
|
|
1656
|
+
// dry run that reports a keep it did not make contradicts the line under it.
|
|
1657
|
+
dryRun: ctx.flags.dryRun,
|
|
1658
|
+
});
|
|
1659
|
+
warnings.push(...context.warnings);
|
|
1660
|
+
notes.push(...context.notes);
|
|
1661
|
+
const permissions = writePermissionProfile({
|
|
1662
|
+
repoRoot,
|
|
1663
|
+
config: effective,
|
|
1664
|
+
plan,
|
|
1665
|
+
// Authoritative: the profile may allow-list only what these two generators actually wrote — the
|
|
1666
|
+
// wrappers in full, and the outer-loop rows their own `agentInvocable` flag says an agent runs.
|
|
1667
|
+
written: wrappers.written,
|
|
1668
|
+
writtenOuterLoop: outerLoop.written,
|
|
1669
|
+
...(flags.referenceToolchainPath === undefined
|
|
1670
|
+
? {}
|
|
1671
|
+
: { referenceToolchainPath: flags.referenceToolchainPath }),
|
|
1672
|
+
// The run's `--force`, because it is the only run that replaces the profile: the generator reads
|
|
1673
|
+
// the file it is about to overwrite and carries forward the plugin-root entries `doctor` told the
|
|
1674
|
+
// adopter to paste in by hand, which nothing here can regenerate.
|
|
1675
|
+
force: ctx.flags.force,
|
|
1676
|
+
// The run's `--dry-run`, which changes that report's tense and nothing else — the same contract
|
|
1677
|
+
// the project-file generator above keeps.
|
|
1678
|
+
dryRun: ctx.flags.dryRun,
|
|
1679
|
+
});
|
|
1680
|
+
warnings.push(...permissions.warnings);
|
|
1681
|
+
notes.push(...permissions.notes);
|
|
1682
|
+
const settings = writeProjectSettings({ repoRoot, plan, flags });
|
|
1683
|
+
warnings.push(...settings.warnings);
|
|
1684
|
+
notes.push(...settings.notes);
|
|
1685
|
+
const repoFiles = writeRepoRootFiles({ repoRoot, config: effective, plan });
|
|
1686
|
+
notes.push(...repoFiles.notes);
|
|
1687
|
+
// The one generator whose target is outside the repository — the account's own push-notification
|
|
1688
|
+
// settings — and therefore the one place `init` asks a question whose answer nothing in the
|
|
1689
|
+
// repository records. It is enqueued into the same plan as everything else, so `--dry-run` skips
|
|
1690
|
+
// it for the same structural reason it skips the rest.
|
|
1691
|
+
const notifications = writeNotifications({
|
|
1692
|
+
repoRoot,
|
|
1693
|
+
config: effective,
|
|
1694
|
+
plan,
|
|
1695
|
+
...resolveNotifications(ctx, flags),
|
|
1696
|
+
});
|
|
1697
|
+
notes.push(...notifications.notes);
|
|
1698
|
+
warnings.push(...notifications.warnings);
|
|
1699
|
+
const hooks = writeGitHooks({
|
|
1700
|
+
repoRoot,
|
|
1701
|
+
config: effective,
|
|
1702
|
+
plan,
|
|
1703
|
+
// The one input to the generator's re-render arm: this run rebuilt the config the hook is
|
|
1704
|
+
// rendered from, so a hook whose `case` label the rebuild made wrong is re-rendered on the same
|
|
1705
|
+
// run instead of waiting for a separate --force. The run's `--force` is not passed and is not
|
|
1706
|
+
// an input — `plan.apply` below still carries it, so it governs this artifact as it always did.
|
|
1707
|
+
configRebuilt: flags.resetConfig,
|
|
1708
|
+
// Wording only — the decision above is mode-independent (`generators/githooks.ts`).
|
|
1709
|
+
dryRun: ctx.flags.dryRun,
|
|
1710
|
+
});
|
|
1711
|
+
warnings.push(...hooks.warnings);
|
|
1712
|
+
notes.push(...hooks.notes);
|
|
1713
|
+
ctx.report.step(ctx.flags.dryRun ? 'files (dry run — nothing is written)' : 'files');
|
|
1714
|
+
plan.apply({ repoRoot, report: ctx.report, dryRun: ctx.flags.dryRun, force: ctx.flags.force });
|
|
1715
|
+
// After the plan, deliberately: this is the one git-configuration write, and pointing
|
|
1716
|
+
// `core.hooksPath` at a directory whose hook has not landed yet would enable nothing.
|
|
1717
|
+
const hooksPath = pointHooksPath({ repoRoot, githooksDir: hooks.githooksDir, dryRun: ctx.flags.dryRun });
|
|
1718
|
+
warnings.push(...hooksPath.warnings);
|
|
1719
|
+
notes.push(...hooksPath.notes);
|
|
1720
|
+
// The second post-plan step, and after the plan for a reason of its own: the managed `.gitignore`
|
|
1721
|
+
// block that decides what `git add -A` may stage arrived with the plan. Before the summary, so the
|
|
1722
|
+
// commit is reported inside the run's action log rather than after its closing pointer.
|
|
1723
|
+
warnings.push(...commitGeneratedFiles(ctx, target));
|
|
1724
|
+
ctx.report.summary();
|
|
1725
|
+
reportLines(ctx, notes, warnings);
|
|
1726
|
+
// `settings` carries the two facts step 1 states — the composite key and the resolved slug — so the
|
|
1727
|
+
// report is worded against what this run's plan actually held rather than against a second
|
|
1728
|
+
// resolution taken here.
|
|
1729
|
+
reportNextSteps(ctx, effective.layers.map((layer) => layer.name), analyze, ctx.flags.force, ctx.flags.dryRun,
|
|
1730
|
+
// The generator's fact, not a second reading: by the time this prints, the plan has been applied
|
|
1731
|
+
// and "did the bytes on disk differ from the ones this run rendered" is no longer answerable.
|
|
1732
|
+
context.claudeMdBackupWouldCarryContent,
|
|
1733
|
+
// The second fact only the generator can supply, and for the same reason: once the plan has been
|
|
1734
|
+
// applied, "was a .bak there before this run" is no longer answerable either.
|
|
1735
|
+
context.claudeMdBackupExisted, settings, effective.phases?.qa === true,
|
|
1736
|
+
// The second gate, and a different question from the one above it: whether this run declared any
|
|
1737
|
+
// browser wiring at all. Taken from the one predicate the two generators that write that wiring
|
|
1738
|
+
// read, never re-spelled here (`config/model.ts`).
|
|
1739
|
+
browserWiringApplies(effective));
|
|
1740
|
+
return EXIT.OK;
|
|
1741
|
+
}
|
|
1742
|
+
/**
|
|
1743
|
+
* The caveats, after the action log: the informational lines first, then the ones needing attention.
|
|
1744
|
+
*
|
|
1745
|
+
* Warnings go to stderr and print even under `--quiet`, which is the reporter's contract and the
|
|
1746
|
+
* whole of what makes a quiet run silent unless something wants a human (`core/report.ts`).
|
|
1747
|
+
*
|
|
1748
|
+
* **One blank line between entries, and only between them.** Each note is a paragraph of several
|
|
1749
|
+
* sentences, and a dozen of them run together as one block otherwise. The heading keeps its first
|
|
1750
|
+
* note and nothing trails the block, so the separator marks where an entry ends rather than padding
|
|
1751
|
+
* the report.
|
|
1752
|
+
*/
|
|
1753
|
+
function reportLines(ctx, notes, warnings) {
|
|
1754
|
+
if (notes.length > 0) {
|
|
1755
|
+
ctx.report.step('notes');
|
|
1756
|
+
notes.forEach((note, index) => {
|
|
1757
|
+
if (index > 0)
|
|
1758
|
+
ctx.report.info('');
|
|
1759
|
+
ctx.report.info(`- ${note}`);
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
for (const warning of warnings)
|
|
1763
|
+
ctx.report.warn(warning);
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* Where the answer was recorded — keyed on **what the write engine actually did** to the
|
|
1767
|
+
* always-loaded file rather than on the answer alone, so no wording asserts a record the tree does
|
|
1768
|
+
* not carry.
|
|
1769
|
+
*
|
|
1770
|
+
* That file is `create-if-absent`, and `--force` upgrades exactly that policy to
|
|
1771
|
+
* overwrite-after-backup (`core/writer.ts`), so there are **three** outcomes and not two: *created*,
|
|
1772
|
+
* so the banner is in the file; *overwritten under `--force`*, so it is re-written and the previous
|
|
1773
|
+
* file survives only as its single `.bak`; and *kept*, so nothing was written into it at all. Nothing
|
|
1774
|
+
* here words anything as if `--force` could not reach this file.
|
|
1775
|
+
*
|
|
1776
|
+
* **The forced outcome has two wordings, and `claudeMdBackupWouldCarryContent` is which.** The
|
|
1777
|
+
* generator fixes `forceOverride: 'never'` on a file whose bytes already are the text it would write
|
|
1778
|
+
* (`generators/claudeContext.ts`), because that write would put nothing in the `.bak` and destroy
|
|
1779
|
+
* whatever is in the one already there. So the promise "anything a previous pass had filled into it is
|
|
1780
|
+
* in that `.bak` alone" is true of the run that overwrote the file and false of the run that kept it —
|
|
1781
|
+
* on which the `.bak` was never touched and still holds what the previous forced pass rescued. Only
|
|
1782
|
+
* the generator holds the rendered text, so the fact is taken from its result rather than recomputed.
|
|
1783
|
+
*
|
|
1784
|
+
* **The keep splits again on `claudeMdBackupExisted`.** A plain `init` followed by `init --force`
|
|
1785
|
+
* renders the same bytes twice, so the keep fires with no `.bak` ever written; that arm says no
|
|
1786
|
+
* backup was made instead of promising a surviving copy of content that was never displaced.
|
|
1787
|
+
*
|
|
1788
|
+
* **The conventions documents are a fourth fact, not a fourth outcome.** `--force` puts every filled
|
|
1789
|
+
* one back to a skeleton after its own `.bak` whatever happened to the always-loaded file, so that
|
|
1790
|
+
* clause is keyed on `filledConventions` alone and appended to whichever wording is true — this run's
|
|
1791
|
+
* report is the only carrier of it, `doctor` never names the `.bak` files.
|
|
1792
|
+
*
|
|
1793
|
+
* On the kept outcome the run itself is the carrier of the answer, and `doctor`'s `setup-analysis`
|
|
1794
|
+
* check is the standing one — it reads the unfilled markers rather than the banner. Nothing here
|
|
1795
|
+
* writes, patches or re-inserts anything.
|
|
1796
|
+
*
|
|
1797
|
+
* **`--dry-run` changes the tense and nothing else.** The outcome is computed from the same real
|
|
1798
|
+
* facts, so a dry run reports which wording it *would* have recorded and whether it would have been
|
|
1799
|
+
* recorded at all — `docs/cli.md` §1's contract read in its other direction, where a dry run may no
|
|
1800
|
+
* more claim a record it did not make than omit one a real run would. Two verb tokens rather than a
|
|
1801
|
+
* second set of wordings, which would drift from this one.
|
|
1802
|
+
*/
|
|
1803
|
+
function analyzeRecordSentence(analyze, force, dryRun, backupWouldCarryContent, backupExisted) {
|
|
1804
|
+
const records = dryRun ? 'would record' : 'records';
|
|
1805
|
+
const recorded = analyze.offer === 'accepted'
|
|
1806
|
+
? `The setup-pending banner in ${CLAUDE_MD_PATH} ${records} that answer, so running it is the next session's first action.`
|
|
1807
|
+
: `The banner in ${CLAUDE_MD_PATH} ${records} that the analysis was declined — the conventions documents are yours to write by hand, ${ANALYZE_COMMAND} is still there if you change your mind, and the banner's own last sentence says how to be rid of the block.`;
|
|
1808
|
+
// What the forced run did to the **conventions documents**, computed from `filledConventions`
|
|
1809
|
+
// alone and never gated on the project file: the two are independent facts, and a first forced
|
|
1810
|
+
// `init` over hand-written rules — no project file yet — is the run that most needs to be told.
|
|
1811
|
+
// Said in one clause and given its subject at each of the two call sites below.
|
|
1812
|
+
const putBack = !force || analyze.filledConventions.length === 0
|
|
1813
|
+
? ''
|
|
1814
|
+
: `${dryRun ? 'would also put' : 'also put'} ${analyze.filledConventions.join(', ')} back to skeletons after their own .bak siblings, so the analysis has to be run again — or those .bak files restored.`;
|
|
1815
|
+
if (!analyze.claudeMdExisted)
|
|
1816
|
+
return putBack === '' ? recorded : `${recorded} --force ${putBack}`;
|
|
1817
|
+
if (force) {
|
|
1818
|
+
// The forced re-run says what it did to the tree rather than what it found — which is the
|
|
1819
|
+
// counterpart of the skip branch's condition (2a): no run can both regenerate these documents and
|
|
1820
|
+
// report them as already filled.
|
|
1821
|
+
//
|
|
1822
|
+
// The keep is not a third answer about the *banner*: the file was kept because its bytes already
|
|
1823
|
+
// are the ones this run renders, banner included, so `recorded` is true of it by identity — an
|
|
1824
|
+
// adopter answering the offer differently between two runs renders different text, the file is
|
|
1825
|
+
// then not identical, and the arm above is the one that fires.
|
|
1826
|
+
if (!backupWouldCarryContent) {
|
|
1827
|
+
// And the keep itself has two wordings, because the `.bak` it leaves alone may not exist: a
|
|
1828
|
+
// plain `init` followed by `init --force` renders the same bytes both times, so the keep fires
|
|
1829
|
+
// in a tree where nothing was ever backed up. Naming a surviving copy there sends an adopter
|
|
1830
|
+
// after a file that is not on disk — the same defect class as the sentence this arm replaced.
|
|
1831
|
+
const backup = backupExisted
|
|
1832
|
+
? ` and ${dryRun ? 'would leave' : 'left'} ${CLAUDE_MD_PATH}.bak alone — whatever a previous pass had filled into this file is still in that .bak`
|
|
1833
|
+
: `, and ${dryRun ? 'would write' : 'wrote'} no ${CLAUDE_MD_PATH}.bak, because there was nothing to back up`;
|
|
1834
|
+
return `${recorded} ${CLAUDE_MD_PATH} ${dryRun ? 'is' : 'was'} already byte-identical to the file this run generates, so --force ${dryRun ? 'would keep' : 'kept'} it${backup}, and ${ANALYZE_COMMAND} is what fills this one again.${putBack === '' ? '' : ` It ${putBack}`}`;
|
|
1835
|
+
}
|
|
1836
|
+
return `${recorded} --force ${dryRun ? 'would regenerate' : 'regenerated'} that whole file from the template after a single ${CLAUDE_MD_PATH}.bak, so anything a previous pass had filled into it ${dryRun ? 'would be' : 'is'} in that .bak alone.${putBack === '' ? '' : ` It ${putBack}`}`;
|
|
1837
|
+
}
|
|
1838
|
+
const kept = `${CLAUDE_MD_PATH} was already there and this run was not forced, so it ${dryRun ? 'would be kept' : 'was kept'} as it stands and nothing ${dryRun ? 'would be' : 'was'} recorded in it`;
|
|
1839
|
+
// The skip branch is read **before** `offer`, which is a placeholder there and not an answer: it
|
|
1840
|
+
// only ever reaches this outcome (unforced, and the file was already there), and the declined
|
|
1841
|
+
// wording below would tell an adopter whose documents are all written to write them by hand.
|
|
1842
|
+
if (analyze.nothingToFill) {
|
|
1843
|
+
return `${kept}: every conventions document is already written, so there was nothing to offer — \`${DOCTOR_COMMAND}\` names what is still unfilled at any time, and ${ANALYZE_COMMAND} revises a document whenever you want one revised.`;
|
|
1844
|
+
}
|
|
1845
|
+
return analyze.offer === 'accepted'
|
|
1846
|
+
? `${kept}: tell the next session to run ${ANALYZE_COMMAND}, and \`${DOCTOR_COMMAND}\` names what is still unfilled at any time.`
|
|
1847
|
+
: `${kept}: the conventions documents are yours to write by hand, ${ANALYZE_COMMAND} is still there if you change your mind, and \`${DOCTOR_COMMAND}\` names what is still unfilled at any time.`;
|
|
1848
|
+
}
|
|
1849
|
+
/**
|
|
1850
|
+
* The closing report — step C of the install story — printed last and on every run that got here,
|
|
1851
|
+
* whatever was written and whatever the phases are. Four numbered steps in the order an adopter
|
|
1852
|
+
* performs them, and one pasteable line under the second. The step count does not move with the
|
|
1853
|
+
* phases (`docs/cli.md` §2 states it): the one phase-gated part of this report is a clause *inside*
|
|
1854
|
+
* step 4, never a fifth step.
|
|
1855
|
+
*
|
|
1856
|
+
* {@link INIT_VERB} deliberately stops short of the judgement calls: it detected a layout from file
|
|
1857
|
+
* existence alone and wrote skeletons for the conventions documents, and turning those into this
|
|
1858
|
+
* project's actual rules is the analyze command's. Saying so here is what keeps a freshly wired
|
|
1859
|
+
* repository from being mistaken for a finished one. The generated config is named because it is the
|
|
1860
|
+
* file the adopter edits to correct anything above.
|
|
1861
|
+
*
|
|
1862
|
+
* **Step 1 is the plugin wiring, and it is first because the ordering was the defect.** The report
|
|
1863
|
+
* used to open by telling every adopter to run a command that exists only in a session which resolves
|
|
1864
|
+
* this package's plugin, with the precondition for it stated nowhere above the instruction. An
|
|
1865
|
+
* ordered sequence is the fix rather than an annotation: the step that makes the command exist cannot
|
|
1866
|
+
* come after the step that runs it.
|
|
1867
|
+
*
|
|
1868
|
+
* **It states what *this run wrote*, never what this machine already has.** `init` cannot see the host
|
|
1869
|
+
* tool's install state — that lives in a user-level registry this command neither reads nor writes —
|
|
1870
|
+
* and probing for it would make the output a function of the machine rather than of the flags and the
|
|
1871
|
+
* repository — the determinism that lets a second `init` over the same repository produce the same
|
|
1872
|
+
* result and an adopter re-derive the recorded preset by hand (`docs/cli.md` §2). So the line
|
|
1873
|
+
* reports the keys the plan carried and stops: the enablement key always, the
|
|
1874
|
+
* `extraKnownMarketplaces` entry only where the owner slug resolved
|
|
1875
|
+
* (`generators/projectSettings.ts`). The unresolved arm names the flag, the slug shape and the
|
|
1876
|
+
* marketplace from that generator's own constants, so this line and the warning an adopter reads a
|
|
1877
|
+
* few lines earlier cannot drift apart. Under `--dry-run` it takes the conditional the rest of the
|
|
1878
|
+
* report keeps (`docs/cli.md` §1) — the `/reload-plugins` pointer included, which is why that clause
|
|
1879
|
+
* belongs to each arm rather than to the printed line: a dry run merged nothing to reload, and the
|
|
1880
|
+
* unresolved arm can offer it only after one of the two routes it names is taken.
|
|
1881
|
+
*
|
|
1882
|
+
* **Step 2 carries the settled vocabulary** (`docs/analyze.md` §1): the no-argument form, and
|
|
1883
|
+
* `<target>` over the configured layer names *plus* {@link RESERVED_ANALYZE_TARGETS} — the three
|
|
1884
|
+
* targets that name no layer. It states what the command does in the settled terms, including the
|
|
1885
|
+
* one thing it does not do: a layer-profile revision is proposed and reaches
|
|
1886
|
+
* `harness.config.json` only through {@link CONFIG_SET_LAYERS_COMMAND} after an explicit yes
|
|
1887
|
+
* (`docs/analyze.md` §3). And it ends with {@link analyzeRecordSentence} — given the generator's
|
|
1888
|
+
* `claudeMdBackupWouldCarryContent` and `claudeMdBackupExisted`, the two inputs to that sentence no
|
|
1889
|
+
* reading taken here could supply — so what the step claims about the tree is what the write engine actually did to it — in
|
|
1890
|
+
* the conditional under
|
|
1891
|
+
* `--dry-run`, which did nothing to it at all. Step 2 is where {@link ANALYZE_COMMAND} is named and
|
|
1892
|
+
* step 1 deliberately refers to it rather than spelling it, so "the numbered step that names the
|
|
1893
|
+
* command" identifies exactly one line of this report — which is how the tests find it.
|
|
1894
|
+
*
|
|
1895
|
+
* **{@link ANALYZE_INVOCATION} is printed under it, on its own line and on the accepted arm alone.**
|
|
1896
|
+
* A line naming a command to type still leaves the adopter to open a session first; a line to paste
|
|
1897
|
+
* does not, and it is indented under step 2 rather than numbered because it is that step's *how*
|
|
1898
|
+
* rather than a fifth thing to do. It names {@link AGENT_CLI} directly where the generated watcher
|
|
1899
|
+
* reaches the same binary through `${HARNESS_AGENT_CLI:-claude}`
|
|
1900
|
+
* (`templates/scripts/autonomous-watcher.sh`) — an override that belongs to a script's own
|
|
1901
|
+
* environment and would mean nothing on a line printed for a person to paste. It names
|
|
1902
|
+
* {@link ANALYZE_COMMAND_QUALIFIED} where step 2 above it names {@link ANALYZE_COMMAND}, and the
|
|
1903
|
+
* difference is the two paths rather than an inconsistency: step 2 is a name to type into a session,
|
|
1904
|
+
* whose picker resolves the bare form, while this line is a first message, which is matched exactly —
|
|
1905
|
+
* the introducing sentence says so, because a report that showed both spellings and explained neither
|
|
1906
|
+
* would read as a typo. It is withheld wherever
|
|
1907
|
+
* it would contradict the report around it: on the declined arm, whose answer the same step just
|
|
1908
|
+
* quoted; on the skip branch, where there is nothing to fill; and under `--dry-run`, which wired
|
|
1909
|
+
* nothing to run it against. On the unresolved-slug arm it is **printed but qualified** rather than
|
|
1910
|
+
* withheld — withholding would make it dead on every run of this package before its repository is
|
|
1911
|
+
* published — because step 1 has just said the session resolves the plugin only once one of the two
|
|
1912
|
+
* routes it names is taken, and handing the line over without that precondition would contradict it.
|
|
1913
|
+
* **The outcome is stated as intended on both arms**: whether the prefixed first-message form
|
|
1914
|
+
* succeeds is not established ({@link ANALYZE_COMMAND_QUALIFIED}), so the line an adopter acts on
|
|
1915
|
+
* immediately names the typed route as the measured one instead of asserting its own.
|
|
1916
|
+
*
|
|
1917
|
+
* **One blank line between the numbered steps, and none before that paste line.** Each step is a
|
|
1918
|
+
* paragraph of several sentences; the paste line is step 2's *how*, so it stays attached to the step
|
|
1919
|
+
* it belongs to rather than reading as a fifth entry.
|
|
1920
|
+
*
|
|
1921
|
+
* **Step 3 names the two run settings — as values that file *may carry*, never as values this run
|
|
1922
|
+
* wrote, since nothing here writes an `agentEffort` line — rather than {@link INIT_VERB} asking about
|
|
1923
|
+
* either**: `agentModel`'s default is a working value for every adopter and an absent `agentEffort` is
|
|
1924
|
+
* already resolved, no effort flag reaching the launch line at all, so a prompt would put a question
|
|
1925
|
+
* whose answer is already correct where the bar for asking is `resolveQaDriver`'s — a default that
|
|
1926
|
+
* cannot reach the application at all (`generators/harnessConfig.ts`) — and the failure actually left
|
|
1927
|
+
* open is the other one: a key that exists only in a file nobody is told to open.
|
|
1928
|
+
*
|
|
1929
|
+
* **Step 4 names the two operator steps `doctor` reports on, and it names them only while
|
|
1930
|
+
* `phases.qa` is on** — the parallel to step 3 above: that step names values instead of asking about
|
|
1931
|
+
* them, and this one names steps instead of performing them, because neither is `init`'s to perform.
|
|
1932
|
+
* The `permissions.allow` entries belong to the interactive-test phase's helper scripts and are
|
|
1933
|
+
* unreachable with the phase off, and a step every adopter is told about but only some owe is how a
|
|
1934
|
+
* closing list stops being read; the daemon half rides the same gate because it is already reported
|
|
1935
|
+
* to everyone by `doctor`'s `repo-registry` check. **Each half names its consequence, not just its
|
|
1936
|
+
* verb**, because the two fail differently: an uninstalled daemon is inert and legible — the file
|
|
1937
|
+
* dropped into the inbox sits there — while a missing permission entry is a silent stall on the first
|
|
1938
|
+
* unattended run, which is the one an adopter has to act on *before* that run rather than after it. The entry
|
|
1939
|
+
* form is not restated: those lines carry a machine-local root this command cannot see, which is why
|
|
1940
|
+
* only the daemon half has a constant here ({@link DAEMON_INSTALL_COMMAND}), and
|
|
1941
|
+
* `plugin/scripts/README.md` and `doctor`'s `plugin-permissions` check own it — so this step points
|
|
1942
|
+
* at {@link DOCTOR_COMMAND} for which of the two is still outstanding rather than carrying a list
|
|
1943
|
+
* that would drift from the one that prints the lines to paste.
|
|
1944
|
+
*
|
|
1945
|
+
* **Step 4 carries a second, separately gated sentence: the browser MCP servers are fetched on first
|
|
1946
|
+
* use.** `.mcp.json` launches them with {@link MCP_LAUNCHER}, so `init` installs nothing and an
|
|
1947
|
+
* adopter offline, behind a proxy, or on a mirror without those pins finds out at the first
|
|
1948
|
+
* interactive-test dispatch — after planning, implementation and both branch reviews have been paid
|
|
1949
|
+
* for. The sentence names {@link DOCTOR_CHECK_REGISTRY_COMMAND}, which answers it beforehand, and it
|
|
1950
|
+
* names no package or version: those live in `templates/repo/mcp.json` and that check enumerates
|
|
1951
|
+
* them. Its gate is `browserWiring` — `config/model.ts`'s `browserWiringApplies`, computed at the
|
|
1952
|
+
* call site — and **not** `qaPhase`, because a mobile-driver run has the phase on, owes the operator
|
|
1953
|
+
* steps above, and has no `.mcp.json` to fetch anything for. Two gates, two parameters, two
|
|
1954
|
+
* sentences; do not merge them.
|
|
1955
|
+
*/
|
|
1956
|
+
function reportNextSteps(ctx, layerNames, analyze, force, dryRun, backupWouldCarryContent, backupExisted, wiring, qaPhase, browserWiring) {
|
|
1957
|
+
const targets = [...layerNames, ...RESERVED_ANALYZE_TARGETS].join(', ');
|
|
1958
|
+
const merged = dryRun ? 'would merge' : 'merged';
|
|
1959
|
+
const resolves = dryRun ? 'would resolve' : 'resolves';
|
|
1960
|
+
// The reload clause is inside each arm rather than appended to the line: it claims there is
|
|
1961
|
+
// something to pick up, which is false under `--dry-run`, and the unresolved arm owes its own
|
|
1962
|
+
// wording because the sentence before it withholds what the resolved arm's promises.
|
|
1963
|
+
const reloadResolved = dryRun ? '' : ' `/reload-plugins` picks it up in a session that was already open.';
|
|
1964
|
+
const reloadUnresolved = dryRun
|
|
1965
|
+
? ''
|
|
1966
|
+
: ' Once either route is taken, `/reload-plugins` picks the change up in a session that was already open.';
|
|
1967
|
+
const wired = wiring.marketplaceSlug === undefined
|
|
1968
|
+
? `This run ${merged} \`${wiring.pluginKey}\` into ${SETTINGS_PATH}, which enables the plugin the next step's command belongs to — but no \`${MARKETPLACES_KEY}\` entry ${dryRun ? 'would be' : 'was'} written, so a session here has never been told where that plugin comes from and ${resolves} it only if this machine already added the \`${MARKETPLACE_NAME}\` marketplace: run \`${MARKETPLACE_ADD_COMMAND}\` for this machine, or re-run ${INIT_VERB} with \`${MARKETPLACE_FLAG} ${SLUG_SHAPE}\` to write the entry into that committed file for everyone who clones it.${reloadUnresolved}`
|
|
1969
|
+
: `Nothing to install by hand: this run ${merged} \`${wiring.pluginKey}\` into ${SETTINGS_PATH} together with the \`${MARKETPLACES_KEY}\` entry naming ${wiring.marketplaceSlug}, and that file is committed — so a session opened in this repository ${resolves} the plugin the next step's command belongs to.${reloadResolved}`;
|
|
1970
|
+
// The paste line is gated on the answer **and** on this run having wired anything to run it
|
|
1971
|
+
// against. `nothingToFill` is read before `offer` for the reason {@link analyzeRecordSentence}
|
|
1972
|
+
// reads it first: `offer` is a placeholder on that path rather than an answer.
|
|
1973
|
+
const pasteable = !analyze.nothingToFill && analyze.offer === 'accepted' && !dryRun;
|
|
1974
|
+
// The sentence introducing the line carries the precondition step 1 just stated. On the unresolved
|
|
1975
|
+
// arm, handing the line over without that precondition would contradict the step above it, which
|
|
1976
|
+
// has said the session resolves the plugin only once one of the two routes it names is taken.
|
|
1977
|
+
const pasteReason = " It is spelled with the plugin prefix because a first message is matched exactly, where a session's command picker matches the name above.";
|
|
1978
|
+
// Whether the prefixed first message succeeds is unmeasured, so the outcome is named as intended
|
|
1979
|
+
// and the measured route is pointed at ({@link ANALYZE_COMMAND_QUALIFIED}, `docs/analyze.md` §9).
|
|
1980
|
+
const pasteUnconfirmed = ' That first-message form is not confirmed on the version measured here; the name above, typed into an open session, is the route that is.';
|
|
1981
|
+
const pasteIntro = wiring.marketplaceSlug === undefined
|
|
1982
|
+
? ` Once the plugin resolves — step 1 names both routes — paste the line below at this repository's root: it is meant to start a session with that command already running.${pasteReason}${pasteUnconfirmed}`
|
|
1983
|
+
: ` Paste the line below at this repository's root: it is meant to start a session with that command already running.${pasteReason}${pasteUnconfirmed}`;
|
|
1984
|
+
ctx.report.step('next');
|
|
1985
|
+
ctx.report.info(`1. ${wired}`);
|
|
1986
|
+
ctx.report.info('');
|
|
1987
|
+
ctx.report.info(`2. Run ${ANALYZE_COMMAND} (or ${ANALYZE_COMMAND} <target> — targets: ${targets}) in this repository: it fills the conventions documents from this repository's real code, and proposes a layer-profile revision it applies only through \`${CONFIG_SET_LAYERS_COMMAND}\` after an explicit yes. ${INIT_VERB} detected this layout from file existence alone and deliberately judges none of it. ${analyzeRecordSentence(analyze, force, dryRun, backupWouldCarryContent, backupExisted)}${pasteable ? pasteIntro : ''}`);
|
|
1988
|
+
// No separator before the paste line: it is step 2's continuation, which is what the indent says.
|
|
1989
|
+
if (pasteable)
|
|
1990
|
+
ctx.report.info(` ${ANALYZE_INVOCATION}`);
|
|
1991
|
+
ctx.report.info('');
|
|
1992
|
+
ctx.report.info(`3. Read ${CONFIG_FILENAME} and correct anything above — it is the file everything here was generated from, it is committed, and after ${INIT_VERB} the copy in this repository is the only copy. Two of the values it may carry are the run settings nothing here could detect: \`agentModel\` chooses which model an unattended run talks to, and \`agentEffort\` pins the reasoning-effort level it runs at — leave it out and the runtime applies its own per-model default. Either one is changed with \`${CONFIG_SET_COMMAND}\`.`);
|
|
1993
|
+
// The clause is the phase's, not the driver's: every interactive-test driver reaches the same
|
|
1994
|
+
// helper scripts under the plugin's root.
|
|
1995
|
+
const operatorSteps = qaPhase
|
|
1996
|
+
? ` Two of the things it reports are an operator's and neither is done from here: \`${DAEMON_INSTALL_COMMAND}\`, without which nothing polls this repository's inbox and a file dropped into it simply sits there; and hand-adding the \`permissions.allow\` entries this machine's plugin roots need, without which an unattended run with the interactive-test phase on parks at the first helper script it reaches, with no error. Running it is how you find out which of the two is still outstanding, and it prints the entries to paste.`
|
|
1997
|
+
: '';
|
|
1998
|
+
// A second gate, deliberately not folded into the one above: that clause is the phase's, this one
|
|
1999
|
+
// is the browser wiring's. A mobile-driver run turns the phase on, owes the operator steps, and
|
|
2000
|
+
// declares no server and fetches nothing — so merging them would print this to an adopter with no
|
|
2001
|
+
// `.mcp.json` at all. The predicate is `browserWiringApplies`, passed in rather than re-spelled.
|
|
2002
|
+
const declared = dryRun ? 'would declare' : 'declared';
|
|
2003
|
+
const registryStep = browserWiring
|
|
2004
|
+
? ` This run ${declared} the two browser MCP servers in ${MCP_PATH}, and they are launched with \`${MCP_LAUNCHER}\`: each pinned package is fetched from the registry the first time the interactive-test phase runs rather than installed now. Offline, behind a proxy, or on a mirror that does not carry those exact versions, that fails at the first interactive-test dispatch — after planning, implementation and both branch reviews have been paid for — and \`${DOCTOR_CHECK_REGISTRY_COMMAND}\` is what answers it before then.`
|
|
2005
|
+
: '';
|
|
2006
|
+
ctx.report.info('');
|
|
2007
|
+
ctx.report.info(`4. Run \`${DOCTOR_COMMAND}\` to verify the wiring.${operatorSteps}${registryStep}`);
|
|
2008
|
+
}
|
|
2009
|
+
/**
|
|
2010
|
+
* The registry row for this command.
|
|
2011
|
+
*
|
|
2012
|
+
* Exported as the row itself rather than as a `run` the table wraps, so the summary, the usage
|
|
2013
|
+
* lines and the behaviour stay in the file that owns them. The import back to `commands/registry.ts`
|
|
2014
|
+
* is type-only and therefore erased, so the table can list this row without a runtime cycle.
|
|
2015
|
+
*/
|
|
2016
|
+
export const INIT_COMMAND = {
|
|
2017
|
+
name: 'init',
|
|
2018
|
+
summary: SUMMARY,
|
|
2019
|
+
roadmapItem: ROADMAP_ITEM,
|
|
2020
|
+
usage: INIT_USAGE,
|
|
2021
|
+
run,
|
|
2022
|
+
};
|
|
2023
|
+
//# sourceMappingURL=init.js.map
|