@indigoai-us/hq-cli 5.115.5 → 5.116.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 (62) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/command-catalog.generated.d.ts +162 -2
  3. package/dist/command-catalog.generated.js +205 -2
  4. package/dist/command-registration-plan.d.ts +6 -0
  5. package/dist/command-registration-plan.js +1 -0
  6. package/dist/commands/agent-enroll.d.ts +105 -0
  7. package/dist/commands/agent-enroll.js +273 -0
  8. package/dist/commands/agent-kit.d.ts +53 -0
  9. package/dist/commands/agent-kit.js +260 -0
  10. package/dist/commands/agent-mcp.d.ts +22 -0
  11. package/dist/commands/agent-mcp.js +104 -0
  12. package/dist/commands/agent-probe.d.ts +71 -0
  13. package/dist/commands/agent-probe.js +294 -0
  14. package/dist/commands/agent.d.ts +12 -0
  15. package/dist/commands/agent.js +23 -0
  16. package/dist/commands/agents.d.ts +27 -0
  17. package/dist/commands/agents.js +280 -6
  18. package/dist/commands/secrets.js +17 -5
  19. package/dist/lib/agent-kit/creds.d.ts +60 -0
  20. package/dist/lib/agent-kit/creds.js +123 -0
  21. package/dist/lib/agent-kit/kit-config.d.ts +29 -0
  22. package/dist/lib/agent-kit/kit-config.js +54 -0
  23. package/dist/lib/agent-kit/log.d.ts +17 -0
  24. package/dist/lib/agent-kit/log.js +46 -0
  25. package/dist/lib/agent-kit/mcp/jsonrpc.d.ts +84 -0
  26. package/dist/lib/agent-kit/mcp/jsonrpc.js +164 -0
  27. package/dist/lib/agent-kit/mcp/tools.d.ts +45 -0
  28. package/dist/lib/agent-kit/mcp/tools.js +280 -0
  29. package/dist/lib/agent-kit/paths.d.ts +42 -0
  30. package/dist/lib/agent-kit/paths.js +56 -0
  31. package/dist/lib/agent-kit/run/heartbeat.d.ts +52 -0
  32. package/dist/lib/agent-kit/run/heartbeat.js +97 -0
  33. package/dist/lib/agent-kit/run/inbox.d.ts +59 -0
  34. package/dist/lib/agent-kit/run/inbox.js +152 -0
  35. package/dist/lib/agent-kit/run/mesh-listener.d.ts +58 -0
  36. package/dist/lib/agent-kit/run/mesh-listener.js +193 -0
  37. package/dist/lib/agent-kit/run/sync.d.ts +33 -0
  38. package/dist/lib/agent-kit/run/sync.js +58 -0
  39. package/dist/lib/agent-kit/services.d.ts +21 -0
  40. package/dist/lib/agent-kit/services.js +46 -0
  41. package/dist/lib/agent-kit/skills.d.ts +18 -0
  42. package/dist/lib/agent-kit/skills.js +149 -0
  43. package/dist/lib/doctor/checks/sync-health.d.ts +19 -0
  44. package/dist/lib/doctor/checks/sync-health.js +55 -2
  45. package/dist/lib/doctor/fix/apply.d.ts +43 -6
  46. package/dist/lib/doctor/fix/apply.js +116 -18
  47. package/dist/lib/doctor/fix/remediation.d.ts +8 -3
  48. package/dist/lib/doctor/fix/remediation.js +21 -2
  49. package/dist/lib/scan-packages/index.js +158 -1
  50. package/dist/lib/service-manager/index.d.ts +43 -0
  51. package/dist/lib/service-manager/index.js +114 -0
  52. package/dist/lib/service-manager/launchd.d.ts +23 -0
  53. package/dist/lib/service-manager/launchd.js +81 -0
  54. package/dist/lib/service-manager/systemd.d.ts +19 -0
  55. package/dist/lib/service-manager/systemd.js +72 -0
  56. package/dist/lib/service-manager/types.d.ts +32 -0
  57. package/dist/lib/service-manager/types.js +26 -0
  58. package/dist/utils/self-update.js +2 -30
  59. package/dist/utils/update-command-supervisor.cjs +194 -0
  60. package/dist/utils/version-gate.d.ts +18 -0
  61. package/dist/utils/version-gate.js +126 -7
  62. package/package.json +2 -2
