@indigoai-us/hq-cli 5.122.2 → 5.123.1
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 +59 -0
- package/README.md +46 -0
- package/dist/command-catalog.generated.d.ts +6 -3
- package/dist/command-catalog.generated.js +7 -3
- package/dist/commands/dm.d.ts +54 -1
- package/dist/commands/dm.js +204 -8
- package/dist/commands/mesh.js +7 -2
- package/dist/commands/update.js +6 -0
- package/dist/commands/whoami.d.ts +2 -2
- package/dist/commands/whoami.js +18 -19
- package/dist/lib/billing/plan-lock.d.ts +64 -69
- package/dist/lib/billing/plan-lock.js +230 -161
- package/dist/lib/mesh/api.js +96 -28
- package/dist/lib/mesh/live/backfill-held.d.ts +43 -7
- package/dist/lib/mesh/live/backfill-held.js +323 -42
- package/dist/lib/mesh/live/daemon/board-refresh.d.ts +4 -1
- package/dist/lib/mesh/live/daemon/board-refresh.js +71 -30
- package/dist/lib/mesh/live/daemon/config.d.ts +9 -0
- package/dist/lib/mesh/live/daemon/config.js +45 -0
- package/dist/lib/mesh/live/daemon/credentials.d.ts +6 -1
- package/dist/lib/mesh/live/daemon/credentials.js +20 -4
- package/dist/lib/mesh/live/daemon/doctor.d.ts +6 -0
- package/dist/lib/mesh/live/daemon/doctor.js +29 -2
- package/dist/lib/mesh/live/daemon/index.d.ts +4 -2
- package/dist/lib/mesh/live/daemon/index.js +3 -2
- package/dist/lib/mesh/live/daemon/install.d.ts +27 -0
- package/dist/lib/mesh/live/daemon/install.js +54 -4
- package/dist/lib/mesh/live/daemon/run.d.ts +6 -0
- package/dist/lib/mesh/live/daemon/run.js +50 -3
- package/dist/lib/mesh/live/daemon/state.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/version-drift.d.ts +5 -0
- package/dist/lib/mesh/live/daemon/version-drift.js +47 -0
- package/dist/lib/mesh/live/paths.d.ts +2 -0
- package/dist/lib/mesh/live/paths.js +4 -0
- package/dist/lib/work-context/reconcile.js +67 -6
- package/dist/lib/work-context/state.d.ts +5 -3
- package/dist/lib/work-context/state.js +28 -4
- package/dist/utils/cli-telemetry.d.ts +21 -0
- package/dist/utils/cli-telemetry.js +77 -1
- package/dist/utils/self-update.d.ts +3 -0
- package/dist/utils/self-update.js +11 -0
- package/package.json +3 -2
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import * as fs from "node:fs";
|
|
12
12
|
import * as os from "node:os";
|
|
13
13
|
import * as path from "node:path";
|
|
14
|
+
import { CLI_VERSION } from "../../../../cli-version.js";
|
|
14
15
|
import { createWorkSessionDeliverer } from "../../client.js";
|
|
15
16
|
import { meshJson, resolveActiveMembershipCompany } from "../../api.js";
|
|
16
17
|
import { ensureCognitoToken } from "../../../../utils/cognito-session.js";
|
|
@@ -31,10 +32,14 @@ import { defaultDaemonState, patchDaemonState, writeDaemonState, } from "./state
|
|
|
31
32
|
import { workMeshHeldPath } from "../paths.js";
|
|
32
33
|
import { noteHookSessionsFromSpoolFile, TRANSCRIPT_WATCH_INTERVAL_MS, TranscriptWatcher, } from "./transcript-watch.js";
|
|
33
34
|
import { resolveMeshEmitMode, runsEmitPath, } from "./mode.js";
|
|
35
|
+
import { hasCliVersionDrift, readInstalledCliVersion } from "./version-drift.js";
|
|
36
|
+
import { readDaemonConfig } from "./config.js";
|
|
34
37
|
export const SPOOL_DEBOUNCE_MS = 2_000;
|
|
35
38
|
export const FLUSH_INTERVAL_MS = 10_000;
|
|
36
39
|
/** Keep startup recovery bounded: the next daemon start continues the backlog. */
|
|
37
40
|
export const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
|
|
41
|
+
/** Non-zero exit reserved for CLI version drift so legacy Restart=on-failure units restart. */
|
|
42
|
+
export const DAEMON_VERSION_DRIFT_EXIT_CODE = 75;
|
|
38
43
|
/**
|
|
39
44
|
* Only producer appends to spool.jsonl should wake the watcher. Every other
|
|
40
45
|
* file in the work-mesh root (held.jsonl, spool.<ts>.claimed, held.<ts>.claimed,
|
|
@@ -76,7 +81,15 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
76
81
|
((d, line) => appendDaemonLog(d, daemonLogLine("info", line, now)));
|
|
77
82
|
const processEvents = deps.processEvents ?? process;
|
|
78
83
|
const exit = deps.exit ?? ((code) => process.exit(code));
|
|
84
|
+
const daemonVersion = deps.daemonVersion ?? CLI_VERSION;
|
|
85
|
+
const installedCliVersion = deps.readInstalledCliVersion ?? readInstalledCliVersion;
|
|
79
86
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
87
|
+
const configuredRoots = readDaemonConfig(dir).trustedHqRoots;
|
|
88
|
+
const trustedRoots = [...new Set([
|
|
89
|
+
...(deps.hqRoot ? [deps.hqRoot] : []),
|
|
90
|
+
...(env.HQ_ROOT?.trim() ? [env.HQ_ROOT.trim()] : []),
|
|
91
|
+
...configuredRoots,
|
|
92
|
+
])];
|
|
80
93
|
ensureDaemonLog(dir);
|
|
81
94
|
const lockDeps = deps.pidLock ?? defaultPidLockDeps({ isProcessAlive: alive });
|
|
82
95
|
const lock = acquirePidLock(dir, lockDeps);
|
|
@@ -132,6 +145,7 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
132
145
|
return backfillHeldSessions({
|
|
133
146
|
workMeshRoot: meshRoot,
|
|
134
147
|
workContextRoot: ctxRoot,
|
|
148
|
+
hqRoots: trustedRoots,
|
|
135
149
|
limit: deps.heldBackfillBatchSize ?? HELD_BACKFILL_STARTUP_BATCH_SIZE,
|
|
136
150
|
env,
|
|
137
151
|
reconcile: (observation) => reconcileObservation(observation, {
|
|
@@ -162,7 +176,7 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
162
176
|
});
|
|
163
177
|
let presence = null;
|
|
164
178
|
if (deps.enablePresence !== false) {
|
|
165
|
-
const
|
|
179
|
+
const fetchCredentialsRaw = deps.fetchCredentials ??
|
|
166
180
|
(async () => {
|
|
167
181
|
const t = await getToken();
|
|
168
182
|
return createContract3Fetcher({
|
|
@@ -171,6 +185,11 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
171
185
|
env,
|
|
172
186
|
})();
|
|
173
187
|
});
|
|
188
|
+
const fetchCredentials = async () => {
|
|
189
|
+
const bundle = await fetchCredentialsRaw();
|
|
190
|
+
patchDaemonState(dir, { presenceDroppedCompanies: bundle.droppedCompanies }, now);
|
|
191
|
+
return bundle;
|
|
192
|
+
};
|
|
174
193
|
const presenceOpts = {
|
|
175
194
|
fetchCredentials,
|
|
176
195
|
mqttConnect: deps.mqttConnect,
|
|
@@ -218,15 +237,28 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
218
237
|
}
|
|
219
238
|
}
|
|
220
239
|
let stopped = false;
|
|
240
|
+
let restartRequested = false;
|
|
221
241
|
let flushInFlight = null;
|
|
222
242
|
let debounceHandle = null;
|
|
223
243
|
let flushIntervalHandle = null;
|
|
244
|
+
let versionIntervalHandle = null;
|
|
224
245
|
let boardIntervalHandle = null;
|
|
225
246
|
let transcriptIntervalHandle = null;
|
|
226
247
|
let watcher = null;
|
|
227
248
|
/** Shared with transcript watcher: sessionIds seen on the hook/spool path. */
|
|
228
249
|
const lastHookEventAt = new Map();
|
|
229
250
|
let transcriptWatcher = null;
|
|
251
|
+
const requestRestartForVersionDrift = () => {
|
|
252
|
+
if (stopped || restartRequested)
|
|
253
|
+
return restartRequested;
|
|
254
|
+
const installedVersion = installedCliVersion();
|
|
255
|
+
if (!hasCliVersionDrift(daemonVersion, installedVersion))
|
|
256
|
+
return false;
|
|
257
|
+
restartRequested = true;
|
|
258
|
+
log(dir, `cli upgraded ${daemonVersion} -> ${installedVersion}, restarting`);
|
|
259
|
+
exit(DAEMON_VERSION_DRIFT_EXIT_CODE);
|
|
260
|
+
return true;
|
|
261
|
+
};
|
|
230
262
|
if (emitPath && deps.enableTranscriptWatch !== false) {
|
|
231
263
|
const hqRoot = deps.hqRoot ?? env.HQ_ROOT?.trim() ?? undefined;
|
|
232
264
|
const getToken = deps.getToken ?? defaultGetToken;
|
|
@@ -268,7 +300,12 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
268
300
|
}
|
|
269
301
|
const doFlushCycle = async (reason, opts = {}) => {
|
|
270
302
|
// Refuse new cycles after stop, except the forced shutdown flush.
|
|
271
|
-
if (stopped && !opts.force)
|
|
303
|
+
if ((stopped || restartRequested) && !opts.force)
|
|
304
|
+
return;
|
|
305
|
+
// A long-lived daemon retains its loaded JS after a global CLI upgrade.
|
|
306
|
+
// The service unit resolves the binary again on restart, so exiting lets
|
|
307
|
+
// launchd/systemd bring up the upgraded implementation.
|
|
308
|
+
if (!opts.force && requestRestartForVersionDrift())
|
|
272
309
|
return;
|
|
273
310
|
if (flushInFlight) {
|
|
274
311
|
await flushInFlight;
|
|
@@ -400,6 +437,14 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
400
437
|
boardIntervalHandle = timers.setTimeout(boardTick, BOARD_REFRESH_INTERVAL_MS);
|
|
401
438
|
});
|
|
402
439
|
}, BOARD_REFRESH_INTERVAL_MS);
|
|
440
|
+
// Version drift is independent of the legacy spool flush. Direct-mode
|
|
441
|
+
// daemons have no flush cycle, so they still need the same periodic restart
|
|
442
|
+
// check after an in-place CLI upgrade.
|
|
443
|
+
versionIntervalHandle = timers.setTimeout(function versionTick() {
|
|
444
|
+
if (stopped || requestRestartForVersionDrift())
|
|
445
|
+
return;
|
|
446
|
+
versionIntervalHandle = timers.setTimeout(versionTick, FLUSH_INTERVAL_MS);
|
|
447
|
+
}, FLUSH_INTERVAL_MS);
|
|
403
448
|
const runTranscriptTick = async () => {
|
|
404
449
|
if (!transcriptWatcher || stopped)
|
|
405
450
|
return;
|
|
@@ -442,7 +487,7 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
442
487
|
}
|
|
443
488
|
// Initial pass. Board refresh runs in both modes; the flush + transcript
|
|
444
489
|
// scan are legacy emit-path only.
|
|
445
|
-
if (emitPath) {
|
|
490
|
+
if (!requestRestartForVersionDrift() && emitPath) {
|
|
446
491
|
void doFlushCycle("start");
|
|
447
492
|
}
|
|
448
493
|
void refreshFn().catch(() => undefined);
|
|
@@ -458,6 +503,8 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
458
503
|
timers.clearTimeout(debounceHandle);
|
|
459
504
|
if (flushIntervalHandle !== null)
|
|
460
505
|
timers.clearTimeout(flushIntervalHandle);
|
|
506
|
+
if (versionIntervalHandle !== null)
|
|
507
|
+
timers.clearTimeout(versionIntervalHandle);
|
|
461
508
|
if (boardIntervalHandle !== null)
|
|
462
509
|
timers.clearTimeout(boardIntervalHandle);
|
|
463
510
|
if (transcriptIntervalHandle !== null) {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { MqttConnectionState } from "./presence.js";
|
|
5
5
|
import type { FlushSummary } from "../flush.js";
|
|
6
|
+
import type { DroppedPresenceCompany } from "./credentials.js";
|
|
6
7
|
export interface PresenceRefusalState {
|
|
7
8
|
code: string;
|
|
8
9
|
status: number;
|
|
@@ -14,6 +15,8 @@ export interface DaemonStateFile {
|
|
|
14
15
|
startedAt: string;
|
|
15
16
|
mqttState: MqttConnectionState;
|
|
16
17
|
companiesOnline: string[];
|
|
18
|
+
/** Companies omitted from the current presence credential bundle. */
|
|
19
|
+
presenceDroppedCompanies?: DroppedPresenceCompany[];
|
|
17
20
|
actorUid?: string;
|
|
18
21
|
lastFlushAt?: string;
|
|
19
22
|
lastFlushResult?: FlushSummary & {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Version installed behind the entrypoint that launched this daemon. */
|
|
2
|
+
export declare function readInstalledCliVersion(entry?: string): string | null;
|
|
3
|
+
/** A missing/unreadable installed version is not proof of a safe restart. */
|
|
4
|
+
export declare function hasCliVersionDrift(daemonVersion: string, installedVersion: string | null | undefined): installedVersion is string;
|
|
5
|
+
//# sourceMappingURL=version-drift.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the version of the CLI package currently selected by the daemon's
|
|
3
|
+
* launch entrypoint. The daemon keeps the version it was loaded with in
|
|
4
|
+
* memory; an npm/pnpm upgrade can repoint this entrypoint without restarting
|
|
5
|
+
* that process.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
function packageVersionAtOrAbove(entry) {
|
|
10
|
+
let dir;
|
|
11
|
+
try {
|
|
12
|
+
dir = path.dirname(fs.realpathSync(entry));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
// A CLI entrypoint is normally <package>/dist/index.js. Keep the walk
|
|
18
|
+
// bounded so a malformed entrypoint never traverses an entire filesystem.
|
|
19
|
+
for (let i = 0; i < 8; i += 1) {
|
|
20
|
+
const pkgPath = path.join(dir, "package.json");
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
23
|
+
if (parsed.name === "@indigoai-us/hq-cli" &&
|
|
24
|
+
typeof parsed.version === "string" &&
|
|
25
|
+
parsed.version.trim()) {
|
|
26
|
+
return parsed.version.trim();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// No package manifest at this level; continue walking upward.
|
|
31
|
+
}
|
|
32
|
+
const parent = path.dirname(dir);
|
|
33
|
+
if (parent === dir)
|
|
34
|
+
break;
|
|
35
|
+
dir = parent;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
/** Version installed behind the entrypoint that launched this daemon. */
|
|
40
|
+
export function readInstalledCliVersion(entry = process.argv[1]) {
|
|
41
|
+
return entry ? packageVersionAtOrAbove(entry) : null;
|
|
42
|
+
}
|
|
43
|
+
/** A missing/unreadable installed version is not proof of a safe restart. */
|
|
44
|
+
export function hasCliVersionDrift(daemonVersion, installedVersion) {
|
|
45
|
+
return Boolean(installedVersion && installedVersion !== daemonVersion);
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=version-drift.js.map
|
|
@@ -6,6 +6,8 @@ export declare const WORK_MESH_DIRNAME = "work-mesh";
|
|
|
6
6
|
export declare function workMeshRoot(home?: string, env?: NodeJS.ProcessEnv): string;
|
|
7
7
|
export declare function workMeshSpoolPath(root?: string): string;
|
|
8
8
|
export declare function workMeshHeldPath(root?: string): string;
|
|
9
|
+
/** Rotating cursor for bounded daemon-start held-event recovery. */
|
|
10
|
+
export declare function workMeshHeldBackfillCursorPath(root?: string): string;
|
|
9
11
|
export declare function workMeshDeadLetterPath(root?: string): string;
|
|
10
12
|
export declare function workMeshDroppedReceiptPath(root?: string): string;
|
|
11
13
|
/** Claim path: spool.<ts>.claimed (never reuse spool.jsonl for claim). */
|
|
@@ -17,6 +17,10 @@ export function workMeshSpoolPath(root) {
|
|
|
17
17
|
export function workMeshHeldPath(root) {
|
|
18
18
|
return path.join(root ?? workMeshRoot(), "held.jsonl");
|
|
19
19
|
}
|
|
20
|
+
/** Rotating cursor for bounded daemon-start held-event recovery. */
|
|
21
|
+
export function workMeshHeldBackfillCursorPath(root) {
|
|
22
|
+
return path.join(root ?? workMeshRoot(), "held-backfill-cursor.json");
|
|
23
|
+
}
|
|
20
24
|
export function workMeshDeadLetterPath(root) {
|
|
21
25
|
return path.join(root ?? workMeshRoot(), "dead-letter.jsonl");
|
|
22
26
|
}
|
|
@@ -10,7 +10,7 @@ import { EXIT_INVALID_IDENTITY, EXIT_NOT_TRACKING, EXIT_OK, InvalidDecisionOrigi
|
|
|
10
10
|
import { decisionFromCandidates, } from "./organize.js";
|
|
11
11
|
import { resolveProjectTask, shouldAskAfter } from "./project.js";
|
|
12
12
|
import { deriveRemoteOwnerSlug, deriveRepoIdentityKey } from "./repo-remote.js";
|
|
13
|
-
import { enqueueOutbox, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, writeAckedRegisterState, } from "./outbox.js";
|
|
13
|
+
import { enqueueOutbox, listOutboxOperations, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, writeAckedRegisterState, } from "./outbox.js";
|
|
14
14
|
import { mergeLocalSessionFields, readSessionState, writeSessionState, } from "./state.js";
|
|
15
15
|
function resultOf(parts) {
|
|
16
16
|
const out = {
|
|
@@ -293,12 +293,10 @@ export async function reconcileObservation(obs, deps) {
|
|
|
293
293
|
startedAt,
|
|
294
294
|
...(typeof toolWrites === "number" ? { toolWrites } : {}),
|
|
295
295
|
...(hasTurn ? { hasTurn: true } : {}),
|
|
296
|
-
// Preserve prior
|
|
296
|
+
// Preserve prior scope for visibility; its receipt belongs to the
|
|
297
|
+
// quarantined binding and must not suppress a later retry.
|
|
297
298
|
...(prior?.projectId ? { projectId: prior.projectId } : {}),
|
|
298
299
|
...(prior?.taskId ? { taskId: prior.taskId } : {}),
|
|
299
|
-
...(prior?.bindingEpisodeId
|
|
300
|
-
? { bindingEpisodeId: prior.bindingEpisodeId }
|
|
301
|
-
: {}),
|
|
302
300
|
...transcriptFields,
|
|
303
301
|
decision: {
|
|
304
302
|
decisionId: `conflict_${sessionId}`,
|
|
@@ -325,7 +323,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
325
323
|
askAfter: false,
|
|
326
324
|
...(correctionPath ? { correctionPath } : {}),
|
|
327
325
|
},
|
|
328
|
-
}, prior), deps.root);
|
|
326
|
+
}, prior, { preserveBindingEpisode: false }), deps.root);
|
|
329
327
|
return {
|
|
330
328
|
result: resultOf({
|
|
331
329
|
kind: "company_conflict",
|
|
@@ -491,6 +489,69 @@ export async function reconcileObservation(obs, deps) {
|
|
|
491
489
|
prior.bindingEpisodeId
|
|
492
490
|
? "needs_project"
|
|
493
491
|
: registerClassification;
|
|
492
|
+
// Re-observing an already acknowledged binding is normal for hooks and the
|
|
493
|
+
// resident daemon. A fresh clientOperationId must not turn that observation
|
|
494
|
+
// into a second semantic register operation: the prior receipt is the stable
|
|
495
|
+
// identity for this exact company/project/task binding.
|
|
496
|
+
const acknowledgedPrior = prior?.bindingEpisodeId &&
|
|
497
|
+
prior.companyUid === resolvedCompany.uid &&
|
|
498
|
+
prior.companySlug === resolvedCompany.slug &&
|
|
499
|
+
prior.projectId === projectId &&
|
|
500
|
+
prior.taskId === taskId &&
|
|
501
|
+
listOutboxOperations(deps.root).some((operation) => operation.kind === "register" &&
|
|
502
|
+
operation.delivery === "acked" &&
|
|
503
|
+
operation.sessionId === sessionId &&
|
|
504
|
+
operation.receiptId === prior.bindingEpisodeId &&
|
|
505
|
+
operation.companyUid === resolvedCompany.uid &&
|
|
506
|
+
operation.companySlug === resolvedCompany.slug &&
|
|
507
|
+
operation.projectId === projectId &&
|
|
508
|
+
operation.taskId === taskId)
|
|
509
|
+
? prior
|
|
510
|
+
: null;
|
|
511
|
+
if (acknowledgedPrior) {
|
|
512
|
+
// Activity is local progress, not binding identity. Retain the receipt
|
|
513
|
+
// without another register while carrying hook counters forward so the
|
|
514
|
+
// normal clarification threshold still advances.
|
|
515
|
+
writeSessionState(mergeLocalSessionFields({
|
|
516
|
+
contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
|
|
517
|
+
sessionId,
|
|
518
|
+
companyUid: acknowledgedPrior.companyUid,
|
|
519
|
+
companySlug: acknowledgedPrior.companySlug,
|
|
520
|
+
contextStatus: stateClassification,
|
|
521
|
+
projectId,
|
|
522
|
+
taskId,
|
|
523
|
+
bindingEpisodeId: acknowledgedPrior.bindingEpisodeId,
|
|
524
|
+
updatedAt: nowIso,
|
|
525
|
+
startedAt,
|
|
526
|
+
...(typeof toolWrites === "number" ? { toolWrites } : {}),
|
|
527
|
+
...(hasTurn ? { hasTurn: true } : {}),
|
|
528
|
+
...(decision ? { decision } : {}),
|
|
529
|
+
...transcriptFields,
|
|
530
|
+
}, prior), deps.root);
|
|
531
|
+
const kind = registerClassification === "bound"
|
|
532
|
+
? "bound"
|
|
533
|
+
: registerClassification === "needs_task"
|
|
534
|
+
? "needs_task"
|
|
535
|
+
: registerClassification === "needs_project"
|
|
536
|
+
? "needs_project"
|
|
537
|
+
: "queued";
|
|
538
|
+
diagnostics.push(`binding unchanged; retained receipt=${acknowledgedPrior.bindingEpisodeId}`);
|
|
539
|
+
return {
|
|
540
|
+
result: resultOf({
|
|
541
|
+
kind,
|
|
542
|
+
classification: registerClassification,
|
|
543
|
+
delivery: "acked",
|
|
544
|
+
sessionId,
|
|
545
|
+
clientOperationId: obs.clientOperationId,
|
|
546
|
+
companyUid: resolvedCompany.uid,
|
|
547
|
+
projectId,
|
|
548
|
+
taskId,
|
|
549
|
+
bindingEpisodeId: acknowledgedPrior.bindingEpisodeId,
|
|
550
|
+
}),
|
|
551
|
+
exitCode: EXIT_OK,
|
|
552
|
+
diagnostics,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
494
555
|
let outboxOp;
|
|
495
556
|
try {
|
|
496
557
|
outboxOp = enqueueOutbox({
|
|
@@ -112,10 +112,12 @@ export declare function writeSessionState(state: SessionStateFile, root: string)
|
|
|
112
112
|
/** Strip any non-allowlisted fields before durable write. */
|
|
113
113
|
export declare function projectSessionState(state: SessionStateFile): SessionStateFile;
|
|
114
114
|
/**
|
|
115
|
-
* Merge prior local
|
|
116
|
-
*
|
|
115
|
+
* Merge prior local fields into a new state write so PostToolUse bumps and
|
|
116
|
+
* reconcile updates do not clobber an acknowledged company binding.
|
|
117
117
|
*/
|
|
118
|
-
export declare function mergeLocalSessionFields(next: SessionStateFile, prior: SessionStateFile | null
|
|
118
|
+
export declare function mergeLocalSessionFields(next: SessionStateFile, prior: SessionStateFile | null, options?: {
|
|
119
|
+
preserveBindingEpisode?: boolean;
|
|
120
|
+
}): SessionStateFile;
|
|
119
121
|
/**
|
|
120
122
|
* Upgrade the local projection after the server acknowledges a register.
|
|
121
123
|
*
|
|
@@ -103,13 +103,30 @@ export function projectSessionState(state) {
|
|
|
103
103
|
return out;
|
|
104
104
|
}
|
|
105
105
|
/**
|
|
106
|
-
* Merge prior local
|
|
107
|
-
*
|
|
106
|
+
* Merge prior local fields into a new state write so PostToolUse bumps and
|
|
107
|
+
* reconcile updates do not clobber an acknowledged company binding.
|
|
108
108
|
*/
|
|
109
|
-
export function mergeLocalSessionFields(next, prior) {
|
|
109
|
+
export function mergeLocalSessionFields(next, prior, options = {}) {
|
|
110
110
|
if (!prior)
|
|
111
111
|
return next;
|
|
112
112
|
const out = { ...next };
|
|
113
|
+
// Hook observations commonly carry only tool-write/session metadata. Those
|
|
114
|
+
// writes must not erase a company that an acknowledged register already
|
|
115
|
+
// projected locally; losing it causes held events and repeat registrations.
|
|
116
|
+
// A caller that carries either company field is intentionally changing the
|
|
117
|
+
// binding and remains authoritative.
|
|
118
|
+
if (!out.companyUid && !out.companySlug && (prior.companyUid || prior.companySlug)) {
|
|
119
|
+
out.companyUid = prior.companyUid;
|
|
120
|
+
out.companySlug = prior.companySlug;
|
|
121
|
+
out.contextStatus = prior.contextStatus;
|
|
122
|
+
if (!out.projectId && prior.projectId)
|
|
123
|
+
out.projectId = prior.projectId;
|
|
124
|
+
if (!out.taskId && prior.taskId)
|
|
125
|
+
out.taskId = prior.taskId;
|
|
126
|
+
if (out.version === undefined && prior.version !== undefined) {
|
|
127
|
+
out.version = prior.version;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
113
130
|
if (out.toolWrites === undefined && typeof prior.toolWrites === "number") {
|
|
114
131
|
out.toolWrites = prior.toolWrites;
|
|
115
132
|
}
|
|
@@ -131,7 +148,14 @@ export function mergeLocalSessionFields(next, prior) {
|
|
|
131
148
|
if (!out.transcriptMarker && prior.transcriptMarker) {
|
|
132
149
|
out.transcriptMarker = prior.transcriptMarker;
|
|
133
150
|
}
|
|
134
|
-
|
|
151
|
+
const sameBinding = prior.companyUid === out.companyUid &&
|
|
152
|
+
prior.companySlug === out.companySlug &&
|
|
153
|
+
prior.projectId === out.projectId &&
|
|
154
|
+
prior.taskId === out.taskId;
|
|
155
|
+
if (options.preserveBindingEpisode !== false &&
|
|
156
|
+
!out.bindingEpisodeId &&
|
|
157
|
+
prior.bindingEpisodeId &&
|
|
158
|
+
sameBinding) {
|
|
135
159
|
out.bindingEpisodeId = prior.bindingEpisodeId;
|
|
136
160
|
}
|
|
137
161
|
return out;
|
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
export declare const TELEMETRY_TIMEOUT_MS = 1200;
|
|
2
|
+
/**
|
|
3
|
+
* Exact stderr line printed once on the first signed-in TTY run that is not
|
|
4
|
+
* `--json`. Wording matches the event body: CLI version + timestamp only.
|
|
5
|
+
*/
|
|
6
|
+
export declare const TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when you run a signed-in command (CLI version and a timestamp, never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.";
|
|
7
|
+
/**
|
|
8
|
+
* `HQ_TELEMETRY=0` (also `false` and `off`, case-insensitive) disables every
|
|
9
|
+
* CLI POST to `/v1/telemetry/events`. `HQ_TELEMETRY_TIMEOUT_MS` is unchanged.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isCliTelemetryDisabled(env?: NodeJS.ProcessEnv): boolean;
|
|
12
|
+
export declare function telemetryDisclosureMarkerPath(): string;
|
|
13
|
+
/**
|
|
14
|
+
* Print the one-line disclosure at most once per machine. Returns whether
|
|
15
|
+
* the notice has been shown (this run or a previous one). `--json` and
|
|
16
|
+
* non-TTY stdout skip the print and do not record the marker.
|
|
17
|
+
*/
|
|
18
|
+
export declare function maybeDiscloseCliTelemetry(stderr?: {
|
|
19
|
+
write: (chunk: string) => unknown;
|
|
20
|
+
}, stdout?: {
|
|
21
|
+
isTTY?: boolean;
|
|
22
|
+
}, argv?: readonly string[]): boolean;
|
|
2
23
|
/**
|
|
3
24
|
* Emit the authenticated CLI session signal using an already-cached token.
|
|
4
25
|
* This deliberately never refreshes a session or opens a browser.
|
|
@@ -1,8 +1,80 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
1
4
|
import { CLI_VERSION } from "../cli-version.js";
|
|
2
5
|
import { isExpiring, isMachineIdentity, loadCachedTokens, } from "./cognito-session.js";
|
|
3
6
|
import { vaultApiFetch } from "./vault-api.js";
|
|
4
7
|
export const TELEMETRY_TIMEOUT_MS = 1_200;
|
|
8
|
+
/**
|
|
9
|
+
* Exact stderr line printed once on the first signed-in TTY run that is not
|
|
10
|
+
* `--json`. Wording matches the event body: CLI version + timestamp only.
|
|
11
|
+
*/
|
|
12
|
+
export const TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when you run a signed-in command (CLI version and a timestamp, never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.";
|
|
13
|
+
const TELEMETRY_DISABLED_VALUES = new Set(["0", "false", "off"]);
|
|
14
|
+
const DISCLOSURE_MARKER = "cli-telemetry-disclosed";
|
|
5
15
|
let cliSessionStartedPromise;
|
|
16
|
+
/**
|
|
17
|
+
* `HQ_TELEMETRY=0` (also `false` and `off`, case-insensitive) disables every
|
|
18
|
+
* CLI POST to `/v1/telemetry/events`. `HQ_TELEMETRY_TIMEOUT_MS` is unchanged.
|
|
19
|
+
*/
|
|
20
|
+
export function isCliTelemetryDisabled(env = process.env) {
|
|
21
|
+
const raw = env.HQ_TELEMETRY?.trim().toLowerCase();
|
|
22
|
+
if (!raw)
|
|
23
|
+
return false;
|
|
24
|
+
return TELEMETRY_DISABLED_VALUES.has(raw);
|
|
25
|
+
}
|
|
26
|
+
function hqConfigDir() {
|
|
27
|
+
const home = process.env.HOME?.trim() || os.homedir();
|
|
28
|
+
return path.join(home, ".hq");
|
|
29
|
+
}
|
|
30
|
+
export function telemetryDisclosureMarkerPath() {
|
|
31
|
+
return path.join(hqConfigDir(), DISCLOSURE_MARKER);
|
|
32
|
+
}
|
|
33
|
+
function wantsJsonOutput(argv = process.argv) {
|
|
34
|
+
return argv.includes("--json");
|
|
35
|
+
}
|
|
36
|
+
function canPrintDisclosure(stdout = process.stdout, argv = process.argv) {
|
|
37
|
+
return Boolean(stdout.isTTY) && !wantsJsonOutput(argv);
|
|
38
|
+
}
|
|
39
|
+
function hasShownDisclosure() {
|
|
40
|
+
try {
|
|
41
|
+
return fs.existsSync(telemetryDisclosureMarkerPath());
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function recordDisclosureShown() {
|
|
48
|
+
try {
|
|
49
|
+
const dir = hqConfigDir();
|
|
50
|
+
// Do not create ~/.hq. Signed-in runs already have it (cached tokens).
|
|
51
|
+
if (!fs.existsSync(dir))
|
|
52
|
+
return;
|
|
53
|
+
fs.writeFileSync(telemetryDisclosureMarkerPath(), `${new Date().toISOString()}\n`);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Marker write is best effort; a repeat print is better than crashing.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Print the one-line disclosure at most once per machine. Returns whether
|
|
61
|
+
* the notice has been shown (this run or a previous one). `--json` and
|
|
62
|
+
* non-TTY stdout skip the print and do not record the marker.
|
|
63
|
+
*/
|
|
64
|
+
export function maybeDiscloseCliTelemetry(stderr = process.stderr, stdout = process.stdout, argv = process.argv) {
|
|
65
|
+
if (hasShownDisclosure())
|
|
66
|
+
return true;
|
|
67
|
+
if (!canPrintDisclosure(stdout, argv))
|
|
68
|
+
return false;
|
|
69
|
+
try {
|
|
70
|
+
stderr.write(`${TELEMETRY_DISCLOSURE_LINE}\n`);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
recordDisclosureShown();
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
6
78
|
/**
|
|
7
79
|
* Emit the authenticated CLI session signal using an already-cached token.
|
|
8
80
|
* This deliberately never refreshes a session or opens a browser.
|
|
@@ -13,12 +85,16 @@ export function emitCliSessionStarted(timeoutMs = TELEMETRY_TIMEOUT_MS) {
|
|
|
13
85
|
}
|
|
14
86
|
async function emitCachedCliSessionStarted(timeoutMs) {
|
|
15
87
|
try {
|
|
88
|
+
if (isCliTelemetryDisabled())
|
|
89
|
+
return;
|
|
16
90
|
const cached = loadCachedTokens();
|
|
17
91
|
if (!cached || isExpiring(cached, 120))
|
|
18
92
|
return;
|
|
19
93
|
const token = isMachineIdentity() ? cached.idToken : cached.accessToken;
|
|
20
94
|
if (!token)
|
|
21
95
|
return;
|
|
96
|
+
const disclosed = maybeDiscloseCliTelemetry();
|
|
97
|
+
const consentBasis = disclosed ? "disclosed" : "no-consent";
|
|
22
98
|
const controller = new AbortController();
|
|
23
99
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
24
100
|
try {
|
|
@@ -33,7 +109,7 @@ async function emitCachedCliSessionStarted(timeoutMs) {
|
|
|
33
109
|
app: "hq-cli",
|
|
34
110
|
source: "cli",
|
|
35
111
|
occurredAt: new Date().toISOString(),
|
|
36
|
-
consentBasis
|
|
112
|
+
consentBasis,
|
|
37
113
|
schemaVersion: 1,
|
|
38
114
|
properties: { version: CLI_VERSION },
|
|
39
115
|
},
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
* version-check), `hq rescue --no-self-update`, and the re-exec guard env.
|
|
58
58
|
*/
|
|
59
59
|
import { type RunningInstall, type UpdateResult } from "./version-gate.js";
|
|
60
|
+
import { type RestartDaemonResult } from "../lib/mesh/live/daemon/install.js";
|
|
60
61
|
/**
|
|
61
62
|
* Set on the re-exec'd child so it can never self-update (and re-exec) again.
|
|
62
63
|
* One update + one re-exec per user invocation, ever.
|
|
@@ -153,6 +154,8 @@ export interface SelfUpdateDeps {
|
|
|
153
154
|
* underneath themselves: hooks, scripts, agents, cron, CI.
|
|
154
155
|
*/
|
|
155
156
|
interactive?: boolean;
|
|
157
|
+
/** Best-effort daemon kick after the CLI install converges (tests). */
|
|
158
|
+
restartDaemon?: () => RestartDaemonResult | void;
|
|
156
159
|
}
|
|
157
160
|
/**
|
|
158
161
|
* Startup path: the running CLI is behind npm `latest`, so update in place and
|
|
@@ -63,6 +63,7 @@ import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
|
63
63
|
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, checkUpdateConvergence, isLocalDependencyInstall, isPrefixWritable, nonWritablePrefixNote, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, runSupervisedUpdateCommand, } from "./version-gate.js";
|
|
64
64
|
import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
|
|
65
65
|
import { markLatestIneffective } from "./version-check.js";
|
|
66
|
+
import { restartDaemonServiceIfInstalled } from "../lib/mesh/live/daemon/install.js";
|
|
66
67
|
/**
|
|
67
68
|
* Set on the re-exec'd child so it can never self-update (and re-exec) again.
|
|
68
69
|
* One update + one re-exec per user invocation, ever.
|
|
@@ -278,6 +279,16 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
278
279
|
console.error(chalk.dim(` Continuing the ${flavor.noun} on ${current}.`));
|
|
279
280
|
return { action: "update-ineffective", latest };
|
|
280
281
|
}
|
|
282
|
+
// A resident mesh daemon keeps the old JS loaded after a global install.
|
|
283
|
+
// Service restart is best-effort and cannot turn a successful update red.
|
|
284
|
+
try {
|
|
285
|
+
const restart = (deps.restartDaemon ?? restartDaemonServiceIfInstalled)();
|
|
286
|
+
if (restart?.message)
|
|
287
|
+
console.error(chalk.yellow(` ${restart.message}`));
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// Defensive only; the default helper already absorbs manager failures.
|
|
291
|
+
}
|
|
281
292
|
const childEnv = { ...env, [REEXEC_GUARD_ENV]: "1" };
|
|
282
293
|
const status = (deps.reexec ?? reexecHq)([...argv.slice(2)], childEnv);
|
|
283
294
|
if (status === null) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.123.1",
|
|
4
4
|
"description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"assets/scaffold",
|
|
14
14
|
"assets/bot-workers",
|
|
15
15
|
"assets/mesh-daemon",
|
|
16
|
-
"CHANGELOG.md"
|
|
16
|
+
"CHANGELOG.md",
|
|
17
|
+
"README.md"
|
|
17
18
|
],
|
|
18
19
|
"scripts": {
|
|
19
20
|
"build": "node scripts/generate-dsn.mjs && tsc && node scripts/generate-command-catalog.mjs --check && node scripts/copy-schemas.mjs && node scripts/chmod-bins.mjs",
|