@indigoai-us/hq-cli 5.290.0 → 5.292.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -3
- package/assets/scaffold/core/scripts/handoff-finalize.sh +18 -0
- package/assets/scaffold/core/scripts/handoff-post.sh +71 -6
- package/assets/scaffold/core/scripts/hook-lib.sh +345 -50
- package/assets/scaffold/core/scripts/hq-delegate-bundle.sh +307 -0
- package/assets/scaffold/core/scripts/hq-delegate-grant.sh +226 -0
- package/assets/scaffold/core/scripts/hq-delegate-publish.sh +91 -0
- package/assets/scaffold/core/scripts/hq-delegate-send.sh +259 -0
- package/assets/scaffold/core/scripts/hq-delegate-verify.sh +213 -0
- package/assets/scaffold/core/scripts/hq-job-notify.sh +389 -0
- package/assets/scaffold/core/scripts/hq-job-probe.sh +835 -0
- package/assets/scaffold/core/scripts/hq-job-remediate.sh +359 -0
- package/assets/scaffold/core/scripts/hq-job-run.sh +1025 -0
- package/assets/scaffold/core/scripts/hq-session.sh +182 -21
- package/assets/scaffold/core/scripts/jobs-schedule-parse.sh +310 -0
- package/assets/scaffold/core/scripts/jobs-validate.sh +451 -0
- package/assets/scaffold/core/scripts/lib/session-id.sh +3 -2
- package/assets/scaffold/core/scripts/lib/work-mesh-enqueue.sh +2 -1
- package/assets/scaffold/core/scripts/outpost-jobs-reconcile.sh +830 -0
- package/dist/command-catalog.generated.d.ts +165 -1
- package/dist/command-catalog.generated.js +205 -1
- package/dist/commands/cloud.d.ts +26 -14
- package/dist/commands/cloud.js +207 -56
- package/dist/commands/core-delegate-w1b.d.ts +8 -0
- package/dist/commands/core-delegate-w1b.js +1049 -0
- package/dist/commands/core-delegate.d.ts +28 -0
- package/dist/commands/core-delegate.js +15 -13
- package/dist/commands/core-handoff.js +152 -23
- package/dist/commands/core-jobs-native.d.ts +23 -0
- package/dist/commands/core-jobs-native.js +1331 -0
- package/dist/commands/core-native-jobs-flag.d.ts +4 -0
- package/dist/commands/core-native-jobs-flag.js +7 -0
- package/dist/commands/core-ontology.d.ts +22 -4
- package/dist/commands/core-ontology.js +90 -8
- package/dist/commands/core-policy.js +24 -5
- package/dist/commands/core.js +17 -0
- package/dist/commands/integrations-core.js +7 -0
- package/dist/commands/lanes.js +111 -35
- package/dist/commands/members.js +5 -0
- package/dist/commands/mesh.js +46 -25
- package/dist/commands/personal-targeted-push-flag.d.ts +4 -0
- package/dist/commands/personal-targeted-push-flag.js +7 -0
- package/dist/commands/personal-targeted-push-paths.d.ts +3 -0
- package/dist/commands/personal-targeted-push-paths.js +67 -0
- package/dist/commands/secrets.js +9 -0
- package/dist/commands/skill.d.ts +3 -0
- package/dist/commands/skill.js +28 -0
- package/dist/fast-core.js +1 -0
- package/dist/lib/billing/free-month.d.ts +18 -0
- package/dist/lib/billing/free-month.js +31 -0
- package/dist/lib/billing/plan-limit-refusals.d.ts +69 -0
- package/dist/lib/billing/plan-limit-refusals.js +187 -0
- package/dist/lib/billing/plan-lock.d.ts +2 -6
- package/dist/lib/billing/plan-lock.js +5 -16
- package/dist/lib/checkpoint-ontology/drop-aliases.d.ts +41 -0
- package/dist/lib/checkpoint-ontology/drop-aliases.js +76 -0
- package/dist/lib/checkpoint-ontology/entity-file.d.ts +6 -3
- package/dist/lib/checkpoint-ontology/entity-file.js +12 -6
- package/dist/lib/checkpoint-ontology/merge-copies.d.ts +27 -8
- package/dist/lib/checkpoint-ontology/merge-copies.js +64 -97
- package/dist/lib/core-utils/secret-patterns.d.ts +3 -1
- package/dist/lib/core-utils/secret-patterns.js +3 -2
- package/dist/lib/daemon/flag.d.ts +2 -0
- package/dist/lib/daemon/flag.js +2 -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/lanes/autopilot.d.ts +69 -0
- package/dist/lib/lanes/autopilot.js +418 -0
- package/dist/lib/lanes/mesh-events.js +2 -0
- package/dist/lib/lanes/store.js +11 -6
- package/dist/lib/mesh/api.js +4 -0
- package/dist/lib/mesh/live/daemon/state.d.ts +7 -1
- package/dist/lib/mesh/live/daemon/state.js +34 -2
- package/dist/main.js +23 -12
- package/dist/utils/plan-gate-error.d.ts +24 -0
- package/dist/utils/plan-gate-error.js +219 -9
- package/dist/utils/scaffold-sibling-references.js +4 -4
- package/dist/utils/self-update.d.ts +6 -0
- package/dist/utils/self-update.js +28 -6
- package/dist/utils/staged-install.d.ts +34 -0
- package/dist/utils/staged-install.js +296 -0
- package/package.json +2 -2
- package/scripts/mesh-daemon-if-stale.mjs +4 -0
package/dist/main.js
CHANGED
|
@@ -36,7 +36,7 @@ import { browserLoginAbandonedMessage } from "./utils/browser-login-abandoned.js
|
|
|
36
36
|
import { callbackPortBusyMessage, DEFAULT_CALLBACK_PORT } from "./utils/callback-port-busy.js";
|
|
37
37
|
import { DEFAULT_COGNITO } from "./utils/cognito-session.js";
|
|
38
38
|
import { isCompanySelectionError } from "./utils/company-selection-error.js";
|
|
39
|
-
import { canOfferTeamUpgrade, formatPlanGateError,
|
|
39
|
+
import { canOfferTeamUpgrade, formatPlanGateError, offerTeamUpgrade, offerUpgradeLink, planGateErrorFromVaultError, } from "./utils/plan-gate-error.js";
|
|
40
40
|
import { upgradeToTeam } from "./utils/team-upgrade.js";
|
|
41
41
|
import { refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
|
|
42
42
|
import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
|
|
@@ -344,6 +344,9 @@ function qmdSpawnFailureCaptureContext(err) {
|
|
|
344
344
|
}
|
|
345
345
|
/** Classify a top-level failure without making the CLI process boundary opaque to tests. */
|
|
346
346
|
export async function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies) {
|
|
347
|
+
// A typed plan gate, or an hq-cloud vault error carrying a plan-limit
|
|
348
|
+
// refusal (402 / PLAN_LIMIT_* from a sync, share or skill upload).
|
|
349
|
+
const planGate = planGateErrorFromVaultError(err);
|
|
347
350
|
// A broken pipe (EPIPE) means the reader of `hq`'s output closed it early
|
|
348
351
|
// (`hq … | head`, `source <(hq …)`, a parent that exited). That is normal
|
|
349
352
|
// Unix behavior with no user-facing degradation — exit cleanly (0) and
|
|
@@ -416,24 +419,32 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
416
419
|
deps.stderr.write(`hq: ${callbackPortBusyMessage(err, DEFAULT_COGNITO.port ?? DEFAULT_CALLBACK_PORT)}\n`);
|
|
417
420
|
deps.setExitCode(1);
|
|
418
421
|
}
|
|
419
|
-
else if (
|
|
422
|
+
else if (planGate) {
|
|
420
423
|
// Plan denials are expected product limits, never a CLI crash. The
|
|
421
424
|
// server URL is always shown. Company refusals that the Workforce
|
|
422
425
|
// checkout satisfies retain the direct Stripe checkout action.
|
|
426
|
+
//
|
|
427
|
+
// A plan-limit refusal (a Starter hard stop) always prints its reason,
|
|
428
|
+
// usage and fix options, even non-interactively: the user can fix it
|
|
429
|
+
// without upgrading, and an agent or script reading stderr needs the
|
|
430
|
+
// reason to act on it. Feature refusals keep the URL-only
|
|
431
|
+
// non-interactive form, because upgrading is their only fix.
|
|
423
432
|
const interactive = deps.interactive ?? canOfferTeamUpgrade(deps.argv);
|
|
424
|
-
const message = formatPlanGateError(
|
|
433
|
+
const message = formatPlanGateError(planGate, {
|
|
434
|
+
urlOnly: !interactive && planGate.code !== "PLAN_LIMIT_EXCEEDED",
|
|
435
|
+
});
|
|
425
436
|
deps.stderr.write(interactive ? `hq: ${message}\n` : `${message}\n`);
|
|
426
|
-
const promptExposure = isCliLimitResourceKind(
|
|
427
|
-
? queueCliLimitNoticeExposure(
|
|
437
|
+
const promptExposure = isCliLimitResourceKind(planGate.details.resource ?? "")
|
|
438
|
+
? queueCliLimitNoticeExposure(planGate.details.resource, "refusal", planGate.details.companyUid)
|
|
428
439
|
: null;
|
|
429
440
|
deps.setExitCode(1);
|
|
430
|
-
if (interactive &&
|
|
431
|
-
const companyUid =
|
|
441
|
+
if (interactive && planGate.details.upgradeUrl) {
|
|
442
|
+
const companyUid = planGate.details.companyUid;
|
|
432
443
|
const isCompanyWorkforceCheckout = typeof companyUid === "string" &&
|
|
433
444
|
/^cmp_[A-Za-z0-9_-]+$/.test(companyUid) &&
|
|
434
|
-
(
|
|
435
|
-
|
|
436
|
-
|
|
445
|
+
(planGate.code === "PLAN_REQUIRED" ||
|
|
446
|
+
planGate.code === "MEETING_PLAN_REQUIRED" ||
|
|
447
|
+
planGate.code === "BILLING_REQUIRED");
|
|
437
448
|
if (isCompanyWorkforceCheckout && companyUid) {
|
|
438
449
|
try {
|
|
439
450
|
const upgraded = await offerTeamUpgrade(async () => {
|
|
@@ -450,7 +461,7 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
450
461
|
const message = upgradeErr instanceof Error ? upgradeErr.message : String(upgradeErr);
|
|
451
462
|
deps.stderr.write(`hq: ${message}\n`);
|
|
452
463
|
try {
|
|
453
|
-
const opened = await offerUpgradeLink(
|
|
464
|
+
const opened = await offerUpgradeLink(planGate.details.upgradeUrl, {
|
|
454
465
|
interactive: true,
|
|
455
466
|
argv: deps.argv,
|
|
456
467
|
ask: deps.ask,
|
|
@@ -467,7 +478,7 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
467
478
|
}
|
|
468
479
|
else {
|
|
469
480
|
try {
|
|
470
|
-
const opened = await offerUpgradeLink(
|
|
481
|
+
const opened = await offerUpgradeLink(planGate.details.upgradeUrl, {
|
|
471
482
|
interactive: true,
|
|
472
483
|
argv: deps.argv,
|
|
473
484
|
ask: deps.ask,
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
import { type PlanLimitFreeMonth } from "../lib/billing/free-month.js";
|
|
1
2
|
export type PlanGateCode = "PLAN_LIMIT_EXCEEDED" | "PLAN_REQUIRED" | "MEETING_PLAN_REQUIRED"
|
|
2
3
|
/** A PERSONAL scope asked for a feature the paid Individual plan includes. */
|
|
3
4
|
| "PERSONAL_PLAN_REQUIRED"
|
|
4
5
|
/** External agent enrollment was denied by the company plan. */
|
|
5
6
|
| "BILLING_REQUIRED";
|
|
7
|
+
/** One over-limit dimension, as hq-pro's hard-stop 402 body names it. */
|
|
8
|
+
export interface PlanGateResourceUsage {
|
|
9
|
+
resource: string;
|
|
10
|
+
used: number;
|
|
11
|
+
limit: number;
|
|
12
|
+
}
|
|
6
13
|
export interface PlanGateDetails {
|
|
7
14
|
resource?: string;
|
|
8
15
|
feature?: string;
|
|
@@ -12,6 +19,14 @@ export interface PlanGateDetails {
|
|
|
12
19
|
reason?: string;
|
|
13
20
|
/** Company target from request context or the server's UID-keyed billing URL. */
|
|
14
21
|
companyUid?: string;
|
|
22
|
+
/** Hard-stop 402: the create action the server refused, e.g. `secrets.create`. */
|
|
23
|
+
blocked?: string;
|
|
24
|
+
/** Hard-stop 402: every over-limit dimension, in server order. */
|
|
25
|
+
resources?: PlanGateResourceUsage[];
|
|
26
|
+
/** Hard-stop 402: how far back each over resource must come, keyed by resource. */
|
|
27
|
+
fixOptions?: Record<string, number>;
|
|
28
|
+
/** Hard-stop 402: the free-month offer, present only when it stands. */
|
|
29
|
+
freeMonth?: PlanLimitFreeMonth;
|
|
15
30
|
}
|
|
16
31
|
/** One renderer for server-selected upgrade links across CLI plan refusals. */
|
|
17
32
|
export declare function formatUpgradeRefusal(reason: string, upgradeUrl: string | undefined, options?: {
|
|
@@ -46,6 +61,15 @@ export declare function offerTeamUpgrade(onConfirm: () => Promise<void>, deps?:
|
|
|
46
61
|
}): Promise<boolean>;
|
|
47
62
|
/** Decode an already-read JSON envelope from either shared HTTP client. */
|
|
48
63
|
export declare function planGateErrorFromPayload(status: number, body: unknown, companyUid?: string): PlanGateError | null;
|
|
64
|
+
/**
|
|
65
|
+
* Recognise a plan-limit refusal carried by an `@indigoai-us/hq-cloud`
|
|
66
|
+
* `VaultClientError` (sync, share, skill upload). Duck-typed so hq-cli keeps
|
|
67
|
+
* working across hq-cloud releases: the error exposes `statusCode`, `code`,
|
|
68
|
+
* `upgradeUrl` and the raw response `body`. The raw body is decoded first so
|
|
69
|
+
* the server's message, fix options and free month survive; a bare code (a
|
|
70
|
+
* personal presign row has no body) still becomes a refusal with its link.
|
|
71
|
+
*/
|
|
72
|
+
export declare function planGateErrorFromVaultError(err: unknown): PlanGateError | null;
|
|
49
73
|
/**
|
|
50
74
|
* Decode the supported plan-gate envelopes. Malformed, unrelated,
|
|
51
75
|
* or non-402 responses retain their existing command-specific handling.
|
|
@@ -3,17 +3,93 @@
|
|
|
3
3
|
* fields on a typed error lets the CLI boundary render one safe, consistent
|
|
4
4
|
* message instead of every command parsing and printing a server response.
|
|
5
5
|
*/
|
|
6
|
-
import { renderPersonalPlanFeatureNotice } from "../lib/plan-limit-nag.js";
|
|
6
|
+
import { PLAN_LIMIT_DIMENSION_LABELS, renderPersonalPlanFeatureNotice, } from "../lib/plan-limit-nag.js";
|
|
7
7
|
import { withCliLimitEntrySurface } from "../lib/billing/plan-limit-upgrade-url.js";
|
|
8
|
+
import { parseFreeMonth, } from "../lib/billing/free-month.js";
|
|
9
|
+
const GIB = 1024 ** 3;
|
|
10
|
+
const MIB = 1024 ** 2;
|
|
11
|
+
const KIB = 1024;
|
|
12
|
+
function formatStorageBytes(bytes) {
|
|
13
|
+
const round = (value) => String(Math.round(value * 10) / 10);
|
|
14
|
+
if (bytes >= GIB)
|
|
15
|
+
return `${round(bytes / GIB)} GB`;
|
|
16
|
+
if (bytes >= MIB)
|
|
17
|
+
return `${round(bytes / MIB)} MB`;
|
|
18
|
+
if (bytes >= KIB)
|
|
19
|
+
return `${round(bytes / KIB)} KB`;
|
|
20
|
+
return `${bytes} bytes`;
|
|
21
|
+
}
|
|
22
|
+
function formatResourceAmount(resource, amount) {
|
|
23
|
+
return resource === "storageBytes" ? formatStorageBytes(amount) : String(amount);
|
|
24
|
+
}
|
|
25
|
+
function resourceLabel(resource) {
|
|
26
|
+
const label = PLAN_LIMIT_DIMENSION_LABELS[resource] ?? resource;
|
|
27
|
+
return label.charAt(0).toUpperCase() + label.slice(1);
|
|
28
|
+
}
|
|
29
|
+
/** The trim-back action for one resource, phrased as what the user does. */
|
|
30
|
+
function resourceFixAction(resource) {
|
|
31
|
+
switch (resource) {
|
|
32
|
+
case "users":
|
|
33
|
+
return "Remove members";
|
|
34
|
+
case "secrets":
|
|
35
|
+
return "Delete secrets";
|
|
36
|
+
case "integrations":
|
|
37
|
+
return "Disconnect integrations";
|
|
38
|
+
case "storageBytes":
|
|
39
|
+
return "Delete files";
|
|
40
|
+
case "agents":
|
|
41
|
+
return "Deprovision agents";
|
|
42
|
+
case "deployments":
|
|
43
|
+
return "Remove deployments";
|
|
44
|
+
default:
|
|
45
|
+
return `Reduce ${PLAN_LIMIT_DIMENSION_LABELS[resource] ?? resource}`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Render a plan-limit refusal: the server's sentence, where each over
|
|
50
|
+
* resource stands, then every way out the server offered. Only the fix options
|
|
51
|
+
* the server actually sent are listed, so the CLI never suggests trimming a
|
|
52
|
+
* dimension nobody measured.
|
|
53
|
+
*/
|
|
54
|
+
function planLimitRefusalText(head, details) {
|
|
55
|
+
const lines = [head];
|
|
56
|
+
for (const row of details.resources ?? []) {
|
|
57
|
+
lines.push(` ${resourceLabel(row.resource)}: ` +
|
|
58
|
+
`${formatResourceAmount(row.resource, row.used)} of ` +
|
|
59
|
+
`${formatResourceAmount(row.resource, row.limit)} used.`);
|
|
60
|
+
}
|
|
61
|
+
const fixes = [];
|
|
62
|
+
const freeMonthUrl = safeUpgradeUrl(details.freeMonth?.url);
|
|
63
|
+
if (details.freeMonth?.eligible && freeMonthUrl) {
|
|
64
|
+
fixes.push(`Start your free month — add a card, first month free: ${freeMonthUrl}`);
|
|
65
|
+
}
|
|
66
|
+
for (const [resource, target] of Object.entries(details.fixOptions ?? {})) {
|
|
67
|
+
fixes.push(`${resourceFixAction(resource)} to make room (your plan allows ` +
|
|
68
|
+
`${formatResourceAmount(resource, target)}).`);
|
|
69
|
+
}
|
|
70
|
+
if (fixes.length > 0) {
|
|
71
|
+
fixes.push("Upgrade your plan.");
|
|
72
|
+
lines.push(" Ways to sort it:");
|
|
73
|
+
fixes.forEach((fix, index) => lines.push(` ${index + 1}. ${fix}`));
|
|
74
|
+
}
|
|
75
|
+
return lines.join("\n");
|
|
76
|
+
}
|
|
8
77
|
function planGateReason(code, details) {
|
|
78
|
+
if (code === "PLAN_LIMIT_EXCEEDED") {
|
|
79
|
+
const head = details.reason?.trim()
|
|
80
|
+
? details.reason.trim()
|
|
81
|
+
: typeof details.resource === "string" &&
|
|
82
|
+
typeof details.used === "number" &&
|
|
83
|
+
typeof details.limit === "number"
|
|
84
|
+
? `Starter plan limit reached: ${details.resource} ${details.used}/${details.limit} used.`
|
|
85
|
+
: details.resources?.length || details.fixOptions || details.freeMonth
|
|
86
|
+
? "Your plan limit has been reached."
|
|
87
|
+
: undefined;
|
|
88
|
+
if (head)
|
|
89
|
+
return planLimitRefusalText(head, details);
|
|
90
|
+
}
|
|
9
91
|
if (details.reason?.trim())
|
|
10
92
|
return details.reason.trim();
|
|
11
|
-
if (code === "PLAN_LIMIT_EXCEEDED" &&
|
|
12
|
-
typeof details.resource === "string" &&
|
|
13
|
-
typeof details.used === "number" &&
|
|
14
|
-
typeof details.limit === "number") {
|
|
15
|
-
return `Starter plan limit reached: ${details.resource} ${details.used}/${details.limit} used.`;
|
|
16
|
-
}
|
|
17
93
|
if (code === "PERSONAL_PLAN_REQUIRED") {
|
|
18
94
|
return renderPersonalPlanFeatureNotice(details.feature ?? "this feature", null);
|
|
19
95
|
}
|
|
@@ -171,6 +247,17 @@ export function planGateErrorFromPayload(status, body, companyUid) {
|
|
|
171
247
|
const code = payload.code;
|
|
172
248
|
const upgradeUrl = safeUpgradeUrl(payload.upgradeUrl);
|
|
173
249
|
const targetCompanyUid = companyUid ?? companyUidFromPlanUpgradeUrl(upgradeUrl);
|
|
250
|
+
// Starter hard stops. The canonical body is `{ error: "plan_limit_reached",
|
|
251
|
+
// code: "PLAN_LIMIT_EXCEEDED", blocked, resources, message, fixOptions,
|
|
252
|
+
// freeMonth?, upgradeUrl }`; older servers send the body without `code`, the
|
|
253
|
+
// membership gate's legacy `{ code: "PLAN_LIMIT_EXCEEDED", resource, used,
|
|
254
|
+
// limit }`, or a personal presign row `{ code: "PLAN_LIMIT_REACHED" }`. All
|
|
255
|
+
// of them are the same refusal to the user, so they share one code.
|
|
256
|
+
if (payload.error === PLAN_LIMIT_REACHED_ERROR ||
|
|
257
|
+
code === "PLAN_LIMIT_EXCEEDED" ||
|
|
258
|
+
code === "PLAN_LIMIT_REACHED") {
|
|
259
|
+
return planLimitRefusalFromPayload(payload, upgradeUrl, targetCompanyUid);
|
|
260
|
+
}
|
|
174
261
|
const planLimit = payload.planLimit !== null &&
|
|
175
262
|
typeof payload.planLimit === "object" &&
|
|
176
263
|
!Array.isArray(payload.planLimit)
|
|
@@ -184,8 +271,7 @@ export function planGateErrorFromPayload(status, body, companyUid) {
|
|
|
184
271
|
return null;
|
|
185
272
|
}
|
|
186
273
|
}
|
|
187
|
-
else if (code !== "
|
|
188
|
-
code !== "PLAN_REQUIRED" &&
|
|
274
|
+
else if (code !== "PLAN_REQUIRED" &&
|
|
189
275
|
code !== "MEETING_PLAN_REQUIRED") {
|
|
190
276
|
return null;
|
|
191
277
|
}
|
|
@@ -204,6 +290,130 @@ export function planGateErrorFromPayload(status, body, companyUid) {
|
|
|
204
290
|
...(targetCompanyUid ? { companyUid: targetCompanyUid } : {}),
|
|
205
291
|
});
|
|
206
292
|
}
|
|
293
|
+
/** Wire value of `error` on hq-pro's hard-stop 402 body. Never shown to users. */
|
|
294
|
+
const PLAN_LIMIT_REACHED_ERROR = "plan_limit_reached";
|
|
295
|
+
function finiteNonNegative(value) {
|
|
296
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
297
|
+
? value
|
|
298
|
+
: undefined;
|
|
299
|
+
}
|
|
300
|
+
function parseResources(value) {
|
|
301
|
+
if (!Array.isArray(value))
|
|
302
|
+
return undefined;
|
|
303
|
+
const rows = [];
|
|
304
|
+
for (const raw of value) {
|
|
305
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
306
|
+
continue;
|
|
307
|
+
const row = raw;
|
|
308
|
+
const used = finiteNonNegative(row.used);
|
|
309
|
+
const limit = finiteNonNegative(row.limit);
|
|
310
|
+
if (typeof row.resource !== "string" || used === undefined || limit === undefined) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
rows.push({ resource: row.resource, used, limit });
|
|
314
|
+
}
|
|
315
|
+
return rows.length > 0 ? rows : undefined;
|
|
316
|
+
}
|
|
317
|
+
function parseFixOptions(value) {
|
|
318
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
319
|
+
return undefined;
|
|
320
|
+
const fix = {};
|
|
321
|
+
for (const [resource, target] of Object.entries(value)) {
|
|
322
|
+
const amount = finiteNonNegative(target);
|
|
323
|
+
if (amount !== undefined)
|
|
324
|
+
fix[resource] = amount;
|
|
325
|
+
}
|
|
326
|
+
return Object.keys(fix).length > 0 ? fix : undefined;
|
|
327
|
+
}
|
|
328
|
+
function planLimitRefusalFromPayload(payload, upgradeUrl, companyUid) {
|
|
329
|
+
// `message` is the customer sentence. A legacy `error` string is prose only
|
|
330
|
+
// when it is not a machine token (`plan_limit_reached`, or the code itself).
|
|
331
|
+
const reason = typeof payload.message === "string" && payload.message.trim()
|
|
332
|
+
? payload.message
|
|
333
|
+
: typeof payload.error === "string" &&
|
|
334
|
+
payload.error.trim() &&
|
|
335
|
+
payload.error !== PLAN_LIMIT_REACHED_ERROR &&
|
|
336
|
+
payload.error !== payload.code
|
|
337
|
+
? payload.error
|
|
338
|
+
: undefined;
|
|
339
|
+
const resources = parseResources(payload.resources);
|
|
340
|
+
const fixOptions = parseFixOptions(payload.fixOptions);
|
|
341
|
+
const parsedFreeMonth = parseFreeMonth(payload.freeMonth);
|
|
342
|
+
const freeMonthUrl = safeUpgradeUrl(parsedFreeMonth?.url);
|
|
343
|
+
const freeMonth = parsedFreeMonth
|
|
344
|
+
? {
|
|
345
|
+
eligible: true,
|
|
346
|
+
...(parsedFreeMonth.trialDays !== undefined
|
|
347
|
+
? { trialDays: parsedFreeMonth.trialDays }
|
|
348
|
+
: {}),
|
|
349
|
+
...(freeMonthUrl ? { url: freeMonthUrl } : {}),
|
|
350
|
+
}
|
|
351
|
+
: undefined;
|
|
352
|
+
const used = finiteNonNegative(payload.used);
|
|
353
|
+
const limit = finiteNonNegative(payload.limit);
|
|
354
|
+
return new PlanGateError("PLAN_LIMIT_EXCEEDED", {
|
|
355
|
+
...(typeof payload.resource === "string" ? { resource: payload.resource } : {}),
|
|
356
|
+
...(used !== undefined ? { used } : {}),
|
|
357
|
+
...(limit !== undefined ? { limit } : {}),
|
|
358
|
+
...(typeof payload.blocked === "string" ? { blocked: payload.blocked } : {}),
|
|
359
|
+
...(resources ? { resources } : {}),
|
|
360
|
+
...(fixOptions ? { fixOptions } : {}),
|
|
361
|
+
...(freeMonth ? { freeMonth } : {}),
|
|
362
|
+
...(upgradeUrl ? { upgradeUrl } : {}),
|
|
363
|
+
...(reason ? { reason } : {}),
|
|
364
|
+
...(companyUid ? { companyUid } : {}),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
/** Wire codes that mark a vault/sync error as a plan-limit refusal. */
|
|
368
|
+
const PLAN_LIMIT_ERROR_CODES = new Set([
|
|
369
|
+
"PLAN_LIMIT_EXCEEDED",
|
|
370
|
+
"PLAN_LIMIT_REACHED",
|
|
371
|
+
PLAN_LIMIT_REACHED_ERROR,
|
|
372
|
+
]);
|
|
373
|
+
/**
|
|
374
|
+
* Recognise a plan-limit refusal carried by an `@indigoai-us/hq-cloud`
|
|
375
|
+
* `VaultClientError` (sync, share, skill upload). Duck-typed so hq-cli keeps
|
|
376
|
+
* working across hq-cloud releases: the error exposes `statusCode`, `code`,
|
|
377
|
+
* `upgradeUrl` and the raw response `body`. The raw body is decoded first so
|
|
378
|
+
* the server's message, fix options and free month survive; a bare code (a
|
|
379
|
+
* personal presign row has no body) still becomes a refusal with its link.
|
|
380
|
+
*/
|
|
381
|
+
export function planGateErrorFromVaultError(err) {
|
|
382
|
+
if (isPlanGateError(err))
|
|
383
|
+
return err;
|
|
384
|
+
if (!err || typeof err !== "object")
|
|
385
|
+
return null;
|
|
386
|
+
const candidate = err;
|
|
387
|
+
const code = typeof candidate.code === "string" ? candidate.code : undefined;
|
|
388
|
+
const isPlanLimitCode = code !== undefined && PLAN_LIMIT_ERROR_CODES.has(code);
|
|
389
|
+
if (candidate.statusCode !== 402 && !isPlanLimitCode)
|
|
390
|
+
return null;
|
|
391
|
+
if (typeof candidate.body === "string" && candidate.body.trim()) {
|
|
392
|
+
try {
|
|
393
|
+
const decoded = planGateErrorFromPayload(402, JSON.parse(candidate.body));
|
|
394
|
+
if (decoded) {
|
|
395
|
+
if (!decoded.details.upgradeUrl) {
|
|
396
|
+
const upgradeUrl = safeUpgradeUrl(candidate.upgradeUrl);
|
|
397
|
+
if (upgradeUrl) {
|
|
398
|
+
return new PlanGateError(decoded.code, { ...decoded.details, upgradeUrl });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return decoded;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
// Not JSON: fall through to the structured fields on the error itself.
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (!isPlanLimitCode)
|
|
409
|
+
return null;
|
|
410
|
+
return planLimitRefusalFromPayload({
|
|
411
|
+
code,
|
|
412
|
+
...(typeof candidate.message === "string"
|
|
413
|
+
? { error: candidate.message }
|
|
414
|
+
: {}),
|
|
415
|
+
}, safeUpgradeUrl(candidate.upgradeUrl), undefined);
|
|
416
|
+
}
|
|
207
417
|
/**
|
|
208
418
|
* Decode the supported plan-gate envelopes. Malformed, unrelated,
|
|
209
419
|
* or non-402 responses retain their existing command-specific handling.
|
|
@@ -42,7 +42,7 @@ function shellLocationAliases(contents) {
|
|
|
42
42
|
const scriptRoot = SCRIPT_ROOT_TOKENS.exec(value);
|
|
43
43
|
if (scriptRoot) {
|
|
44
44
|
const suffix = value.slice(scriptRoot.index + scriptRoot[0].length)
|
|
45
|
-
.match(/^\/([^"'`\s;|&)]+)/)?.[1] ?? ".";
|
|
45
|
+
.match(/^\/([^"'`\s;|&)}]+)/)?.[1] ?? ".";
|
|
46
46
|
aliases.set(name, { kind: "asset", baseDirectory: path.posix.normalize(suffix) });
|
|
47
47
|
continue;
|
|
48
48
|
}
|
|
@@ -116,15 +116,15 @@ export function scanScaffoldSiblingReferences(scaffoldRoot) {
|
|
|
116
116
|
(/\b(?:source|exec|bash|sh|node)\b/.test(line) || /^\s*\.\s+/.test(line)));
|
|
117
117
|
for (const line of commandLines) {
|
|
118
118
|
SCRIPT_ROOT_TOKENS.lastIndex = 0;
|
|
119
|
-
for (const match of line.matchAll(/(?:\$\{SCRIPT_DIR\}|\$SCRIPT_DIR)\/([^"'`\s;|&)]+)/g)) {
|
|
119
|
+
for (const match of line.matchAll(/(?:\$\{SCRIPT_DIR\}|\$SCRIPT_DIR)\/([^"'`\s;|&)}]+)/g)) {
|
|
120
120
|
recordRelative(sourceFile, match[1]);
|
|
121
121
|
}
|
|
122
|
-
const dirnamePath = new RegExp(String.raw `\$\(\s*dirname\s+["']?${BASH_SOURCE_TOKEN}["']?\s*\)(\/[^"'\x60\s;|&)]+)`, "g");
|
|
122
|
+
const dirnamePath = new RegExp(String.raw `\$\(\s*dirname\s+["']?${BASH_SOURCE_TOKEN}["']?\s*\)(\/[^"'\x60\s;|&)}]+)`, "g");
|
|
123
123
|
for (const match of line.matchAll(dirnamePath))
|
|
124
124
|
recordRelative(sourceFile, match[1]);
|
|
125
125
|
for (const [alias, location] of aliases) {
|
|
126
126
|
const escapedAlias = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
127
|
-
const aliasPath = new RegExp(String.raw `(?:\$\{${escapedAlias}\}|\$${escapedAlias})\/([^"'\x60\s;|&)]+)`, "g");
|
|
127
|
+
const aliasPath = new RegExp(String.raw `(?:\$\{${escapedAlias}\}|\$${escapedAlias})\/([^"'\x60\s;|&)}]+)`, "g");
|
|
128
128
|
for (const match of line.matchAll(aliasPath)) {
|
|
129
129
|
if (location.kind === "hq-root") {
|
|
130
130
|
const reference = match[0];
|
|
@@ -161,6 +161,12 @@ export interface SelfUpdateDeps {
|
|
|
161
161
|
interactive?: boolean;
|
|
162
162
|
/** Best-effort daemon kick after the CLI install converges (tests). */
|
|
163
163
|
restartDaemon?: () => RestartDaemonResult | void;
|
|
164
|
+
/**
|
|
165
|
+
* Whether `cli.staged-self-update` is on: build the new version beside the
|
|
166
|
+
* running package and swap it in, instead of moving the running package
|
|
167
|
+
* aside for the whole install. Read only when an npm global install is due.
|
|
168
|
+
*/
|
|
169
|
+
stagedSelfUpdate?: () => Promise<boolean>;
|
|
164
170
|
}
|
|
165
171
|
/**
|
|
166
172
|
* Startup path: the running CLI is behind npm `latest`, so update in place and
|
|
@@ -64,6 +64,7 @@ import semver from "semver";
|
|
|
64
64
|
import chalk from "chalk";
|
|
65
65
|
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
66
66
|
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, checkUpdateConvergence, derivePnpmHome, isBunManagedPackageDir, isLocalDependencyInstall, isPnpmManagedPackageDir, isPrefixWritable, nonWritablePrefixNote, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, runSupervisedUpdateCommand, } from "./version-gate.js";
|
|
67
|
+
import { canStageInstall, stagedNpmInstall } from "./staged-install.js";
|
|
67
68
|
import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
|
|
68
69
|
import { markLatestIneffective } from "./version-check.js";
|
|
69
70
|
import { restartDaemonServiceIfInstalled } from "../lib/mesh/live/daemon/install.js";
|
|
@@ -414,12 +415,15 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
414
415
|
console.error(chalk.dim(`hq-cli ${latest} is available, but this copy is a local dependency (${install.packageRoot}) — update the project that owns it.`));
|
|
415
416
|
return { action: "skipped", latest };
|
|
416
417
|
}
|
|
418
|
+
const staged = canStageInstall(install) && (await readStagedSelfUpdateFlag(deps, env));
|
|
417
419
|
const releaseLock = flavor.lock ? (deps.acquireLock ?? acquireUpdateLock)() : () => { };
|
|
418
420
|
if (!releaseLock)
|
|
419
421
|
return { action: "skipped", latest };
|
|
420
422
|
const renamePath = deps.renamePath ?? fs.renameSync;
|
|
421
|
-
|
|
422
|
-
|
|
423
|
+
// The staged install keeps the running package in place until the swap and
|
|
424
|
+
// restores it itself, so it takes no snapshot.
|
|
425
|
+
const installSnapshot = staged ? null : snapshotInstall(install, renamePath);
|
|
426
|
+
if (!staged && !installSnapshot) {
|
|
423
427
|
console.error(chalk.dim(" No backup could be made for this hq-cli install; proceeding without rollback protection."));
|
|
424
428
|
}
|
|
425
429
|
let result;
|
|
@@ -440,10 +444,15 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
440
444
|
// Forward env only when PNPM_HOME had to be injected, so the common path
|
|
441
445
|
// spawns with the inherited environment and keeps the two-argument runner
|
|
442
446
|
// call it has always made.
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
+
if (staged) {
|
|
448
|
+
result = stagedNpmInstall(install, latestValid, { run: runner, env, rename: renamePath });
|
|
449
|
+
}
|
|
450
|
+
else if (updateEnv === undefined) {
|
|
451
|
+
result = runner(plan.cmd, plan.args);
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
result = runner(plan.cmd, plan.args, updateEnv);
|
|
455
|
+
}
|
|
447
456
|
if (!result.ok && installSnapshot) {
|
|
448
457
|
restoredAfterFailure = restoreInstall(installSnapshot, renamePath);
|
|
449
458
|
if (restoredAfterFailure)
|
|
@@ -537,6 +546,19 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
537
546
|
console.error(chalk.dim(`✓ hq-cli updated to ${latest}.`));
|
|
538
547
|
return { action: "reexec", reexecStatus: status, latest };
|
|
539
548
|
}
|
|
549
|
+
async function readStagedSelfUpdateFlag(deps, env) {
|
|
550
|
+
try {
|
|
551
|
+
if (deps.stagedSelfUpdate)
|
|
552
|
+
return await deps.stagedSelfUpdate();
|
|
553
|
+
// Loaded here so the startup path pays for the flag client only when an
|
|
554
|
+
// update is actually due.
|
|
555
|
+
const { readDaemonFlag, STAGED_SELF_UPDATE_FLAG } = await import("../lib/daemon/flag.js");
|
|
556
|
+
return await readDaemonFlag(STAGED_SELF_UPDATE_FLAG, { env });
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
540
562
|
/**
|
|
541
563
|
* Startup path: the running CLI is behind npm `latest`, so update in place and
|
|
542
564
|
* re-run the user's command on the new version. `latest` is normally the cached
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staged npm self-update: install the new hq-cli beside the running package,
|
|
3
|
+
* check that it starts, then swap it in with two renames.
|
|
4
|
+
*
|
|
5
|
+
* The in-place update moves the live package aside for the whole
|
|
6
|
+
* `npm install -g`, so anything that starts `hq` during the install finds no
|
|
7
|
+
* CLI on disk ("hq: command not found", MODULE_NOT_FOUND; outposts primary and
|
|
8
|
+
* 2, 5.290.0 -> 5.291.0). Here the live package stays in place until the new
|
|
9
|
+
* one is complete and has answered `--version`.
|
|
10
|
+
*
|
|
11
|
+
* Only a plain npm global layout is staged: `<prefix>/lib/node_modules/<pkg>`
|
|
12
|
+
* as a real directory, with bin links in `<prefix>/bin`. pnpm, Bun, Windows
|
|
13
|
+
* and linked checkouts keep the in-place update.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import { type RunningInstall, type UpdateResult } from "./version-gate.js";
|
|
17
|
+
/** Set for the staging install; hq-cli's own postinstall skips itself on it. */
|
|
18
|
+
export declare const STAGED_INSTALL_ENV = "HQ_STAGED_INSTALL";
|
|
19
|
+
export interface StagedInstallDeps {
|
|
20
|
+
/** Runs the package manager. Defaults to {@link runUpdateCommand}. */
|
|
21
|
+
run?: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
|
|
22
|
+
env?: NodeJS.ProcessEnv;
|
|
23
|
+
/** The version the staged package's `hq --version` reports, or null. */
|
|
24
|
+
probeVersion?: (packageRoot: string) => string | null;
|
|
25
|
+
/** The package's postinstall, run from the live path after the swap. */
|
|
26
|
+
runPostinstall?: (packageRoot: string) => void;
|
|
27
|
+
rename?: typeof fs.renameSync;
|
|
28
|
+
/** Removes the staging and backup directories. */
|
|
29
|
+
remove?: (target: string) => void;
|
|
30
|
+
platform?: NodeJS.Platform;
|
|
31
|
+
}
|
|
32
|
+
export declare function canStageInstall(install: RunningInstall, platform?: NodeJS.Platform): boolean;
|
|
33
|
+
export declare function stagedNpmInstall(install: RunningInstall, version: string, deps?: StagedInstallDeps): UpdateResult;
|
|
34
|
+
//# sourceMappingURL=staged-install.d.ts.map
|