@@ -8,11 +8,13 @@
8
8
  * `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses and exits non-zero
9
9
  * unless `--force`, so a repair can never be tangled up with unrelated
10
10
  * in-flight edits to the security layer.
11
- * 2. Allowlist only. It repairs exactly three classes — restore an execute
12
- * bit, add a hook id to the gate profiles it is missing from, re-register a
13
- * script present on disk and NEVER rewrites a hook body or deletes a file.
14
- * Classification is owned by {@link deriveRemediation}; a content-drift
15
- * finding is manual-only and simply never appears in the fixable set.
11
+ * 2. Allowlist only. It repairs the classified safe classes — restore an
12
+ * execute bit, add a hook id to the gate profiles it is missing from,
13
+ * re-register a script present on disk, or pull a company whose local
14
+ * tree exists but whose journal is stale and NEVER rewrites a hook body
15
+ * or deletes a file. Classification is owned by {@link deriveRemediation};
16
+ * a content-drift finding is manual-only and simply never appears in the
17
+ * fixable set.
16
18
  * 3. Preview + confirmation. Every change is shown diff-style and requires
17
19
  * confirmation, with `--yes` for non-interactive use.
18
20
  * 4. Backup first. Before any write, the affected files are copied under
@@ -26,7 +28,7 @@
26
28
  * (the command resolves it once), keeping this module decoupled from the CLI and
27
29
  * trivially testable against a fake tree.
28
30
  */
29
- import type { DoctorStatus } from "../types.js";
31
+ import type { CheckResult, DoctorStatus } from "../types.js";
30
32
  import { type GateProfile } from "../hook-gate-profiles.js";
31
33
  import { type FixClass } from "./remediation.js";
32
34
  /** The tree subtrees whose uncommitted changes block a `--fix` run. */
@@ -56,7 +58,35 @@ export interface ApplyFixesOptions {
56
58
  * [] when clean or not a git repo. Default shells out to `git status`.
57
59
  */
58
60
  dirtyCheck?: (hqRoot: string) => string[];
61
+ /**
62
+ * Check runner used to collect findings and to re-verify after applying.
63
+ * Default runs the real registry. Tests inject a fake list so refresh-sync
64
+ * does not depend on the machine's live journal store.
65
+ */
66
+ runChecks?: (hqRoot: string) => Promise<CheckResult[]>;
67
+ /**
68
+ * Targeted pull used by the `refresh-sync` class. Default shells out to
69
+ * `hq sync pull --company <slug> --on-conflict keep`. Tests inject a stub
70
+ * so `--fix` never hits the network.
71
+ */
72
+ refreshSync?: (target: RefreshSyncTarget) => Promise<RefreshSyncResult>;
59
73
  }
74
+ /** One company the `refresh-sync` class will pull. */
75
+ export interface RefreshSyncTarget {
76
+ /** Company slug from the `sync.journal.<slug>` check id. */
77
+ slug: string;
78
+ /** HQ root the pull writes into. */
79
+ hqRoot: string;
80
+ }
81
+ /** Outcome of one {@link ApplyFixesOptions.refreshSync} call. */
82
+ export interface RefreshSyncResult {
83
+ /** Whether the pull exited 0. */
84
+ ok: boolean;
85
+ /** One-line summary for the post-fix report. */
86
+ message: string;
87
+ }
88
+ /** Per-company bound for the default `hq sync pull` repair. */
89
+ export declare const REFRESH_SYNC_TIMEOUT_MS = 90000;
60
90
  /** One applied (or attempted) repair, with its post-fix re-check status. */
61
91
  export interface AppliedFix {
62
92
  /** The check id of the finding that was repaired. */
@@ -94,6 +124,13 @@ export interface ApplyFixesResult {
94
124
  * assert on it.
95
125
  */
96
126
  export declare function applyFixes(options: ApplyFixesOptions): Promise<ApplyFixesResult>;
127
+ /**
128
+ * Default `refresh-sync` repair: a bounded, pull-only, keep-conflicts
129
+ * `hq sync pull --company <slug>` against the tree `--fix` is repairing.
130
+ * Never a push, never interactive. A timeout or non-zero exit is reported
131
+ * rather than thrown so later plans still run.
132
+ */
133
+ export declare function defaultRefreshSync(target: RefreshSyncTarget): Promise<RefreshSyncResult>;
97
134
  /**
98
135
  * The uncommitted changes under {@link HOOK_CONFIG_DIRS}, one porcelain line
99
136
  * each. Returns [] when the tree is clean OR when `hqRoot` is not a git repo
@@ -8,11 +8,13 @@
8
8
  * `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses and exits non-zero
9
9
  * unless `--force`, so a repair can never be tangled up with unrelated
10
10
  * in-flight edits to the security layer.
