@indigoai-us/hq-cli 5.131.0 → 5.133.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/CHANGELOG.md +147 -0
- package/dist/command-catalog.generated.d.ts +12 -0
- package/dist/command-catalog.generated.js +16 -0
- package/dist/commands/lanes.d.ts +15 -4
- package/dist/commands/lanes.js +478 -48
- package/dist/commands/mesh.js +24 -2
- package/dist/commands/update.js +1 -1
- package/dist/lib/bot/paths.d.ts +7 -5
- package/dist/lib/bot/paths.js +8 -1
- package/dist/lib/bot/prompt.d.ts +0 -2
- package/dist/lib/bot/prompt.js +5 -11
- package/dist/lib/bot/room-policy.d.ts +1 -24
- package/dist/lib/bot/room-policy.js +2 -35
- package/dist/lib/bot/run.d.ts +7 -8
- package/dist/lib/bot/run.js +21 -33
- package/dist/lib/bot/session.d.ts +1 -1
- package/dist/lib/bot/session.js +1 -1
- package/dist/lib/lanes/accounts.d.ts +6 -0
- package/dist/lib/lanes/accounts.js +2 -1
- package/dist/lib/lanes/config.d.ts +4 -0
- package/dist/lib/lanes/config.js +9 -0
- package/dist/lib/lanes/constants.d.ts +3 -0
- package/dist/lib/lanes/constants.js +3 -0
- package/dist/lib/lanes/index.d.ts +11 -3
- package/dist/lib/lanes/index.js +6 -1
- package/dist/lib/lanes/mesh-events.js +4 -0
- package/dist/lib/lanes/models.d.ts +78 -0
- package/dist/lib/lanes/models.js +205 -0
- package/dist/lib/lanes/move-account.d.ts +56 -0
- package/dist/lib/lanes/move-account.js +193 -0
- package/dist/lib/lanes/provider-rejection.d.ts +86 -0
- package/dist/lib/lanes/provider-rejection.js +258 -0
- package/dist/lib/lanes/quota.d.ts +23 -3
- package/dist/lib/lanes/quota.js +88 -14
- package/dist/lib/lanes/reconcile-install.d.ts +24 -3
- package/dist/lib/lanes/reconcile-install.js +48 -12
- package/dist/lib/lanes/reconcile.d.ts +5 -2
- package/dist/lib/lanes/reconcile.js +135 -13
- package/dist/lib/lanes/spawn.js +26 -5
- package/dist/lib/lanes/store.js +10 -0
- package/dist/lib/lanes/types.d.ts +51 -2
- package/dist/lib/lanes/types.js +5 -3
- package/dist/lib/lanes/watcher.d.ts +27 -0
- package/dist/lib/lanes/watcher.js +68 -5
- package/dist/lib/mesh/live/backfill-held.d.ts +4 -3
- package/dist/lib/mesh/live/backfill-held.js +37 -14
- package/dist/lib/mesh/live/daemon/doctor.d.ts +6 -0
- package/dist/lib/mesh/live/daemon/doctor.js +16 -2
- package/dist/lib/mesh/live/daemon/install.d.ts +12 -0
- package/dist/lib/mesh/live/daemon/install.js +100 -7
- package/dist/lib/mesh/live/daemon/legacy-listen.d.ts +43 -0
- package/dist/lib/mesh/live/daemon/legacy-listen.js +89 -0
- package/dist/lib/mesh/live/daemon/live-cache.d.ts +26 -0
- package/dist/lib/mesh/live/daemon/live-cache.js +116 -0
- package/dist/lib/mesh/live/daemon/presence.d.ts +7 -3
- package/dist/lib/mesh/live/daemon/presence.js +10 -9
- package/dist/lib/mesh/live/daemon/run.d.ts +18 -0
- package/dist/lib/mesh/live/daemon/run.js +65 -6
- package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +31 -0
- package/dist/lib/mesh/live/daemon/work-subscribe.js +124 -0
- package/dist/lib/mesh/live/emit-evidence.d.ts +19 -0
- package/dist/lib/mesh/live/emit-evidence.js +20 -2
- package/dist/lib/work-context/company.d.ts +16 -0
- package/dist/lib/work-context/company.js +46 -14
- package/dist/utils/self-update.js +4 -1
- package/package.json +2 -2
|
@@ -163,9 +163,11 @@ function localCompanyCandidates(ref, state, canonicalHqRoot, canonicalHqRoots =
|
|
|
163
163
|
return candidates;
|
|
164
164
|
}
|
|
165
165
|
/**
|
|
166
|
-
* Keep only event evidence
|
|
167
|
-
*
|
|
168
|
-
*
|
|
166
|
+
* Keep only event evidence for the current session instance. When the state
|
|
167
|
+
* file has a binding episode, that id is the instance. A timestamp, including
|
|
168
|
+
* one later than startedAt, does not make another episode current.
|
|
169
|
+
* Sessions that predate episodes still use startedAt so an older event is
|
|
170
|
+
* not treated as this instance.
|
|
169
171
|
*/
|
|
170
172
|
function currentInstanceEventEvidence(ref, state) {
|
|
171
173
|
const evidence = ref.eventEvidence ?? [
|
|
@@ -176,6 +178,9 @@ function currentInstanceEventEvidence(ref, state) {
|
|
|
176
178
|
},
|
|
177
179
|
...(ref.hqRoots ?? []).filter((hqRoot) => hqRoot !== ref.hqRoot).map((hqRoot) => ({ hqRoot })),
|
|
178
180
|
];
|
|
181
|
+
if (state?.bindingEpisodeId) {
|
|
182
|
+
return evidence.filter((entry) => Boolean(entry.hqRoot) && entry.bindingEpisodeId === state.bindingEpisodeId);
|
|
183
|
+
}
|
|
179
184
|
if (!state?.startedAt)
|
|
180
185
|
return evidence;
|
|
181
186
|
const startedAt = Date.parse(state.startedAt);
|
|
@@ -188,8 +193,19 @@ function currentInstanceEventEvidence(ref, state) {
|
|
|
188
193
|
return Number.isFinite(eventAt) && eventAt >= startedAt;
|
|
189
194
|
});
|
|
190
195
|
}
|
|
196
|
+
/** Plain reason when every rooted held event belongs to another episode. */
|
|
197
|
+
function episodeMismatchReason(ref, state) {
|
|
198
|
+
if (!state?.bindingEpisodeId)
|
|
199
|
+
return null;
|
|
200
|
+
const rooted = (ref.eventEvidence ?? []).filter((entry) => entry.hqRoot);
|
|
201
|
+
if (rooted.length === 0)
|
|
202
|
+
return null;
|
|
203
|
+
if (rooted.some((entry) => entry.bindingEpisodeId === state.bindingEpisodeId))
|
|
204
|
+
return null;
|
|
205
|
+
return "episode does not match the current instance";
|
|
206
|
+
}
|
|
191
207
|
function hasOnlyStaleEventEvidence(ref, state) {
|
|
192
|
-
return Boolean(state?.startedAt &&
|
|
208
|
+
return Boolean((state?.bindingEpisodeId || state?.startedAt) &&
|
|
193
209
|
ref.eventEvidence?.some((entry) => entry.hqRoot) &&
|
|
194
210
|
!currentInstanceEventEvidence(ref, state).some((entry) => entry.hqRoot));
|
|
195
211
|
}
|
|
@@ -310,12 +326,18 @@ function latestAcknowledgedRegistration(sessionId, operations) {
|
|
|
310
326
|
function acknowledgedRegistrationMatches(operation, ref, evidence, state) {
|
|
311
327
|
if (!operation.companyUid || !operation.receiptId)
|
|
312
328
|
return false;
|
|
313
|
-
if (
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
329
|
+
if (state?.bindingEpisodeId) {
|
|
330
|
+
if (state.bindingEpisodeId !== operation.receiptId)
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
if (!state?.startedAt)
|
|
335
|
+
return false;
|
|
336
|
+
const startedAt = Date.parse(state.startedAt);
|
|
337
|
+
const registeredAt = Date.parse(operation.createdAt);
|
|
338
|
+
if (!Number.isFinite(startedAt) || !Number.isFinite(registeredAt) || registeredAt < startedAt) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
319
341
|
}
|
|
320
342
|
if (evidence &&
|
|
321
343
|
!candidatesAgree(evidence, {
|
|
@@ -325,8 +347,6 @@ function acknowledgedRegistrationMatches(operation, ref, evidence, state) {
|
|
|
325
347
|
return false;
|
|
326
348
|
if (ref.harness && operation.harness && ref.harness !== operation.harness)
|
|
327
349
|
return false;
|
|
328
|
-
if (state?.bindingEpisodeId && state.bindingEpisodeId !== operation.receiptId)
|
|
329
|
-
return false;
|
|
330
350
|
return true;
|
|
331
351
|
}
|
|
332
352
|
/** Shared doctor/backfill gate for a receipt that is safe to treat as current evidence. */
|
|
@@ -407,8 +427,9 @@ export function readHeldSessions(workMeshRoot) {
|
|
|
407
427
|
if (task && !ref.task)
|
|
408
428
|
ref.task = task;
|
|
409
429
|
const at = str(event.at) ?? (envelope ? str(envelope.heldAt) : undefined);
|
|
430
|
+
const bindingEpisodeId = str(event.bindingEpisodeId);
|
|
410
431
|
if (hqRoot || cwd) {
|
|
411
|
-
ref.eventEvidence = [...(ref.eventEvidence ?? []), { hqRoot, cwd, harness, at }];
|
|
432
|
+
ref.eventEvidence = [...(ref.eventEvidence ?? []), { hqRoot, cwd, harness, at, bindingEpisodeId }];
|
|
412
433
|
}
|
|
413
434
|
seen.set(sessionId, ref);
|
|
414
435
|
}
|
|
@@ -481,7 +502,9 @@ export async function backfillHeldSessions(deps) {
|
|
|
481
502
|
if (!evidence.candidate?.slug && deps.useDeviceDefault !== true) {
|
|
482
503
|
result.unresolved += 1;
|
|
483
504
|
result.noEvidence += 1;
|
|
484
|
-
|
|
505
|
+
const staleReason = episodeMismatchReason(ref, state)
|
|
506
|
+
?? (hasOnlyStaleEventEvidence(ref, state) ? "STALE_EVIDENCE" : "no local company evidence");
|
|
507
|
+
deps.log?.(`would stay unresolved ${sessionId} (${staleReason})`);
|
|
485
508
|
continue;
|
|
486
509
|
}
|
|
487
510
|
const evidenceRef = {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { type CognitoActorKind, type CognitoTokenSource } from "../../../../utils/cognito-session.js";
|
|
6
6
|
import { type UnattributedEventCounts } from "../spool.js";
|
|
7
7
|
import { type DaemonStateFile } from "./state.js";
|
|
8
|
+
import { type LegacyListenPlatform } from "./legacy-listen.js";
|
|
8
9
|
export declare const UNHEALTHY_SPOOL_AGE_MS = 60000;
|
|
9
10
|
export interface DaemonDoctorReport {
|
|
10
11
|
running: boolean;
|
|
@@ -54,6 +55,8 @@ export interface DaemonDoctorReport {
|
|
|
54
55
|
};
|
|
55
56
|
/** Work held or quarantined after default-derived membership was denied. */
|
|
56
57
|
deviceDefaultAuthorizationDenials: number;
|
|
58
|
+
/** True when the pack-era listen unit is still on disk. */
|
|
59
|
+
legacyListenerInstalled: boolean;
|
|
57
60
|
}
|
|
58
61
|
export interface DaemonDoctorDeps {
|
|
59
62
|
home?: string;
|
|
@@ -64,6 +67,9 @@ export interface DaemonDoctorDeps {
|
|
|
64
67
|
env?: NodeJS.ProcessEnv;
|
|
65
68
|
isProcessAlive?: (pid: number) => boolean;
|
|
66
69
|
now?: () => number;
|
|
70
|
+
/** Override the legacy-listen probe (tests). */
|
|
71
|
+
legacyListenerInstalled?: boolean;
|
|
72
|
+
platform?: LegacyListenPlatform;
|
|
67
73
|
}
|
|
68
74
|
export declare function collectDaemonDoctor(deps?: DaemonDoctorDeps): DaemonDoctorReport;
|
|
69
75
|
export declare function formatDaemonDoctor(report: DaemonDoctorReport): string[];
|
|
@@ -18,6 +18,7 @@ import { hasCurrentAcknowledgedRegistration, readHeldSessions, refWithLocalCompa
|
|
|
18
18
|
import { readDaemonConfig } from "./config.js";
|
|
19
19
|
import { pidLockStatus, readPidLock } from "./pid-lock.js";
|
|
20
20
|
import { readDaemonState } from "./state.js";
|
|
21
|
+
import { LEGACY_LISTENER_DOCTOR_LINE, legacyListenInstalled, } from "./legacy-listen.js";
|
|
21
22
|
export const UNHEALTHY_SPOOL_AGE_MS = 60_000;
|
|
22
23
|
function oldestJsonlAgeMs(filePath, nowMs) {
|
|
23
24
|
if (!fs.existsSync(filePath))
|
|
@@ -126,7 +127,7 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
126
127
|
if (deadLetterCount > 0) {
|
|
127
128
|
unhealthyReasons.push(`dead-letter count=${deadLetterCount}`);
|
|
128
129
|
}
|
|
129
|
-
if (
|
|
130
|
+
if (heldCount > 0) {
|
|
130
131
|
unhealthyReasons.push(`held events awaiting company attribution=${heldCount}`);
|
|
131
132
|
}
|
|
132
133
|
if (heldWithNoLocalEvidence > 0) {
|
|
@@ -144,6 +145,14 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
144
145
|
if (presenceRefusal) {
|
|
145
146
|
unhealthyReasons.push(`presence credential refused (${presenceRefusal.code}); next retry ${presenceRefusal.nextRetryAt}`);
|
|
146
147
|
}
|
|
148
|
+
const platform = deps.platform ?? process.platform;
|
|
149
|
+
const legacyListener = deps.legacyListenerInstalled ??
|
|
150
|
+
(platform === "darwin" || platform === "linux"
|
|
151
|
+
? legacyListenInstalled({ home, platform })
|
|
152
|
+
: false);
|
|
153
|
+
if (legacyListener) {
|
|
154
|
+
unhealthyReasons.push(LEGACY_LISTENER_DOCTOR_LINE);
|
|
155
|
+
}
|
|
147
156
|
const auth = describeCognitoTokenSource({ home, env });
|
|
148
157
|
const whoamiTokens = readPersonTokenFile(home, env);
|
|
149
158
|
const daemonTokens = auth.tokenSource === "machine"
|
|
@@ -192,6 +201,7 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
192
201
|
}
|
|
193
202
|
: undefined,
|
|
194
203
|
deviceDefaultAuthorizationDenials,
|
|
204
|
+
legacyListenerInstalled: legacyListener,
|
|
195
205
|
};
|
|
196
206
|
}
|
|
197
207
|
export function formatDaemonDoctor(report) {
|
|
@@ -200,12 +210,16 @@ export function formatDaemonDoctor(report) {
|
|
|
200
210
|
`mode: ${report.emitMode}`,
|
|
201
211
|
`mqtt: ${report.mqttState}`,
|
|
202
212
|
`subscription: ${report.subscribed ? "connected" : "disconnected"}`,
|
|
213
|
+
...(report.legacyListenerInstalled ? [LEGACY_LISTENER_DOCTOR_LINE] : []),
|
|
203
214
|
`companies online: ${report.companiesOnline.length ? report.companiesOnline.join(", ") : "(none)"}`,
|
|
204
215
|
];
|
|
205
216
|
if (report.emitMode === "legacy") {
|
|
206
217
|
// Legacy emit path only: the spool/held/outbox queues do not exist in
|
|
207
218
|
// receive-only (direct) mode.
|
|
208
|
-
lines.push(`spool depth: ${report.spoolDepth}`, `held: ${report.heldCount}`, `held with no local evidence: ${report.heldWithNoLocalEvidence}`, `outbox depth: ${report.outboxDepth}`);
|
|
219
|
+
lines.push(`spool depth: ${report.spoolDepth}`, `held: ${report.heldCount}${report.heldCount > 0 ? " (needs_company; not sent as NONE)" : ""}`, `held with no local evidence: ${report.heldWithNoLocalEvidence}`, `outbox depth: ${report.outboxDepth}`);
|
|
220
|
+
}
|
|
221
|
+
if (report.emitMode !== "legacy" && report.heldCount > 0) {
|
|
222
|
+
lines.push(`held: ${report.heldCount} (needs_company; not sent as NONE)`);
|
|
209
223
|
}
|
|
210
224
|
lines.push(`attributed by device default: ${report.attributedByDeviceDefault}`, `device-default authorization denials: ${report.deviceDefaultAuthorizationDenials}`, `dead-letter: ${report.deadLetterCount}`, `unattributed events: ${report.unattributedEvents.total} ` +
|
|
211
225
|
`(spool=${report.unattributedEvents.spool} held=${report.unattributedEvents.held} dead-letter=${report.unattributedEvents.deadLetter})`, `token source: ${report.tokenSource}`, `actor kind: ${report.actorKind}`, `identity whoami: ${report.whoamiSubject}`, `identity daemon: ${report.daemonSubject}`);
|
|
@@ -101,7 +101,12 @@ export declare function restartDaemonServiceIfInstalled(opts?: {
|
|
|
101
101
|
platform?: DaemonInstallPlatform;
|
|
102
102
|
home?: string;
|
|
103
103
|
uid?: number;
|
|
104
|
+
/** HQ root the daemon was serving, or the root this update was run from. */
|
|
105
|
+
hqRoot?: string;
|
|
106
|
+
env?: NodeJS.ProcessEnv;
|
|
104
107
|
existsSync?: typeof fs.existsSync;
|
|
108
|
+
readFileSync?: typeof fs.readFileSync;
|
|
109
|
+
writeFileSync?: typeof fs.writeFileSync;
|
|
105
110
|
run?: (command: string, args: string[]) => {
|
|
106
111
|
status: number | null;
|
|
107
112
|
};
|
|
@@ -230,6 +235,13 @@ export declare function installDaemonService(opts: {
|
|
|
230
235
|
schtasks?: SchtasksRunner;
|
|
231
236
|
/** Windows: terminate the recorded daemon pid when replacing a task (tests inject). */
|
|
232
237
|
kill?: (pid: number) => void;
|
|
238
|
+
/** Service-manager runner for retiring the legacy listen unit (tests inject). */
|
|
239
|
+
runCommand?: (cmd: string, args: string[]) => {
|
|
240
|
+
status: number | null;
|
|
241
|
+
stdout: string;
|
|
242
|
+
stderr: string;
|
|
243
|
+
};
|
|
244
|
+
windowsLegacyTaskPresent?: boolean;
|
|
233
245
|
}): InstallResult;
|
|
234
246
|
export declare function uninstallDaemonService(opts: {
|
|
235
247
|
platform?: DaemonInstallPlatform;
|
|
@@ -17,8 +17,9 @@ import * as path from "node:path";
|
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
18
|
import { resolveDaemonAssetDir } from "./log.js";
|
|
19
19
|
import { LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, WINDOWS_TASK_NAME, daemonDir, daemonLogPath, daemonPidPath, } from "./paths.js";
|
|
20
|
-
import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot } from "./config.js";
|
|
20
|
+
import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot, verifyHqRoot } from "./config.js";
|
|
21
21
|
import { defaultPidLockDeps, pidLockStatus, readPidLock } from "./pid-lock.js";
|
|
22
|
+
import { retireLegacyListen } from "./legacy-listen.js";
|
|
22
23
|
/**
|
|
23
24
|
* `schtasks /End` terminates the task's root process only. The daemon is
|
|
24
25
|
* launched through `conhost.exe --headless`, so ending the task kills conhost
|
|
@@ -49,6 +50,86 @@ export const defaultSchtasks = (args) => {
|
|
|
49
50
|
stderr: r.error ? r.error.message : (r.stderr ?? ""),
|
|
50
51
|
};
|
|
51
52
|
};
|
|
53
|
+
function readServiceText(file, existsSync, readFileSync) {
|
|
54
|
+
if (!existsSync(file))
|
|
55
|
+
return "";
|
|
56
|
+
const raw = readFileSync(file);
|
|
57
|
+
const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw));
|
|
58
|
+
if ((buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) || buf.includes(0)) {
|
|
59
|
+
return buf.toString("utf16le").replace(/^\uFEFF/, "");
|
|
60
|
+
}
|
|
61
|
+
return buf.toString("utf8");
|
|
62
|
+
}
|
|
63
|
+
function hqRootFromUnitText(platform, text) {
|
|
64
|
+
if (platform === "win32")
|
|
65
|
+
return parseWindowsTaskHqRoot(text);
|
|
66
|
+
if (platform === "darwin") {
|
|
67
|
+
const match = /<key>HQ_ROOT<\/key>\s*<string>([^<]*)<\/string>/.exec(text);
|
|
68
|
+
return match?.[1] ?? null;
|
|
69
|
+
}
|
|
70
|
+
const match = /^Environment=HQ_ROOT=(.*)$/m.exec(text);
|
|
71
|
+
if (!match)
|
|
72
|
+
return null;
|
|
73
|
+
const value = match[1].trim();
|
|
74
|
+
return value.startsWith("'") && value.endsWith("'") ? value.slice(1, -1) : value;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Record the HQ root the daemon was serving and replace a pre-allowlist unit
|
|
78
|
+
* before the process is restarted. A unit with no HQ_ROOT (or Restart=on-failure)
|
|
79
|
+
* comes back with an empty root allowlist unless this runs first.
|
|
80
|
+
*/
|
|
81
|
+
function migrateLegacyDaemonUnit(opts) {
|
|
82
|
+
const { platform, home } = opts;
|
|
83
|
+
if (platform !== "darwin" && platform !== "linux" && platform !== "win32")
|
|
84
|
+
return;
|
|
85
|
+
const unitPath = platform === "darwin"
|
|
86
|
+
? path.join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`)
|
|
87
|
+
: platform === "linux"
|
|
88
|
+
? path.join(home, ".config", "systemd", "user", SYSTEMD_UNIT_NAME)
|
|
89
|
+
: path.join(daemonDir(undefined, home, opts.env), `${WINDOWS_TASK_NAME}.xml`);
|
|
90
|
+
if (!opts.existsSync(unitPath))
|
|
91
|
+
return;
|
|
92
|
+
const text = readServiceText(unitPath, opts.existsSync, opts.readFileSync);
|
|
93
|
+
const fromUnit = hqRootFromUnitText(platform, text);
|
|
94
|
+
const verified = verifyHqRoot(fromUnit ?? undefined)
|
|
95
|
+
?? verifyHqRoot(opts.hqRoot)
|
|
96
|
+
?? verifyHqRoot(opts.env?.HQ_ROOT);
|
|
97
|
+
if (!verified)
|
|
98
|
+
return;
|
|
99
|
+
registerTrustedHqRoot(daemonDir(undefined, home, opts.env), verified);
|
|
100
|
+
const legacy = platform === "linux"
|
|
101
|
+
? !text.includes("Environment=HQ_ROOT=") || /Restart=on-failure/.test(text)
|
|
102
|
+
: platform === "darwin"
|
|
103
|
+
? !/<key>HQ_ROOT<\/key>/.test(text)
|
|
104
|
+
: !fromUnit;
|
|
105
|
+
if (!legacy)
|
|
106
|
+
return;
|
|
107
|
+
const paths = buildInstallPaths({
|
|
108
|
+
home,
|
|
109
|
+
env: opts.env,
|
|
110
|
+
hqRoot: verified,
|
|
111
|
+
hqBinary: "/usr/bin/hq",
|
|
112
|
+
nodeBinary: process.execPath || "/usr/bin/node",
|
|
113
|
+
});
|
|
114
|
+
try {
|
|
115
|
+
paths.hqBinary = resolveHqBinary(process.argv, opts.env ?? process.env);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// The rendered command only has to name a binary; install already resolved one.
|
|
119
|
+
}
|
|
120
|
+
if (platform === "darwin") {
|
|
121
|
+
opts.writeFileSync(unitPath, renderLaunchdPlist(paths), { mode: 0o600 });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (platform === "linux") {
|
|
125
|
+
opts.writeFileSync(unitPath, renderSystemdUserUnit(paths), { mode: 0o600 });
|
|
126
|
+
opts.run("systemctl", ["--user", "daemon-reload"]);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const rendered = renderWindowsTaskXml(paths);
|
|
130
|
+
opts.writeFileSync(unitPath, encodeWindowsTaskXml(rendered));
|
|
131
|
+
opts.run("schtasks.exe", ["/Create", "/TN", WINDOWS_TASK_NAME, "/XML", unitPath, "/F"]);
|
|
132
|
+
}
|
|
52
133
|
/**
|
|
53
134
|
* Restart the installed user daemon after a CLI upgrade. This is deliberately
|
|
54
135
|
* best-effort: an update must remain successful if a user has no service,
|
|
@@ -58,8 +139,11 @@ export function restartDaemonServiceIfInstalled(opts = {}) {
|
|
|
58
139
|
const platform = opts.platform ?? detectPlatform();
|
|
59
140
|
const home = opts.home ?? os.homedir();
|
|
60
141
|
const existsSync = opts.existsSync ?? fs.existsSync;
|
|
142
|
+
const readFileSync = opts.readFileSync ?? fs.readFileSync;
|
|
143
|
+
const writeFileSync = opts.writeFileSync ?? fs.writeFileSync;
|
|
61
144
|
const run = opts.run ?? ((command, args) => spawnSync(command, args, { stdio: "ignore" }));
|
|
62
145
|
try {
|
|
146
|
+
migrateLegacyDaemonUnit({ platform, home, hqRoot: opts.hqRoot, env: opts.env, existsSync, readFileSync, writeFileSync, run });
|
|
63
147
|
if (platform === "darwin") {
|
|
64
148
|
const plist = path.join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
65
149
|
if (!existsSync(plist))
|
|
@@ -568,6 +652,15 @@ export function installDaemonService(opts) {
|
|
|
568
652
|
const mkdirSync = opts.mkdirSync ?? fs.mkdirSync;
|
|
569
653
|
mkdirSync(paths.daemonDir, { recursive: true, mode: 0o700 });
|
|
570
654
|
registerTrustedHqRoot(paths.daemonDir, paths.hqRoot);
|
|
655
|
+
const retired = retireLegacyListen({
|
|
656
|
+
home: paths.home,
|
|
657
|
+
platform,
|
|
658
|
+
run: opts.runCommand,
|
|
659
|
+
windowsTaskPresent: opts.windowsLegacyTaskPresent,
|
|
660
|
+
});
|
|
661
|
+
const legacyNote = retired.removed
|
|
662
|
+
? " Retired legacy listener ai.getindigo.hq-work-mesh-listen."
|
|
663
|
+
: "";
|
|
571
664
|
if (platform === "darwin") {
|
|
572
665
|
const rendered = renderLaunchdPlist(paths);
|
|
573
666
|
mkdirSync(paths.launchAgentsDir, { recursive: true });
|
|
@@ -579,7 +672,7 @@ export function installDaemonService(opts) {
|
|
|
579
672
|
paths,
|
|
580
673
|
rendered,
|
|
581
674
|
dest: paths.plistPath,
|
|
582
|
-
message: `Installed LaunchAgent ${LAUNCHD_LABEL} → ${paths.plistPath}`,
|
|
675
|
+
message: `Installed LaunchAgent ${LAUNCHD_LABEL} → ${paths.plistPath}${legacyNote}`,
|
|
583
676
|
};
|
|
584
677
|
}
|
|
585
678
|
if (platform === "linux") {
|
|
@@ -599,7 +692,7 @@ export function installDaemonService(opts) {
|
|
|
599
692
|
dest,
|
|
600
693
|
message: `Wrote system unit template for user ${opts.systemUser ?? paths.systemUser} at ${dest}. ` +
|
|
601
694
|
`Install with: sudo cp ${dest} /etc/systemd/system/${SYSTEMD_UNIT_NAME} && ` +
|
|
602
|
-
`sudo systemctl daemon-reload && sudo systemctl enable --now ${SYSTEMD_UNIT_NAME}`,
|
|
695
|
+
`sudo systemctl daemon-reload && sudo systemctl enable --now ${SYSTEMD_UNIT_NAME}${legacyNote}`,
|
|
603
696
|
};
|
|
604
697
|
}
|
|
605
698
|
const rendered = renderSystemdUserUnit(paths);
|
|
@@ -613,7 +706,7 @@ export function installDaemonService(opts) {
|
|
|
613
706
|
paths,
|
|
614
707
|
rendered,
|
|
615
708
|
dest: paths.systemdUnitPath,
|
|
616
|
-
message: `Installed systemd user unit ${SYSTEMD_UNIT_NAME} → ${paths.systemdUnitPath}`,
|
|
709
|
+
message: `Installed systemd user unit ${SYSTEMD_UNIT_NAME} → ${paths.systemdUnitPath}${legacyNote}`,
|
|
617
710
|
};
|
|
618
711
|
}
|
|
619
712
|
if (platform === "win32") {
|
|
@@ -645,9 +738,9 @@ export function installDaemonService(opts) {
|
|
|
645
738
|
dest: paths.windowsTaskXmlPath,
|
|
646
739
|
running,
|
|
647
740
|
message: running
|
|
648
|
-
? `${rootLine}Installed Scheduled Task ${WINDOWS_TASK_NAME} → ${paths.windowsTaskXmlPath} (started; ${mode})`
|
|
741
|
+
? `${rootLine}Installed Scheduled Task ${WINDOWS_TASK_NAME} → ${paths.windowsTaskXmlPath} (started; ${mode})${legacyNote}`
|
|
649
742
|
: `${rootLine}Installed Scheduled Task ${WINDOWS_TASK_NAME} → ${paths.windowsTaskXmlPath} (${mode}); ` +
|
|
650
|
-
`start failed: ${(started.stderr || started.stdout).trim()}. It starts at next logon
|
|
743
|
+
`start failed: ${(started.stderr || started.stdout).trim()}. It starts at next logon.${legacyNote}`,
|
|
651
744
|
notice: windowsLogonNotice(paths),
|
|
652
745
|
};
|
|
653
746
|
}
|
|
@@ -655,7 +748,7 @@ export function installDaemonService(opts) {
|
|
|
655
748
|
platform,
|
|
656
749
|
action: "install",
|
|
657
750
|
paths,
|
|
658
|
-
message: `Unsupported platform for service install; run: ${paths.nodeBinary} ${paths.hqBinary} mesh daemon run`,
|
|
751
|
+
message: `Unsupported platform for service install; run: ${paths.nodeBinary} ${paths.hqBinary} mesh daemon run${legacyNote}`,
|
|
659
752
|
};
|
|
660
753
|
}
|
|
661
754
|
export function uninstallDaemonService(opts) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retire the pack-era work-mesh listen unit so only `hq mesh daemon` subscribes.
|
|
3
|
+
*
|
|
4
|
+
* macOS: ~/Library/LaunchAgents/ai.getindigo.hq-work-mesh-listen.plist
|
|
5
|
+
* Linux: ~/.config/systemd/user/hq-work-mesh-listen.service
|
|
6
|
+
* Windows: Scheduled Task ai.getindigo.hq-work-mesh-listen
|
|
7
|
+
*/
|
|
8
|
+
export type LegacyListenPlatform = "darwin" | "linux" | "win32" | "other";
|
|
9
|
+
export declare const LEGACY_LISTEN_LABEL = "ai.getindigo.hq-work-mesh-listen";
|
|
10
|
+
export declare const LEGACY_LISTEN_SYSTEMD_UNIT = "hq-work-mesh-listen.service";
|
|
11
|
+
export declare const LEGACY_LISTEN_TASK = "ai.getindigo.hq-work-mesh-listen";
|
|
12
|
+
export declare const LEGACY_LISTENER_DOCTOR_LINE = "legacy listener still installed";
|
|
13
|
+
export interface CommandResult {
|
|
14
|
+
status: number | null;
|
|
15
|
+
stdout: string;
|
|
16
|
+
stderr: string;
|
|
17
|
+
}
|
|
18
|
+
export type CommandRunner = (cmd: string, args: string[]) => CommandResult;
|
|
19
|
+
export declare function legacyListenPlistPath(home: string): string;
|
|
20
|
+
export declare function legacyListenSystemdPath(home: string): string;
|
|
21
|
+
export declare function legacyListenInstalled(opts: {
|
|
22
|
+
home: string;
|
|
23
|
+
platform: LegacyListenPlatform;
|
|
24
|
+
existsSync?: (p: string) => boolean;
|
|
25
|
+
}): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Unload and delete the legacy listen unit when it is present.
|
|
28
|
+
* On-disk unit files are removed on every host. launchctl, systemctl, and
|
|
29
|
+
* schtasks run only for the matching platform, and only through `run` when
|
|
30
|
+
* the caller injected one (tests never hit a real service manager).
|
|
31
|
+
* Missing unit is a no-op.
|
|
32
|
+
*/
|
|
33
|
+
export declare function retireLegacyListen(opts: {
|
|
34
|
+
home: string;
|
|
35
|
+
platform: LegacyListenPlatform;
|
|
36
|
+
run?: CommandRunner;
|
|
37
|
+
uid?: number;
|
|
38
|
+
/** Windows task probe: true when the scheduled task is registered. */
|
|
39
|
+
windowsTaskPresent?: boolean;
|
|
40
|
+
}): {
|
|
41
|
+
removed: boolean;
|
|
42
|
+
};
|
|
43
|
+
//# sourceMappingURL=legacy-listen.d.ts.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retire the pack-era work-mesh listen unit so only `hq mesh daemon` subscribes.
|
|
3
|
+
*
|
|
4
|
+
* macOS: ~/Library/LaunchAgents/ai.getindigo.hq-work-mesh-listen.plist
|
|
5
|
+
* Linux: ~/.config/systemd/user/hq-work-mesh-listen.service
|
|
6
|
+
* Windows: Scheduled Task ai.getindigo.hq-work-mesh-listen
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
export const LEGACY_LISTEN_LABEL = "ai.getindigo.hq-work-mesh-listen";
|
|
12
|
+
export const LEGACY_LISTEN_SYSTEMD_UNIT = "hq-work-mesh-listen.service";
|
|
13
|
+
export const LEGACY_LISTEN_TASK = LEGACY_LISTEN_LABEL;
|
|
14
|
+
export const LEGACY_LISTENER_DOCTOR_LINE = "legacy listener still installed";
|
|
15
|
+
export function legacyListenPlistPath(home) {
|
|
16
|
+
return path.join(home, "Library", "LaunchAgents", `${LEGACY_LISTEN_LABEL}.plist`);
|
|
17
|
+
}
|
|
18
|
+
export function legacyListenSystemdPath(home) {
|
|
19
|
+
return path.join(home, ".config", "systemd", "user", LEGACY_LISTEN_SYSTEMD_UNIT);
|
|
20
|
+
}
|
|
21
|
+
const defaultRunner = (cmd, args) => {
|
|
22
|
+
const r = spawnSync(cmd, args, { encoding: "utf8" });
|
|
23
|
+
return {
|
|
24
|
+
status: r.status,
|
|
25
|
+
stdout: r.stdout ?? "",
|
|
26
|
+
stderr: r.error ? r.error.message : (r.stderr ?? ""),
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
export function legacyListenInstalled(opts) {
|
|
30
|
+
const exists = opts.existsSync ?? fs.existsSync;
|
|
31
|
+
if (opts.platform === "darwin")
|
|
32
|
+
return exists(legacyListenPlistPath(opts.home));
|
|
33
|
+
if (opts.platform === "linux")
|
|
34
|
+
return exists(legacyListenSystemdPath(opts.home));
|
|
35
|
+
if (opts.platform === "win32")
|
|
36
|
+
return false;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Unload and delete the legacy listen unit when it is present.
|
|
41
|
+
* On-disk unit files are removed on every host. launchctl, systemctl, and
|
|
42
|
+
* schtasks run only for the matching platform, and only through `run` when
|
|
43
|
+
* the caller injected one (tests never hit a real service manager).
|
|
44
|
+
* Missing unit is a no-op.
|
|
45
|
+
*/
|
|
46
|
+
export function retireLegacyListen(opts) {
|
|
47
|
+
const run = opts.run ?? (opts.platform === "other" ? undefined : defaultRunner);
|
|
48
|
+
let removed = false;
|
|
49
|
+
const plist = legacyListenPlistPath(opts.home);
|
|
50
|
+
if (fs.existsSync(plist)) {
|
|
51
|
+
if (opts.platform === "darwin" && run) {
|
|
52
|
+
const uid = opts.uid ?? (typeof process.getuid === "function" ? process.getuid() : undefined);
|
|
53
|
+
if (uid !== undefined) {
|
|
54
|
+
run("launchctl", ["bootout", `gui/${uid}/${LEGACY_LISTEN_LABEL}`]);
|
|
55
|
+
}
|
|
56
|
+
run("launchctl", ["unload", plist]);
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
fs.unlinkSync(plist);
|
|
60
|
+
removed = true;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// leave removed false for this file
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const unitPath = legacyListenSystemdPath(opts.home);
|
|
67
|
+
if (fs.existsSync(unitPath)) {
|
|
68
|
+
if (opts.platform === "linux" && run) {
|
|
69
|
+
run("systemctl", ["--user", "disable", "--now", LEGACY_LISTEN_SYSTEMD_UNIT]);
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
fs.unlinkSync(unitPath);
|
|
73
|
+
removed = true;
|
|
74
|
+
if (opts.platform === "linux" && run) {
|
|
75
|
+
run("systemctl", ["--user", "daemon-reload"]);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// leave prior removed state
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (opts.platform === "win32" && opts.windowsTaskPresent && run) {
|
|
83
|
+
const result = run("schtasks.exe", ["/Delete", "/TN", LEGACY_LISTEN_TASK, "/F"]);
|
|
84
|
+
if (result.status === 0)
|
|
85
|
+
removed = true;
|
|
86
|
+
}
|
|
87
|
+
return { removed };
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=legacy-listen.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon-owned work-mesh live cache.
|
|
3
|
+
*
|
|
4
|
+
* ~/.hq/work-mesh/live-cache.json plus live-cache.json.cursor-v2.json.
|
|
5
|
+
* A project-view push updates the project row and bumps the cursor once.
|
|
6
|
+
* The same company/project/boardVersion (or eventId) does not bump again.
|
|
7
|
+
*/
|
|
8
|
+
export declare function liveCacheFile(meshRoot: string): string;
|
|
9
|
+
export declare function liveCacheCursorFile(meshRoot: string): string;
|
|
10
|
+
export interface ProjectViewPush {
|
|
11
|
+
companyUid: string;
|
|
12
|
+
projectId: string;
|
|
13
|
+
boardVersion?: string;
|
|
14
|
+
eventId?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Parse a board wake. Returns null for every other payload. */
|
|
17
|
+
export declare function parseProjectViewPush(payload: unknown): ProjectViewPush | null;
|
|
18
|
+
/**
|
|
19
|
+
* Apply one project-view push. Cursor increases by exactly one on first sight
|
|
20
|
+
* of the key, and is left unchanged when the same push is applied again.
|
|
21
|
+
*/
|
|
22
|
+
export declare function applyProjectViewPush(meshRoot: string, push: ProjectViewPush, nowIso?: string): {
|
|
23
|
+
applied: boolean;
|
|
24
|
+
cursor: number;
|
|
25
|
+
};
|
|
26
|
+
//# sourceMappingURL=live-cache.d.ts.map
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon-owned work-mesh live cache.
|
|
3
|
+
*
|
|
4
|
+
* ~/.hq/work-mesh/live-cache.json plus live-cache.json.cursor-v2.json.
|
|
5
|
+
* A project-view push updates the project row and bumps the cursor once.
|
|
6
|
+
* The same company/project/boardVersion (or eventId) does not bump again.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
export function liveCacheFile(meshRoot) {
|
|
11
|
+
return path.join(meshRoot, "live-cache.json");
|
|
12
|
+
}
|
|
13
|
+
export function liveCacheCursorFile(meshRoot) {
|
|
14
|
+
return path.join(meshRoot, "live-cache.json.cursor-v2.json");
|
|
15
|
+
}
|
|
16
|
+
function readJson(file) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function writeJson(file, value) {
|
|
25
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
26
|
+
const tmp = `${file}.tmp.${process.pid}`;
|
|
27
|
+
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
28
|
+
fs.renameSync(tmp, file);
|
|
29
|
+
}
|
|
30
|
+
function pushKey(push) {
|
|
31
|
+
if (push.eventId && push.eventId.trim())
|
|
32
|
+
return `id:${push.eventId.trim()}`;
|
|
33
|
+
return `pv:${push.companyUid}:${push.projectId}:${push.boardVersion ?? ""}`;
|
|
34
|
+
}
|
|
35
|
+
/** Parse a board wake. Returns null for every other payload. */
|
|
36
|
+
export function parseProjectViewPush(payload) {
|
|
37
|
+
let value = payload;
|
|
38
|
+
if (Buffer.isBuffer(value))
|
|
39
|
+
value = value.toString("utf8");
|
|
40
|
+
if (typeof value === "string") {
|
|
41
|
+
try {
|
|
42
|
+
value = JSON.parse(value);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
49
|
+
return null;
|
|
50
|
+
const rec = value;
|
|
51
|
+
if (rec.kind !== "board" || rec.mutation !== "project-view")
|
|
52
|
+
return null;
|
|
53
|
+
if (typeof rec.companyUid !== "string" || typeof rec.projectId !== "string")
|
|
54
|
+
return null;
|
|
55
|
+
if (!rec.companyUid || !rec.projectId)
|
|
56
|
+
return null;
|
|
57
|
+
return {
|
|
58
|
+
companyUid: rec.companyUid,
|
|
59
|
+
projectId: rec.projectId,
|
|
60
|
+
boardVersion: typeof rec.boardVersion === "string" ? rec.boardVersion : undefined,
|
|
61
|
+
eventId: typeof rec.eventId === "string" ? rec.eventId : undefined,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Apply one project-view push. Cursor increases by exactly one on first sight
|
|
66
|
+
* of the key, and is left unchanged when the same push is applied again.
|
|
67
|
+
*/
|
|
68
|
+
export function applyProjectViewPush(meshRoot, push, nowIso = new Date().toISOString()) {
|
|
69
|
+
const key = pushKey(push);
|
|
70
|
+
const stored = readJson(liveCacheCursorFile(meshRoot));
|
|
71
|
+
const previous = stored &&
|
|
72
|
+
typeof stored === "object" &&
|
|
73
|
+
!Array.isArray(stored) &&
|
|
74
|
+
stored.version === 1 &&
|
|
75
|
+
typeof stored.cursor === "number"
|
|
76
|
+
? {
|
|
77
|
+
version: 1,
|
|
78
|
+
cursor: stored.cursor,
|
|
79
|
+
seen: Array.isArray(stored.seen)
|
|
80
|
+
? stored.seen.filter((s) => typeof s === "string").slice(-200)
|
|
81
|
+
: [],
|
|
82
|
+
}
|
|
83
|
+
: { version: 1, cursor: 0, seen: [] };
|
|
84
|
+
if (previous.seen.includes(key)) {
|
|
85
|
+
return { applied: false, cursor: previous.cursor };
|
|
86
|
+
}
|
|
87
|
+
const cursor = previous.cursor + 1;
|
|
88
|
+
const cacheRaw = readJson(liveCacheFile(meshRoot));
|
|
89
|
+
const cache = cacheRaw && typeof cacheRaw === "object" && !Array.isArray(cacheRaw)
|
|
90
|
+
? cacheRaw
|
|
91
|
+
: {};
|
|
92
|
+
const projects = cache.projects && typeof cache.projects === "object" && !Array.isArray(cache.projects)
|
|
93
|
+
? { ...cache.projects }
|
|
94
|
+
: {};
|
|
95
|
+
const rowKey = `${push.companyUid}/${push.projectId}`;
|
|
96
|
+
projects[rowKey] = {
|
|
97
|
+
companyUid: push.companyUid,
|
|
98
|
+
projectId: push.projectId,
|
|
99
|
+
mutation: "project-view",
|
|
100
|
+
boardVersion: push.boardVersion,
|
|
101
|
+
updatedAt: nowIso,
|
|
102
|
+
};
|
|
103
|
+
writeJson(liveCacheFile(meshRoot), {
|
|
104
|
+
...cache,
|
|
105
|
+
updatedAt: nowIso,
|
|
106
|
+
cursor,
|
|
107
|
+
projects,
|
|
108
|
+
});
|
|
109
|
+
writeJson(liveCacheCursorFile(meshRoot), {
|
|
110
|
+
version: 1,
|
|
111
|
+
cursor,
|
|
112
|
+
seen: [...previous.seen, key].slice(-200),
|
|
113
|
+
});
|
|
114
|
+
return { applied: true, cursor };
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=live-cache.js.map
|