@indigoai-us/hq-cli 5.345.146 → 5.345.148
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 +45 -0
- package/README.md +14 -0
- package/assets/bot-workers/setup/worker.yaml +11 -0
- package/dist/command-catalog.generated.d.ts +326 -4
- package/dist/command-catalog.generated.js +403 -4
- package/dist/commands/agent-inbox.js +2 -1
- package/dist/commands/auth.js +4 -2
- package/dist/commands/bot.js +1 -1
- package/dist/commands/cloud.js +31 -1
- package/dist/commands/core-refresh-vault-access-native.d.ts +17 -0
- package/dist/commands/core-refresh-vault-access-native.js +214 -19
- package/dist/commands/core-work-mesh-bind-trusted-native.d.ts +9 -0
- package/dist/commands/core-work-mesh-bind-trusted-native.js +26 -19
- package/dist/commands/core.js +6 -0
- package/dist/commands/daemon.d.ts +7 -0
- package/dist/commands/daemon.js +37 -3
- package/dist/commands/dm.js +9 -2
- package/dist/commands/doctor.d.ts +11 -1
- package/dist/commands/doctor.js +56 -1
- package/dist/commands/files.js +10 -6
- package/dist/commands/install-global.d.ts +7 -0
- package/dist/commands/install-global.js +129 -35
- package/dist/commands/lanes-shared.d.ts +3 -1
- package/dist/commands/lanes.d.ts +4 -4
- package/dist/commands/lanes.js +380 -25
- package/dist/commands/login.js +4 -3
- package/dist/commands/pack-install.d.ts +2 -2
- package/dist/commands/pack-install.js +133 -35
- package/dist/commands/sync-manifest.d.ts +2 -9
- package/dist/commands/sync-manifest.js +4 -15
- package/dist/hook-engine-path.d.ts +4 -0
- package/dist/hook-engine-path.js +36 -0
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2784 -2360
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/index.js +6 -0
- package/dist/lib/agent-kit/inbox-redact.d.ts +44 -0
- package/dist/lib/agent-kit/inbox-redact.js +361 -0
- package/dist/lib/agent-kit/inbox-state.js +11 -2
- package/dist/lib/agent-kit/mcp/tools.js +2 -1
- package/dist/lib/agent-kit/run/inbox.js +36 -7
- package/dist/lib/anywhere-runtime-flag-key.d.ts +3 -0
- package/dist/lib/anywhere-runtime-flag-key.js +9 -0
- package/dist/lib/bot/daemon.js +3 -0
- package/dist/lib/bot/runtime/index.js +3 -1
- package/dist/lib/company-skill-wrapper.js +6 -4
- package/dist/lib/daemon/install.d.ts +47 -1
- package/dist/lib/daemon/install.js +181 -15
- package/dist/lib/daemon/lock.d.ts +32 -0
- package/dist/lib/daemon/lock.js +55 -2
- package/dist/lib/doctor/__testing__/run-doctor.js +4 -0
- package/dist/lib/doctor/checks/anywhere.d.ts +3 -0
- package/dist/lib/doctor/checks/anywhere.js +23 -8
- package/dist/lib/doctor/checks/cli-path.d.ts +11 -0
- package/dist/lib/doctor/checks/cli-path.js +45 -0
- package/dist/lib/doctor/checks/hook-engine.d.ts +4 -0
- package/dist/lib/doctor/checks/hook-engine.js +16 -0
- package/dist/lib/doctor/checks/release-links.d.ts +23 -0
- package/dist/lib/doctor/checks/release-links.js +152 -0
- package/dist/lib/doctor/checks/sync-health.d.ts +7 -0
- package/dist/lib/doctor/checks/sync-health.js +38 -2
- package/dist/lib/doctor/checks/temp-homes.d.ts +19 -0
- package/dist/lib/doctor/checks/temp-homes.js +54 -0
- package/dist/lib/doctor/fix/apply.d.ts +90 -1
- package/dist/lib/doctor/fix/apply.js +176 -10
- package/dist/lib/doctor/fix/json-output.d.ts +4 -1
- package/dist/lib/doctor/fix/json-output.js +6 -0
- package/dist/lib/doctor/fix/remediation.d.ts +7 -1
- package/dist/lib/doctor/fix/remediation.js +23 -1
- package/dist/lib/doctor/registry.js +13 -0
- package/dist/lib/doctor/temp-home-sweeper.d.ts +149 -0
- package/dist/lib/doctor/temp-home-sweeper.js +418 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/flag-registry.js +9 -1
- package/dist/lib/flag-snapshot-file.js +8 -3
- package/dist/lib/hook-engine/resolve.d.ts +31 -0
- package/dist/lib/hook-engine/resolve.js +134 -0
- package/dist/lib/lanes/admission-host.d.ts +1 -0
- package/dist/lib/lanes/admission-host.js +8 -1
- package/dist/lib/lanes/admission.d.ts +4 -0
- package/dist/lib/lanes/admission.js +6 -2
- package/dist/lib/lanes/envelope-repair-dispatch.d.ts +5 -0
- package/dist/lib/lanes/envelope-repair-dispatch.js +12 -1
- package/dist/lib/lanes/index.d.ts +2 -2
- package/dist/lib/lanes/index.js +1 -1
- package/dist/lib/lanes/keep-alive.js +4 -1
- package/dist/lib/lanes/local-host-identity.d.ts +40 -0
- package/dist/lib/lanes/local-host-identity.js +314 -0
- package/dist/lib/lanes/reaper.d.ts +1 -1
- package/dist/lib/lanes/reaper.js +7 -0
- package/dist/lib/lanes/reconcile.d.ts +4 -0
- package/dist/lib/lanes/reconcile.js +16 -8
- package/dist/lib/lanes/spawn.d.ts +20 -0
- package/dist/lib/lanes/spawn.js +48 -2
- package/dist/lib/lanes/status-fields.js +6 -1
- package/dist/lib/lanes/store.js +8 -1
- package/dist/lib/lanes/throttle.d.ts +49 -6
- package/dist/lib/lanes/throttle.js +154 -17
- package/dist/lib/lanes/types.d.ts +22 -1
- package/dist/lib/lanes/types.js +27 -3
- package/dist/lib/lanes/watcher.js +27 -2
- package/dist/lib/lanes/worker-continuity.d.ts +2 -0
- package/dist/lib/lanes/worker-continuity.js +18 -11
- package/dist/lib/manifest-disabled.d.ts +13 -0
- package/dist/lib/manifest-disabled.js +19 -0
- package/dist/lib/service-manager/launchd.d.ts +1 -0
- package/dist/lib/service-manager/launchd.js +1 -1
- package/dist/lib/windows/user-path.d.ts +48 -0
- package/dist/lib/windows/user-path.js +114 -0
- package/dist/lib/work-context/auto-bind.d.ts +1 -1
- package/dist/lib/work-context/auto-bind.js +22 -3
- package/dist/lib/work-context/lane-bind.d.ts +31 -0
- package/dist/lib/work-context/lane-bind.js +101 -0
- package/dist/lib/work-context/organize.js +1 -0
- package/dist/lib/work-context/prd-sync.js +7 -0
- package/dist/lib/work-context/project.d.ts +36 -10
- package/dist/lib/work-context/project.js +130 -41
- package/dist/lib/work-context/reconcile.d.ts +6 -0
- package/dist/lib/work-context/reconcile.js +25 -5
- package/dist/lib/work-context/state.d.ts +6 -0
- package/dist/lib/work-context/state.js +29 -1
- package/dist/lib/workers-registry/index.d.ts +6 -0
- package/dist/lib/workers-registry/index.js +8 -0
- package/dist/utils/copy-tree.d.ts +61 -0
- package/dist/utils/copy-tree.js +249 -0
- package/dist/utils/environmental-error.js +7 -3
- package/dist/utils/headless.d.ts +2 -0
- package/dist/utils/headless.js +8 -0
- package/dist/utils/login-browser.d.ts +4 -0
- package/dist/utils/login-browser.js +122 -0
- package/dist/utils/npm-invocation.d.ts +27 -0
- package/dist/utils/npm-invocation.js +54 -0
- package/dist/utils/safe-link.d.ts +50 -0
- package/dist/utils/safe-link.js +300 -0
- package/dist/utils/team-upgrade.js +1 -7
- package/package.json +17 -5
- package/scripts/hook-engine-manifest.mjs +22 -0
- package/scripts/install-hook-engine.mjs +10 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** Native local manifest refresh for core/scripts/refresh-vault-access.sh. */
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
3
4
|
import * as fs from "node:fs";
|
|
5
|
+
import * as os from "node:os";
|
|
4
6
|
import * as path from "node:path";
|
|
5
7
|
import { resolveWindowsExecutable } from "../lib/bot/runtime/windows.js";
|
|
6
8
|
import { serializeJqJson } from "../lib/core-utils/jq-json-serializer.js";
|
|
@@ -36,6 +38,11 @@ export const REFRESH_VAULT_ACCESS_HELP = `# hq-core: public
|
|
|
36
38
|
# the authoritative security boundary — this manifest only powers the local
|
|
37
39
|
# early-warning block for read-only shares.
|
|
38
40
|
#
|
|
41
|
+
# Scope: with no --company the roll-up rewrites the whole manifest. With
|
|
42
|
+
# --company, only the named companies' entries are replaced; every other
|
|
43
|
+
# company's entry is kept as it was. Writes are atomic and serialized by a
|
|
44
|
+
# lock file next to the manifest.
|
|
45
|
+
#
|
|
39
46
|
# Usage:
|
|
40
47
|
# bash core/scripts/refresh-vault-access.sh [--company <slug>]... [--root <hqRoot>]
|
|
41
48
|
#
|
|
@@ -189,8 +196,13 @@ export function runRefreshVaultAccess(args, deps) {
|
|
|
189
196
|
const runHq = deps.runHq ?? ((hqArgs, cwd) => defaultHq(hqArgs, cwd, deps.env));
|
|
190
197
|
const identity = resolveEmail(deps, runHq);
|
|
191
198
|
let stderr = identity.stderr;
|
|
192
|
-
const explicit = parsed.slugs;
|
|
199
|
+
const explicit = [...new Set(parsed.slugs)];
|
|
193
200
|
const allGrants = new Map();
|
|
201
|
+
// Companies whose entry this run replaces. A scoped run (--company) owns only
|
|
202
|
+
// the companies it queried successfully; every other company already in the
|
|
203
|
+
// manifest is carried over untouched. The unscoped roll-up owns the whole
|
|
204
|
+
// manifest, so a company that lost all its grants drops out of it.
|
|
205
|
+
const refreshed = new Set();
|
|
194
206
|
if (explicit.length > 0) {
|
|
195
207
|
for (const slug of explicit) {
|
|
196
208
|
const result = runHq(["files", "shared-with-me", "--company", slug], deps.cwd);
|
|
@@ -198,45 +210,228 @@ export function runRefreshVaultAccess(args, deps) {
|
|
|
198
210
|
stderr += log(`skipping ${slug} — shared-with-me query failed`);
|
|
199
211
|
continue;
|
|
200
212
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
213
|
+
refreshed.add(slug);
|
|
214
|
+
const grants = parseGrantTable(result.stdout).get(slug) ?? [];
|
|
215
|
+
allGrants.set(slug, [...(allGrants.get(slug) ?? []), ...grants]);
|
|
204
216
|
}
|
|
205
217
|
}
|
|
206
218
|
else {
|
|
207
219
|
const result = runHq(["files", "shared-with-me"], deps.cwd);
|
|
208
220
|
if (result.code !== 0)
|
|
209
221
|
return { code: 0, stdout: "", stderr: `${stderr}${log("shared-with-me roll-up failed — nothing refreshed")}` };
|
|
210
|
-
for (const [company, grants] of parseGrantTable(result.stdout))
|
|
222
|
+
for (const [company, grants] of parseGrantTable(result.stdout)) {
|
|
211
223
|
allGrants.set(company, grants);
|
|
224
|
+
refreshed.add(company);
|
|
225
|
+
}
|
|
212
226
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const sortedSlugs = [...companies].sort();
|
|
217
|
-
if (sortedSlugs.length === 0)
|
|
227
|
+
if (explicit.length > 0 && refreshed.size === 0)
|
|
228
|
+
return { code: 0, stdout: "", stderr: `${stderr}${log("no requested company could be queried — nothing refreshed")}` };
|
|
229
|
+
if (explicit.length === 0 && refreshed.size === 0)
|
|
218
230
|
stderr += log("no explicit grants found — writing an empty manifest (hook stays fail-open everywhere)");
|
|
219
|
-
const
|
|
220
|
-
for (const slug of
|
|
231
|
+
const fresh = new Map();
|
|
232
|
+
for (const slug of [...refreshed].sort()) {
|
|
221
233
|
let role = "unknown";
|
|
222
234
|
if (identity.email) {
|
|
223
235
|
const result = runHq(["members", "--company", slug, "list"], deps.cwd);
|
|
224
236
|
role = result.code === 0 ? roleFromTable(result.stdout, identity.email) : "unknown";
|
|
225
237
|
}
|
|
226
238
|
const grants = (allGrants.get(slug) ?? []).map((grant) => ({ path: grant.path, permission: grant.permission }));
|
|
227
|
-
|
|
239
|
+
fresh.set(slug, { role, grants });
|
|
228
240
|
}
|
|
229
|
-
const manifest = { version: 1, generatedAt: deps.now().toISOString().replace(/\.\d{3}Z$/, "Z"), companies: companyData };
|
|
230
241
|
const directory = path.join(root, ".hq");
|
|
231
242
|
fs.mkdirSync(directory, { recursive: true });
|
|
232
243
|
const target = path.join(directory, "vault-access.json");
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
244
|
+
// Read the current manifest only once the lock is held, after the slow hq
|
|
245
|
+
// queries, so a refresh for another company that finished meanwhile is kept.
|
|
246
|
+
const written = withManifestLock(target, deps.lockWaitMs ?? LOCK_WAIT_MS, (stillOwned) => {
|
|
247
|
+
// Company slugs come from --company and from the manifest, so they are
|
|
248
|
+
// kept in a Map and a null-prototype object; "__proto__" stays a plain key.
|
|
249
|
+
const companyData = new Map();
|
|
250
|
+
if (explicit.length > 0) {
|
|
251
|
+
const existing = readCompanies(target);
|
|
252
|
+
stderr += existing.warning;
|
|
253
|
+
for (const [slug, entry] of Object.entries(existing.companies))
|
|
254
|
+
if (!refreshed.has(slug))
|
|
255
|
+
companyData.set(slug, entry);
|
|
256
|
+
}
|
|
257
|
+
for (const [slug, entry] of fresh)
|
|
258
|
+
companyData.set(slug, entry);
|
|
259
|
+
const sorted = Object.create(null);
|
|
260
|
+
for (const slug of [...companyData.keys()].sort())
|
|
261
|
+
sorted[slug] = companyData.get(slug);
|
|
262
|
+
const manifest = { version: 1, generatedAt: deps.now().toISOString().replace(/\.\d{3}Z$/, "Z"), companies: sorted };
|
|
263
|
+
const temp = `${target}.${process.pid}.${randomBytes(8).toString("hex")}`;
|
|
264
|
+
fs.writeFileSync(temp, serializeJqJson(manifest, "pretty"), { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
265
|
+
try {
|
|
266
|
+
fs.chmodSync(temp, 0o600);
|
|
267
|
+
// A refresh that held the lock past the stale threshold may have been
|
|
268
|
+
// taken over; it must not overwrite the new holder's merge.
|
|
269
|
+
if (!stillOwned()) {
|
|
270
|
+
fs.rmSync(temp, { force: true });
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
fs.renameSync(temp, target);
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
fs.rmSync(temp, { force: true });
|
|
277
|
+
throw err;
|
|
278
|
+
}
|
|
279
|
+
return companyData.size;
|
|
280
|
+
});
|
|
281
|
+
if (written === undefined)
|
|
282
|
+
return { code: 0, stdout: "", stderr: `${stderr}${log("another refresh is still writing .hq/vault-access.json — nothing refreshed")}` };
|
|
283
|
+
const scope = explicit.length > 0 ? `, refreshed: ${[...refreshed].sort().join(", ")}` : "";
|
|
284
|
+
stderr += log(`wrote .hq/vault-access.json (${written} companies${scope}, identity: ${identity.email || "unknown"})`);
|
|
238
285
|
return { code: 0, stdout: "", stderr };
|
|
239
286
|
}
|
|
287
|
+
/** Company entries already in the manifest; empty, with a warning, when it is missing or unreadable. */
|
|
288
|
+
function readCompanies(target) {
|
|
289
|
+
let text;
|
|
290
|
+
try {
|
|
291
|
+
text = fs.readFileSync(target, "utf8");
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
const code = err.code;
|
|
295
|
+
return { companies: {}, warning: code === "ENOENT" ? "" : log(`could not read the existing manifest (${code ?? "error"}) — other companies' entries are not carried over`) };
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const companies = JSON.parse(text)?.companies;
|
|
299
|
+
if (companies && typeof companies === "object" && !Array.isArray(companies))
|
|
300
|
+
return { companies: companies, warning: "" };
|
|
301
|
+
}
|
|
302
|
+
catch { /* reported below */ }
|
|
303
|
+
return { companies: {}, warning: log("the existing manifest is not valid — other companies' entries are not carried over") };
|
|
304
|
+
}
|
|
305
|
+
/** A lock older than this belongs to a refresh that died holding it. */
|
|
306
|
+
const LOCK_STALE_MS = 30_000;
|
|
307
|
+
const LOCK_WAIT_MS = 5_000;
|
|
308
|
+
function sleepSync(ms) {
|
|
309
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
310
|
+
}
|
|
311
|
+
function readLockToken(lock) {
|
|
312
|
+
try {
|
|
313
|
+
return fs.readFileSync(lock, "utf8");
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/** True when the token names a process on this host that no longer exists. */
|
|
320
|
+
function holderIsGone(token) {
|
|
321
|
+
const match = /^(\d+):(.*):[0-9a-f]+$/.exec(token);
|
|
322
|
+
if (!match || match[2] !== os.hostname())
|
|
323
|
+
return false;
|
|
324
|
+
const pid = Number(match[1]);
|
|
325
|
+
if (pid === process.pid)
|
|
326
|
+
return false;
|
|
327
|
+
try {
|
|
328
|
+
process.kill(pid, 0);
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
return err.code === "ESRCH";
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Remove the lock only if it still carries `expected`. The lock is first
|
|
337
|
+
* renamed to a name private to this caller, which only one process can do for
|
|
338
|
+
* a given lock file. If the moved file is not the expected lock (another
|
|
339
|
+
* process replaced it in the meantime), it is linked back under the lock name
|
|
340
|
+
* unchanged.
|
|
341
|
+
*/
|
|
342
|
+
function removeLockIf(lock, expected, id) {
|
|
343
|
+
const moved = `${lock}.${id}.moved`;
|
|
344
|
+
try {
|
|
345
|
+
fs.renameSync(lock, moved);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
const ours = readLockToken(moved) === expected;
|
|
351
|
+
if (!ours) {
|
|
352
|
+
try {
|
|
353
|
+
fs.linkSync(moved, lock);
|
|
354
|
+
}
|
|
355
|
+
catch { /* a newer lock already exists; leave it */ }
|
|
356
|
+
}
|
|
357
|
+
fs.rmSync(moved, { force: true });
|
|
358
|
+
return ours;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Take the manifest lock and return its owner token, or undefined when the
|
|
362
|
+
* lock stays busy past `waitMs`. The lock file is created by hard-linking a
|
|
363
|
+
* file that already holds the token, so it never exists without an owner. A
|
|
364
|
+
* stale lock (older than LOCK_STALE_MS, or whose holder on this host has
|
|
365
|
+
* exited) is taken over through removeLockIf, so two waiters that both judge
|
|
366
|
+
* it stale cannot remove each other's new lock.
|
|
367
|
+
*/
|
|
368
|
+
export function acquireManifestLock(lock, waitMs, hooks = {}) {
|
|
369
|
+
const nonce = randomBytes(8).toString("hex");
|
|
370
|
+
const id = `${process.pid}.${nonce}`;
|
|
371
|
+
const token = `${process.pid}:${os.hostname()}:${nonce}`;
|
|
372
|
+
const pending = `${lock}.${id}.new`;
|
|
373
|
+
const deadline = Date.now() + Math.max(0, waitMs);
|
|
374
|
+
fs.writeFileSync(pending, token, { flag: "wx", mode: 0o600 });
|
|
375
|
+
try {
|
|
376
|
+
for (;;) {
|
|
377
|
+
try {
|
|
378
|
+
fs.linkSync(pending, lock);
|
|
379
|
+
return token;
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
if (err.code !== "EEXIST")
|
|
383
|
+
throw err;
|
|
384
|
+
}
|
|
385
|
+
const held = readLockToken(lock);
|
|
386
|
+
let age;
|
|
387
|
+
// lstat, so a lock name that is a dangling link still counts as held.
|
|
388
|
+
try {
|
|
389
|
+
age = Date.now() - fs.lstatSync(lock).mtimeMs;
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (held !== undefined && (age > LOCK_STALE_MS || holderIsGone(held))) {
|
|
395
|
+
hooks.afterStaleCheck?.();
|
|
396
|
+
if (removeLockIf(lock, held, id))
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
// Every path that did not just free the lock waits and honours the
|
|
400
|
+
// deadline, so a lock that cannot be read or taken over never spins.
|
|
401
|
+
if (Date.now() >= deadline)
|
|
402
|
+
return undefined;
|
|
403
|
+
sleepSync(25);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
fs.rmSync(pending, { force: true });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/** Release the lock if `token` still owns it. Returns false when another process has taken it over. */
|
|
411
|
+
export function releaseManifestLock(lock, token) {
|
|
412
|
+
if (readLockToken(lock) !== token)
|
|
413
|
+
return false;
|
|
414
|
+
return removeLockIf(lock, token, `${process.pid}.${randomBytes(8).toString("hex")}`);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Serialize the read-merge-write of the manifest across processes. Without it
|
|
418
|
+
* two scoped refreshes for different companies can each read the old file and
|
|
419
|
+
* the second rename drops the first one's company. `fn` gets a check that the
|
|
420
|
+
* lock is still held, to call just before it commits. Returns undefined when
|
|
421
|
+
* the lock stays busy past `waitMs`.
|
|
422
|
+
*/
|
|
423
|
+
function withManifestLock(target, waitMs, fn) {
|
|
424
|
+
const lock = `${target}.lock`;
|
|
425
|
+
const token = acquireManifestLock(lock, waitMs);
|
|
426
|
+
if (token === undefined)
|
|
427
|
+
return undefined;
|
|
428
|
+
try {
|
|
429
|
+
return fn(() => readLockToken(lock) === token);
|
|
430
|
+
}
|
|
431
|
+
finally {
|
|
432
|
+
releaseManifestLock(lock, token);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
240
435
|
export function registerCoreRefreshVaultAccessNative(core) {
|
|
241
436
|
registerNativeUtility(core, {
|
|
242
437
|
name: "refresh-vault-access", root: "cwd", summary: "Refresh the local vault access manifest",
|
|
@@ -45,6 +45,15 @@ type DetachedSpawnOptions = {
|
|
|
45
45
|
};
|
|
46
46
|
};
|
|
47
47
|
export declare function spawnWorkMeshHqDetached(args: string[], options: DetachedSpawnOptions): void;
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the bind session. Only a session id the caller owns (the process env)
|
|
50
|
+
* is trusted; workspace/sessions/.current is last-writer-wins and can name
|
|
51
|
+
* another session, so it is a fallback that never carries trusted authority.
|
|
52
|
+
*/
|
|
53
|
+
export declare function resolveWorkMeshBindSession(root: string, env: NodeJS.ProcessEnv, fileSystem?: typeof fs): {
|
|
54
|
+
sid: string;
|
|
55
|
+
trusted: boolean;
|
|
56
|
+
};
|
|
48
57
|
export declare function resolveWorkMeshBindSessionId(root: string, env: NodeJS.ProcessEnv, fileSystem?: typeof fs): string;
|
|
49
58
|
export declare function runWorkMeshBindTrusted(args: string[], deps: WorkMeshBindDeps): UtilityOutput;
|
|
50
59
|
export declare function registerCoreWorkMeshBindTrustedNative(core: Command): void;
|
|
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { spawn, spawnSync } from "node:child_process";
|
|
5
5
|
import { resolveWindowsExecutable } from "../lib/bot/runtime/windows.js";
|
|
6
|
+
import { sessionIdFromEnv, validSessionId } from "../lib/core-utils/session-id.js";
|
|
6
7
|
import { generateUlid } from "../lib/mesh/live/ulid.js";
|
|
7
8
|
import { resolveLiveRoot } from "../utils/hq-roots.js";
|
|
8
9
|
import { planResolvedCommandSpawn } from "../utils/windows-spawn.js";
|
|
@@ -72,20 +73,26 @@ export function spawnWorkMeshHqDetached(args, options) {
|
|
|
72
73
|
});
|
|
73
74
|
child.unref();
|
|
74
75
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the bind session. Only a session id the caller owns (the process env)
|
|
78
|
+
* is trusted; workspace/sessions/.current is last-writer-wins and can name
|
|
79
|
+
* another session, so it is a fallback that never carries trusted authority.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveWorkMeshBindSession(root, env, fileSystem = fs) {
|
|
82
|
+
const fromEnv = sessionIdFromEnv(env);
|
|
83
|
+
if (fromEnv)
|
|
84
|
+
return { sid: fromEnv, trusted: true };
|
|
81
85
|
try {
|
|
82
|
-
const value = fileSystem.readFileSync(path.join(root, "workspace/sessions/.current"), "utf8").replace(
|
|
83
|
-
return value;
|
|
86
|
+
const value = fileSystem.readFileSync(path.join(root, "workspace/sessions/.current"), "utf8").replace(/[\t\n\v\f\r ]/g, "");
|
|
87
|
+
return { sid: validSessionId(value) ? value : "", trusted: false };
|
|
84
88
|
}
|
|
85
89
|
catch {
|
|
86
|
-
return "";
|
|
90
|
+
return { sid: "", trusted: false };
|
|
87
91
|
}
|
|
88
92
|
}
|
|
93
|
+
export function resolveWorkMeshBindSessionId(root, env, fileSystem = fs) {
|
|
94
|
+
return resolveWorkMeshBindSession(root, env, fileSystem).sid;
|
|
95
|
+
}
|
|
89
96
|
function hasExecutable(deps, command) {
|
|
90
97
|
if (process.platform === "win32") {
|
|
91
98
|
return Boolean(resolveWindowsExecutable(command, deps.env, (candidate) => {
|
|
@@ -158,17 +165,15 @@ export function runWorkMeshBindTrusted(args, deps) {
|
|
|
158
165
|
else
|
|
159
166
|
i += 1;
|
|
160
167
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
catch {
|
|
168
|
-
sid = "";
|
|
169
|
-
}
|
|
170
|
-
}
|
|
168
|
+
// Trusted authority needs a session id the caller owns: --session or the
|
|
169
|
+
// process env. An invalid --session stops the bind, so it cannot fall back to
|
|
170
|
+
// whichever session .current names.
|
|
171
|
+
if (sid && !validSessionId(sid)) {
|
|
172
|
+
return { code: 2, stdout: "", stderr: "work-mesh-live-bind-trusted: invalid --session id\n" };
|
|
171
173
|
}
|
|
174
|
+
let sessionTrusted = Boolean(sid);
|
|
175
|
+
if (!sid)
|
|
176
|
+
({ sid, trusted: sessionTrusted } = resolveWorkMeshBindSession(root, deps.env, deps.fs));
|
|
172
177
|
const childEnv = { ...deps.env, HQ_ROOT: root };
|
|
173
178
|
const pin = sid ? ["--session-id", sid] : [];
|
|
174
179
|
let stderr = "";
|
|
@@ -207,6 +212,8 @@ export function runWorkMeshBindTrusted(args, deps) {
|
|
|
207
212
|
trusted.project = project;
|
|
208
213
|
if (task)
|
|
209
214
|
trusted.task = task;
|
|
215
|
+
if (project && sessionTrusted)
|
|
216
|
+
trusted.authority = "trusted";
|
|
210
217
|
observation.trustedContext = trusted;
|
|
211
218
|
}
|
|
212
219
|
const directory = path.join(deps.tmpDir, "hq-work-mesh-obs");
|
package/dist/commands/core.js
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
import { Option } from "commander";
|
|
32
32
|
import { registerCoreCheckpointCommand } from "./core-checkpoint.js";
|
|
33
|
+
import { runHookEnginePath } from '../hook-engine-path.js';
|
|
33
34
|
import { registerCoreDelegateCommand } from "./core-delegate.js";
|
|
34
35
|
import { registerCoreDelegateW1b } from "./core-delegate-w1b.js";
|
|
35
36
|
import { registerCoreHandoffCommand } from "./core-handoff.js";
|
|
@@ -439,6 +440,11 @@ export function registerCoreCommands(program) {
|
|
|
439
440
|
// This group primarily hosts manifest-driven bundled assets, but it also
|
|
440
441
|
// hosts native TypeScript plumbing when a scaffold contract needs it.
|
|
441
442
|
registerCoreCheckpointCommand(core);
|
|
443
|
+
const hookEnginePath = core.command('hook-engine-path')
|
|
444
|
+
.description('Print the offline, version-pinned native hook engine path')
|
|
445
|
+
.option('--repair', 'Repair the pinned copy after closing running hook processes')
|
|
446
|
+
.action((options) => runHookEnginePath(options.repair ? ['--repair'] : []));
|
|
447
|
+
describeCoreCommand(hookEnginePath, { execution: 'native', root: 'cwd' });
|
|
442
448
|
registerCoreOntologyCommand(core);
|
|
443
449
|
registerCoreCheckpointGateSettingCommand(core);
|
|
444
450
|
registerCoreDelegateCommand(core, registerCoreDelegateW1b);
|
|
@@ -45,6 +45,13 @@ export interface DaemonCommandDeps {
|
|
|
45
45
|
userHome: (user: string) => string;
|
|
46
46
|
/** The pid is a live `hq daemon run`. */
|
|
47
47
|
daemonAlive: (pid: number) => boolean;
|
|
48
|
+
/** Blocking sleep for install's wait for the Windows task to start (tests inject). */
|
|
49
|
+
sleep?: (ms: number) => void;
|
|
50
|
+
/**
|
|
51
|
+
* Windows: this user has an active signed-in session, so the logon task
|
|
52
|
+
* can start now. Null when the sessions cannot be listed.
|
|
53
|
+
*/
|
|
54
|
+
interactiveSession?: () => boolean | null | "unavailable";
|
|
48
55
|
/** Test seam for the combined hq-flags and person-setting gate. */
|
|
49
56
|
runtimeAllowed?: () => boolean | Promise<boolean>;
|
|
50
57
|
/** Test seam for the company sync-mode commands. */
|
package/dist/commands/daemon.js
CHANGED
|
@@ -24,9 +24,9 @@ import { DEFAULT_SERVICES, SERVICE_DESCRIPTIONS, enabledServices, hqCloudUnits }
|
|
|
24
24
|
import { applyServiceToggle, effectiveSyncOwner, readDaemonConfig, writeDaemonConfig } from "../lib/daemon/config.js";
|
|
25
25
|
import { daemonUnitInstalled } from "../lib/daemon/handoff.js";
|
|
26
26
|
import { adoptLegacyUnits, readAdoptedUnits, restoreLegacyUnits } from "../lib/daemon/migrate.js";
|
|
27
|
-
import { ROOT_REFUSAL, SYSTEM_UNIT_DIR, daemonRootRefusal, installDaemonUnit, installScopeFor, uninstallDaemonUnit, } from "../lib/daemon/install.js";
|
|
27
|
+
import { DAEMON_WINDOWS_TASK, ROOT_REFUSAL, SYSTEM_UNIT_DIR, WINDOWS_START_WAIT_MS, daemonRootRefusal, installDaemonUnit, installScopeFor, uninstallDaemonUnit, windowsInteractiveSession, } from "../lib/daemon/install.js";
|
|
28
28
|
import { describeLegacyUnit, findLegacyUnits } from "../lib/daemon/legacy.js";
|
|
29
|
-
import { isDaemonProcess, runningDaemonPid } from "../lib/daemon/lock.js";
|
|
29
|
+
import { isDaemonProcess, liveDaemonLock, runningDaemonPid } from "../lib/daemon/lock.js";
|
|
30
30
|
import { bundledHqCloud, managedHqCloud, managedHqCloudRoot, readHqCloudCopy, readHqCloudUpdateState } from "../lib/daemon/hq-cloud.js";
|
|
31
31
|
import { MACHINE_TYPE_MARKER_PATH, detectMachineType, homeFromPasswd, } from "../lib/daemon/machine-type.js";
|
|
32
32
|
import { daemonPaths, ensurePrivateDir } from "../lib/daemon/paths.js";
|
|
@@ -137,6 +137,8 @@ async function loadDefaults(over) {
|
|
|
137
137
|
resolveHqRoot: over.resolveHqRoot ?? ((flag) => resolveDaemonHqRoot(flag, { env, cwd: process.cwd(), home })),
|
|
138
138
|
userHome: over.userHome ?? ((user) => loginHome(user, run)),
|
|
139
139
|
daemonAlive: over.daemonAlive ?? ((pid) => isDaemonProcess(pid)),
|
|
140
|
+
sleep: over.sleep,
|
|
141
|
+
interactiveSession: over.interactiveSession,
|
|
140
142
|
windowsUser: over.windowsUser ?? (platform === "win32" ? mesh.resolveWindowsUser(env) : ""),
|
|
141
143
|
windowsHeadlessLauncher: over.windowsHeadlessLauncher ?? (platform === "win32" ? mesh.resolveWindowsHeadlessLauncher(env) : undefined),
|
|
142
144
|
systemUnitDir: over.systemUnitDir ?? SYSTEM_UNIT_DIR,
|
|
@@ -298,12 +300,42 @@ async function runInstall(d, opts) {
|
|
|
298
300
|
d.writeFile(MACHINE_TYPE_MARKER_PATH, `type=${detected.type}\nuser=${runAsUser}\n`);
|
|
299
301
|
}
|
|
300
302
|
const runAsHome = runAsUser ? d.userHome(runAsUser) : undefined;
|
|
301
|
-
const result = installDaemonUnit(hostFor(d, paths, scope === "user"), { scope, runAsUser, runAsHome }, {
|
|
303
|
+
const result = installDaemonUnit(hostFor(d, paths, scope === "user"), { scope, runAsUser, runAsHome }, {
|
|
304
|
+
run: d.run,
|
|
305
|
+
sleep: d.sleep,
|
|
306
|
+
runningDaemon: () => liveDaemonLock(paths.lockPath, d.daemonAlive),
|
|
307
|
+
});
|
|
302
308
|
handOver(d, { paths, detected, runAsUser });
|
|
309
|
+
if (result.warning)
|
|
310
|
+
d.err(result.warning);
|
|
303
311
|
if (result.kind === "manual") {
|
|
304
312
|
d.out(`This platform has no supported service manager. Start the daemon yourself with:\n ${result.manualCommand}`);
|
|
305
313
|
return;
|
|
306
314
|
}
|
|
315
|
+
if (result.startPending) {
|
|
316
|
+
const signedIn = (d.interactiveSession ?? (() => windowsInteractiveSession(d.run, d.windowsUser ?? "")))();
|
|
317
|
+
// A missing daemon is not a failure when the user is signed out, or when
|
|
318
|
+
// Constrained Language Mode refuses the session check; the latter says the
|
|
319
|
+
// check did not run. A check that failed for any other reason (null) is
|
|
320
|
+
// treated like a known sign-in and exits non-zero.
|
|
321
|
+
if (signedIn === false || signedIn === "unavailable") {
|
|
322
|
+
d.out(`Installed: ${result.unitPath}`);
|
|
323
|
+
if (signedIn === "unavailable") {
|
|
324
|
+
d.out("HQ could not check whether you are signed in to Windows, so it could not confirm the daemon started.");
|
|
325
|
+
}
|
|
326
|
+
d.out("The daemon starts at your next sign-in to Windows. It is not running yet.");
|
|
327
|
+
d.out("Check it with: hq daemon status");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const seconds = WINDOWS_START_WAIT_MS / 1000;
|
|
331
|
+
throw new DaemonCommandError([
|
|
332
|
+
signedIn
|
|
333
|
+
? `Installed ${result.unitPath}, but the daemon did not start within ${seconds} seconds although you are signed in to Windows.`
|
|
334
|
+
: `Installed ${result.unitPath}, but the daemon did not start within ${seconds} seconds, and HQ could not check whether you are signed in to Windows.`,
|
|
335
|
+
"Check it with: hq daemon status",
|
|
336
|
+
`The task's last result: powershell -NoProfile -Command "(Get-ScheduledTaskInfo -TaskName '${DAEMON_WINDOWS_TASK}').LastTaskResult"`,
|
|
337
|
+
].join("\n"));
|
|
338
|
+
}
|
|
307
339
|
if (!result.running) {
|
|
308
340
|
throw new DaemonCommandError(`Installed ${result.unitPath}, but it did not start: ${result.error ?? "unknown error"}`);
|
|
309
341
|
}
|
|
@@ -354,6 +386,8 @@ async function runUninstall(d, opts) {
|
|
|
354
386
|
}
|
|
355
387
|
const result = uninstallDaemonUnit(hostFor(d, paths, false), { scope: installScopeFor(detected.type) }, { run: d.run });
|
|
356
388
|
d.out(result.removed ? `Removed ${result.unitPath}` : "hq daemon was not installed.");
|
|
389
|
+
if (result.warning)
|
|
390
|
+
d.err(result.warning);
|
|
357
391
|
if (opts.restoreLegacy) {
|
|
358
392
|
const restored = restoreLegacyUnits(migrate);
|
|
359
393
|
for (const u of restored.restored)
|
package/dist/commands/dm.js
CHANGED
|
@@ -4,6 +4,7 @@ import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
|
4
4
|
import { resolveDestinationCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
|
|
5
5
|
import { isMachineIdentity } from "@indigoai-us/hq-cloud";
|
|
6
6
|
import { peekIdToken } from "../utils/id-token.js";
|
|
7
|
+
import { redactAgentInboxItem as redactInboxCredentials } from "../lib/agent-kit/inbox-redact.js";
|
|
7
8
|
import { EMAIL_PATTERN, RECIPIENT_UID_PATTERN } from "../lib/notify-recipient.js";
|
|
8
9
|
import { dmForwardCliEnabled } from "./dm-forward-flag.js";
|
|
9
10
|
/**
|
|
@@ -1281,9 +1282,15 @@ function formatAgentInboxItem(item) {
|
|
|
1281
1282
|
const text = str("text") || str("body") || str("subject");
|
|
1282
1283
|
return `${chalk.bold(from)} ${chalk.dim(`[${channel}] ${id}${when ? ` ${when}` : ""}`)}\n ${firstLine(text)}`;
|
|
1283
1284
|
}
|
|
1284
|
-
/**
|
|
1285
|
+
/**
|
|
1286
|
+
* Stdout for `hq dm` drops the whole actingContext. Credential removal itself
|
|
1287
|
+
* is redactInboxCredentials, shared with the mirror, so a later edit cannot
|
|
1288
|
+
* leave a credential key on one path only. The extra omit stays here because
|
|
1289
|
+
* terminal output must not grow the delegation block back.
|
|
1290
|
+
*/
|
|
1285
1291
|
function redactAgentInboxItem(item) {
|
|
1286
|
-
const
|
|
1292
|
+
const redacted = redactInboxCredentials(item);
|
|
1293
|
+
const { actingContext: _actingContext, ...safe } = redacted;
|
|
1287
1294
|
return safe;
|
|
1288
1295
|
}
|
|
1289
1296
|
async function runAgentInbox(token, agentUid, opts) {
|
|
@@ -25,6 +25,7 @@ import { Command } from "commander";
|
|
|
25
25
|
import { type DoctorRegistry } from "../lib/doctor/registry.js";
|
|
26
26
|
import type { FamilyRun } from "../lib/doctor/types.js";
|
|
27
27
|
import { type DoctorPlatform } from "../lib/doctor/report.js";
|
|
28
|
+
import { type TempHomeSweepResult } from "../lib/doctor/temp-home-sweeper.js";
|
|
28
29
|
/**
|
|
29
30
|
* Marker directories that identify an HQ tree root. A directory is an HQ root
|
|
30
31
|
* only when it contains ALL of these. `.claude` + `core` are present in both
|
|
@@ -126,6 +127,15 @@ export interface RunDoctorResult {
|
|
|
126
127
|
export declare function runDoctor(options?: RunDoctorOptions): Promise<RunDoctorResult>;
|
|
127
128
|
export declare function createClientHealthReasonCodeCompanyResolver(hqRoot: string): (companySlug: string | null) => Promise<boolean>;
|
|
128
129
|
export declare function createClientHealthCheckStatusCompanyResolver(hqRoot: string): (companySlug: string | null) => Promise<boolean>;
|
|
130
|
+
/** Test seams for {@link registerDoctorCommand}. */
|
|
131
|
+
export interface DoctorCommandDeps {
|
|
132
|
+
/** Runs the temp-home sweep for `--sweep-temp-homes`. */
|
|
133
|
+
sweepTempHomes?: (options: {
|
|
134
|
+
dryRun: boolean;
|
|
135
|
+
}) => TempHomeSweepResult;
|
|
136
|
+
}
|
|
137
|
+
/** Text report for `hq doctor --sweep-temp-homes`. */
|
|
138
|
+
export declare function renderTempHomeSweepText(result: TempHomeSweepResult): string;
|
|
129
139
|
/** Register the top-level `hq doctor` command so it appears in `hq --help`. */
|
|
130
|
-
export declare function registerDoctorCommand(program: Command): void;
|
|
140
|
+
export declare function registerDoctorCommand(program: Command, deps?: DoctorCommandDeps): void;
|
|
131
141
|
//# sourceMappingURL=doctor.d.ts.map
|
package/dist/commands/doctor.js
CHANGED
|
@@ -37,6 +37,7 @@ import { clientHealthCompanyScopeFromCheckId } from "../lib/doctor/checks/sync-h
|
|
|
37
37
|
import { readCompaniesManifestMap } from "../lib/work-context/repo-remote.js";
|
|
38
38
|
import * as readline from "node:readline";
|
|
39
39
|
import { runSideEffectTests } from "../lib/doctor/deep/sandbox.js";
|
|
40
|
+
import { sweepTempHomes, } from "../lib/doctor/temp-home-sweeper.js";
|
|
40
41
|
/**
|
|
41
42
|
* Marker directories that identify an HQ tree root. A directory is an HQ root
|
|
42
43
|
* only when it contains ALL of these. `.claude` + `core` are present in both
|
|
@@ -315,8 +316,33 @@ export function createClientHealthCheckStatusCompanyResolver(hqRoot) {
|
|
|
315
316
|
});
|
|
316
317
|
};
|
|
317
318
|
}
|
|
319
|
+
/** Text report for `hq doctor --sweep-temp-homes`. */
|
|
320
|
+
export function renderTempHomeSweepText(result) {
|
|
321
|
+
const lines = [];
|
|
322
|
+
const verb = result.dryRun ? "Would remove" : "Removed";
|
|
323
|
+
for (const entry of result.entries) {
|
|
324
|
+
if (entry.action === "removed" || entry.action === "would-remove") {
|
|
325
|
+
lines.push(` ${verb.toLowerCase()} ${entry.path}`);
|
|
326
|
+
}
|
|
327
|
+
else if (entry.action === "failed") {
|
|
328
|
+
lines.push(` failed ${entry.path}: ${entry.error ?? "unknown error"}`);
|
|
329
|
+
}
|
|
330
|
+
else if (entry.reason === "in-use") {
|
|
331
|
+
lines.push(` kept ${entry.path} (in use)`);
|
|
332
|
+
}
|
|
333
|
+
else if (entry.reason === "in-use-unknown") {
|
|
334
|
+
lines.push(` kept ${entry.path} (could not check whether it is in use: ${entry.error ?? "unknown error"})`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const count = result.entries.filter((entry) => entry.action === "removed" || entry.action === "would-remove").length;
|
|
338
|
+
const failed = result.entries.filter((entry) => entry.action === "failed").length;
|
|
339
|
+
lines.push(`${verb} ${count} idle temp home${count === 1 ? "" : "s"}; ` +
|
|
340
|
+
`${result.dryRun ? "would free" : "freed"} ${result.gbFreed.toFixed(2)} GB.` +
|
|
341
|
+
(failed > 0 ? ` ${failed} could not be removed.` : ""));
|
|
342
|
+
return `${lines.join("\n")}\n`;
|
|
343
|
+
}
|
|
318
344
|
/** Register the top-level `hq doctor` command so it appears in `hq --help`. */
|
|
319
|
-
export function registerDoctorCommand(program) {
|
|
345
|
+
export function registerDoctorCommand(program, deps = {}) {
|
|
320
346
|
program
|
|
321
347
|
.command("doctor")
|
|
322
348
|
.description("Verify HQ hook wiring, runtimes, and connection health (read-only; integrations uses one control-plane inventory read).")
|
|
@@ -330,9 +356,38 @@ export function registerDoctorCommand(program) {
|
|
|
330
356
|
.option("--only <families>", "Run only these comma-separated check families (e.g. hooks). Unknown names are rejected; --json then records the scope.")
|
|
331
357
|
.option("--fix", "Apply the allowlisted safe repairs (backs up first; read-only without this flag).")
|
|
332
358
|
.addOption(new Option("--client-health-report", "Resolve the opt-in hq-flags gate used by the client-health report hook.").hideHelp())
|
|
359
|
+
.option("--sweep-temp-homes", "Delete idle temp homes (90+ minutes untouched, not in use): tmp.* folders holding .rustup or .cargo, and hq-temp-home.* folders left by the wrapper. Reports GB freed.")
|
|
360
|
+
.option("--dry-run", "With --sweep-temp-homes, list what would be removed and delete nothing.")
|
|
333
361
|
.option("--yes", "Skip the interactive --fix confirmation (non-interactive use).")
|
|
334
362
|
.option("--force", "Let --fix run despite uncommitted changes under .claude/, .codex/, or .grok/.")
|
|
335
363
|
.action(async (opts, command) => {
|
|
364
|
+
// `--sweep-temp-homes` is a second write path with its own fixed
|
|
365
|
+
// scope (idle, unused temp homes in the temp dirs). Like `--fix`,
|
|
366
|
+
// it refuses a family scope it would otherwise ignore, and it does not
|
|
367
|
+
// combine with `--fix`.
|
|
368
|
+
if (opts.sweepTempHomes === true) {
|
|
369
|
+
if (parseOnly(opts.only) !== undefined || opts.fix === true) {
|
|
370
|
+
process.stderr.write(`hq doctor: --sweep-temp-homes cannot be combined with --only or --fix.\n` +
|
|
371
|
+
` It removes only idle temp homes in the temp folders; run it on its own.\n`);
|
|
372
|
+
process.exitCode = 1;
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const sweep = deps.sweepTempHomes ?? ((options) => sweepTempHomes(options));
|
|
376
|
+
const result = sweep({ dryRun: opts.dryRun === true });
|
|
377
|
+
if (opts.json === true) {
|
|
378
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
process.stdout.write(renderTempHomeSweepText(result));
|
|
382
|
+
}
|
|
383
|
+
process.exitCode = result.entries.some((entry) => entry.action === "failed") ? 1 : 0;
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (opts.dryRun === true) {
|
|
387
|
+
process.stderr.write(`hq doctor: --dry-run only applies to --sweep-temp-homes.\n`);
|
|
388
|
+
process.exitCode = 1;
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
336
391
|
// `--fix` repairs the whole tree from its own allowlist and has no
|
|
337
392
|
// notion of a check family, so a scope passed alongside it could only
|
|
338
393
|
// be ignored. Ignoring it silently is the dangerous reading: an
|