@indigoai-us/hq-cli 5.167.0 → 5.169.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 (37) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/command-catalog.generated.d.ts +88 -1
  3. package/dist/command-catalog.generated.js +112 -1
  4. package/dist/commands/daemon.d.ts +2 -0
  5. package/dist/commands/daemon.js +79 -22
  6. package/dist/commands/lanes.js +146 -31
  7. package/dist/commands/mesh.js +13 -19
  8. package/dist/commands/outpost-pruner.d.ts +3 -0
  9. package/dist/commands/outpost-pruner.js +125 -0
  10. package/dist/lib/daemon/catalog.d.ts +11 -1
  11. package/dist/lib/daemon/catalog.js +31 -6
  12. package/dist/lib/daemon/install.d.ts +7 -0
  13. package/dist/lib/daemon/install.js +18 -2
  14. package/dist/lib/daemon/legacy.js +6 -1
  15. package/dist/lib/daemon/machine-type.d.ts +4 -1
  16. package/dist/lib/daemon/machine-type.js +29 -9
  17. package/dist/lib/daemon/migrate.js +2 -2
  18. package/dist/lib/daemon/remote-control.d.ts +32 -9
  19. package/dist/lib/daemon/remote-control.js +55 -11
  20. package/dist/lib/daemon/run.d.ts +6 -0
  21. package/dist/lib/daemon/run.js +43 -2
  22. package/dist/lib/daemon/tools.d.ts +52 -0
  23. package/dist/lib/daemon/tools.js +95 -0
  24. package/dist/lib/daemon/updater.d.ts +23 -15
  25. package/dist/lib/daemon/updater.js +97 -60
  26. package/dist/lib/lanes/mesh-events.d.ts +4 -0
  27. package/dist/lib/lanes/mesh-events.js +72 -4
  28. package/dist/lib/lanes/outpost-pruner-install.d.ts +37 -0
  29. package/dist/lib/lanes/outpost-pruner-install.js +226 -0
  30. package/dist/lib/lanes/outpost-worktree-pruner-flag.d.ts +19 -0
  31. package/dist/lib/lanes/outpost-worktree-pruner-flag.js +76 -0
  32. package/dist/lib/lanes/outpost-worktree-pruner.d.ts +60 -0
  33. package/dist/lib/lanes/outpost-worktree-pruner.js +1135 -0
  34. package/dist/lib/mesh/live/session-events-client.js +3 -2
  35. package/dist/lib/mesh/project-board.d.ts +7 -0
  36. package/dist/lib/mesh/project-board.js +29 -1
  37. package/package.json +1 -1
@@ -3,56 +3,77 @@
3
3
  * replaces the boxes' `hq-cli-update.timer` and `*-codex-update.timer`:
4
4
  *
5
5
  * 1. Update the hq CLI through the shared self-update path (same install
6
- * routing, lock and convergence check as every other CLI update).
7
- * 2. Update Codex when it is installed as a global npm package and behind.
6
+ * routing, lock and convergence check as every other CLI update), when it
7
+ * is a global install this process may replace.
8
+ * 2. Update the Claude, Codex and Grok CLIs the login user installed, each
9
+ * through its own update route. A copy this user cannot write (root's
10
+ * under /usr) is reported and left alone.
8
11
  * 3. After a CLI update, ask the daemon to restart itself so the supervisor and
9
- * every service run the new version.
12
+ * every service run the new version. The remote-control hosts notice an
13
+ * updated Claude or Codex on disk themselves and restart once idle.
10
14
  */
11
15
  import { spawnSync } from "node:child_process";
