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,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
|