11
- * 2. Allowlist only. It repairs exactly three classes — restore an execute
12
- * bit, add a hook id to the gate profiles it is missing from, re-register a
13
- * script present on disk and NEVER rewrites a hook body or deletes a file.
14
- * Classification is owned by {@link deriveRemediation}; a content-drift
15
- * finding is manual-only and simply never appears in the fixable set.
11
+ * 2. Allowlist only. It repairs the classified safe classes — restore an
12
+ * execute bit, add a hook id to the gate profiles it is missing from,
13
+ * re-register a script present on disk, or pull a company whose local
14
+ * tree exists but whose journal is stale and NEVER rewrites a hook body
15
+ * or deletes a file. Classification is owned by {@link deriveRemediation};
16
+ * a content-drift finding is manual-only and simply never appears in the
17
+ * fixable set.
16
18
  * 3. Preview + confirmation. Every change is shown diff-style and requires
17
19
  * confirmation, with `--yes` for non-interactive use.
18
20
  * 4. Backup first. Before any write, the affected files are copied under
@@ -36,6 +38,8 @@ import { createBackup } from "./backup.js";
36
38
  import { deriveRemediation } from "./remediation.js";
37
39
  /** The tree subtrees whose uncommitted changes block a `--fix` run. */
38
40
  export const HOOK_CONFIG_DIRS = [".claude", ".codex", ".grok"];
41
+ /** Per-company bound for the default `hq sync pull` repair. */
42
+ export const REFRESH_SYNC_TIMEOUT_MS = 90_000;
39
43
  /**
40
44
  * Apply every auto-fixable finding, honouring the dirty-tree refusal, the
41
45
  * preview/confirmation gate, the pre-write backup, and the post-fix re-check.
@@ -68,8 +72,10 @@ export async function applyFixes(options) {
68
72
  return empty({ exitCode: 1, refused: "dirty-tree" });
69
73
  }
70
74
  }
75
+ const collect = options.runChecks ?? defaultRunChecks;
76
+ const refreshSync = options.refreshSync ?? defaultRefreshSync;
71
77
  // 2. Collect findings and keep only the allowlisted auto-fixable ones.
72
- const findings = await runChecks(hqRoot);
78
+ const findings = await collect(hqRoot);
73
79
  const fixable = [];
74
80
  for (const result of findings) {
75
81
  const rem = deriveRemediation(result);
@@ -81,7 +87,7 @@ export async function applyFixes(options) {
81
87
  return empty();
82
88
  }
83
89
  const plans = fixable
84
- .map(({ result, rem }) => planFix(hqRoot, result, rem))
90
+ .map(({ result, rem }) => planFix(hqRoot, result, rem, refreshSync))
85
91
  .filter((plan) => plan !== null);
86
92
  if (plans.length === 0) {
87
93
  write("hq doctor --fix: nothing to repair — no auto-fixable findings.\n");
@@ -99,16 +105,20 @@ export async function applyFixes(options) {
99
105
  }
100
106
  }
101
107
  // 4. Back up every file about to change BEFORE the first write (AC6).
102
- const affected = unique(plans.map((plan) => plan.relpath));
103
- const backup = createBackup(hqRoot, affected, options.now);
108
+ // refresh-sync plans have no hook file; skip the backup pass when nothing
109
+ // in the tree is about to be copied.
110
+ const affected = unique(plans.map((plan) => plan.relpath).filter((rel) => rel.length > 0));
111
+ const backup = affected.length > 0 ? createBackup(hqRoot, affected, options.now) : null;
104
112
  // 5. Apply. Each plan reads the current on-disk state, so multiple plans that
105
113
  // touch the same file (two gate ids) compose correctly.
106
114
  for (const plan of plans)
107
- plan.apply();
108
- write(`\nBacked up ${backup.files.length} file${backup.files.length === 1 ? "" : "s"} to ${backup.dir}\n`);
109
- write(`To restore: ${backup.restoreCommand}\n`);
115
+ await plan.apply();
116
+ if (backup) {
117
+ write(`\nBacked up ${backup.files.length} file${backup.files.length === 1 ? "" : "s"} to ${backup.dir}\n`);
118
+ write(`To restore: ${backup.restoreCommand}\n`);
119
+ }
110
120
  // 6. Re-run the checks and report the post-fix status of each repair (AC7).
111
- const after = await runChecks(hqRoot);
121
+ const after = await collect(hqRoot);
112
122
  const applied = plans.map((plan) => ({
113
123
  checkId: plan.checkId,
114
124
  fixClass: plan.fixClass,
@@ -125,17 +135,83 @@ export async function applyFixes(options) {
125
135
  wrote: true,
126
136
  refused: null,
127
137
  fixableCount: fixable.length,
128
- backupDir: backup.dir,
129
- restoreCommand: backup.restoreCommand,
138
+ backupDir: backup?.dir ?? null,
139
+ restoreCommand: backup?.restoreCommand ?? null,
130
140
  applied,
131
141
  };
132
142
  }
133
143
  /** Run the default (read-only) check registry and flatten to a result list. */
