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.
Files changed (171) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +7 -0
  3. package/README.md +24 -0
  4. package/dist/cli.js +194 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/commands/config.js +561 -0
  7. package/dist/commands/config.js.map +1 -0
  8. package/dist/commands/daemon.js +791 -0
  9. package/dist/commands/daemon.js.map +1 -0
  10. package/dist/commands/doctor.js +336 -0
  11. package/dist/commands/doctor.js.map +1 -0
  12. package/dist/commands/init.js +2023 -0
  13. package/dist/commands/init.js.map +1 -0
  14. package/dist/commands/registry.js +42 -0
  15. package/dist/commands/registry.js.map +1 -0
  16. package/dist/config/check.js +505 -0
  17. package/dist/config/check.js.map +1 -0
  18. package/dist/config/io.js +177 -0
  19. package/dist/config/io.js.map +1 -0
  20. package/dist/config/model.js +406 -0
  21. package/dist/config/model.js.map +1 -0
  22. package/dist/core/errors.js +71 -0
  23. package/dist/core/errors.js.map +1 -0
  24. package/dist/core/git.js +537 -0
  25. package/dist/core/git.js.map +1 -0
  26. package/dist/core/json.js +125 -0
  27. package/dist/core/json.js.map +1 -0
  28. package/dist/core/layerCoverage.js +141 -0
  29. package/dist/core/layerCoverage.js.map +1 -0
  30. package/dist/core/layerGapRemedy.js +62 -0
  31. package/dist/core/layerGapRemedy.js.map +1 -0
  32. package/dist/core/nameList.js +23 -0
  33. package/dist/core/nameList.js.map +1 -0
  34. package/dist/core/paths.js +153 -0
  35. package/dist/core/paths.js.map +1 -0
  36. package/dist/core/prompt.js +206 -0
  37. package/dist/core/prompt.js.map +1 -0
  38. package/dist/core/repoPaths.js +55 -0
  39. package/dist/core/repoPaths.js.map +1 -0
  40. package/dist/core/report.js +150 -0
  41. package/dist/core/report.js.map +1 -0
  42. package/dist/core/templating.js +88 -0
  43. package/dist/core/templating.js.map +1 -0
  44. package/dist/core/writer.js +479 -0
  45. package/dist/core/writer.js.map +1 -0
  46. package/dist/daemon/backend.js +180 -0
  47. package/dist/daemon/backend.js.map +1 -0
  48. package/dist/daemon/units.js +380 -0
  49. package/dist/daemon/units.js.map +1 -0
  50. package/dist/detect/nestedApplication.js +79 -0
  51. package/dist/detect/nestedApplication.js.map +1 -0
  52. package/dist/detect/presets.js +2033 -0
  53. package/dist/detect/presets.js.map +1 -0
  54. package/dist/detect/signals.js +1368 -0
  55. package/dist/detect/signals.js.map +1 -0
  56. package/dist/doctor/checks.js +3530 -0
  57. package/dist/doctor/checks.js.map +1 -0
  58. package/dist/generators/claudeContext.js +588 -0
  59. package/dist/generators/claudeContext.js.map +1 -0
  60. package/dist/generators/githooks.js +446 -0
  61. package/dist/generators/githooks.js.map +1 -0
  62. package/dist/generators/harnessConfig.js +632 -0
  63. package/dist/generators/harnessConfig.js.map +1 -0
  64. package/dist/generators/notifications.js +191 -0
  65. package/dist/generators/notifications.js.map +1 -0
  66. package/dist/generators/outerLoopScripts.js +165 -0
  67. package/dist/generators/outerLoopScripts.js.map +1 -0
  68. package/dist/generators/permissionProfile.js +1172 -0
  69. package/dist/generators/permissionProfile.js.map +1 -0
  70. package/dist/generators/projectSettings.js +322 -0
  71. package/dist/generators/projectSettings.js.map +1 -0
  72. package/dist/generators/repoRoot.js +417 -0
  73. package/dist/generators/repoRoot.js.map +1 -0
  74. package/dist/generators/scripts.js +557 -0
  75. package/dist/generators/scripts.js.map +1 -0
  76. package/dist/generators/stateDir.js +221 -0
  77. package/dist/generators/stateDir.js.map +1 -0
  78. package/dist/machine/paths.js +111 -0
  79. package/dist/machine/paths.js.map +1 -0
  80. package/dist/machine/plugins.js +224 -0
  81. package/dist/machine/plugins.js.map +1 -0
  82. package/dist/machine/registry.js +330 -0
  83. package/dist/machine/registry.js.map +1 -0
  84. package/package.json +23 -0
  85. package/scripts/README.md +13 -0
  86. package/scripts/daemon/launchd.plist.template +59 -0
  87. package/scripts/daemon/systemd.service.template +58 -0
  88. package/templates/README.md +15 -0
  89. package/templates/claude/CLAUDE.md +54 -0
  90. package/templates/claude/README.md +5 -0
  91. package/templates/claude/context/api.md +29 -0
  92. package/templates/claude/context/conventions.md +23 -0
  93. package/templates/claude/context/data-layer.md +28 -0
  94. package/templates/claude/context/data-storage.md +29 -0
  95. package/templates/claude/context/docs-catalog.md +29 -0
  96. package/templates/claude/context/domain.md +28 -0
  97. package/templates/claude/context/layer.md +20 -0
  98. package/templates/claude/context/module.md +30 -0
  99. package/templates/claude/context/package.md +29 -0
  100. package/templates/claude/context/presentation.md +32 -0
  101. package/templates/claude/context/state-slices.md +28 -0
  102. package/templates/claude/context/tests.md +28 -0
  103. package/templates/claude/harness-task-offer.md +58 -0
  104. package/templates/claude/push-notify.env.example +21 -0
  105. package/templates/claude/qa-accounts.env.example +38 -0
  106. package/templates/claude/qa_test_scenarios.md +110 -0
  107. package/templates/claude/settings.autonomous.json +93 -0
  108. package/templates/claude/settings.autonomous.qa.json +36 -0
  109. package/templates/githooks/README.md +3 -0
  110. package/templates/githooks/pre-push +72 -0
  111. package/templates/repo/README.md +3 -0
  112. package/templates/repo/gitattributes +16 -0
  113. package/templates/repo/gitignore +61 -0
  114. package/templates/repo/gitignore.qa +25 -0
  115. package/templates/repo/mcp.json +17 -0
  116. package/templates/scripts/README.md +5 -0
  117. package/templates/scripts/autonomous-format-stream.sh +95 -0
  118. package/templates/scripts/autonomous-notify.sh +337 -0
  119. package/templates/scripts/autonomous-watcher.sh +3087 -0
  120. package/templates/scripts/cleanup-merged-worktrees.sh +327 -0
  121. package/templates/scripts/commit-on-branch.sh +288 -0
  122. package/templates/scripts/create-worktree.sh +360 -0
  123. package/templates/scripts/deploy.sh +47 -0
  124. package/templates/scripts/lib/harness-run-lib.sh +1481 -0
  125. package/templates/scripts/push-branch.sh +140 -0
  126. package/templates/scripts/refresh-branch.sh +244 -0
  127. package/templates/scripts/restart-watcher.sh +401 -0
  128. package/templates/scripts/scratch-run.sh +302 -0
  129. package/templates/scripts/setup-worktree.sh +262 -0
  130. package/templates/scripts/start-dev-server.sh +99 -0
  131. package/templates/scripts/test.sh +50 -0
  132. package/templates/scripts/typecheck.sh +50 -0
  133. package/templates/state-dir/README-root.md +13 -0
  134. package/templates/state-dir/README.md +9 -0
  135. package/templates/state-dir/architecture_branch_review_point_reviews/README.md +9 -0
  136. package/templates/state-dir/architecture_branch_reviews/README.md +9 -0
  137. package/templates/state-dir/architecture_reviews/README.md +9 -0
  138. package/templates/state-dir/architecture_user_review_reviews/README.md +9 -0
  139. package/templates/state-dir/autonomous_inbox/README.md +9 -0
  140. package/templates/state-dir/autonomous_logs/README.md +9 -0
  141. package/templates/state-dir/branch_statistics/README.md +9 -0
  142. package/templates/state-dir/business_parity_branch_review_point_reviews/README.md +9 -0
  143. package/templates/state-dir/business_parity_branch_reviews/README.md +9 -0
  144. package/templates/state-dir/business_parity_reviews/README.md +9 -0
  145. package/templates/state-dir/business_parity_user_review_reviews/README.md +9 -0
  146. package/templates/state-dir/clarification_digests/README.md +9 -0
  147. package/templates/state-dir/clarifications/README.md +9 -0
  148. package/templates/state-dir/code_reviews/README.md +9 -0
  149. package/templates/state-dir/dispatch_additions/README.md +19 -0
  150. package/templates/state-dir/docs_catalog/README.md +9 -0
  151. package/templates/state-dir/flow_progress/README.md +9 -0
  152. package/templates/state-dir/improvement_observations/README.md +19 -0
  153. package/templates/state-dir/improvement_suggestions.md +29 -0
  154. package/templates/state-dir/lessons.md +23 -0
  155. package/templates/state-dir/qa_review_point_reviews/README.md +9 -0
  156. package/templates/state-dir/qa_reviews/README.md +9 -0
  157. package/templates/state-dir/review_plan_point_reviews/README.md +9 -0
  158. package/templates/state-dir/review_plan_reviews/README.md +9 -0
  159. package/templates/state-dir/scratch/README.md +11 -0
  160. package/templates/state-dir/skeptic_review_plan_reviews/README.md +9 -0
  161. package/templates/state-dir/skeptic_review_point_reviews/README.md +9 -0
  162. package/templates/state-dir/skeptic_reviews/README.md +9 -0
  163. package/templates/state-dir/story_plans/README.md +9 -0
  164. package/templates/state-dir/task_plan_point_reviews/README.md +9 -0
  165. package/templates/state-dir/task_plan_reviews/README.md +9 -0
  166. package/templates/state-dir/task_plans/README.md +9 -0
  167. package/templates/state-dir/task_prompts/README.md +9 -0
  168. package/templates/state-dir/ui_test_plan_reviews/README.md +9 -0
  169. package/templates/state-dir/ui_test_plans/README.md +9 -0
  170. package/templates/state-dir/user_review_fix_plan_point_reviews/README.md +9 -0
  171. package/templates/state-dir/user_reviews/README.md +9 -0
