@indigoai-us/hq-cli 5.345.147 → 5.345.149
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 +65 -0
- package/README.md +2 -0
- package/assets/bot-workers/setup/worker.yaml +11 -0
- package/dist/command-catalog.generated.d.ts +341 -11
- package/dist/command-catalog.generated.js +421 -11
- package/dist/command-help.js +2 -1
- package/dist/commands/access.d.ts +45 -2
- package/dist/commands/access.js +537 -223
- package/dist/commands/agent-inbox.js +2 -1
- package/dist/commands/billing-usage.d.ts +33 -2
- package/dist/commands/billing-usage.js +75 -4
- package/dist/commands/bot.js +2 -2
- package/dist/commands/channels.d.ts +27 -0
- package/dist/commands/channels.js +108 -4
- package/dist/commands/cloud.js +31 -1
- package/dist/commands/core-refresh-vault-access-native.d.ts +33 -0
- package/dist/commands/core-refresh-vault-access-native.js +356 -28
- 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 +9 -0
- package/dist/commands/daemon.d.ts +7 -0
- package/dist/commands/daemon.js +37 -3
- package/dist/commands/dm.js +18 -3
- package/dist/commands/doctor.d.ts +11 -1
- package/dist/commands/doctor.js +56 -1
- package/dist/commands/install-global.d.ts +8 -8
- package/dist/commands/install-global.js +139 -61
- 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/library-add.d.ts +31 -1
- package/dist/commands/library-add.js +69 -2
- package/dist/commands/library.js +37 -3
- package/dist/commands/pack-install.d.ts +2 -2
- package/dist/commands/pack-install.js +133 -35
- package/dist/commands/reindex.js +19 -0
- 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 +43 -0
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2783 -2354
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.js +76 -5
- package/dist/lazy-commands.js +2 -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/billing/usage-cost.d.ts +31 -1
- package/dist/lib/billing/usage-cost.js +46 -0
- 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/db/local.d.ts +2 -2
- package/dist/lib/db/local.js +2 -2
- package/dist/lib/db/sql.js +2 -2
- package/dist/lib/db/sqlite-binding.d.ts +22 -0
- package/dist/lib/db/sqlite-binding.js +168 -0
- package/dist/lib/doctor/__testing__/run-doctor.js +4 -0
- package/dist/lib/doctor/checks/anywhere.d.ts +15 -0
- package/dist/lib/doctor/checks/anywhere.js +77 -11
- package/dist/lib/doctor/checks/cli-path.d.ts +12 -0
- package/dist/lib/doctor/checks/cli-path.js +46 -0
- package/dist/lib/doctor/checks/hook-engine.d.ts +4 -0
- package/dist/lib/doctor/checks/hook-engine.js +17 -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 +10 -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/reconcile.d.ts +9 -0
- package/dist/lib/hook-engine/reconcile.js +134 -0
- package/dist/lib/hook-engine/registration.d.ts +17 -0
- package/dist/lib/hook-engine/registration.js +50 -0
- package/dist/lib/hook-engine/resolve.d.ts +31 -0
- package/dist/lib/hook-engine/resolve.js +134 -0
- package/dist/lib/install/claude-targets.d.ts +2 -1
- package/dist/lib/install/claude-targets.js +8 -3
- package/dist/lib/install/codex-targets.d.ts +3 -0
- package/dist/lib/install/codex-targets.js +15 -4
- package/dist/lib/install/hook-target.d.ts +9 -0
- package/dist/lib/install/hook-target.js +39 -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/console-encoding.d.ts +24 -0
- package/dist/lib/windows/console-encoding.js +83 -0
- package/dist/lib/windows/install-followup.d.ts +35 -0
- package/dist/lib/windows/install-followup.js +142 -0
- package/dist/lib/windows/user-path.d.ts +125 -0
- package/dist/lib/windows/user-path.js +275 -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/main.js +2 -1
- package/dist/unknown-command.d.ts +18 -0
- package/dist/unknown-command.js +88 -0
- package/dist/utils/cognito-session.d.ts +8 -0
- package/dist/utils/cognito-session.js +14 -2
- 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/hook-trust.d.ts +9 -0
- package/dist/utils/hook-trust.js +30 -2
- package/dist/utils/npm-invocation.d.ts +27 -0
- package/dist/utils/npm-invocation.js +54 -0
- package/dist/utils/qmd-native-binding-error.js +3 -1
- package/dist/utils/resolve-executable.d.ts +2 -0
- package/dist/utils/resolve-executable.js +6 -2
- package/dist/utils/safe-link.d.ts +50 -0
- package/dist/utils/safe-link.js +300 -0
- package/package.json +17 -5
- package/scripts/hook-engine-manifest.mjs +22 -0
- package/scripts/install-hook-engine.mjs +10 -0
|
@@ -20,7 +20,8 @@ import * as path from "node:path";
|
|
|
20
20
|
import { vaultApiFetch } from "../../../utils/vault-api.js";
|
|
21
21
|
import { writeComponentStatus } from "../creds.js";
|
|
22
22
|
import { pendingInbox } from "../inbox-state.js";
|
|
23
|
-
import {
|
|
23
|
+
import { redactAgentInboxItem, scrubInboxMirror, withInboxMirrorLock } from "../inbox-redact.js";
|
|
24
|
+
import { mkdirPrivate, writePrivateFile } from "./../private-file.js";
|
|
24
25
|
import { createAgentKitInboxBackoffFlagReader } from "../inbox-poll-backoff-flag.js";
|
|
25
26
|
import { buildWakeNotice, currentWakeState, fireWake, readWakeConfig, shouldWake, } from "../wake.js";
|
|
26
27
|
export const SEEN_IDS_NAME = "seen-ids.json";
|
|
@@ -79,12 +80,22 @@ function inboxJournalContains(paths, id) {
|
|
|
79
80
|
}
|
|
80
81
|
export function mirrorInboxItem(paths, item) {
|
|
81
82
|
const dest = path.join(paths.inboxDir, `${item.id}.json`);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
// One redaction covers the per-id file and the journal line. The journal
|
|
84
|
+
// spreads this object, so a credential left on `raw` would be written twice.
|
|
85
|
+
const safeRaw = redactAgentInboxItem(item.raw);
|
|
86
|
+
const write = () => {
|
|
87
|
+
const recoveringInterruptedMirror = fs.existsSync(dest);
|
|
88
|
+
writePrivateFile(dest, `${JSON.stringify(safeRaw, null, 2)}\n`, 0o600);
|
|
89
|
+
if (!recoveringInterruptedMirror || !inboxJournalContains(paths, item.id)) {
|
|
90
|
+
fs.appendFileSync(path.join(paths.inboxDir, INBOX_JSONL_NAME), `${JSON.stringify({ id: item.id, mirroredAt: new Date().toISOString(), ...safeRaw })}\n`, { mode: 0o600 });
|
|
91
|
+
}
|
|
92
|
+
return dest;
|
|
93
|
+
};
|
|
94
|
+
// Create the directory first so the scrub lock has somewhere to live.
|
|
95
|
+
// The write still runs without the lock if the directory cannot be locked;
|
|
96
|
+
// dropping the message is worse than a rare overlapping rewrite.
|
|
97
|
+
mkdirPrivate(paths.inboxDir);
|
|
98
|
+
return withInboxMirrorLock(paths.inboxDir, 10_000, write) ?? write();
|
|
88
99
|
}
|
|
89
100
|
export const INBOX_ERROR_BODY_MAX = 300;
|
|
90
101
|
/**
|
|
@@ -142,6 +153,24 @@ export function defaultAckItem(agentUid, apiBaseUrl) {
|
|
|
142
153
|
export async function pollInboxOnce(deps) {
|
|
143
154
|
if (deps.signal?.aborted)
|
|
144
155
|
return { ok: false, mirrored: 0 };
|
|
156
|
+
// Mirrors written before credential redaction are scrubbed on the next poll,
|
|
157
|
+
// before a wake can send the bot at the files. A write or rename error is
|
|
158
|
+
// counted and retried on the next poll. The log is a count and errno codes,
|
|
159
|
+
// never a path or a file byte.
|
|
160
|
+
try {
|
|
161
|
+
scrubInboxMirror(deps.paths, (report) => {
|
|
162
|
+
if (report.scrubbed > 0) {
|
|
163
|
+
deps.log("info", `inbox scrubbed ${report.scrubbed} mirrored record(s) containing delegated credentials`);
|
|
164
|
+
}
|
|
165
|
+
if (report.failures > 0) {
|
|
166
|
+
const codes = report.codes.length > 0 ? report.codes.join(",") : "ERR";
|
|
167
|
+
deps.log("warn", `inbox scrub failed for ${report.failures} file(s) (${codes})`);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
deps.log("warn", "inbox scrub failed for 1 file(s) (ERR)");
|
|
173
|
+
}
|
|
145
174
|
const fetchInbox = deps.fetchInbox ?? defaultFetchInbox(deps.agentUid, deps.apiBaseUrl);
|
|
146
175
|
const ackItem = deps.ackItem ?? defaultAckItem(deps.agentUid, deps.apiBaseUrl);
|
|
147
176
|
// No default cache warm: the human conversation routes 403 for agent callers.
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/** hq-flags key for the staged HQ Anywhere runtime. The registry default is on. */
|
|
2
2
|
export declare const HQ_ANYWHERE_RUNTIME_FLAG = "hq-anywhere-runtime";
|
|
3
3
|
export declare const HQ_ANYWHERE_RUNTIME_REVOCATION_MARKER = "hq-anywhere-runtime.revoked";
|
|
4
|
+
export declare const HQ_ANYWHERE_RUNTIME_FALSE_MAX_AGE_MS: number;
|
|
5
|
+
/** An old disable decision must not survive an unavailable registry indefinitely. */
|
|
6
|
+
export declare function freshAnywhereRuntimeFlags(flags: Record<string, boolean>, fetchedAt: number, now?: number): Record<string, boolean>;
|
|
4
7
|
/** The HQ Anywhere environment kill switch stays compatible with managed provider wrappers. */
|
|
5
8
|
export declare function hqAnywhereRuntimeLocallyDisabled(env?: NodeJS.ProcessEnv): boolean;
|
|
6
9
|
//# sourceMappingURL=anywhere-runtime-flag-key.d.ts.map
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
/** hq-flags key for the staged HQ Anywhere runtime. The registry default is on. */
|
|
2
2
|
export const HQ_ANYWHERE_RUNTIME_FLAG = "hq-anywhere-runtime";
|
|
3
3
|
export const HQ_ANYWHERE_RUNTIME_REVOCATION_MARKER = "hq-anywhere-runtime.revoked";
|
|
4
|
+
export const HQ_ANYWHERE_RUNTIME_FALSE_MAX_AGE_MS = 60 * 60 * 1000;
|
|
5
|
+
/** An old disable decision must not survive an unavailable registry indefinitely. */
|
|
6
|
+
export function freshAnywhereRuntimeFlags(flags, fetchedAt, now = Date.now()) {
|
|
7
|
+
if (flags[HQ_ANYWHERE_RUNTIME_FLAG] !== false || now - fetchedAt < HQ_ANYWHERE_RUNTIME_FALSE_MAX_AGE_MS)
|
|
8
|
+
return flags;
|
|
9
|
+
const fresh = { ...flags };
|
|
10
|
+
delete fresh[HQ_ANYWHERE_RUNTIME_FLAG];
|
|
11
|
+
return fresh;
|
|
12
|
+
}
|
|
4
13
|
/** The HQ Anywhere environment kill switch stays compatible with managed provider wrappers. */
|
|
5
14
|
export function hqAnywhereRuntimeLocallyDisabled(env = process.env) {
|
|
6
15
|
const value = env.HQ_FLAG_HQ_ANYWHERE_RUNTIME;
|
|
@@ -201,14 +201,44 @@ export interface UsageReport {
|
|
|
201
201
|
freeItems: string;
|
|
202
202
|
drivers: Driver[];
|
|
203
203
|
noSyncGuideUrl: string;
|
|
204
|
+
/** Set by `hq billing usage`: the account is on usage pricing, so meters are priced. */
|
|
205
|
+
onUsagePricing?: true;
|
|
204
206
|
readOnly: true;
|
|
205
207
|
}
|
|
208
|
+
/** One meter's reading with no price attached. */
|
|
209
|
+
export interface MeterReadingRow {
|
|
210
|
+
key: UsageMeterKey;
|
|
211
|
+
displayName: string;
|
|
212
|
+
/** In billed units (GB for storage). Null unless measured. */
|
|
213
|
+
value: number | null;
|
|
214
|
+
state: MeterState;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The report for an account that is not on usage pricing (a flat plan, or the
|
|
218
|
+
* server did not say). Readings only: no rates, no cost, no projected bill.
|
|
219
|
+
*/
|
|
220
|
+
export interface UsageReadingsReport {
|
|
221
|
+
scope: UsageScope;
|
|
222
|
+
source: MonthUsage["source"];
|
|
223
|
+
/** False: flat plan, meters are not billed. Null: HQ did not say. */
|
|
224
|
+
onUsagePricing: false | null;
|
|
225
|
+
period: MonthUsage["period"];
|
|
226
|
+
meters: MeterReadingRow[];
|
|
227
|
+
storage?: StorageSplit;
|
|
228
|
+
/** The one line that says why no cost is shown. */
|
|
229
|
+
notice: string;
|
|
230
|
+
readOnly: true;
|
|
231
|
+
}
|
|
232
|
+
export type BillingUsageReport = UsageReport | UsageReadingsReport;
|
|
233
|
+
export declare function isReadingsReport(r: BillingUsageReport): r is UsageReadingsReport;
|
|
234
|
+
/** Readings for an account not on usage pricing. Never prices a meter. */
|
|
235
|
+
export declare function buildReadingsReport(usage: MonthUsage, onUsagePricing: false | null): UsageReadingsReport;
|
|
206
236
|
export declare function buildReport(usage: MonthUsage, rates: {
|
|
207
237
|
rates: readonly UsageMeterRate[];
|
|
208
238
|
allowanceUsd: number;
|
|
209
239
|
source: "published" | "bundled";
|
|
210
240
|
card?: UsageCard;
|
|
211
241
|
}): UsageReport;
|
|
212
|
-
export declare function formatReport(r:
|
|
242
|
+
export declare function formatReport(r: BillingUsageReport): string;
|
|
213
243
|
export {};
|
|
214
244
|
//# sourceMappingURL=usage-cost.d.ts.map
|
|
@@ -362,6 +362,38 @@ export function findDrivers(usage, bill) {
|
|
|
362
362
|
// ── Report ──────────────────────────────────────────────────────────────────
|
|
363
363
|
/** The self-hosted / no-sync guide the usage card links (feed `signup.selfHosted.url`). */
|
|
364
364
|
export const NO_SYNC_GUIDE_URL = SELF_HOSTED_GUIDE_URL;
|
|
365
|
+
export function isReadingsReport(r) {
|
|
366
|
+
return !("bill" in r);
|
|
367
|
+
}
|
|
368
|
+
function readingsNotice(scope, onUsagePricing) {
|
|
369
|
+
if (onUsagePricing === false) {
|
|
370
|
+
return scope.kind === "company"
|
|
371
|
+
? `${scope.companySlug} is on a flat plan, so usage meters are not billed.`
|
|
372
|
+
: "Your personal account is on a flat plan, so usage meters are not billed.";
|
|
373
|
+
}
|
|
374
|
+
const who = scope.kind === "company" ? scope.companySlug : "your personal account";
|
|
375
|
+
return `HQ did not say whether ${who} is on usage pricing, so no costs are shown.`;
|
|
376
|
+
}
|
|
377
|
+
/** Readings for an account not on usage pricing. Never prices a meter. */
|
|
378
|
+
export function buildReadingsReport(usage, onUsagePricing) {
|
|
379
|
+
const byKey = new Map(usage.meters.map((m) => [m.key, m]));
|
|
380
|
+
const meters = BUNDLED_USAGE_METER_RATES.map((rate) => {
|
|
381
|
+
const reading = byKey.get(rate.key);
|
|
382
|
+
return reading && reading.state === "measured" && reading.value !== null
|
|
383
|
+
? { key: rate.key, displayName: rate.displayName, value: reading.value, state: "measured" }
|
|
384
|
+
: { key: rate.key, displayName: rate.displayName, value: null, state: reading?.state ?? "not-available" };
|
|
385
|
+
});
|
|
386
|
+
return {
|
|
387
|
+
scope: usage.scope,
|
|
388
|
+
source: usage.source,
|
|
389
|
+
onUsagePricing,
|
|
390
|
+
period: usage.period,
|
|
391
|
+
meters,
|
|
392
|
+
...(usage.storage ? { storage: usage.storage } : {}),
|
|
393
|
+
notice: readingsNotice(usage.scope, onUsagePricing),
|
|
394
|
+
readOnly: true,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
365
397
|
export function buildReport(usage, rates) {
|
|
366
398
|
const bill = computeBill(usage, rates.rates, rates.allowanceUsd);
|
|
367
399
|
return {
|
|
@@ -394,7 +426,21 @@ function valueText(m) {
|
|
|
394
426
|
return `${Math.round(m.value)} vault${Math.round(m.value) === 1 ? "" : "s"}`;
|
|
395
427
|
return `${Math.round(m.value)}`;
|
|
396
428
|
}
|
|
429
|
+
function formatReadingsReport(r) {
|
|
430
|
+
const who = r.scope.kind === "company" ? r.scope.companySlug : "your personal HQ";
|
|
431
|
+
const lines = [];
|
|
432
|
+
lines.push(`Usage for ${who}, ${r.period.start.slice(0, 7)} (as of ${r.period.asOf.slice(0, 10)})`);
|
|
433
|
+
lines.push("");
|
|
434
|
+
const nameW = Math.max(...r.meters.map((m) => m.displayName.length));
|
|
435
|
+
for (const m of r.meters)
|
|
436
|
+
lines.push(` ${m.displayName.padEnd(nameW)} ${valueText(m)}`);
|
|
437
|
+
lines.push("");
|
|
438
|
+
lines.push(r.notice);
|
|
439
|
+
return lines.join("\n");
|
|
440
|
+
}
|
|
397
441
|
export function formatReport(r) {
|
|
442
|
+
if (isReadingsReport(r))
|
|
443
|
+
return formatReadingsReport(r);
|
|
398
444
|
const who = r.scope.kind === "company" ? r.scope.companySlug : "your personal HQ";
|
|
399
445
|
const month = r.period.start.slice(0, 7);
|
|
400
446
|
const lines = [];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { removeEntryNoFollow } from "../utils/safe-link.js";
|
|
3
4
|
const WIN32_RESERVED_CHARS = /[<>:"/\\|?*]/;
|
|
4
5
|
const WIN32_DEVICE_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
|
|
5
6
|
const LOCAL_SLUG = /^[a-z0-9][a-z0-9-]*$/;
|
|
@@ -227,7 +228,8 @@ function pruneStaleNamespacedWrappers(runtimeRoot, currentWrapperName, hqRoot, c
|
|
|
227
228
|
if (LOCAL_SLUG.test(skillSlug) &&
|
|
228
229
|
!fs.existsSync(canonicalSkill) &&
|
|
229
230
|
isGeneratedNamespacedWrapper(candidate, hqRoot, companySlug)) {
|
|
230
|
-
|
|
231
|
+
// A Windows wrapper is a junction; never recurse into its target.
|
|
232
|
+
removeEntryNoFollow(candidate);
|
|
231
233
|
removeGeneratedMarker(candidate);
|
|
232
234
|
}
|
|
233
235
|
}
|
|
@@ -340,7 +342,7 @@ export function surfaceCompanySkill(input) {
|
|
|
340
342
|
}
|
|
341
343
|
if (previousMoved) {
|
|
342
344
|
try {
|
|
343
|
-
|
|
345
|
+
removeEntryNoFollow(backupPath, { force: true });
|
|
344
346
|
}
|
|
345
347
|
catch {
|
|
346
348
|
// The new wrapper is already committed. A hidden generated backup is
|
|
@@ -354,13 +356,13 @@ export function surfaceCompanySkill(input) {
|
|
|
354
356
|
for (const legacyPath of [legacyBase, `${legacyBase}.md`]) {
|
|
355
357
|
if (legacyPath !== wrapperPath &&
|
|
356
358
|
isGeneratedLegacyEntry(legacyPath, sourceDir, skillFile)) {
|
|
357
|
-
|
|
359
|
+
removeEntryNoFollow(legacyPath);
|
|
358
360
|
}
|
|
359
361
|
}
|
|
360
362
|
}
|
|
361
363
|
}
|
|
362
364
|
catch (error) {
|
|
363
|
-
|
|
365
|
+
removeEntryNoFollow(stagingPath, { force: true });
|
|
364
366
|
throw error;
|
|
365
367
|
}
|
|
366
368
|
return { status: 0, wrapperPath };
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* never touch launchctl, systemctl or schtasks.
|
|
17
17
|
*/
|
|
18
18
|
import { type RunCommand, type SleepFn } from "../service-manager/launchd.js";
|
|
19
|
+
import { type PidLockRecord } from "../mesh/live/daemon/pid-lock.js";
|
|
19
20
|
import type { MachineType } from "./machine-type.js";
|
|
20
21
|
export declare const DAEMON_LAUNCHD_LABEL = "ai.getindigo.hq-daemon";
|
|
21
22
|
export declare const DAEMON_SYSTEMD_UNIT = "hq-daemon.service";
|
|
@@ -60,12 +61,28 @@ export interface DaemonUnitResult {
|
|
|
60
61
|
running: boolean;
|
|
61
62
|
error?: string;
|
|
62
63
|
manualCommand?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Windows: the task is registered but no new daemon process appeared within
|
|
66
|
+
* WINDOWS_START_WAIT_MS of `schtasks /Run`. With no interactive session the
|
|
67
|
+
* logon trigger starts it at the next sign-in; with one, it did not start.
|
|
68
|
+
* The caller decides which, since only it can check the session.
|
|
69
|
+
*/
|
|
70
|
+
startPending?: true;
|
|
71
|
+
/** Windows: an older daemon may still be running; HQ could not verify it, so did not stop it. */
|
|
72
|
+
warning?: string;
|
|
63
73
|
}
|
|
64
74
|
export interface InstallDeps {
|
|
65
75
|
run: RunCommand;
|
|
66
|
-
/** Blocking sleep for the launchd reload wait (tests inject). */
|
|
76
|
+
/** Blocking sleep for the launchd reload wait and the Windows start wait (tests inject). */
|
|
67
77
|
sleep?: SleepFn;
|
|
78
|
+
/**
|
|
79
|
+
* The running daemon's lock record (pid and start time), or null. Default:
|
|
80
|
+
* the daemon lock, when it names a live `hq daemon run` process.
|
|
81
|
+
*/
|
|
82
|
+
runningDaemon?: () => PidLockRecord | null;
|
|
68
83
|
}
|
|
84
|
+
/** How long install waits for the Windows task to start a daemon process. */
|
|
85
|
+
export declare const WINDOWS_START_WAIT_MS = 10000;
|
|
69
86
|
export declare const ROOT_REFUSAL = "hq daemon runs its services as the machine's login user, never as root.";
|
|
70
87
|
/**
|
|
71
88
|
* Why the daemon must not run here as root, or null when it may. Agent kits
|
|
@@ -76,10 +93,39 @@ export declare function daemonRootRefusal(isRoot: boolean, machineType: MachineT
|
|
|
76
93
|
/** Boxes run the daemon as a system unit; everything else per user. */
|
|
77
94
|
export declare function installScopeFor(machineType: MachineType): InstallScope;
|
|
78
95
|
export declare function planDaemonUnit(host: DaemonHost, options: InstallOptions): DaemonUnitPlan;
|
|
96
|
+
/** What to tell the user when HQ did not stop pid `pid` because it could not verify it. */
|
|
97
|
+
export declare function oldDaemonWarning(pid: number, reason: string): string;
|
|
79
98
|
export declare function installDaemonUnit(host: DaemonHost, options: InstallOptions, deps: InstallDeps): DaemonUnitResult;
|
|
99
|
+
/**
|
|
100
|
+
* PowerShell script that lists Windows sessions as `id|state|DOMAIN\user`
|
|
101
|
+
* lines through WTSEnumerateSessions and WTSQuerySessionInformation. State is
|
|
102
|
+
* the numeric WTS_CONNECTSTATE_CLASS (0 = WTSActive), so the output does not
|
|
103
|
+
* depend on the display language, and it needs no query.exe (absent on
|
|
104
|
+
* Windows Home). Constrained Language Mode refuses Add-Type, so there the
|
|
105
|
+
* script prints `unavailable|<mode>` and lists nothing.
|
|
106
|
+
*/
|
|
107
|
+
export declare const WINDOWS_SESSIONS_SCRIPT: string;
|
|
108
|
+
/** One Windows session from WINDOWS_SESSIONS_SCRIPT output. */
|
|
109
|
+
export interface WindowsSession {
|
|
110
|
+
id: number;
|
|
111
|
+
/** WTS_CONNECTSTATE_CLASS: 0 is WTSActive. */
|
|
112
|
+
state: number;
|
|
113
|
+
/** `DOMAIN\user`, a bare user name, or "" when nobody is signed in to it. */
|
|
114
|
+
user: string;
|
|
115
|
+
}
|
|
116
|
+
export declare function parseWindowsSessions(stdout: string): WindowsSession[];
|
|
117
|
+
/**
|
|
118
|
+
* Whether `user` (`DOMAIN\user` or a bare name) has an active Windows session.
|
|
119
|
+
* A disconnected session does not count: an interactive-token task cannot
|
|
120
|
+
* start in it. Returns "unavailable" when PowerShell runs in Constrained
|
|
121
|
+
* Language Mode, which refuses the check, and null when the check failed in
|
|
122
|
+
* any other way.
|
|
123
|
+
*/
|
|
124
|
+
export declare function windowsInteractiveSession(run: RunCommand, user: string): boolean | null | "unavailable";
|
|
80
125
|
export declare function uninstallDaemonUnit(host: DaemonHost, options: InstallOptions, deps: InstallDeps): {
|
|
81
126
|
kind: DaemonUnitPlan["kind"];
|
|
82
127
|
unitPath: string;
|
|
83
128
|
removed: boolean;
|
|
129
|
+
warning?: string;
|
|
84
130
|
};
|
|
85
131
|
//# sourceMappingURL=install.d.ts.map
|
|
@@ -17,15 +17,20 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import * as fs from "node:fs";
|
|
19
19
|
import * as path from "node:path";
|
|
20
|
-
import { activateLaunchd, deactivateLaunchd, defaultRunCommand, installLaunchdPlist, launchdIsLoaded, launchdPlistPath, renderLaunchdPlist, } from "../service-manager/launchd.js";
|
|
20
|
+
import { activateLaunchd, blockingSleep, deactivateLaunchd, defaultRunCommand, installLaunchdPlist, launchdIsLoaded, launchdPlistPath, renderLaunchdPlist, } from "../service-manager/launchd.js";
|
|
21
21
|
import { activateSystemd, deactivateSystemd, renderSystemdUserUnit, systemdUnitPath, } from "../service-manager/systemd.js";
|
|
22
22
|
import { escapeXml, shellQuote } from "../service-manager/types.js";
|
|
23
23
|
import { readPidLock } from "../mesh/live/daemon/pid-lock.js";
|
|
24
24
|
import { userToolDirs } from "./tools.js";
|
|
25
|
+
import { liveDaemonLock, verifyDaemonForKill } from "./lock.js";
|
|
26
|
+
import { daemonPaths } from "./paths.js";
|
|
25
27
|
export const DAEMON_LAUNCHD_LABEL = "ai.getindigo.hq-daemon";
|
|
26
28
|
export const DAEMON_SYSTEMD_UNIT = "hq-daemon.service";
|
|
27
29
|
export const DAEMON_WINDOWS_TASK = "ai.getindigo.hq-daemon";
|
|
28
30
|
export const SYSTEM_UNIT_DIR = "/etc/systemd/system";
|
|
31
|
+
/** How long install waits for the Windows task to start a daemon process. */
|
|
32
|
+
export const WINDOWS_START_WAIT_MS = 10_000;
|
|
33
|
+
const WINDOWS_START_POLL_MS = 500;
|
|
29
34
|
export const ROOT_REFUSAL = "hq daemon runs its services as the machine's login user, never as root.";
|
|
30
35
|
/**
|
|
31
36
|
* Why the daemon must not run here as root, or null when it may. Agent kits
|
|
@@ -209,7 +214,39 @@ export function planDaemonUnit(host, options) {
|
|
|
209
214
|
function commandError(what, r) {
|
|
210
215
|
return new Error(`${what} failed: ${r.stderr.trim() || r.stdout.trim() || `exit ${r.status}`}`);
|
|
211
216
|
}
|
|
212
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Stop the Windows task and the recorded daemon's whole process tree. Ending
|
|
219
|
+
* the task stops only its root process (conhost when --headless is used);
|
|
220
|
+
* node and the services it runs keep going.
|
|
221
|
+
*
|
|
222
|
+
* The pid comes from the lock file, which can be stale: after the daemon
|
|
223
|
+
* exits, Windows may give its pid to another program. The process is checked
|
|
224
|
+
* immediately before the kill and killed only when it is verifiably the
|
|
225
|
+
* daemon. Returns a warning when a process was left running unverified.
|
|
226
|
+
*/
|
|
227
|
+
function endWindowsDaemon(run, owner) {
|
|
228
|
+
run("schtasks.exe", ["/End", "/TN", DAEMON_WINDOWS_TASK]);
|
|
229
|
+
if (!owner || owner.pid === process.pid)
|
|
230
|
+
return undefined;
|
|
231
|
+
const check = verifyDaemonForKill(owner, run);
|
|
232
|
+
if (check.verdict === "daemon") {
|
|
233
|
+
run("taskkill.exe", ["/PID", String(owner.pid), "/T", "/F"]);
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
if (check.verdict === "gone")
|
|
237
|
+
return undefined;
|
|
238
|
+
return oldDaemonWarning(owner.pid, check.reason);
|
|
239
|
+
}
|
|
240
|
+
/** What to tell the user when HQ did not stop pid `pid` because it could not verify it. */
|
|
241
|
+
export function oldDaemonWarning(pid, reason) {
|
|
242
|
+
return [
|
|
243
|
+
`An older HQ daemon may still be running. HQ did not stop process ${pid} because ${reason}.`,
|
|
244
|
+
"To find a running HQ daemon:",
|
|
245
|
+
` powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object CommandLine -match 'daemon run' | Select-Object ProcessId,CommandLine"`,
|
|
246
|
+
"Stop the process it lists with: taskkill /PID <ProcessId> /T /F",
|
|
247
|
+
].join("\n");
|
|
248
|
+
}
|
|
249
|
+
function activate(plan, host, run, sleep, previous = null) {
|
|
213
250
|
switch (plan.kind) {
|
|
214
251
|
case "launchd":
|
|
215
252
|
activateLaunchd(plan.unitPath, run, host.uid, { sleep });
|
|
@@ -231,13 +268,17 @@ function activate(plan, host, run, sleep) {
|
|
|
231
268
|
const create = run("schtasks.exe", ["/Create", "/TN", DAEMON_WINDOWS_TASK, "/XML", plan.unitPath, "/F"]);
|
|
232
269
|
if (create.status !== 0)
|
|
233
270
|
throw commandError("schtasks /Create", create);
|
|
271
|
+
// `/Create /F` leaves a running instance alone and `/Run` is ignored
|
|
272
|
+
// while one runs, so a daemon from an earlier install would keep the old
|
|
273
|
+
// settings. Stop it first so `/Run` starts a new one.
|
|
274
|
+
const warning = previous ? endWindowsDaemon(run, previous) : undefined;
|
|
234
275
|
const start = run("schtasks.exe", ["/Run", "/TN", DAEMON_WINDOWS_TASK]);
|
|
235
276
|
if (start.status !== 0)
|
|
236
277
|
throw commandError("schtasks /Run", start);
|
|
237
|
-
return;
|
|
278
|
+
return warning;
|
|
238
279
|
}
|
|
239
280
|
case "manual":
|
|
240
|
-
return;
|
|
281
|
+
return undefined;
|
|
241
282
|
}
|
|
242
283
|
}
|
|
243
284
|
export function installDaemonUnit(host, options, deps) {
|
|
@@ -286,8 +327,32 @@ export function installDaemonUnit(host, options, deps) {
|
|
|
286
327
|
else
|
|
287
328
|
fs.writeFileSync(plan.unitPath, plan.rendered, { mode: plan.kind === "systemd-system" ? 0o644 : 0o600 });
|
|
288
329
|
try {
|
|
289
|
-
|
|
290
|
-
|
|
330
|
+
const lockPath = daemonPaths(host.home, { HQ_DAEMON_DIR: host.daemonDir }).lockPath;
|
|
331
|
+
const runningDaemon = deps.runningDaemon ?? (() => liveDaemonLock(lockPath));
|
|
332
|
+
const previous = plan.kind === "schtasks" ? runningDaemon() : null;
|
|
333
|
+
const warning = plan.kind !== "launchd" ? activate(plan, host, deps.run, deps.sleep, previous) : undefined;
|
|
334
|
+
const warn = warning ? { warning } : {};
|
|
335
|
+
if (plan.kind === "schtasks") {
|
|
336
|
+
// schtasks /Run exits 0 even when the task cannot run: an
|
|
337
|
+
// InteractiveToken task with no signed-in session stays at "has not yet
|
|
338
|
+
// run" (267011). Report running only once a daemon started by this
|
|
339
|
+
// install holds the lock; a daemon from before the install does not count.
|
|
340
|
+
const outcome = waitForNewDaemon(runningDaemon, previous, deps.sleep ?? blockingSleep);
|
|
341
|
+
if (outcome === "started")
|
|
342
|
+
return { kind: plan.kind, unitPath: plan.unitPath, installed: true, running: true, ...warn };
|
|
343
|
+
if (outcome === "previous") {
|
|
344
|
+
return {
|
|
345
|
+
kind: plan.kind,
|
|
346
|
+
unitPath: plan.unitPath,
|
|
347
|
+
installed: true,
|
|
348
|
+
running: false,
|
|
349
|
+
error: `the daemon from before this install (pid ${previous?.pid}) is still running, ` +
|
|
350
|
+
"so the new task did not start. Run hq daemon uninstall, then hq daemon install",
|
|
351
|
+
...warn,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
return { kind: plan.kind, unitPath: plan.unitPath, installed: true, running: false, startPending: true, ...warn };
|
|
355
|
+
}
|
|
291
356
|
return { kind: plan.kind, unitPath: plan.unitPath, installed: true, running: true };
|
|
292
357
|
}
|
|
293
358
|
catch (err) {
|
|
@@ -300,6 +365,112 @@ export function installDaemonUnit(host, options, deps) {
|
|
|
300
365
|
};
|
|
301
366
|
}
|
|
302
367
|
}
|
|
368
|
+
/** A lock record names a different daemon than `previous` (new pid or new start time). */
|
|
369
|
+
function isNewDaemon(now, previous) {
|
|
370
|
+
return !previous || now.pid !== previous.pid || now.ts !== previous.ts;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Wait up to WINDOWS_START_WAIT_MS for a daemon started after `previous`.
|
|
374
|
+
* "previous" means the daemon from before the install still holds the lock at
|
|
375
|
+
* the end of the wait; "none" means no daemon is running.
|
|
376
|
+
*/
|
|
377
|
+
function waitForNewDaemon(runningDaemon, previous, sleep) {
|
|
378
|
+
for (let waited = 0;; waited += WINDOWS_START_POLL_MS) {
|
|
379
|
+
const now = runningDaemon();
|
|
380
|
+
if (now && isNewDaemon(now, previous))
|
|
381
|
+
return "started";
|
|
382
|
+
if (waited >= WINDOWS_START_WAIT_MS)
|
|
383
|
+
return now ? "previous" : "none";
|
|
384
|
+
sleep(WINDOWS_START_POLL_MS);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* PowerShell script that lists Windows sessions as `id|state|DOMAIN\user`
|
|
389
|
+
* lines through WTSEnumerateSessions and WTSQuerySessionInformation. State is
|
|
390
|
+
* the numeric WTS_CONNECTSTATE_CLASS (0 = WTSActive), so the output does not
|
|
391
|
+
* depend on the display language, and it needs no query.exe (absent on
|
|
392
|
+
* Windows Home). Constrained Language Mode refuses Add-Type, so there the
|
|
393
|
+
* script prints `unavailable|<mode>` and lists nothing.
|
|
394
|
+
*/
|
|
395
|
+
export const WINDOWS_SESSIONS_SCRIPT = [
|
|
396
|
+
"$ErrorActionPreference = 'Stop'",
|
|
397
|
+
"$mode = [string]$ExecutionContext.SessionState.LanguageMode",
|
|
398
|
+
"if ($mode -ne 'FullLanguage') { 'unavailable|' + $mode; exit 0 }",
|
|
399
|
+
"Add-Type -TypeDefinition @'",
|
|
400
|
+
"using System;",
|
|
401
|
+
"using System.Runtime.InteropServices;",
|
|
402
|
+
"public static class HqWts {",
|
|
403
|
+
' [DllImport("wtsapi32.dll", SetLastError = true)]',
|
|
404
|
+
" public static extern bool WTSEnumerateSessionsW(IntPtr server, int reserved, int version, out IntPtr info, out int count);",
|
|
405
|
+
' [DllImport("wtsapi32.dll", SetLastError = true)]',
|
|
406
|
+
" public static extern bool WTSQuerySessionInformationW(IntPtr server, int sessionId, int infoClass, out IntPtr buffer, out int bytes);",
|
|
407
|
+
' [DllImport("wtsapi32.dll")]',
|
|
408
|
+
" public static extern void WTSFreeMemory(IntPtr memory);",
|
|
409
|
+
" static string Query(int id, int infoClass) {",
|
|
410
|
+
" IntPtr buffer; int bytes;",
|
|
411
|
+
' if (!WTSQuerySessionInformationW(IntPtr.Zero, id, infoClass, out buffer, out bytes)) return "";',
|
|
412
|
+
" try { return Marshal.PtrToStringUni(buffer); } finally { WTSFreeMemory(buffer); }",
|
|
413
|
+
" }",
|
|
414
|
+
" public static string[] List() {",
|
|
415
|
+
" IntPtr info; int count;",
|
|
416
|
+
' if (!WTSEnumerateSessionsW(IntPtr.Zero, 0, 1, out info, out count)) throw new System.ComponentModel.Win32Exception();',
|
|
417
|
+
" try {",
|
|
418
|
+
" int size = 3 * IntPtr.Size;",
|
|
419
|
+
" string[] lines = new string[count];",
|
|
420
|
+
" for (int i = 0; i < count; i++) {",
|
|
421
|
+
" IntPtr row = new IntPtr(info.ToInt64() + (long)i * size);",
|
|
422
|
+
" int id = Marshal.ReadInt32(row);",
|
|
423
|
+
" int state = Marshal.ReadInt32(row, 2 * IntPtr.Size);",
|
|
424
|
+
" string user = Query(id, 5);",
|
|
425
|
+
" string domain = Query(id, 7);",
|
|
426
|
+
' lines[i] = id + "|" + state + "|" + (domain.Length > 0 ? domain + "\\\\" + user : user);',
|
|
427
|
+
" }",
|
|
428
|
+
" return lines;",
|
|
429
|
+
" } finally { WTSFreeMemory(info); }",
|
|
430
|
+
" }",
|
|
431
|
+
"}",
|
|
432
|
+
"'@",
|
|
433
|
+
"[HqWts]::List() | ForEach-Object { $_ }",
|
|
434
|
+
].join("\n");
|
|
435
|
+
const WTS_ACTIVE = 0;
|
|
436
|
+
export function parseWindowsSessions(stdout) {
|
|
437
|
+
const sessions = [];
|
|
438
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
439
|
+
const match = /^(\d+)\|(\d+)\|(.*)$/.exec(line.trim());
|
|
440
|
+
if (match)
|
|
441
|
+
sessions.push({ id: Number(match[1]), state: Number(match[2]), user: match[3].trim() });
|
|
442
|
+
}
|
|
443
|
+
return sessions;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Whether `user` (`DOMAIN\user` or a bare name) has an active Windows session.
|
|
447
|
+
* A disconnected session does not count: an interactive-token task cannot
|
|
448
|
+
* start in it. Returns "unavailable" when PowerShell runs in Constrained
|
|
449
|
+
* Language Mode, which refuses the check, and null when the check failed in
|
|
450
|
+
* any other way.
|
|
451
|
+
*/
|
|
452
|
+
export function windowsInteractiveSession(run, user) {
|
|
453
|
+
const encoded = Buffer.from(WINDOWS_SESSIONS_SCRIPT, "utf16le").toString("base64");
|
|
454
|
+
const r = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]);
|
|
455
|
+
if (r.status !== 0)
|
|
456
|
+
return null;
|
|
457
|
+
if (/^unavailable\|/m.test(r.stdout.trim()))
|
|
458
|
+
return "unavailable";
|
|
459
|
+
const sessions = parseWindowsSessions(r.stdout);
|
|
460
|
+
if (sessions.length === 0)
|
|
461
|
+
return null;
|
|
462
|
+
const wanted = user.trim().toLowerCase();
|
|
463
|
+
const bare = (wanted.split("\\").pop() ?? "").trim();
|
|
464
|
+
if (!bare)
|
|
465
|
+
return null;
|
|
466
|
+
return sessions.some((s) => {
|
|
467
|
+
if (s.state !== WTS_ACTIVE || !s.user)
|
|
468
|
+
return false;
|
|
469
|
+
const name = s.user.toLowerCase();
|
|
470
|
+
// Compare the full DOMAIN\user when both sides have a domain.
|
|
471
|
+
return name.includes("\\") && wanted.includes("\\") ? name === wanted : name.split("\\").pop() === bare;
|
|
472
|
+
});
|
|
473
|
+
}
|
|
303
474
|
export function uninstallDaemonUnit(host, options, deps) {
|
|
304
475
|
// A system uninstall does not need the login user; only the unit path matters.
|
|
305
476
|
const plan = host.platform === "linux" && options.scope === "system"
|
|
@@ -312,6 +483,7 @@ export function uninstallDaemonUnit(host, options, deps) {
|
|
|
312
483
|
(plan.kind === "schtasks" && deps.run("schtasks.exe", ["/Query", "/TN", DAEMON_WINDOWS_TASK]).status === 0);
|
|
313
484
|
if (!present)
|
|
314
485
|
return { kind: plan.kind, unitPath: plan.unitPath, removed: false };
|
|
486
|
+
let warning;
|
|
315
487
|
switch (plan.kind) {
|
|
316
488
|
case "launchd":
|
|
317
489
|
deactivateLaunchd(plan.unitPath, deps.run, host.uid);
|
|
@@ -330,14 +502,8 @@ export function uninstallDaemonUnit(host, options, deps) {
|
|
|
330
502
|
break;
|
|
331
503
|
}
|
|
332
504
|
case "schtasks": {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
// used); node and the services it runs keep going. End the recorded
|
|
336
|
-
// daemon's whole process tree.
|
|
337
|
-
const owner = readPidLock(path.join(host.daemonDir, "daemon.pid"));
|
|
338
|
-
if (owner && owner.pid !== process.pid) {
|
|
339
|
-
deps.run("taskkill.exe", ["/PID", String(owner.pid), "/T", "/F"]);
|
|
340
|
-
}
|
|
505
|
+
// The lock may be stale; endWindowsDaemon kills only a verified daemon.
|
|
506
|
+
warning = endWindowsDaemon(deps.run, readPidLock(path.join(host.daemonDir, "daemon.pid")));
|
|
341
507
|
const del = deps.run("schtasks.exe", ["/Delete", "/TN", DAEMON_WINDOWS_TASK, "/F"]);
|
|
342
508
|
// Keep the XML so a later uninstall still finds the task.
|
|
343
509
|
if (del.status !== 0)
|
|
@@ -346,6 +512,6 @@ export function uninstallDaemonUnit(host, options, deps) {
|
|
|
346
512
|
}
|
|
347
513
|
}
|
|
348
514
|
fs.rmSync(plan.unitPath, { force: true });
|
|
349
|
-
return { kind: plan.kind, unitPath: plan.unitPath, removed: true };
|
|
515
|
+
return { kind: plan.kind, unitPath: plan.unitPath, removed: true, ...(warning ? { warning } : {}) };
|
|
350
516
|
}
|
|
351
517
|
//# sourceMappingURL=install.js.map
|
|
@@ -31,8 +31,38 @@ export interface DaemonProcessDeps {
|
|
|
31
31
|
* The pid is alive and is an `hq daemon run`. After a reboot the pid in an old
|
|
32
32
|
* lock file often belongs to an unrelated program; that must not keep the
|
|
33
33
|
* daemon from starting.
|
|
34
|
+
*
|
|
35
|
+
* An unreadable command line counts as the daemon here, so a second daemon is
|
|
36
|
+
* never started next to one HQ cannot inspect. Never use this to decide a
|
|
37
|
+
* kill: use `verifyDaemonForKill`, which treats an unreadable command line as
|
|
38
|
+
* "not the daemon".
|
|
34
39
|
*/
|
|
35
40
|
export declare function isDaemonProcess(pid: number, deps?: DaemonProcessDeps): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* PowerShell that prints a Windows process's creation time (UTC, ISO 8601) on
|
|
43
|
+
* the first line and its command line on the rest, or nothing when no process
|
|
44
|
+
* has that id. It uses only a cmdlet and DateTime methods, so it also runs in
|
|
45
|
+
* Constrained Language Mode. `-ErrorAction Stop` makes a WMI failure exit
|
|
46
|
+
* non-zero, so it reads as "could not read", never as "no such process".
|
|
47
|
+
*/
|
|
48
|
+
export declare function windowsProcessIdentityScript(pid: number): string;
|
|
49
|
+
export type DaemonKillCheck = {
|
|
50
|
+
verdict: "daemon";
|
|
51
|
+
} | {
|
|
52
|
+
verdict: "gone";
|
|
53
|
+
} | {
|
|
54
|
+
verdict: "unverified";
|
|
55
|
+
reason: string;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Decide whether the Windows process in `owner` may be killed as the HQ
|
|
59
|
+
* daemon. Run this immediately before the kill. It is the daemon only when the
|
|
60
|
+
* process exists, its command line is readable and is `hq daemon run`, and it
|
|
61
|
+
* was created no later than the lock was written. A process created after the
|
|
62
|
+
* lock took over a pid the old daemon left behind. Anything that cannot be
|
|
63
|
+
* read is "unverified", and the caller must not kill.
|
|
64
|
+
*/
|
|
65
|
+
export declare function verifyDaemonForKill(owner: PidLockRecord, run: RunCommand): DaemonKillCheck;
|
|
36
66
|
export type DaemonLockResult = {
|
|
37
67
|
ok: true;
|
|
38
68
|
} | {
|
|
@@ -41,6 +71,8 @@ export type DaemonLockResult = {
|
|
|
41
71
|
};
|
|
42
72
|
export declare function acquireDaemonLock(lockPath: string, pid?: number, alive?: (pid: number) => boolean, now?: () => number): DaemonLockResult;
|
|
43
73
|
export declare function releaseDaemonLock(lockPath: string, pid?: number): void;
|
|
74
|
+
/** The live supervisor's lock record (pid and start time), or null when none is running. */
|
|
75
|
+
export declare function liveDaemonLock(lockPath: string, alive?: (pid: number) => boolean): PidLockRecord | null;
|
|
44
76
|
/** The live supervisor's pid, or null when none is running. */
|
|
45
77
|
export declare function runningDaemonPid(lockPath: string, alive?: (pid: number) => boolean): number | null;
|
|
46
78
|
export {};
|