12
- import semver from "semver";
13
- export const CODEX_PACKAGE = "@openai/codex";
16
+ import * as fs from "node:fs";
17
+ import { locateTool, toolUpdateCommand } from "./tools.js";
14
18
  export async function runDaemonUpdater(deps) {
15
19
  let ok = true;
16
- const cli = await deps.updateCli();
17
20
  // The restart stops this process too, so it is requested only once all the
18
- // updater's work (including a global npm install of Codex) is done.
21
+ // updater's work (every tool install) is done.
19
22
  let restart = false;
20
- switch (cli.action) {
21
- case "updated":
22
- deps.log(`hq CLI updated to ${cli.latest ?? "the latest version"}; the daemon restarts onto it once Codex is checked`);
23
- restart = true;
24
- break;
25
- case "update-failed":
26
- case "update-ineffective":
27
- deps.log(`hq CLI update to ${cli.latest ?? "latest"} did not take effect (${cli.action})`);
23
+ if (deps.updateCli === null) {
24
+ deps.log("hq CLI: not a global install, so it is updated with the project or app that owns it");
25
+ }
26
+ else {
27
+ const cli = await deps.updateCli();
28
+ switch (cli.action) {
29
+ case "updated":
30
+ deps.log(`hq CLI updated to ${cli.latest ?? "the latest version"}; the daemon restarts onto it once the tools are checked`);
31
+ restart = true;
32
+ break;
33
+ case "update-failed":
34
+ case "update-ineffective":
35
+ deps.log(`hq CLI update to ${cli.latest ?? "latest"} did not take effect (${cli.action})`);
36
+ ok = false;
37
+ break;
38
+ default:
39
+ deps.log(`hq CLI: ${cli.action === "current" ? "up to date" : cli.action}`);
40
+ }
41
+ }
42
+ const updated = [];
43
+ for (const tool of deps.tools) {
44
+ const outcome = updateTool(tool, deps.log);
45
+ if (outcome === "failed")
28
46
  ok = false;
29
- break;
30
- default:
31
- deps.log(`hq CLI: ${cli.action === "current" ? "up to date" : cli.action}`);
47
+ if (outcome === "updated")
48
+ updated.push(tool.name);
32
49
  }
33
- if (!updateCodex(deps))
34
- ok = false;
35
50
  if (restart)
36
51
  deps.requestDaemonRestart();
37
- return { ok };
52
+ return { ok, updated };
38
53
  }
39
- /** Update Codex when it is a global npm install and behind; false when that update failed. */
40
- function updateCodex(deps) {
41
- const installed = deps.codex.installedVersion();
42
- if (installed === null)
43
- return true;
44
- const latest = deps.codex.latestVersion();
45
- if (latest === null || !semver.valid(installed) || !semver.valid(latest) || !semver.gt(latest, installed)) {
46
- deps.log(`Codex ${installed}: nothing to update`);
47
- return true;
54
+ function updateTool(tool, log) {
55
+ const before = tool.locate();
56
+ if (!before) {
57
+ log(`${tool.name}: not installed`);
58
+ return "skipped";
59
+ }
60
+ if (!before.writable) {
61
+ log(`${tool.name} at ${before.path} belongs to another account, so it is not updated as this user`);
62
+ return "skipped";
63
+ }
64
+ const result = tool.update(before);
65
+ if (!result.ok) {
66
+ log(`${tool.name} update failed: ${result.detail ?? "unknown error"}`);
67
+ return "failed";
48
68
  }
49
- const result = deps.codex.install();
50
- if (result.ok) {
51
- deps.log(`Codex updated ${installed} -> ${latest}`);
52
- return true;
69
+ const after = tool.locate();
70
+ const from = before.version ?? "unknown version";
71
+ if (after?.version && after.version !== before.version) {
72
+ log(`${tool.name} updated ${from} -> ${after.version}`);
73
+ return "updated";
53
74
  }
54
- deps.log(`Codex update ${installed} -> ${latest} failed: ${result.detail ?? "unknown error"}`);
55
- return false;
75
+ log(`${tool.name} ${from}: up to date`);
76
+ return "current";
56
77
  }
57
78
  /**
58
79
  * The variables for the updater's own CLI update. Every supervised service
@@ -64,39 +85,55 @@ export function updaterEnv(vars) {
64
85
  delete copy.HQ_NO_UPDATE_CHECK;
65
86
  return copy;
66
87
  }
67
- function npm(args) {
68
- const r = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", args, {
69
- encoding: "utf8",
70
- timeout: 10 * 60_000,
71
- windowsHide: true,
72
- shell: process.platform === "win32",
73
- });
74
- return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
75
- }
76
- /** Codex through the global npm install, the way the boxes install it. */
77
- export function npmCodexUpdater() {
88
+ function hostProbe(env, home) {
78
89
  return {
79
- installedVersion: () => {
80
- const r = npm(["ls", "-g", CODEX_PACKAGE, "--depth=0", "--json"]);
90
+ home,
91
+ env,
92
+ exists: (p) => {
81
93
  try {
82
- const parsed = JSON.parse(r.stdout);
83
- return parsed.dependencies?.[CODEX_PACKAGE]?.version ?? null;
94
+ fs.accessSync(p, fs.constants.X_OK);
95
+ return true;
84
96
  }
85
97
  catch {
86
- return null;
98
+ return false;
87
99
  }
88
100
  },
89
- latestVersion: () => {
90
- const r = npm(["view", CODEX_PACKAGE, "version"]);
91
- const v = r.status === 0 ? r.stdout.trim() : "";
92
- return v || null;
101
+ realpath: (p) => fs.realpathSync(p),
102
+ writable: (dir) => {
103
+ try {
104
+ fs.accessSync(dir, fs.constants.W_OK);
105
+ return true;
106
+ }
107
+ catch {
108
+ return false;
109
+ }
110
+ },
111
+ version: (bin) => {
112
+ const r = spawnSync(bin, ["--version"], { encoding: "utf8", env, timeout: 30_000, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
113
+ return { status: r.status, stdout: r.stdout ?? "" };
93
114
  },
94
- install: () => {
95
- const r = npm(["install", "-g", `${CODEX_PACKAGE}@latest`]);
115
+ };
116
+ }
117
+ /** The real updater for one tool, run as this (the login) user. */
118
+ export function hostToolUpdater(name, env, home) {
119
+ const probe = hostProbe(env, home);
120
+ return {
121
+ name,
122
+ locate: () => locateTool(name, probe),
123
+ update: (install) => {
124
+ const cmd = toolUpdateCommand(name, install);
125
+ const r = spawnSync(cmd.file, cmd.args, {
126
+ encoding: "utf8",
127
+ env: { ...env, ...cmd.env },
128
+ timeout: 10 * 60_000,
129
+ stdio: ["ignore", "pipe", "pipe"],
130
+ windowsHide: true,
131
+ shell: process.platform === "win32" && cmd.file === "npm",
132
+ });
96
133
  if (r.status === 0)
97
134
  return { ok: true };
98
- const tail = r.stderr.trim().split("\n").slice(-3).join(" ").slice(0, 400);
99
- return { ok: false, detail: tail || `npm exited ${r.status ?? "by signal"}` };
135
+ const tail = `${r.stderr ?? ""}\n${r.stdout ?? ""}`.trim().split("\n").slice(-3).join(" ").slice(0, 400);
136
+ return { ok: false, detail: tail || (r.error ? r.error.message : `${cmd.file} exited ${r.status ?? "by signal"}`) };
100
137
  },
101
138
  };
102
139
  }
@@ -42,6 +42,7 @@
42
42
  * of opening a browser nobody is watching, and the watcher records
43
43
  * `mesh_event_failed` and moves on. No credential file is read here.
44
44
  */
45
+ import { LanesError } from "./errors.js";
45
46
  import type { LaneCompanyRef, LaneEnvelope, LaneRecord, TerminalLaneState } from "./types.js";
46
47
  export declare const LANE_MESH_EVENTS: readonly ["lane_started", "lane_progress", "lane_question", "lane_question_at_user", "lane_ask", "lane_blocked", "lane_done", "lane_failed", "lane_interrupted", "lane_cancelled", "lane_killed"];
47
48
  export type LaneMeshEvent = (typeof LANE_MESH_EVENTS)[number];
@@ -73,6 +74,8 @@ export interface LaneMesh {
73
74
  ensureLaneThread?: (company: LaneCompanyRef, lane: LaneRecord, laneStatus?: string, signal?: AbortSignal) => Promise<string>;
74
75
  appendThreadEvent: (company: LaneCompanyRef, threadId: string, event: LaneMeshEvent, payload: Record<string, unknown>, signal?: AbortSignal) => Promise<void>;
75
76
  patchStoryStatus: (company: LaneCompanyRef, projectId: string, storyId: string, status: string) => Promise<void>;
77
+ /** Read-only Board membership check before a command writes lane state. */
78
+ assertProjectStoriesOnBoard?: (company: LaneCompanyRef, projectId: string, storyIds: readonly string[]) => Promise<void>;
76
79
  }
77
80
  export interface CreateLaneMeshOptions {
78
81
  /** False for detached callers (the watcher). See the token note above. */
@@ -88,6 +91,7 @@ export interface CreateLaneMeshOptions {
88
91
  status: string;
89
92
  }) => Promise<void>;
90
93
  }
94
+ export declare function storyNotOnBoardError(company: LaneCompanyRef, projectId: string, storyId: string): LanesError;
91
95
  export declare function createLaneMesh(opts?: CreateLaneMeshOptions): LaneMesh;
92
96
  /** Resolve the explicit mesh attribution before falling back to the lane tenant. */
93
97
  export declare function laneMeshCompany(lane: Pick<LaneRecord, "company" | "mesh_company">): LaneCompanyRef | undefined;
@@ -220,6 +220,31 @@ function isProjectViewNotFound(error) {
220
220
  return (candidate.code === "PROJECT_VIEW_NOT_FOUND" ||
221
221
  (typeof candidate.message === "string" && candidate.message.includes("PROJECT_VIEW_NOT_FOUND")));
222
222
  }
223
+ function isUnknownProjectStory(error) {
224
+ if (!error || typeof error !== "object")
225
+ return false;
226
+ const candidate = error;
227
+ return (candidate.status === 400 &&
228
+ candidate.code === "PROJECT_VIEW_INVALID" &&
229
+ typeof candidate.message === "string" &&
230
+ candidate.message.includes("Unknown story"));
231
+ }
232
+ function shellQuote(value) {
233
+ return "'" + value.replace(/'/g, "'\\''") + "'";
234
+ }
235
+ export function storyNotOnBoardError(company, projectId, storyId) {
236
+ const command = "hq mesh project ensure " + shellQuote(projectId) +
237
+ " --company " + shellQuote(company.slug) +
238
+ " --stories-file '<prd.json>'";
239
+ const message = "Story " + JSON.stringify(storyId) + " is not on the Work Mesh Board for project " +
240
+ JSON.stringify(projectId) + ". Add it to the project PRD, then run: " + command;
241
+ return new LanesError("story_not_on_board", message, {
242
+ message,
243
+ project_id: projectId,
244
+ story_id: storyId,
245
+ command,
246
+ });
247
+ }
223
248
  function isAlreadyRegistered(error) {
224
249
  if (!error || typeof error !== "object")
225
250
  return false;
@@ -293,18 +318,61 @@ export function createLaneMesh(opts = {}) {
293
318
  senior: { kind: lane.senior.kind, id: lane.senior.id },
294
319
  laneVersion: lane.version,
295
320
  };
296
- const ensured = await ensureLaneMeshThread(tok, resolved, lane.project_id, descriptor, isMeshLaneStatus(laneStatus) ? laneStatus : "running", signal);
297
- return ensured.threadId;
321
+ try {
322
+ const ensured = await ensureLaneMeshThread(tok, resolved, lane.project_id, descriptor, isMeshLaneStatus(laneStatus) ? laneStatus : "running", signal);
323
+ return ensured.threadId;
324
+ }
325
+ catch (error) {
326
+ if (lane.story_id && isUnknownProjectStory(error)) {
327
+ throw storyNotOnBoardError(company, lane.project_id, lane.story_id);
328
+ }
329
+ throw error;
330
+ }
298
331
  },
299
332
  async appendThreadEvent(company, threadId, event, payload, signal) {
300
333
  const wire = toWireThreadEvent(event, payload);
301
334
  const tok = await token();
302
335
  await appendThreadEvent(tok, company.uid, threadId, wire.eventKind, wire.payload, signal);
303
336
  },
337
+ async assertProjectStoriesOnBoard(company, projectId, storyIds) {
338
+ if (storyIds.length === 0)
339
+ return;
340
+ const tok = await token();
341
+ let view;
342
+ try {
343
+ view = await getProjectView(tok, company.uid, projectId);
344
+ }
345
+ catch (error) {
346
+ // A missing Board has a separate, established repair path in
347
+ // patchStoryStatus. Keep it available; an existing Board is strict.
348
+ if (isProjectViewNotFound(error))
349
+ return;
350
+ throw error;
351
+ }
352
+ if (view.companyUid !== company.uid || view.projectId !== projectId) {
353
+ throw new LanesError("project_board_scope_mismatch", "Work Mesh returned a Board for a different company or project", { project_id: projectId });
354
+ }
355
+ for (const storyId of storyIds) {
356
+ if (!view.stories.some((story) => story.id === storyId)) {
357
+ throw storyNotOnBoardError(company, projectId, storyId);
358
+ }
359
+ }
360
+ },
304
361
  async patchStoryStatus(company, projectId, storyId, status) {
305
362
  const tok = await token();
363
+ const patch = async () => {
364
+ try {
365
+ await patchStoryStatus(tok, company.uid, projectId, storyId, status);
366
+ }
367
+ catch (error) {
368
+ if (isUnknownProjectStory(error)) {
369
+ throw storyNotOnBoardError(company, projectId, storyId);
370
+ }
371
+ throw error;
372
+ }
373
+ };
306
374
  try {
307
- await patchStoryStatus(tok, company.uid, projectId, storyId, status);
375
+ await patch();
308
376
  }
309
377
  catch (error) {
310
378
  if (!isProjectViewNotFound(error))
@@ -316,7 +384,7 @@ export function createLaneMesh(opts = {}) {
316
384
  storyId,
317
385
  status,
318
386
  });
319
- await patchStoryStatus(tok, company.uid, projectId, storyId, status);
387
+ await patch();
320
388
  }
321
389
  },
322
390
  };
@@ -0,0 +1,37 @@
1
+ export declare const OUTPOST_PRUNER_PRESSURE_SERVICE = "hq-outpost-worktree-pruner-pressure.service";
2
+ export declare const OUTPOST_PRUNER_PRESSURE_TIMER = "hq-outpost-worktree-pruner-pressure.timer";
3
+ export declare const OUTPOST_PRUNER_DAILY_SERVICE = "hq-outpost-worktree-pruner-daily.service";
4
+ export declare const OUTPOST_PRUNER_DAILY_TIMER = "hq-outpost-worktree-pruner-daily.timer";
5
+ export declare const OUTPOST_PRUNER_MIN_AGE_DAYS = 14;
6
+ export interface OutpostPrunerInstallOptions {
7
+ systemdRoot: string;
8
+ hqRoot: string;
9
+ nodeBinary?: string;
10
+ hqBinary?: string;
11
+ home?: string;
12
+ flagApiUrl?: string;
13
+ companyUid?: string;
14
+ enable?: boolean;
15
+ runCommand?: (command: string, args: string[]) => {
16
+ status: number;
17
+ stdout?: string;
18
+ stderr?: string;
19
+ };
20
+ }
21
+ export interface OutpostPrunerInstallResult {
22
+ ok: true;
23
+ action: "outpost-pruner.install" | "outpost-pruner.uninstall";
24
+ systemd_root: string;
25
+ units: string[];
26
+ written?: boolean;
27
+ enabled?: boolean;
28
+ removed?: boolean;
29
+ }
30
+ export declare function renderOutpostPrunerPressureTimer(): string;
31
+ export declare function renderOutpostPrunerDailyTimer(): string;
32
+ export declare function defaultOutpostPrunerSystemdDir(home?: string): string;
33
+ export declare function installOutpostPrunerTimers(options: OutpostPrunerInstallOptions): OutpostPrunerInstallResult;
34
+ export declare function uninstallOutpostPrunerTimers(options: Pick<OutpostPrunerInstallOptions, "systemdRoot" | "runCommand"> & {
35
+ disable?: boolean;
36
+ }): OutpostPrunerInstallResult;
37
+ //# sourceMappingURL=outpost-pruner-install.d.ts.map
@@ -0,0 +1,226 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { resolveHqBinary, resolveNodeBinary } from "../mesh/live/daemon/install.js";
5
+ import { SYSTEMD_PATH_ENV } from "../service-manager/systemd.js";
6
+ export const OUTPOST_PRUNER_PRESSURE_SERVICE = "hq-outpost-worktree-pruner-pressure.service";
7
+ export const OUTPOST_PRUNER_PRESSURE_TIMER = "hq-outpost-worktree-pruner-pressure.timer";
8
+ export const OUTPOST_PRUNER_DAILY_SERVICE = "hq-outpost-worktree-pruner-daily.service";
9
+ export const OUTPOST_PRUNER_DAILY_TIMER = "hq-outpost-worktree-pruner-daily.timer";
10
+ export const OUTPOST_PRUNER_MIN_AGE_DAYS = 14;
11
+ function shellQuote(value) {
12
+ return /^[A-Za-z0-9_./:-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
13
+ }
14
+ function systemdEnvironment(name, value) {
15
+ if (/[\r\n\0]/.test(value))
16
+ throw new TypeError(`${name} contains a line break or NUL`);
17
+ const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%");
18
+ return `Environment="${name}=${escaped}"`;
19
+ }
20
+ function writeIfChanged(filePath, contents) {
21
+ try {
22
+ if (fs.readFileSync(filePath, "utf8") === contents)
23
+ return false;
24
+ }
25
+ catch (err) {
26
+ const code = err.code;
27
+ if (code !== "ENOENT")
28
+ throw err;
29
+ }
30
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
31
+ const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
32
+ try {
33
+ fs.writeFileSync(temporary, contents, { encoding: "utf8", mode: 0o644 });
34
+ fs.renameSync(temporary, filePath);
35
+ }
36
+ catch (err) {
37
+ try {
38
+ fs.unlinkSync(temporary);
39
+ }
40
+ catch (unlinkError) {
41
+ if (unlinkError.code !== "ENOENT") {
42
+ process.stderr.write(`outpost pruner install: temporary unit cleanup failed (${unlinkError.code ?? "Error"})\n`);
43
+ }
44
+ }
45
+ throw err;
46
+ }
47
+ return true;
48
+ }
49
+ function unlinkIfExists(filePath) {
50
+ try {
51
+ fs.unlinkSync(filePath);
52
+ return true;
53
+ }
54
+ catch (err) {
55
+ const code = err.code;
56
+ if (code === "ENOENT")
57
+ return false;
58
+ throw err;
59
+ }
60
+ }
61
+ function runSystemctl(args, run) {
62
+ const result = run("systemctl", args);
63
+ if (result.status !== 0)
64
+ throw new Error(`systemctl ${args.join(" ")} failed (${result.status})`);
65
+ }
66
+ function pnpmBinaryDirs() {
67
+ const resolved = [];
68
+ for (const directory of (process.env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
69
+ const binary = path.join(directory, "pnpm");
70
+ try {
71
+ fs.accessSync(binary, fs.constants.X_OK);
72
+ for (const candidate of [path.resolve(directory), path.dirname(fs.realpathSync(binary))]) {
73
+ if (!resolved.includes(candidate))
74
+ resolved.push(candidate);
75
+ }
76
+ break;
77
+ }
78
+ catch {
79
+ // Keep looking. The full interactive-shell PATH is not copied into units.
80
+ }
81
+ }
82
+ return resolved;
83
+ }
84
+ function unitPathEnv(hqBinary, nodeBinary) {
85
+ const dirs = [
86
+ ...pnpmBinaryDirs(),
87
+ path.dirname(path.resolve(hqBinary)),
88
+ path.dirname(path.resolve(nodeBinary)),
89
+ ...SYSTEMD_PATH_ENV.split(":"),
90
+ ];
91
+ return [...new Set(dirs)].filter(Boolean).join(":");
92
+ }
93
+ function renderService(opts) {
94
+ const command = [
95
+ opts.nodeBinary,
96
+ opts.hqBinary,
97
+ "lanes",
98
+ "outpost-pruner",
99
+ "run",
100
+ "--trigger",
101
+ opts.trigger,
102
+ "--min-age-days",
103
+ String(OUTPOST_PRUNER_MIN_AGE_DAYS),
104
+ ].map(shellQuote).join(" ");
105
+ return `[Unit]
106
+ Description=HQ outpost worktree pruning (${opts.trigger})
107
+ After=network-online.target
108
+ Wants=network-online.target
109
+
110
+ [Service]
111
+ Type=oneshot
112
+ ExecStart=${command}
113
+ WorkingDirectory=${shellQuote(opts.hqRoot)}
114
+ Nice=10
115
+ Environment=HOME=${shellQuote(opts.home)}
116
+ Environment=PATH=${shellQuote(opts.pathEnv)}
117
+ Environment=HQ_ROOT=${shellQuote(opts.hqRoot)}
118
+ Environment=HQ_NO_UPDATE_CHECK=1
119
+ ${opts.flagEnvironment}
120
+ KillMode=process
121
+
122
+ [Install]
123
+ WantedBy=default.target
124
+ `;
125
+ }
126
+ export function renderOutpostPrunerPressureTimer() {
127
+ return `[Unit]
128
+ Description=Check HQ outpost disk pressure for safe worktree cleanup
129
+
130
+ [Timer]
131
+ OnBootSec=2min
132
+ OnUnitActiveSec=5min
133
+ AccuracySec=30s
134
+ Persistent=true
135
+ Unit=${OUTPOST_PRUNER_PRESSURE_SERVICE}
136
+
137
+ [Install]
138
+ WantedBy=timers.target
139
+ `;
140
+ }
141
+ export function renderOutpostPrunerDailyTimer() {
142
+ return `[Unit]
143
+ Description=Daily HQ outpost safe worktree cleanup
144
+
145
+ [Timer]
146
+ OnCalendar=*-*-* 03:10:00
147
+ RandomizedDelaySec=10min
148
+ AccuracySec=1min
149
+ Persistent=true
150
+ Unit=${OUTPOST_PRUNER_DAILY_SERVICE}
151
+
152
+ [Install]
153
+ WantedBy=timers.target
154
+ `;
155
+ }
156
+ export function defaultOutpostPrunerSystemdDir(home = os.homedir()) {
157
+ return path.join(home, ".config", "systemd", "user");
158
+ }
159
+ export function installOutpostPrunerTimers(options) {
160
+ const nodeBinary = options.nodeBinary ?? resolveNodeBinary();
161
+ const hqBinary = options.hqBinary ?? resolveHqBinary();
162
+ const home = options.home ?? os.homedir();
163
+ const flagApiUrl = (options.flagApiUrl ?? process.env.HQ_FLAGS_API_URL ?? "").trim();
164
+ const companyUid = (options.companyUid ?? process.env.HQ_COMPANY_UID ?? "").trim();
165
+ if (!flagApiUrl || !companyUid) {
166
+ throw new TypeError("install requires HQ_FLAGS_API_URL and HQ_COMPANY_UID for the timer services");
167
+ }
168
+ const flagEnvironment = [
169
+ systemdEnvironment("HQ_FLAGS_API_URL", flagApiUrl),
170
+ systemdEnvironment("HQ_COMPANY_UID", companyUid),
171
+ ].join("\n");
172
+ const pathEnv = unitPathEnv(hqBinary, nodeBinary);
173
+ const units = [
174
+ [OUTPOST_PRUNER_PRESSURE_SERVICE, renderService({ trigger: "pressure", nodeBinary, hqBinary, hqRoot: options.hqRoot, home, pathEnv, flagEnvironment })],
175
+ [OUTPOST_PRUNER_PRESSURE_TIMER, renderOutpostPrunerPressureTimer()],
176
+ [OUTPOST_PRUNER_DAILY_SERVICE, renderService({ trigger: "daily", nodeBinary, hqBinary, hqRoot: options.hqRoot, home, pathEnv, flagEnvironment })],
177
+ [OUTPOST_PRUNER_DAILY_TIMER, renderOutpostPrunerDailyTimer()],
178
+ ];
179
+ let written = false;
180
+ for (const [name, contents] of units) {
181
+ written = writeIfChanged(path.join(options.systemdRoot, name), contents) || written;
182
+ }
183
+ let enabled = false;
184
+ if (options.enable) {
185
+ if (!options.runCommand)
186
+ throw new Error("--enable needs a systemctl runner");
187
+ runSystemctl(["--user", "daemon-reload"], options.runCommand);
188
+ runSystemctl(["--user", "enable", "--now", OUTPOST_PRUNER_PRESSURE_TIMER, OUTPOST_PRUNER_DAILY_TIMER], options.runCommand);
189
+ enabled = true;
190
+ }
191
+ return {
192
+ ok: true,
193
+ action: "outpost-pruner.install",
194
+ systemd_root: options.systemdRoot,
195
+ units: units.map(([name]) => path.join(options.systemdRoot, name)),
196
+ written,
197
+ enabled,
198
+ };
199
+ }
200
+ export function uninstallOutpostPrunerTimers(options) {
201
+ if (options.disable) {
202
+ if (!options.runCommand)
203
+ throw new Error("--disable needs a systemctl runner");
204
+ runSystemctl(["--user", "disable", "--now", OUTPOST_PRUNER_PRESSURE_TIMER, OUTPOST_PRUNER_DAILY_TIMER], options.runCommand);
205
+ }
206
+ const units = [
207
+ OUTPOST_PRUNER_PRESSURE_SERVICE,
208
+ OUTPOST_PRUNER_PRESSURE_TIMER,
209
+ OUTPOST_PRUNER_DAILY_SERVICE,
210
+ OUTPOST_PRUNER_DAILY_TIMER,
211
+ ];
212
+ let removed = false;
213
+ for (const name of units) {
214
+ removed = unlinkIfExists(path.join(options.systemdRoot, name)) || removed;
215
+ }
216
+ if (options.disable && options.runCommand)
217
+ runSystemctl(["--user", "daemon-reload"], options.runCommand);
218
+ return {
219
+ ok: true,
220
+ action: "outpost-pruner.uninstall",
221
+ systemd_root: options.systemdRoot,
222
+ units: units.map((name) => path.join(options.systemdRoot, name)),
223
+ removed,
224
+ };
225
+ }
226
+ //# sourceMappingURL=outpost-pruner-install.js.map
@@ -0,0 +1,19 @@
1
+ import { createFlagClient } from "@indigoai-us/hq-flags-client";
2
+ export declare const OUTPOST_WORKTREE_PRUNER_FLAG = "outpost.worktree-pruner";
3
+ export declare const OUTPOST_WORKTREE_PRUNER_FLAG_FALLBACK = false;
4
+ export declare const OUTPOST_WORKTREE_PRUNER_FLAG_TIMEOUT_MS = 5000;
5
+ export interface OutpostPrunerFlagClient {
6
+ ready(): Promise<void>;
7
+ isEnabled(flagKey: string, options?: {
8
+ fallback?: boolean;
9
+ }): boolean;
10
+ close(): void;
11
+ }
12
+ export interface OutpostPrunerFlagDependencies {
13
+ env?: NodeJS.ProcessEnv;
14
+ createClient?: typeof createFlagClient;
15
+ getToken?: () => string | Promise<string>;
16
+ reportFailure?: (error: unknown) => void;
17
+ }
18
+ export declare function outpostWorktreePrunerEnabled(dependencies?: OutpostPrunerFlagDependencies): Promise<boolean>;
19
+ //# sourceMappingURL=outpost-worktree-pruner-flag.d.ts.map