@@ -0,0 +1,791 @@
1
+ /**
2
+ * Command: `daemon` — this repository's run daemon on the service manager this host has: installing,
3
+ * starting and stopping it, and listing every repository on this machine one is installed for.
4
+ *
5
+ * **The rule this module exists to enforce: the unit is written, loaded and addressed from one
6
+ * answer.** The backend comes from `daemon/backend.ts` — the same detection `doctor` reports, so the
7
+ * two commands can never tell an operator different things about one host — and the unit's text, its
8
+ * install path and the identifier the service manager knows it by come from a single `renderUnit`
9
+ * call, so a daemon cannot be installed under one name and then addressed by another. This file
10
+ * orders those answers, writes at most one unit file and one entry in the registry
11
+ * `machine/registry.ts` owns, and runs at most one external command; it derives none of them itself.
12
+ *
13
+ * ## Four non-obvious choices, and where each comes from
14
+ *
15
+ * 1. **launchd is driven; systemd is instructed.** The settled decision behind this command is
16
+ * "launchd on macOS plus a *documented* systemd unit template", and the asymmetry is the whole of
17
+ * what that means: a per-user systemd instance is not reliably addressable from an arbitrary
18
+ * process — an ssh session or a container with no user bus has `systemctl` and no manager behind
19
+ * it — and a `systemctl --user` call that fails there fails silently enough to look like success.
20
+ * So on systemd this command writes the unit and **prints** the two commands the operator runs
21
+ * themselves. An instruction that works beats a call that might not.
22
+ * 2. **A missing watcher is a refusal, not a warning — and the watcher is the repository's own.**
23
+ * `init` writes it into the configured `scriptsDir`, so `daemon/backend.ts` resolves it under the
24
+ * repository root and the rendered unit's `ExecStart` names a path inside the very repository its
25
+ * `WorkingDirectory` already names. That is what makes an `npx`-installed CLI a non-issue: the
26
+ * package directory it ran from can be evicted from the cache without the service losing its
27
+ * program. `doctor` grades an absence a warning, because a repository whose watcher was deleted
28
+ * must still exit 0; `install` refuses, because a unit whose program does not exist installs
29
+ * cleanly and then restarts forever, logging into files nobody reads. Both use
30
+ * `daemon/backend.ts`'s one sentence for the condition. `--watcher` points the unit at a watcher
31
+ * of your own, which is how the lifecycle is exercised against a watcher under development.
32
+ * 3. **Every refusal holds under `--dry-run` too.** A dry run computes against the real host — real
33
+ * detection, real watcher probe, real unit-file existence — and the preview of a run that would
34
+ * refuse is that refusal. A dry run that skipped the checks in order to print something would
35
+ * hide exactly the conditions it is run to discover.
36
+ * 4. **`list` is the one verb that does not stand in a repository.** Every other verb opens with
37
+ * `resolveRepoRoot(ctx.cwd)` and `requireConfig`, because it acts on *this* repository's daemon.
38
+ * `list` reads machine state — `machine/registry.ts`'s registry of every repository a daemon was
39
+ * installed for — so it has to answer from anywhere, including a directory that is not a
40
+ * repository at all: an operator asking "what is armed on this machine" is very often standing
41
+ * outside all of them. It marks this checkout's row when there is one, and says nothing when
42
+ * there is not.
43
+ *
44
+ * ## What this command deliberately does not do
45
+ *
46
+ * - **It removes nothing from disk.** There is no uninstall verb: `stop` boots the service out of
47
+ * the session and leaves the unit file where `install` put it, so no verb here has any reason to
48
+ * delete a path. `list --prune` is not an exception — it removes stale *entries* from the machine
49
+ * registry through `machine/registry.ts` and never the unit file, the repository or a directory
50
+ * any of them named. Were a path-removing verb added, it would remove that single file in-process
51
+ * through `fs.rm` — the CLI never shells out a recursive removal (`cli.ts` header), because a
52
+ * user-level deny rule on that command is realistic, is evaluated before any allow, and silently
53
+ * blocks teardown.
54
+ * - **It shells nothing out.** Every external invocation below is `execFileSync` with an argument
55
+ * vector: no shell string, no compound statement, no quoted path. That is the same form the
56
+ * generated permission profile teaches an unattended run to allow-list, and a call the CLI makes in
57
+ * any other form would be one the profile it generates cannot admit.
58
+ * - **It does not write the watcher, and it never repairs one.** `init` writes the outer-loop scripts
59
+ * into the configured `scriptsDir`; this command resolves the watcher's path, refuses when nothing
60
+ * is there, and names it in the unit. Writing a missing one here would be a second writer for a
61
+ * generated file, aimed at a repository the operator only asked to install a service for.
62
+ */
63
+ import { execFileSync } from 'node:child_process';
64
+ import { existsSync, readFileSync } from 'node:fs';
65
+ import { requireConfig } from '../config/io.js';
66
+ import { EXIT, HarnessError } from '../core/errors.js';
67
+ import { probeRepoRoot, resolveRepoRoot } from '../core/git.js';
68
+ import { packageScriptsDir } from '../core/paths.js';
69
+ import { WritePlan } from '../core/writer.js';
70
+ import { detectBackend, resolveWatcherPath, watcherMissingMessage, } from '../daemon/backend.js';
71
+ import { renderUnit, repoSlug, unitEnvValue } from '../daemon/units.js';
72
+ import { inspect, readRegistry, registryPath, removeRepositories, upsertRepository, } from '../machine/registry.js';
73
+ /** The command's one-line summary, in the usage block and at the head of its own `--help`. */
74
+ const SUMMARY = 'Install, start, stop and list run daemons (launchd on macOS, systemd unit template elsewhere)';
75
+ /** The roadmap item this command belongs to, as the registry reports it. */
76
+ const ROADMAP_ITEM = 13;
77
+ /**
78
+ * The verbs, in the order `--help` lists them and the order they are used in.
79
+ *
80
+ * **This array is the verb list's one home.** Every sentence in this file that spells the list out —
81
+ * {@link INVOCATION_FORM}, {@link verbList} — derives it from here rather than repeating it, so a
82
+ * verb added below cannot leave a stale enumeration behind in a refusal or in the usage block.
83
+ */
84
+ const VERBS = ['install', 'start', 'stop', 'list'];
85
+ /** The flag every verb but `list` takes: a development override for the resolved watcher. */
86
+ const WATCHER_FLAG = '--watcher';
87
+ /** The flag only `list` takes: drop the entries this listing graded stale. */
88
+ const PRUNE_FLAG = '--prune';
89
+ /** How the CLI is typed, for the messages that tell an operator what to run next. The `npx` prefix is not decoration: the rule for which occurrences carry it is stated once in `commands/init.ts`, beside its own `CLI`. */
90
+ const CLI = 'npx autonomous-sdlc-harness';
91
+ /** Mode of the written unit file: readable by the service manager, writable only by its owner. */
92
+ const UNIT_MODE = 0o644;
93
+ /** What a `PATH` is split on. Both backends are POSIX-only, so there is one separator to know. */
94
+ const PATH_SEPARATOR = ':';
95
+ /**
96
+ * The one line explaining why the `systemctl` commands are printed rather than run. Stated once and
97
+ * used by the three verbs that drive the service manager, so the reason cannot be given differently
98
+ * depending on which verb the operator reached first. `list` touches no service manager and prints
99
+ * no `systemctl` instruction, so it is the one verb that never reaches this.
100
+ */
101
+ const SYSTEMD_PRINTED_REASON = 'printed rather than run: a per-user systemd instance is not reliably addressable from an arbitrary process, and a systemctl call that quietly fails is worse than an instruction that works';
102
+ /**
103
+ * How the command is invoked, written once: the registry's generic `daemon [options]` synopsis line
104
+ * cannot show a required verb, and a refusal quotes the same form back at whoever typed it wrong.
105
+ */
106
+ const INVOCATION_FORM = `${CLI} daemon <${VERBS.join('|')}> [${WATCHER_FLAG} <path>] [${PRUNE_FLAG}]`;
107
+ /** The lines the registry renders under this command's synopsis. */
108
+ const DAEMON_USAGE = Object.freeze([
109
+ `The verb is required: ${INVOCATION_FORM}`,
110
+ '',
111
+ 'Verbs:',
112
+ ' install Render the unit for the detected backend, write it, load it, and register the repository',
113
+ ' start Start the installed daemon',
114
+ ' stop Stop the daemon loaded under this repository\'s label, whether or not its unit file is still there',
115
+ ' list List the repositories on this machine a daemon has been installed for',
116
+ '',
117
+ 'Daemon options:',
118
+ ` ${WATCHER_FLAG} <path> Use this watcher script instead of the one init wrote into scriptsDir`,
119
+ ` ${PRUNE_FLAG} Remove the entries this listing graded stale — entries only, never a file`,
120
+ '',
121
+ `${PRUNE_FLAG} is accepted on list alone and ${WATCHER_FLAG} on the verbs that address a unit: a flag typed`,
122
+ 'on a verb that has no use for it is refused rather than quietly ignored.',
123
+ '',
124
+ 'On macOS the lifecycle is driven for you with launchctl. On systemd the unit is written and the',
125
+ 'systemctl --user commands to load, start or stop it are printed for you to run: a per-user systemd',
126
+ 'instance is not reliably addressable from an arbitrary process.',
127
+ '',
128
+ 'list needs neither a repository nor a harness.config.json: it reads the machine-local registry that',
129
+ 'install records every installation in, and marks this checkout when it is run inside one.',
130
+ '',
131
+ 'The unit file is written outside the repository, at ~/Library/LaunchAgents (launchd) or',
132
+ '~/.config/systemd/user (systemd) — one of the three paths this CLI writes there, with the machine',
133
+ 'registry repos.json that install records this repository in and the push.env that',
134
+ 'init --notifications writes. It is create-if-absent; --force regenerates it after writing a .bak',
135
+ 'sibling, and applies to install only.',
136
+ 'The file write is idempotent; the load is not — re-installing over an agent already loaded under',
137
+ 'this label exits non-zero rather than reporting a success: stop it first, then install again.',
138
+ '',
139
+ '--dry-run reports the unit path, the label, the exact commands and what a --prune would remove,',
140
+ 'and runs and writes nothing — including nothing into the registry.',
141
+ 'Every refusal still applies under it, so a dry run cannot report a success a real run would not have.',
142
+ ]);
143
+ /**
144
+ * {@link VERBS} as a sentence, for a refusal — `install, start, stop or list` as this release stands.
145
+ *
146
+ * Derived from the array rather than written out, so a verb added there reaches every refusal that
147
+ * names the verbs without anyone remembering to come here.
148
+ */
149
+ function verbList() {
150
+ return `${VERBS.slice(0, -1).join(', ')} or ${VERBS[VERBS.length - 1]}`;
151
+ }
152
+ /**
153
+ * Which verbs one local flag is meaningful on. A flag typed anywhere else is refused.
154
+ *
155
+ * The file's standard, applied to flags as {@link parseInvocation} applies it to verbs: a `--prune`
156
+ * on `start` or a `--watcher` on `list` is a request the command cannot carry out, and silently
157
+ * dropping it would report a success for something other than what was typed. `list` addresses no
158
+ * unit, so there is no watcher for it to override; every other verb addresses one and has no
159
+ * registry entries to drop.
160
+ */
161
+ const FLAG_VERBS = Object.freeze({
162
+ [WATCHER_FLAG]: VERBS.filter((verb) => verb !== 'list'),
163
+ [PRUNE_FLAG]: ['list'],
164
+ });
165
+ /** The usage a bad invocation is refused with: the verbs, and where the rest of it is. */
166
+ function usageHint() {
167
+ return `usage: \`${INVOCATION_FORM}\` — run \`${CLI} daemon --help\` for the full usage`;
168
+ }
169
+ /**
170
+ * Refuse a flag on a verb it means nothing to, naming the verbs it does mean something to.
171
+ *
172
+ * Checked after the whole argument list has been read rather than as each token arrives, because a
173
+ * flag may legitimately be typed before its verb — `daemon --prune list` is the same invocation as
174
+ * `daemon list --prune`, and neither can be judged until the verb is known.
175
+ */
176
+ function assertFlagVerb(flag, verb) {
177
+ const allowed = FLAG_VERBS[flag];
178
+ if (allowed.includes(verb))
179
+ return;
180
+ throw new HarnessError(`daemon: ${flag} means nothing to ${verb} — it belongs to ${allowed.join(', ')}; ${usageHint()}`);
181
+ }
182
+ /**
183
+ * Parse the verb and the two local flags.
184
+ *
185
+ * A missing verb, an unknown verb, a second verb, an unrecognised flag and a known flag on a verb
186
+ * that has no use for it are all refusals rather than defaults or silent drops: this command loads
187
+ * and unloads a background service and prunes a machine-wide index, so "did something other than
188
+ * what was typed" is the outcome worth the most to prevent, and there is no verb harmless enough to
189
+ * be the default.
190
+ */
191
+ function parseInvocation(argv) {
192
+ let verb;
193
+ let watcher;
194
+ let prune = false;
195
+ for (let index = 0; index < argv.length; index += 1) {
196
+ const token = argv[index];
197
+ const separator = token.startsWith('--') ? token.indexOf('=') : -1;
198
+ const name = separator > 0 ? token.slice(0, separator) : token;
199
+ const inlineValue = separator > 0 ? token.slice(separator + 1) : undefined;
200
+ if (name === WATCHER_FLAG) {
201
+ let value = inlineValue;
202
+ if (value === undefined) {
203
+ index += 1;
204
+ value = argv[index];
205
+ }
206
+ if (value === undefined || value === '') {
207
+ throw new HarnessError(`daemon: ${WATCHER_FLAG} requires <path>`);
208
+ }
209
+ watcher = value;
210
+ continue;
211
+ }
212
+ if (name === PRUNE_FLAG) {
213
+ // It takes no value, so `--prune=<anything>` is a misunderstanding of what it does rather
214
+ // than a value to interpret — and the thing it would be misunderstood into is a removal.
215
+ if (inlineValue !== undefined) {
216
+ throw new HarnessError(`daemon: ${PRUNE_FLAG} takes no value — ${usageHint()}`);
217
+ }
218
+ prune = true;
219
+ continue;
220
+ }
221
+ if (token.startsWith('-')) {
222
+ throw new HarnessError(`daemon: unknown option ${JSON.stringify(name)} — ${usageHint()}`);
223
+ }
224
+ if (!VERBS.includes(token)) {
225
+ throw new HarnessError(`daemon: unknown verb ${JSON.stringify(token)} — expected ${verbList()}; ${usageHint()}`);
226
+ }
227
+ if (verb !== undefined) {
228
+ throw new HarnessError(`daemon: unexpected argument ${JSON.stringify(token)} after ${verb} — ${usageHint()}`);
229
+ }
230
+ verb = token;
231
+ }
232
+ if (verb === undefined)
233
+ throw new HarnessError(`daemon: no verb given — expected ${verbList()}; ${usageHint()}`);
234
+ if (watcher !== undefined)
235
+ assertFlagVerb(WATCHER_FLAG, verb);
236
+ if (prune)
237
+ assertFlagVerb(PRUNE_FLAG, verb);
238
+ return watcher === undefined ? { verb, prune } : { verb, watcher, prune };
239
+ }
240
+ /**
241
+ * The detected backend, or a refusal naming the platform and what an operator can still do.
242
+ *
243
+ * `none` is a statement about the host rather than a fault, which is why detection returns it and
244
+ * `doctor` prints it; for this command it is fatal, since there is nothing to install into. The
245
+ * refusal points at the shipped systemd template, because the one remaining path on an unsupported
246
+ * host is to adapt it by hand — the template carries every value this command would have substituted.
247
+ */
248
+ function requireBackend() {
249
+ const backend = detectBackend();
250
+ if (backend.kind === 'launchd' || backend.kind === 'systemd')
251
+ return { ...backend, kind: backend.kind };
252
+ throw new HarnessError(`no service manager to install the run daemon into on ${process.platform}: ${backend.reason}. To run the daemon here anyway, adapt the systemd user-unit template shipped in this package's scripts directory (${packageScriptsDir()}) by hand`);
253
+ }
254
+ /**
255
+ * The calling account's numeric id, which every `launchctl` domain target below is built from.
256
+ *
257
+ * Unreachable in practice — the launchd backend is only ever returned on macOS, where `getuid` is
258
+ * always there — so its absence means detection and this command disagree about what platform the
259
+ * process is on, which is a fault in this CLI rather than in the host.
260
+ */
261
+ function requireUid() {
262
+ const getuid = process.getuid;
263
+ if (getuid === undefined) {
264
+ throw new HarnessError('detection reported the launchd backend, but this runtime exposes no POSIX user id to build the launchctl domain target from', EXIT.INTERNAL);
265
+ }
266
+ return getuid.call(process);
267
+ }
268
+ /**
269
+ * Run one tool with a fixed argument vector and report how it exited.
270
+ *
271
+ * A non-zero status is returned rather than thrown, so the caller can render it as the readable line
272
+ * this command's contract promises before deciding what it means. A tool that could not be spawned
273
+ * at all *is* thrown, because it is a different condition with a different fix: detection said this
274
+ * host has the service manager, and the binary then was not there.
275
+ */
276
+ function runTool(command, args) {
277
+ try {
278
+ execFileSync(command, [...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
279
+ return { status: 0, output: '' };
280
+ }
281
+ catch (error) {
282
+ const failure = error;
283
+ if (failure.code === 'ENOENT') {
284
+ throw new HarnessError(`${command} is not on PATH, although the daemon backend was detected as available: nothing was changed`);
285
+ }
286
+ const status = typeof failure.status === 'number' ? failure.status : EXIT.FAILURE;
287
+ const stderr = typeof failure.stderr === 'string' ? failure.stderr.trim() : '';
288
+ return { status, output: stderr === '' ? failure.message : stderr };
289
+ }
290
+ }
291
+ /**
292
+ * How an invocation is shown to a person: the program and its arguments, space-joined.
293
+ *
294
+ * **Display only.** Nothing built here is ever executed — every call goes out as the argument vector
295
+ * itself — so a value containing a space renders ambiguously and still runs as one argument.
296
+ */
297
+ function commandLine(command, args) {
298
+ return [command, ...args].join(' ');
299
+ }
300
+ /** `systemctl --user <words…>` — the exact line an operator types, printed and never run. */
301
+ function systemctlLine(...words) {
302
+ return commandLine('systemctl', ['--user', ...words]);
303
+ }
304
+ /**
305
+ * Run one service-manager command, report the status as a line, and refuse on a non-zero one.
306
+ *
307
+ * The status is reported either way, because "it ran and said 3" is the only useful thing to know
308
+ * about a service manager that declined, and `hint` says what that usually means for the verb that
309
+ * asked — a service already loaded, or one that was not running to begin with.
310
+ */
311
+ function drive(ctx, command, args, hint) {
312
+ const line = commandLine(command, args);
313
+ const result = runTool(command, args);
314
+ if (result.status === 0) {
315
+ ctx.report.ok(`${line} — exit 0`);
316
+ return;
317
+ }
318
+ const detail = result.output === '' ? '' : `: ${result.output}`;
319
+ throw new HarnessError(`${line} — exit ${result.status}${detail}. ${hint}`);
320
+ }
321
+ /**
322
+ * The two values an operator needs to address this service by hand, and the one a dry run is asked
323
+ * for: where the unit is, and what the service manager calls it.
324
+ *
325
+ * Routed through the reporter's `result` sink — stdout whatever the flags are — rather than through
326
+ * narration, and on the same sink in both modes: they are what the command was run to find out, and
327
+ * a quiet run that printed neither would leave the operator with nothing to type.
328
+ */
329
+ function describeUnit(ctx, backend, unit) {
330
+ ctx.report.info(`backend: ${backend.kind} — ${backend.reason}`);
331
+ ctx.report.result(`unit: ${unit.targetPath}`);
332
+ ctx.report.result(`label: ${unit.label}`);
333
+ }
334
+ /** An error's message, for a line that reports a failure without being one. */
335
+ function messageOf(error) {
336
+ return error instanceof Error ? error.message : String(error);
337
+ }
338
+ /**
339
+ * Read the installed unit's `PATH` back, through the parse that inverts the escaping the render wrote
340
+ * (`daemon/units.ts`, {@link unitEnvValue}). Neither the path nor the backend is re-derived: both
341
+ * come from the `renderUnit` result the caller already holds, per that module's founding rule.
342
+ */
343
+ function installedEnvPath(kind, path) {
344
+ if (!existsSync(path))
345
+ return { comparable: false };
346
+ try {
347
+ return { comparable: true, envPath: unitEnvValue(kind, readFileSync(path, 'utf8'), 'PATH') };
348
+ }
349
+ catch {
350
+ return { comparable: false };
351
+ }
352
+ }
353
+ /** The entries of `value` that `other` does not hold, in `value`'s own order and each named once. */
354
+ function entriesNotIn(value, other) {
355
+ const present = new Set(other.split(PATH_SEPARATOR));
356
+ const missing = [];
357
+ for (const entry of value.split(PATH_SEPARATOR)) {
358
+ if (!present.has(entry) && !missing.includes(entry))
359
+ missing.push(entry);
360
+ }
361
+ return missing;
362
+ }
363
+ /**
364
+ * Report the `PATH` this install carries, and say whether it moved.
365
+ *
366
+ * On a **first** install the whole value is printed: there is nothing to diff it against, and it is
367
+ * the one value in the unit that comes from the environment rather than from the repository, which
368
+ * only the operator can say reaches their toolchain. A dry run renders the same value it would
369
+ * write, so that line is this machine's own `PATH` printed by a run that writes nothing. On a
370
+ * **re-install** the whole value in the same line and the same place is precisely what carries no
371
+ * signal, so what is printed instead is the difference from the unit on disk — `+` for an entry this
372
+ * install adds, `-` for one it drops, and a single line when there is neither. A key appearing or
373
+ * disappearing is stated rather than diffed, because a set difference against nothing reads as a
374
+ * first install.
375
+ *
376
+ * A dropped entry additionally goes to `warn`: a toolchain directory leaving the daemon's `PATH` is
377
+ * the failure `daemon/units.ts` choice 5 exists to prevent, and is what `doctor`'s `daemon-path`
378
+ * check reports later as a command the daemon cannot resolve.
379
+ *
380
+ * **`willReplace` decides whether the comparison may be worded as a change**, and every arm that
381
+ * asserts one is gated on it. The unit write is `create-if-absent`, so a re-install without
382
+ * `--force` keeps the file on disk and the daemon goes on running on exactly the `PATH` it had:
383
+ * warning there that a directory *left* names a loss that did not happen, and its remedy — re-install
384
+ * from a richer shell — is the same run declining to write again. On that arm the entries are still
385
+ * printed, because the divergence between this shell and the installed unit is what the operator came
386
+ * for, but they are printed as a comparison, on `info` (nothing was lost, so nothing needs a human),
387
+ * and they name `--force` as what acts on it.
388
+ *
389
+ * **`dryRun` decides the tense of the arms `willReplace` opened.** `--force --dry-run` plans a
390
+ * replacement and commits none, so those same sentences would assert a change from a run that writes
391
+ * nothing — and the two that report a dropped directory go to `warn`, which prints under `--quiet`
392
+ * where the step header saying this is a preview does not. Each therefore switches verb and says the
393
+ * loss has not happened yet, the way `renderProfile` and `writeClaudeContext` word their own previews.
394
+ * The comparison arms need no such gate: they are true in either mood.
395
+ *
396
+ * Returns whether the daemon's `PATH` moved — the second half of {@link bootstrapRefusedHint} — which
397
+ * a run that writes nothing never did, and never reads there: `install` returns before `drive` under
398
+ * `--dry-run`.
399
+ */
400
+ function reportEnvPath(ctx, unit, installed, willReplace, dryRun) {
401
+ const previous = installed.comparable ? installed.envPath : undefined;
402
+ const forceHint = `re-run \`${CLI} daemon install --force\` to write this shell's PATH into it, after a .bak`;
403
+ if (unit.envPath === undefined) {
404
+ if (!willReplace) {
405
+ ctx.report.warn(`this shell has no PATH, so a unit rendered from it would carry no environment key — but this run keeps the unit already at ${unit.targetPath}, so what the daemon runs on does not change. A forced re-install from this shell would drop it to the service manager's own default directories`);
406
+ return false;
407
+ }
408
+ const noKeyRemedy = `re-run \`${CLI} daemon install --force\` from a shell whose PATH reaches the toolchain, or add the PATH by hand to ${unit.targetPath} and reload the unit`;
409
+ ctx.report.warn(dryRun
410
+ ? `this shell has no PATH, so the unit this run would write carries no environment key and would leave the daemon on the service manager's own default directories — this run writes nothing, so it is not there yet: ${noKeyRemedy}`
411
+ : `this shell has no PATH, so the unit carries no environment key and the daemon will run on the service manager's own default directories: ${noKeyRemedy}`);
412
+ if (previous === undefined)
413
+ return false;
414
+ ctx.report.warn(`the unit already installed carries a PATH and this one carries none, so replacing it ${dryRun ? 'would drop' : 'drops'} the daemon to those defaults. What is installed today: ${previous}`);
415
+ return true;
416
+ }
417
+ if (!installed.comparable) {
418
+ // Nothing installed, or a unit that could not be read. The first replaces; the second is a file
419
+ // this run keeps, so the value is named as the one it would have written rather than as the
420
+ // daemon's.
421
+ ctx.report.info(willReplace
422
+ ? `PATH the daemon will run on: ${unit.envPath}`
423
+ : `the unit already at ${unit.targetPath} could not be read for a PATH comparison, and this run keeps it: the PATH this shell would install is ${unit.envPath}`);
424
+ return false;
425
+ }
426
+ if (previous === undefined) {
427
+ if (!willReplace) {
428
+ ctx.report.info(`the unit already installed carries no PATH and this run keeps it, so the daemon stays on the service manager's own default directories: ${forceHint} — this shell's is ${unit.envPath}`);
429
+ return false;
430
+ }
431
+ ctx.report.info(dryRun
432
+ ? `the unit already installed carries no PATH, and the one this run would write carries: ${unit.envPath}`
433
+ : `the unit already installed carries no PATH, and this one carries: ${unit.envPath}`);
434
+ return true;
435
+ }
436
+ if (previous === unit.envPath) {
437
+ // The one comparison that is the same sentence either way: nothing moved and nothing would.
438
+ ctx.report.info('PATH unchanged from the installed unit');
439
+ return false;
440
+ }
441
+ const added = entriesNotIn(unit.envPath, previous);
442
+ const removed = entriesNotIn(previous, unit.envPath);
443
+ if (added.length === 0 && removed.length === 0) {
444
+ // Same entries, different order — a real change when it is written, since a PATH resolves left
445
+ // to right, and nothing at all when the unit is kept.
446
+ ctx.report.info(willReplace
447
+ ? 'PATH holds the same entries as the installed unit, in a different order'
448
+ : `PATH holds the same entries as the installed unit in a different order, and this run keeps that unit: ${forceHint}`);
449
+ return willReplace;
450
+ }
451
+ if (!willReplace) {
452
+ ctx.report.info(`the unit already installed carries a different PATH from this shell's, and this run keeps that unit: ${added.length} ${added.length === 1 ? 'directory' : 'directories'} this shell has that it does not, ${removed.length} it has that this shell does not. ${forceHint}`);
453
+ for (const entry of added)
454
+ ctx.report.info(` + ${entry}`);
455
+ for (const entry of removed)
456
+ ctx.report.info(` - ${entry}`);
457
+ return false;
458
+ }
459
+ ctx.report.info(dryRun
460
+ ? "PATH would differ from the installed unit (+ this install's, - the installed unit's):"
461
+ : "PATH differs from the installed unit (+ this install's, - the installed unit's):");
462
+ for (const entry of added)
463
+ ctx.report.info(` + ${entry}`);
464
+ for (const entry of removed)
465
+ ctx.report.warn(` - ${entry}`);
466
+ if (removed.length > 0) {
467
+ ctx.report.warn(`a directory leaving the daemon's PATH is what \`${CLI} doctor\`'s daemon-path check reports later as a command the daemon cannot resolve${dryRun
468
+ ? ': this run writes nothing, so nothing has left it yet — re-run without --dry-run from a shell whose PATH reaches the toolchain if that was not intended'
469
+ : ': if that was not intended, re-install from a shell whose PATH reaches the toolchain'}`);
470
+ }
471
+ return true;
472
+ }
473
+ /**
474
+ * What a refused `launchctl bootstrap` means, in the two states an install can be refused in.
475
+ *
476
+ * The second arm exists because the exit code says the load failed, not that the environment moved:
477
+ * the agent still loaded runs on the `PATH` it was loaded with, the unit file now carries a different
478
+ * one, and the `.bak` beside it holds the only copy on disk of what is actually running. It is
479
+ * composed from what this run measured — `backupPath` is given only when the unit was replaced *and*
480
+ * the `PATH` changed — so an unrelated refusal is not decorated with a story that does not apply.
481
+ */
482
+ function bootstrapRefusedHint(unit, backupPath) {
483
+ const remedy = `run \`${CLI} daemon stop\` first, then install again`;
484
+ if (backupPath === undefined) {
485
+ return `An agent already loaded under this label cannot be bootstrapped a second time: ${remedy}`;
486
+ }
487
+ return `An agent already loaded under this label cannot be bootstrapped a second time, and this install moved its PATH, so three things now disagree: the agent still running was loaded with the previous PATH, ${unit.targetPath} carries the one written just now, and ${backupPath} holds the only copy on disk of what is running. To make the running daemon and the unit file agree, ${remedy}`;
488
+ }
489
+ /**
490
+ * Record this repository in the machine registry — the one write `install` makes besides the unit.
491
+ *
492
+ * **Every value comes from the answers the unit itself was written from**: the `renderUnit` result
493
+ * (`label`, `targetPath`, the descriptive `projectName` its text carries) and the detected backend.
494
+ * Nothing is re-derived here, which is what stops an entry describing a unit that does not exist
495
+ * under that name — the same rule `daemon/units.ts` exists to enforce, extended one artifact further.
496
+ *
497
+ * **A failed registration warns and does not fail the install.** The unit is on disk and the service
498
+ * is loadable at that point; a machine-wide index that could not be updated costs a line in
499
+ * `daemon list` and nothing else, and reporting the install as failed would send an operator to
500
+ * repair something that already worked. `docs/watcher.md` §7 is why that trade is safe: the registry
501
+ * is derived state, and re-running `daemon install` rebuilds the entry.
502
+ *
503
+ * Registration happens on **both** backends. On systemd the load is the operator's, but the unit
504
+ * file has been written either way, and this index mirrors installed units rather than running ones.
505
+ */
506
+ function register(ctx, repoRoot, backend, unit) {
507
+ try {
508
+ upsertRepository({
509
+ root: repoRoot,
510
+ projectName: unit.projectName,
511
+ label: unit.label,
512
+ backend: backend.kind,
513
+ unitPath: unit.targetPath,
514
+ registered_at: Math.floor(Date.now() / 1000),
515
+ });
516
+ ctx.report.ok(`registered ${repoRoot} in ${registryPath()}`);
517
+ }
518
+ catch (error) {
519
+ ctx.report.warn(`the daemon is installed, but this repository could not be recorded in ${registryPath()} (${messageOf(error)}): \`${CLI} daemon list\` will not show it until a later \`daemon install\` succeeds in writing it`);
520
+ }
521
+ }
522
+ /** `daemon install`: write the unit, register the repository, then load it or print how to. */
523
+ function install(ctx, invocation) {
524
+ const repoRoot = resolveRepoRoot(ctx.cwd);
525
+ const config = requireConfig(repoRoot);
526
+ const backend = requireBackend();
527
+ // Before anything is rendered or enqueued: a unit whose program is absent is worse than no unit,
528
+ // because it installs, starts, fails and is restarted forever.
529
+ const watcher = resolveWatcherPath({ repoRoot, config, override: invocation.watcher });
530
+ if (!watcher.present)
531
+ throw new HarnessError(watcherMissingMessage(watcher.path));
532
+ // `process.env.PATH` is the whole of the capture: a user unit runs on the service manager's
533
+ // environment, not this shell's, so the PATH this command was typed at is the only evidence of
534
+ // where the adopter's toolchain lives (`daemon/units.ts`, choice 5).
535
+ const unit = renderUnit({ backend, config, repoRoot, watcherPath: watcher.path, envPath: process.env['PATH'] });
536
+ ctx.report.step(ctx.flags.dryRun ? 'daemon install (dry run — nothing is written and nothing is run)' : 'daemon install');
537
+ describeUnit(ctx, backend, unit);
538
+ ctx.report.info(`watcher: ${watcher.path}`);
539
+ // The installed unit is read here, before the plan below is built: that is the last moment the
540
+ // value this install replaces still exists, and reading it before rather than after the write is
541
+ // what lets a dry run report the comparison a real run would make. What is compared is the PATH
542
+ // `renderUnit` says it wrote, never a second reading of the environment.
543
+ //
544
+ // The plan's own decision is handed to the report rather than left for it to assume: the unit
545
+ // write below is `create-if-absent`, so this run replaces the file only when `--force` is given or
546
+ // nothing is there, and a comparison against a unit the run then keeps must not be worded as a
547
+ // change to it. Same predicate as the plan's, evaluated before the plan for the same reason the
548
+ // read above is — and, like the plan's, it is only half the answer: `--dry-run` skips the one
549
+ // commit boundary `core/writer.ts` has, so a replacement it plans is still a replacement that does
550
+ // not happen. Both facts go to the report, which words the difference and its tense from them.
551
+ const willReplace = ctx.flags.force || !existsSync(unit.targetPath);
552
+ const pathMoved = reportEnvPath(ctx, unit, installedEnvPath(backend.kind, unit.targetPath), willReplace, ctx.flags.dryRun);
553
+ // One of the writes the CLI makes outside the repository — the others are the machine-local
554
+ // push-notification settings file `init --notifications` writes (`generators/notifications.ts`)
555
+ // and the registry entry below — and each is machine-local by its artifact's definition rather
556
+ // than by its command's choice: a user unit lives in the account's home because that is where a
557
+ // user service manager reads units from. `create-if-absent` keeps a re-install from silently
558
+ // discarding an operator's edits to the unit; `--force` regenerates it after the engine has
559
+ // written a `.bak` sibling.
560
+ const plan = new WritePlan();
561
+ plan.add({
562
+ path: unit.targetPath,
563
+ policy: 'create-if-absent',
564
+ content: unit.text,
565
+ label: `${backend.kind} unit`,
566
+ mode: UNIT_MODE,
567
+ allowOutsideRepo: true,
568
+ });
569
+ const [written] = plan.apply({ repoRoot, report: ctx.report, dryRun: ctx.flags.dryRun, force: ctx.flags.force });
570
+ // After the unit exists and before the service manager is addressed. `upsertRepository` writes
571
+ // when it is called and is not on the plan above (`machine/registry.ts`'s last paragraph), so a
572
+ // dry run reports the registration it would make instead of making it.
573
+ if (ctx.flags.dryRun)
574
+ ctx.report.result(`would register ${repoRoot} in ${registryPath()}`);
575
+ else
576
+ register(ctx, repoRoot, backend, unit);
577
+ if (backend.kind === 'systemd') {
578
+ ctx.report.result(systemctlLine('daemon-reload'));
579
+ ctx.report.result(systemctlLine('enable', '--now', unit.label));
580
+ ctx.report.info(SYSTEMD_PRINTED_REASON);
581
+ return EXIT.OK;
582
+ }
583
+ const args = ['bootstrap', `gui/${requireUid()}`, unit.targetPath];
584
+ if (ctx.flags.dryRun) {
585
+ ctx.report.result(`would run: ${commandLine('launchctl', args)}`);
586
+ return EXIT.OK;
587
+ }
588
+ // The `.bak` is named only when this install both replaced the unit and moved its PATH, which is
589
+ // the state where the running daemon, the file and the backup are three different answers.
590
+ const replaced = pathMoved && written?.effect === 'backed-up-and-replaced' ? written.backupPath : undefined;
591
+ drive(ctx, 'launchctl', args, bootstrapRefusedHint(unit, replaced));
592
+ return EXIT.OK;
593
+ }
594
+ /**
595
+ * `daemon start` / `daemon stop`.
596
+ *
597
+ * The unit is re-rendered rather than looked up, because rendering is what produces the label and the
598
+ * install path *together*: composing the label here from the configuration would be a second
599
+ * derivation of it, and the day the two disagreed the daemon would be one no `stop` could reach.
600
+ * Its text is discarded — nothing is written by either verb.
601
+ *
602
+ * The file precondition is `start`'s alone. What `stop` is asked is whether an agent is loaded under
603
+ * this label, which the unit file's presence does not answer and `bootout` does not need — the label
604
+ * arrives from the same `renderUnit` call the path does, so nothing is re-derived to reach it, and a
605
+ * unit file deleted by hand while its agent is still loaded (the only removal route this CLI
606
+ * documents, per the header) must leave a note behind rather than a stranded daemon. `start` keeps
607
+ * the precondition, and for two reasons rather than one: on systemd the `systemctl --user start`
608
+ * line it prints reads the unit file, so the file is a real requirement; on launchd `kickstart`
609
+ * addresses a loaded label and would not need it either, so the check there is a deliberate guard —
610
+ * a missing unit almost always means the operator wants `install`, and `start` is the one verb
611
+ * whose pointing at `install` leads nowhere circular.
612
+ */
613
+ function lifecycle(ctx, invocation, verb) {
614
+ const repoRoot = resolveRepoRoot(ctx.cwd);
615
+ const config = requireConfig(repoRoot);
616
+ const backend = requireBackend();
617
+ const watcher = resolveWatcherPath({ repoRoot, config, override: invocation.watcher });
618
+ // Rendered for its label and its path; the text — and so this PATH — is discarded. The value is
619
+ // supplied all the same because `renderUnit` requires one, which is what stops a second call site
620
+ // from quietly rendering a different unit than the one `install` writes.
621
+ const unit = renderUnit({ backend, config, repoRoot, watcherPath: watcher.path, envPath: process.env['PATH'] });
622
+ const installed = existsSync(unit.targetPath);
623
+ if (verb === 'start' && !installed) {
624
+ throw new HarnessError(`no ${backend.kind} unit at ${unit.targetPath}, so there is nothing to ${verb}: run \`${CLI} daemon install\` first`);
625
+ }
626
+ ctx.report.step(ctx.flags.dryRun ? `daemon ${verb} (dry run — nothing is run)` : `daemon ${verb}`);
627
+ describeUnit(ctx, backend, unit);
628
+ // Reported under `--dry-run` too (choice 3 in the header): a dry run that dropped this would hide
629
+ // the state it was run to discover. It names no next command — an `install` over a still-loaded
630
+ // agent is the one that is guaranteed to fail.
631
+ if (!installed)
632
+ ctx.report.warn(missingUnitNote(repoRoot, backend, unit, verb));
633
+ if (backend.kind === 'systemd') {
634
+ ctx.report.result(systemctlLine(verb, unit.label));
635
+ ctx.report.info(SYSTEMD_PRINTED_REASON);
636
+ return EXIT.OK;
637
+ }
638
+ const uid = requireUid();
639
+ const target = `gui/${uid}/${unit.label}`;
640
+ // `kickstart -k` starts the service and restarts it if it was already running, which is what makes
641
+ // `start` usable as "make it be running" after the unit has been regenerated.
642
+ const args = verb === 'start' ? ['kickstart', '-k', target] : ['bootout', target];
643
+ const hint = verb === 'start'
644
+ ? `The unit file is installed but may not be loaded into this login session: run \`${CLI} daemon install\` to bootstrap it, then start it`
645
+ : 'Nothing was loaded under that label, or it had already been booted out; check with `launchctl print` on the domain target above';
646
+ if (ctx.flags.dryRun) {
647
+ ctx.report.result(`would run: ${commandLine('launchctl', args)}`);
648
+ return EXIT.OK;
649
+ }
650
+ drive(ctx, 'launchctl', args, hint);
651
+ return EXIT.OK;
652
+ }
653
+ /**
654
+ * The note {@link lifecycle} prints when no unit file is at the install path.
655
+ *
656
+ * Only `stop` reaches it — `start` refuses above — and neither the label nor the command turns on the
657
+ * file, so nothing about the *action* is decided here. What is decided is the sentence: an absent
658
+ * unit has two causes, and asserting the wrong one sends an operator hunting a file they never had.
659
+ *
660
+ * **Why the registry is read here and nowhere else in {@link lifecycle}.** It is the only record that
661
+ * separates them. `machine/registry.ts` holds an entry per `install`, so an entry naming this root
662
+ * *and this unit path* with no file there is the one state this CLI can attribute: it removes no
663
+ * path, so a hand removed that one. Both fields are compared because each rules out a different
664
+ * impostor — a truncated slug can be shared by two checkouts, and an entry written under another
665
+ * `HOME` names a unit this run was never asked about.
666
+ *
667
+ * **The converse does not hold, which is why the other wording names both causes.** The read fails
668
+ * open (`readRegistry`), and `list --prune` drops the entry of a repository whose unit is already
669
+ * gone — so "no entry" is not "no install", and the note says what it saw rather than picking one.
670
+ */
671
+ function missingUnitNote(repoRoot, backend, unit, verb) {
672
+ const entry = readRegistry().repos[repoSlug(repoRoot)];
673
+ const attributable = entry?.root === repoRoot && entry?.unitPath === unit.targetPath;
674
+ const cause = attributable
675
+ ? `${registryPath()} records an install from this checkout, and this CLI removes no path — so the unit file was removed by hand`
676
+ : 'either no daemon was installed from this checkout, or its unit file was removed by hand — this CLI removes no path';
677
+ return `no ${backend.kind} unit at ${unit.targetPath}: ${cause}; this ${verb} acts on the label above, which is rendered from the repository root and needs no file`;
678
+ }
679
+ /**
680
+ * The slug of the repository this command was run in, or `undefined` when it was not run in one.
681
+ *
682
+ * Probed rather than resolved: `list` must answer from anywhere (choice 4 in the header), so being
683
+ * outside a repository is an ordinary answer here and not a refusal. The slug comes from the same
684
+ * `repoSlug` the registry is keyed on and the unit is named by, so "this checkout" means the same
685
+ * thing in a listing as it does in a label.
686
+ */
687
+ function currentSlug(cwd) {
688
+ const probe = probeRepoRoot(cwd);
689
+ return probe.kind === 'repository' ? repoSlug(probe.root) : undefined;
690
+ }
691
+ /** `1 stale entry` / `N stale entries` — the count a prune reports, in the grammar it deserves. */
692
+ function staleCount(count) {
693
+ return count === 1 ? '1 stale entry' : `${count} stale entries`;
694
+ }
695
+ /**
696
+ * One entry as a line: where it is, what it was installed into, what it is called, and — only when
697
+ * it is not `ok` — how it is stale. A leading `*` marks the repository the command was run in.
698
+ */
699
+ function entryLine(inspected, here) {
700
+ const marker = inspected.slug === here ? '*' : ' ';
701
+ const state = inspected.state === 'ok' ? '' : ` [${inspected.state}]`;
702
+ const { root, backend, label } = inspected.entry;
703
+ return `${marker} ${inspected.slug} ${root} ${backend} ${label}${state}`;
704
+ }
705
+ /**
706
+ * `--prune`: drop exactly the entries this listing graded stale, and say what went.
707
+ *
708
+ * **It removes entries and nothing else** — never a unit file, never a repository, never a
709
+ * directory, and never through a shelled-out command (the header's "removes nothing from disk").
710
+ * The slugs are the ones {@link inspect} graded a moment ago rather than a second grading of its
711
+ * own, so what is removed is what was printed.
712
+ *
713
+ * The count is `removeRepositories`' own answer rather than the number of lines above it, because a
714
+ * `daemon install` running concurrently in one of those repositories may legitimately have re-added
715
+ * an entry between the grading and the removal.
716
+ */
717
+ function prune(ctx, entries) {
718
+ const stale = entries.filter((entry) => entry.state !== 'ok');
719
+ if (stale.length === 0) {
720
+ // Said even when there was nothing to prune, and even when there was nothing at all: a flag
721
+ // that was typed and then produced no line of its own reads as a flag that was ignored.
722
+ ctx.report.result(entries.length === 0
723
+ ? 'nothing to prune: no repositories are registered'
724
+ : 'nothing to prune: every registered repository is still there, with its unit installed');
725
+ return;
726
+ }
727
+ if (ctx.flags.dryRun) {
728
+ for (const entry of stale)
729
+ ctx.report.result(`would remove ${entry.slug} (${entry.state})`);
730
+ ctx.report.result(`would remove ${staleCount(stale.length)} from ${registryPath()}`);
731
+ return;
732
+ }
733
+ const removed = removeRepositories(stale.map((entry) => entry.slug));
734
+ for (const entry of stale)
735
+ ctx.report.result(`removed ${entry.slug} (${entry.state})`);
736
+ ctx.report.result(`removed ${staleCount(removed)} from ${registryPath()}`);
737
+ }
738
+ /**
739
+ * `daemon list [--prune]`: what is armed on this machine.
740
+ *
741
+ * The enumeration goes to the `result` sink rather than to `info` — stdout whatever the flags are —
742
+ * for the reason {@link describeUnit} records for the unit path and the label: it is what the
743
+ * command was run for, and a `--quiet` run that printed none of it would have answered nothing.
744
+ *
745
+ * **An empty registry is exit 0 and one line.** Nothing is wrong with a machine that has no daemons
746
+ * installed, so the answer is the fact plus what would change it, not a refusal.
747
+ */
748
+ function list(ctx, invocation) {
749
+ const entries = inspect(readRegistry());
750
+ const here = currentSlug(ctx.cwd);
751
+ const dryPrune = invocation.prune && ctx.flags.dryRun;
752
+ ctx.report.step(dryPrune ? 'daemon list (dry run — nothing is removed)' : 'daemon list');
753
+ ctx.report.info(`registry: ${registryPath()}`);
754
+ if (entries.length === 0) {
755
+ ctx.report.result(`no repositories are registered on this machine: \`${CLI} daemon install\`, run in a wired repository, is what adds one`);
756
+ }
757
+ else {
758
+ for (const entry of entries)
759
+ ctx.report.result(entryLine(entry, here));
760
+ if (entries.some((entry) => entry.slug === here)) {
761
+ ctx.report.info('* marks the repository this command was run in');
762
+ }
763
+ }
764
+ if (invocation.prune)
765
+ prune(ctx, entries);
766
+ return EXIT.OK;
767
+ }
768
+ /** Parse the verb and hand off. Every verb returns its own exit code and throws to refuse. */
769
+ async function run(ctx) {
770
+ const invocation = parseInvocation(ctx.argv);
771
+ if (invocation.verb === 'install')
772
+ return install(ctx, invocation);
773
+ if (invocation.verb === 'list')
774
+ return list(ctx, invocation);
775
+ return lifecycle(ctx, invocation, invocation.verb);
776
+ }
777
+ /**
778
+ * The registry row for this command.
779
+ *
780
+ * Exported as the row itself rather than as a `run` the table wraps, so the summary, the usage lines
781
+ * and the behaviour stay in the file that owns them. The import back to `commands/registry.ts` is
782
+ * type-only and therefore erased, so the table can list this row without a runtime cycle.
783
+ */
784
+ export const DAEMON_COMMAND = {
785
+ name: 'daemon',
786
+ summary: SUMMARY,
787
+ roadmapItem: ROADMAP_ITEM,
788
+ usage: DAEMON_USAGE,
789
+ run,
790
+ };
791
+ //# sourceMappingURL=daemon.js.map