134
- async function runChecks(hqRoot) {
135
- const context = { hqRoot, platform: { id: "unknown" } };
144
+ async function defaultRunChecks(hqRoot) {
145
+ // `--fix` never repairs the integrations family (networked inventory), so
146
+ // skip it: a hung control-plane read must not stall the safe repair pass.
147
+ const context = {
148
+ hqRoot,
149
+ platform: { id: "unknown" },
150
+ integrations: false,
151
+ };
136
152
  const families = await createDefaultRegistry().run(context);
137
153
  return flattenFamilies(families);
138
154
  }
155
+ /**
156
+ * Default `refresh-sync` repair: a bounded, pull-only, keep-conflicts
157
+ * `hq sync pull --company <slug>` against the tree `--fix` is repairing.
158
+ * Never a push, never interactive. A timeout or non-zero exit is reported
159
+ * rather than thrown so later plans still run.
160
+ */
161
+ export function defaultRefreshSync(target) {
162
+ const hqBin = process.argv[1];
163
+ const args = hqBin
164
+ ? [
165
+ hqBin,
166
+ "sync",
167
+ "pull",
168
+ "--company",
169
+ target.slug,
170
+ "--hq-root",
171
+ target.hqRoot,
172
+ "--on-conflict",
173
+ "keep",
174
+ "--lock-timeout",
175
+ "30",
176
+ ]
177
+ : [
178
+ "sync",
179
+ "pull",
180
+ "--company",
181
+ target.slug,
182
+ "--hq-root",
183
+ target.hqRoot,
184
+ "--on-conflict",
185
+ "keep",
186
+ "--lock-timeout",
187
+ "30",
188
+ ];
189
+ const result = spawnSync(hqBin ? process.execPath : "hq", args, {
190
+ encoding: "utf8",
191
+ timeout: REFRESH_SYNC_TIMEOUT_MS,
192
+ cwd: target.hqRoot,
193
+ });
194
+ if (result.error) {
195
+ const timedOut = result.error.message.includes("ETIMEDOUT") || result.signal === "SIGTERM";
196
+ return Promise.resolve({
197
+ ok: false,
198
+ message: timedOut
199
+ ? `timed out pulling '${target.slug}'`
200
+ : `could not start pull for '${target.slug}': ${result.error.message}`,
201
+ });
202
+ }
203
+ if (result.status !== 0) {
204
+ const detail = (result.stderr || result.stdout || "").trim().split("\n").at(-1) ?? "";
205
+ return Promise.resolve({
206
+ ok: false,
207
+ message: `pull of '${target.slug}' exited ${result.status}${detail ? `: ${detail}` : ""}`,
208
+ });
209
+ }
210
+ return Promise.resolve({
211
+ ok: true,
212
+ message: `pulled '${target.slug}'`,
213
+ });
214
+ }
139
215
  // --- Dirty-tree probe ----------------------------------------------------------
140
216
  /**
141
217
  * The uncommitted changes under {@link HOOK_CONFIG_DIRS}, one porcelain line
@@ -155,7 +231,7 @@ export function uncommittedHookConfigChanges(hqRoot) {
155
231
  }
156
232
  // --- Fix planning --------------------------------------------------------------
157
233
  /** Build the concrete plan for one auto-fixable finding, or null if unplannable. */
158
- function planFix(hqRoot, result, rem) {
234
+ function planFix(hqRoot, result, rem, refreshSync) {
159
235
  switch (rem.fixClass) {
160
236
  case "executable-bit":
161
237
  return planExecutableBit(hqRoot, result, rem);
@@ -163,10 +239,32 @@ function planFix(hqRoot, result, rem) {
163
239
  return planGateProfile(hqRoot, result, rem);
164
240
  case "register-hook":
165
241
  return planRegisterHook(hqRoot, result, rem);
242
+ case "refresh-sync":
243
+ return planRefreshSync(hqRoot, result, rem, refreshSync);
166
244
  default:
167
245
  return null;
168
246
  }
169
247
  }
248
+ /** Pull one company to refresh a stale or never-synced journal. */
249
+ function planRefreshSync(hqRoot, result, rem, refreshSync) {
250
+ const slug = rem.fixTarget;
251
+ if (!slug)
252
+ return null;
253
+ return {
254
+ checkId: result.checkId,
255
+ fixClass: "refresh-sync",
256
+ target: slug,
257
+ relpath: "",
258
+ preview: ` companies/${slug}\n` +
259
+ ` ~ hq sync pull --company ${slug} --on-conflict keep\n` +
260
+ ` refresh the stale sync journal (keeps local conflict copies)`,
261
+ summary: `pull ${slug} to refresh its sync journal`,
262
+ apply: async () => {
263
+ await refreshSync({ slug, hqRoot });
264
+ },
265
+ postStatus: (results) => results.find((entry) => entry.checkId === result.checkId)?.status ?? null,
266
+ };
267
+ }
170
268
  /** Restore the execute bit on a hook script. */
171
269
  function planExecutableBit(hqRoot, result, rem) {
172
270
  const abs = rem.fixTarget;
@@ -7,8 +7,8 @@
7
7
  * classes, and if so exactly which file or hook id to act on. This module is the
8
8
  * single classifier that turns a {@link CheckResult} into that structured shape.
9
9
  *
10
- * The three — and only three — auto-fixable classes are a deliberate, security
11
- * -reviewed allowlist (see the PRD decision record):
10
+ * The auto-fixable classes are a deliberate, security-reviewed allowlist
11
+ * (see the PRD decision record):
12
12
  *
13
13
  * - `executable-bit` — restore the execute bit on a hook script that exists
14
14
  * but lost `+x`. Detected by the shared `chmod +x …`
@@ -19,6 +19,11 @@
19
19
  * `…gate-profiles` FAIL from the Claude wiring tier.
20
20
  * - `register-hook` — re-register a script that is present on disk but wired
21
21
  * nowhere. Detected by the `…orphan` WARN.
22
+ * - `refresh-sync` — run a targeted pull for a company journal that is
23
+ * stale or never-synced AND has a local
24
+ * `companies/<slug>` tree. Pull-only, `--on-conflict
25
+ * keep`, no hook-file rewrite. Personal journals and
26
+ * leftover shards without a local tree stay manual.
22
27
  *
23
28
  * EVERYTHING else — content drift between platform copies, a missing script, a
24
29
  * missing Codex counterpart, an unquoted `$CLAUDE_PROJECT_DIR`, a stale
@@ -30,7 +35,7 @@
30
35
  */
31
36
  import type { CheckResult } from "../types.js";
32
37
  /** The allowlisted safe repair classes `--fix` is permitted to apply. */
33
- export type FixClass = "executable-bit" | "gate-profile" | "register-hook";
38
+ export type FixClass = "executable-bit" | "gate-profile" | "register-hook" | "refresh-sync";
34
39
  /**
35
40
  * A finding's structured remediation. `autoFixable`, `action`, and `command`
36
41
  * are the `--json` surface (AC1); `fixTarget`/`fixClass` are the internal handle
@@ -7,8 +7,8 @@
7
7
  * classes, and if so exactly which file or hook id to act on. This module is the
8
8
  * single classifier that turns a {@link CheckResult} into that structured shape.
9
9
  *
10
- * The three — and only three — auto-fixable classes are a deliberate, security
11
- * -reviewed allowlist (see the PRD decision record):
10
+ * The auto-fixable classes are a deliberate, security-reviewed allowlist
11
+ * (see the PRD decision record):
12
12
  *
13
13
  * - `executable-bit` — restore the execute bit on a hook script that exists
14
14
  * but lost `+x`. Detected by the shared `chmod +x …`
@@ -19,6 +19,11 @@
19
19
  * `…gate-profiles` FAIL from the Claude wiring tier.
20
20
  * - `register-hook` — re-register a script that is present on disk but wired
21
21
  * nowhere. Detected by the `…orphan` WARN.
22
+ * - `refresh-sync` — run a targeted pull for a company journal that is
23
+ * stale or never-synced AND has a local
24
+ * `companies/<slug>` tree. Pull-only, `--on-conflict
25
+ * keep`, no hook-file rewrite. Personal journals and
26
+ * leftover shards without a local tree stay manual.
22
27
  *
23
28
  * EVERYTHING else — content drift between platform copies, a missing script, a
24
29
  * missing Codex counterpart, an unquoted `$CLAUDE_PROJECT_DIR`, a stale
@@ -28,6 +33,7 @@
28
33
  * content-drift finding is manual-only, because that boundary is load-bearing:
29
34
  * deciding which of two diverged copies is correct needs human judgement.
30
35
  */
36
+ import { isCompanyJournalSlug, journalSlugFromCheckId, } from "../checks/sync-health.js";
31
37
  /** Matches an exec-bit remediation command, quoted or not: `chmod +x <path>`. */
32
38
  const CHMOD_EXEC = /^chmod\s+\+x\s+(.+)$/;
33
39
  /**
@@ -90,6 +96,19 @@ export function deriveRemediation(result) {
90
96
  command: remediation ?? `Register ${target} in .claude/settings.json.`,
91
97
  };
92
98
  }
99
+ // refresh-sync — a company journal that is stale or never-synced. Only
100
+ // company slugs: personal shards have no companies/<slug> tree and a
101
+ // SessionStart `--fix --yes` must not pull the personal vault unprompted.
102
+ const journalSlug = journalSlugFromCheckId(checkId);
103
+ if (journalSlug && isCompanyJournalSlug(journalSlug)) {
104
+ return {
105
+ autoFixable: true,
106
+ fixClass: "refresh-sync",
107
+ fixTarget: journalSlug,
108
+ action: `Pull company '${journalSlug}' to refresh its sync journal (keeps local conflict copies).`,
109
+ command: `hq sync pull --company ${journalSlug} --on-conflict keep`,
110
+ };
111
+ }
93
112
  // Manual-only: content drift, missing scripts/counterparts, unquoted
94
113
  // expansions, stale allowed-divergence entries, invalid settings, etc. `--fix`
95
114
  // never touches these.
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import * as yaml from 'js-yaml';
5
5
  import { contributionLinks } from '../../utils/pack-contributions.js';
6
+ import { CONTRIBUTION_TABLE } from '../../utils/contribution-table.js';
6
7
  function isDirectory(target) { try {
7
8
  return fs.statSync(target).isDirectory();
8
9
  }
@@ -140,6 +141,23 @@ function workerIdClashes(hqRoot, source) {
140
141
  }
141
142
  return undefined;
142
143
  }
144
+ /**
145
+ * What a symlink's target resolves to: `present`, `absent`, or the errno of a
146
+ * lookup that answered neither. Only ENOENT (nothing there) and ENOTDIR (a
147
+ * path component is not a directory, so the target cannot exist) mean absent.
148
+ * EACCES, ELOOP and friends mean the question went unanswered, which is not a
149
+ * licence to destroy what the link points at.
150
+ */
151
+ function targetLookup(target) {
152
+ try {
153
+ fs.statSync(target);
154
+ return 'present';
155
+ }
156
+ catch (error) {
157
+ const code = error.code;
158
+ return code === 'ENOENT' || code === 'ENOTDIR' ? 'absent' : code ?? 'unknown error';
159
+ }
160
+ }
143
161
  function ensureSymlink(link, info, warn) {
144
162
  if (!existsOrSymlink(link.src)) {
145
163
  warn(`payload missing: ${link.src} (declared but not shipped)`);
@@ -152,6 +170,26 @@ function ensureSymlink(link, info, warn) {
152
170
  const existing = fs.readlinkSync(link.dst);
153
171
  if (existing === link.src)
154
172
  return;
173
+ // "Host content wins" protects content. A symlink that does not resolve
174
+ // is not content — and it is the shape every pack link takes when a tree
175
+ // moves between machines, because these links are absolute and the old
176
+ // HQ root does not exist on the new host. Skipping it would make that
177
+ // state permanent: stale on this run, and on every run after it.
178
+ //
179
+ // Only a lookup that specifically says "not there" earns a relink.
180
+ // fs.existsSync also answers false for EACCES, which would read content
181
+ // this user merely cannot traverse as absent and delete the link to it.
182
+ const lookup = targetLookup(link.dst);
183
+ if (lookup === 'absent') {
184
+ fs.rmSync(link.dst, { force: true });
185
+ fs.symlinkSync(link.src, link.dst);
186
+ info(`relinked ${link.dst} -> ${link.src} (was ${existing}, which does not exist here)`);
187
+ return;
188
+ }
189
+ if (lookup !== 'present') {
190
+ warn(`collision: ${link.dst} points at ${existing}, which could not be checked (${lookup}) — leaving it alone`);
191
+ return;
192
+ }
155
193
  warn(`collision: ${link.dst} already points at ${existing} (wanted ${link.src}) — skipping`);
156
194
  return;
157
195
  }
@@ -165,6 +203,102 @@ function ensureSymlink(link, info, warn) {
165
203
  fs.symlinkSync(link.src, link.dst);
166
204
  info(`linked ${link.dst} -> ${link.src}`);
167
205
  }
206
+ /**
207
+ * The link text, when `entry` is a symlink whose target is definitively not
208
+ * there; `undefined` for anything else — a real file or directory, a link that
209
+ * resolves, or a lookup that failed for a reason other than absence.
210
+ */
211
+ function danglingLinkTarget(entry) {
212
+ let link;
213
+ try {
214
+ if (!fs.lstatSync(entry).isSymbolicLink())
215
+ return undefined;
216
+ link = fs.readlinkSync(entry);
217
+ }
218
+ catch {
219
+ return undefined;
220
+ }
221
+ return targetLookup(entry) === 'absent' ? link : undefined;
222
+ }
223
+ /** Host directories the table routes symlinks into, HQ-root relative. */
224
+ function symlinkHostRoots() {
225
+ const roots = new Set();
226
+ for (const row of Object.values(CONTRIBUTION_TABLE)) {
227
+ if (row.wire === 'symlink')
228
+ roots.add(row.host);
229
+ }
230
+ return [...roots].sort();
231
+ }
232
+ /**
233
+ * Remove host symlinks that point into `core/packages/` at a payload that is
234
+ * no longer there.
235
+ *
236
+ * Wiring only ever adds. A contribution a pack stops shipping, or a pack whose
237
+ * directory is itself a link into a deleted worktree, leaves its host links
238
+ * pointing at nothing, and nothing removed them — so they accumulated and
239
+ * warned on every run forever.
240
+ *
241
+ * Scoped three ways so this only reaps what the pack system wired:
242
+ * - only inside the host roots the contribution table declares,
243
+ * - only symlinks whose target resolves inside `core/packages/`,
244
+ * - only when the lookup specifically says the target is absent. EACCES and
245
+ * friends mean the question went unanswered, which is not a licence to
246
+ * delete (same discipline as the relink path).
247
+ *
248
+ * A symlinked directory is classified, never descended into: a Dirent reports
249
+ * it as a symlink rather than a directory, so the walk cannot wander out of
250
+ * the host roots through one.
251
+ */
252
+ function reapOrphanedLinks(hqRoot, info, warn) {
253
+ const packagesRoot = path.join(hqRoot, 'core/packages') + path.sep;
254
+ for (const hostRelative of symlinkHostRoots()) {
255
+ const stack = [path.join(hqRoot, hostRelative)];
256
+ while (stack.length > 0) {
257
+ const current = stack.pop();
258
+ let entries;
259
+ try {
260
+ entries = fs.readdirSync(current, { withFileTypes: true });
261
+ }
262
+ catch {
263
+ continue;
264
+ }
265
+ entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
266
+ for (const entry of entries) {
267
+ const absolute = path.join(current, entry.name);
268
+ if (entry.isDirectory()) {
269
+ stack.push(absolute);
270
+ continue;
271
+ }
272
+ if (!entry.isSymbolicLink())
273
+ continue;
274
+ let target;
275
+ try {
276
+ target = fs.readlinkSync(absolute);
277
+ }
278
+ catch {
279
+ continue;
280
+ }
281
+ const resolved = path.resolve(path.dirname(absolute), target);
282
+ if (!resolved.startsWith(packagesRoot))
283
+ continue;
284
+ const lookup = targetLookup(absolute);
285
+ if (lookup === 'present')
286
+ continue;
287
+ if (lookup !== 'absent') {
288
+ warn(`${absolute} points into core/packages at ${resolved}, which could not be checked (${lookup}) — leaving it alone`);
289
+ continue;
290
+ }
291
+ try {
292
+ fs.rmSync(absolute, { force: true });
293
+ info(`unwired ${absolute} (its pack payload ${resolved} is gone)`);
294
+ }
295
+ catch (error) {
296
+ warn(`could not unwire ${absolute} (${error.message})`);
297
+ }
298
+ }
299
+ }
300
+ }
301
+ }
168
302
  /** Wire installed pack contributions into their table-declared host locations. */
169
303
  export function scanPackages(hqRoot, options = {}) {
170
304
  const log = options.log ?? ((message) => process.stdout.write(`${message}\n`));
@@ -174,6 +308,9 @@ export function scanPackages(hqRoot, options = {}) {
174
308
  const warn = (message) => warnSink(` [warn] ${message}`);
175
309
  const packages = path.join(hqRoot, 'core/packages');
176
310
  if (!isDirectory(packages)) {
311
+ // Still sweep: a packages directory that is gone entirely is exactly when
312
+ // every link into it is an orphan.
313
+ reapOrphanedLinks(hqRoot, info, warn);
177
314
  info('[scan-packages] no core/packages/ dir; nothing to wire');
178
315
  return { status: 0 };
179
316
  }
@@ -190,7 +327,24 @@ export function scanPackages(hqRoot, options = {}) {
190
327
  for (const name of packageNames) {
191
328
  const packDir = path.join(packages, name);
192
329
  const manifest = path.join(packDir, 'package.yaml');
193
- if (!isDirectory(packDir) || !fs.existsSync(manifest))
330
+ if (!isDirectory(packDir)) {
331
+ // A dangling entry in core/packages/ is not a pack and cannot become
332
+ // one — typically an install from a worktree that was later removed.
333
+ // Only a symlink to nothing qualifies: a real directory holds real
334
+ // files whatever its manifest situation, and is left alone.
335
+ const danglingTarget = danglingLinkTarget(packDir);
336
+ if (danglingTarget !== undefined) {
337
+ try {
338
+ fs.rmSync(packDir, { force: true });
339
+ info(`[scan-packages] removed ${name}: its payload is gone (was a link to ${danglingTarget})`);
340
+ }
341
+ catch (error) {
342
+ warn(`could not remove the dangling pack entry ${packDir} (${error.message})`);
343
+ }
344
+ }
345
+ continue;
346
+ }
347
+ if (!fs.existsSync(manifest))
194
348
  continue;
195
349
  any = true;
196
350
  info(`[scan-packages] wiring ${name}`);
@@ -211,6 +365,9 @@ export function scanPackages(hqRoot, options = {}) {
211
365
  warnSink(`[scan-packages] error: ${error.message}`);
212
366
  return { status: 1 };
213
367
  }
368
+ // After wiring, so anything just created or repointed is present and only
369
+ // genuine orphans are left to reap.
370
+ reapOrphanedLinks(hqRoot, info, warn);
214
371
  if (!any)
215
372
  info('[scan-packages] no hq-pack manifests found in core/packages');
216
373
  return { status: 0 };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Install / uninstall / status for a SET of user-level services, dispatching
3
+ * to launchd or systemd. All filesystem and process-control effects are
4
+ * injectable so the kit tests exercise the real rendering and dispatch
5
+ * without touching the host's service manager.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import { type RunCommand } from "./launchd.js";
9
+ import { type ServiceHostPaths, type ServicePlatform, type ServiceSpec } from "./types.js";
10
+ export type { ServiceSpec, ServiceHostPaths, ServicePlatform } from "./types.js";
11
+ export { detectServicePlatform } from "./types.js";
12
+ export { renderLaunchdPlist } from "./launchd.js";
13
+ export { renderSystemdUserUnit, systemdUnitName } from "./systemd.js";
14
+ export declare const defaultRunCommand: RunCommand;
15
+ export interface ServiceManagerDeps {
16
+ platform?: ServicePlatform;
17
+ run?: RunCommand;
18
+ writeFileSync?: typeof fs.writeFileSync;
19
+ mkdirSync?: typeof fs.mkdirSync;
20
+ unlinkSync?: typeof fs.unlinkSync;
21
+ existsSync?: typeof fs.existsSync;
22
+ /** When false, render + write units but do not (de)activate them. */
23
+ activate?: boolean;
24
+ }
25
+ export interface ServiceUnitResult {
26
+ name: string;
27
+ unitPath: string;
28
+ rendered?: string;
29
+ installed: boolean;
30
+ running?: boolean;
31
+ error?: string;
32
+ }
33
+ export interface ServiceSetResult {
34
+ platform: ServicePlatform;
35
+ units: ServiceUnitResult[];
36
+ /** Human hint when the platform has no service manager integration. */
37
+ manualCommands?: string[];
38
+ }
39
+ export declare function renderUnit(platform: ServicePlatform, spec: ServiceSpec, host: ServiceHostPaths): string;
40
+ export declare function installServices(specs: ServiceSpec[], host: ServiceHostPaths, deps?: ServiceManagerDeps): ServiceSetResult;
41
+ export declare function uninstallServices(specs: ServiceSpec[], host: ServiceHostPaths, deps?: ServiceManagerDeps): ServiceSetResult;
42
+ export declare function servicesStatus(specs: ServiceSpec[], host: ServiceHostPaths, deps?: ServiceManagerDeps): ServiceSetResult;
43
+ //# sourceMappingURL=index.d.ts.map