@indigoai-us/hq-cli 5.76.0 → 5.77.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/dist/commands/agents.js +8 -3
- package/dist/commands/cloud.d.ts +7 -0
- package/dist/commands/cloud.js +21 -0
- package/dist/commands/files.d.ts +61 -0
- package/dist/commands/files.js +274 -0
- package/dist/commands/outposts.js +2 -2
- package/dist/main.js +8 -1
- package/dist/utils/billing-gate.d.ts +15 -0
- package/dist/utils/billing-gate.js +35 -0
- package/dist/utils/settle-with-timeout.d.ts +7 -0
- package/dist/utils/settle-with-timeout.js +22 -0
- package/dist/utils/vault-api.js +3 -0
- package/package.json +1 -1
- package/src/commands/agents.test.ts +41 -0
- package/src/commands/agents.ts +7 -3
- package/src/commands/cloud.test.ts +41 -0
- package/src/commands/cloud.ts +33 -0
- package/src/commands/files-recovery.test.ts +361 -0
- package/src/commands/files.ts +410 -0
- package/src/commands/outposts.test.ts +37 -0
- package/src/commands/outposts.ts +2 -2
- package/src/main.ts +12 -1
- package/src/utils/billing-gate.ts +46 -0
- package/src/utils/settle-with-timeout.test.ts +21 -0
- package/src/utils/settle-with-timeout.ts +22 -0
- package/src/utils/vault-api.test.ts +14 -0
- package/src/utils/vault-api.ts +8 -0
package/CHANGELOG.md
CHANGED
package/dist/commands/agents.js
CHANGED
|
@@ -25,7 +25,7 @@ import chalk from "chalk";
|
|
|
25
25
|
import { randomUUID } from "node:crypto";
|
|
26
26
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
27
27
|
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
28
|
-
import { AGENT_PRICE_CENTS, confirmChargeOrExit, parseBillingPayload,
|
|
28
|
+
import { AGENT_PRICE_CENTS, confirmChargeOrExit, parseBillingPayload, surfaceBillingBlocked, } from "../utils/billing-gate.js";
|
|
29
29
|
/** Reasoning-effort values hq-pro accepts on `runtime-config`. */
|
|
30
30
|
export const VALID_EFFORTS = new Set([
|
|
31
31
|
"minimal",
|
|
@@ -64,7 +64,12 @@ export async function agentsRequest(opts) {
|
|
|
64
64
|
const res = await vaultApiFetch(opts);
|
|
65
65
|
if (!res.ok) {
|
|
66
66
|
const body = (await res.json().catch(() => ({})));
|
|
67
|
-
throw new AgentsHttpError(res.status,
|
|
67
|
+
throw new AgentsHttpError(res.status,
|
|
68
|
+
// `message` FIRST: on a payment_failed envelope it carries the friendly
|
|
69
|
+
// decline copy ("Your card was declined…") while `error` is the generic
|
|
70
|
+
// "payment required" — error-first would feed surfaceBillingBlocked the
|
|
71
|
+
// generic string and lose the decline reason (mirrors outpostRequest).
|
|
72
|
+
body.message ?? body.error ?? res.statusText, body.code, parseBillingPayload(body));
|
|
68
73
|
}
|
|
69
74
|
return (await res.json());
|
|
70
75
|
}
|
|
@@ -371,7 +376,7 @@ export function registerAgentsCommand(program) {
|
|
|
371
376
|
if (err instanceof AgentsHttpError &&
|
|
372
377
|
err.status === 402 &&
|
|
373
378
|
err.billing) {
|
|
374
|
-
await
|
|
379
|
+
await surfaceBillingBlocked(token, err.billing, err.message);
|
|
375
380
|
process.exit(1);
|
|
376
381
|
}
|
|
377
382
|
throw err;
|
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -22,6 +22,13 @@ import { type BannerLevel } from "../lib/narrow-hint-banner.js";
|
|
|
22
22
|
* succeed when files were dropped" guarantee unit-testable.
|
|
23
23
|
*/
|
|
24
24
|
export declare function scopeExcludedWarning(count: number): string | null;
|
|
25
|
+
/**
|
|
26
|
+
* Bound foreground waits for a watcher/manual sync that currently owns the
|
|
27
|
+
* per-root operation lock. The cloud engine reads this environment value on
|
|
28
|
+
* every lock acquisition. A command-line value wins; otherwise preserve a
|
|
29
|
+
* valid explicit caller environment value and supply a finite CLI default.
|
|
30
|
+
*/
|
|
31
|
+
export declare function configureSyncLockTimeout(raw: string | undefined): void;
|
|
25
32
|
export interface PullAllVaultClient {
|
|
26
33
|
listMyMemberships(): Promise<Array<{
|
|
27
34
|
companyUid: string;
|
package/dist/commands/cloud.js
CHANGED
|
@@ -59,6 +59,21 @@ function resolveDeletePolicy() {
|
|
|
59
59
|
}
|
|
60
60
|
return "currency-gated";
|
|
61
61
|
}
|
|
62
|
+
const DEFAULT_SYNC_LOCK_TIMEOUT_SECONDS = 300;
|
|
63
|
+
/**
|
|
64
|
+
* Bound foreground waits for a watcher/manual sync that currently owns the
|
|
65
|
+
* per-root operation lock. The cloud engine reads this environment value on
|
|
66
|
+
* every lock acquisition. A command-line value wins; otherwise preserve a
|
|
67
|
+
* valid explicit caller environment value and supply a finite CLI default.
|
|
68
|
+
*/
|
|
69
|
+
export function configureSyncLockTimeout(raw) {
|
|
70
|
+
const value = raw ?? process.env.HQ_OP_LOCK_TIMEOUT ?? String(DEFAULT_SYNC_LOCK_TIMEOUT_SECONDS);
|
|
71
|
+
const seconds = Number(value);
|
|
72
|
+
if (!Number.isInteger(seconds) || seconds < 0) {
|
|
73
|
+
throw new Error("--lock-timeout must be a non-negative integer number of seconds");
|
|
74
|
+
}
|
|
75
|
+
process.env.HQ_OP_LOCK_TIMEOUT = String(seconds);
|
|
76
|
+
}
|
|
62
77
|
// Oldest-first by createdAt, ties broken by uid lexicographic — matches
|
|
63
78
|
// `pickCanonicalPersonEntity` in @indigoai-us/hq-cloud so the CLI lands on
|
|
64
79
|
// the same person bucket that `hq-sync-runner` picks.
|
|
@@ -478,6 +493,7 @@ export function registerCloudCommands(program) {
|
|
|
478
493
|
.argument("[paths...]", "Paths to push (defaults to current directory)")
|
|
479
494
|
.option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
|
|
480
495
|
.option("--company <slug>", "Company slug or UID (defaults to active company in .hq/config.json)")
|
|
496
|
+
.option("--lock-timeout <seconds>", "Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)")
|
|
481
497
|
.option("--message <msg>", "Optional message attached to journal entries for these uploads")
|
|
482
498
|
.option("--on-conflict <strategy>", "Conflict strategy: overwrite | keep | abort (omit for interactive)")
|
|
483
499
|
.option("--creds-from-stdin", "Read a pre-vended EntityContext as JSON from stdin instead of vending " +
|
|
@@ -509,6 +525,7 @@ export function registerCloudCommands(program) {
|
|
|
509
525
|
"`--all`.")
|
|
510
526
|
.action(async (paths, options) => {
|
|
511
527
|
try {
|
|
528
|
+
configureSyncLockTimeout(options.lockTimeout);
|
|
512
529
|
assertSingleSelector(options, "push");
|
|
513
530
|
}
|
|
514
531
|
catch (err) {
|
|
@@ -688,6 +705,7 @@ export function registerCloudCommands(program) {
|
|
|
688
705
|
.description("Pull permitted files from the company vault to local HQ")
|
|
689
706
|
.option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
|
|
690
707
|
.option("--company <slug>", "Company slug or UID (defaults to active company in .hq/config.json)")
|
|
708
|
+
.option("--lock-timeout <seconds>", "Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)")
|
|
691
709
|
.option("--on-conflict <strategy>", "Conflict strategy: overwrite | keep | abort (omit for interactive)")
|
|
692
710
|
.option("--all", "Pull every company you are a member of plus your personal vault " +
|
|
693
711
|
"into <hq-root>. Companies land at <hq-root>/companies/<slug>; " +
|
|
@@ -711,6 +729,7 @@ export function registerCloudCommands(program) {
|
|
|
711
729
|
"quarantined under .hq/scope-quarantine/ (recoverable).")
|
|
712
730
|
.action(async (options) => {
|
|
713
731
|
try {
|
|
732
|
+
configureSyncLockTimeout(options.lockTimeout);
|
|
714
733
|
assertSingleSelector(options, "pull");
|
|
715
734
|
}
|
|
716
735
|
catch (err) {
|
|
@@ -882,6 +901,7 @@ export function registerCloudCommands(program) {
|
|
|
882
901
|
"(mirrors AppBar HQ Sync's \"Sync Now\" button)")
|
|
883
902
|
.option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
|
|
884
903
|
.option("--company <slug>", "Company slug or UID (defaults to active company in .hq/config.json)")
|
|
904
|
+
.option("--lock-timeout <seconds>", "Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)")
|
|
885
905
|
.option("--message <msg>", "Optional message attached to journal entries for the push leg")
|
|
886
906
|
.option("--on-conflict <strategy>", "Conflict strategy: overwrite | keep | abort (omit for interactive)")
|
|
887
907
|
.option("--all", "Sync every company you are a member of plus your personal vault " +
|
|
@@ -901,6 +921,7 @@ export function registerCloudCommands(program) {
|
|
|
901
921
|
"out-of-scope files are quarantined under .hq/scope-quarantine/.")
|
|
902
922
|
.action(async (options) => {
|
|
903
923
|
try {
|
|
924
|
+
configureSyncLockTimeout(options.lockTimeout);
|
|
904
925
|
assertSingleSelector(options, "now");
|
|
905
926
|
if (options.all) {
|
|
906
927
|
// `options.personal === false` is Commander's auto-negation
|
package/dist/commands/files.d.ts
CHANGED
|
@@ -92,5 +92,66 @@ export declare function stripRedundantCompanyScope(prefix: string): {
|
|
|
92
92
|
export declare function runFilesDelete(params: RunFilesDeleteParams, deps?: {
|
|
93
93
|
confirm?: ConfirmFn;
|
|
94
94
|
}): Promise<void>;
|
|
95
|
+
export interface FileVersionRow {
|
|
96
|
+
versionId: string;
|
|
97
|
+
isLatest: boolean;
|
|
98
|
+
lastModified: string;
|
|
99
|
+
size: number;
|
|
100
|
+
isDeleteMarker?: boolean;
|
|
101
|
+
}
|
|
102
|
+
export interface FilesVersionsResponse {
|
|
103
|
+
key: string;
|
|
104
|
+
versions: FileVersionRow[];
|
|
105
|
+
computedAt: string;
|
|
106
|
+
}
|
|
107
|
+
export interface FileTombstoneRow {
|
|
108
|
+
key: string;
|
|
109
|
+
deletedAt: string;
|
|
110
|
+
deletedBy: string;
|
|
111
|
+
deletedPrefix: string;
|
|
112
|
+
}
|
|
113
|
+
export interface FilesTrashResponse {
|
|
114
|
+
companyUid?: string;
|
|
115
|
+
personal?: true;
|
|
116
|
+
tombstones: FileTombstoneRow[];
|
|
117
|
+
cursor?: string | null;
|
|
118
|
+
truncated: boolean;
|
|
119
|
+
computedAt: string;
|
|
120
|
+
}
|
|
121
|
+
export interface FilesRestoreResponse {
|
|
122
|
+
key: string;
|
|
123
|
+
wasDeleted: boolean;
|
|
124
|
+
restoredFromVersionId: string;
|
|
125
|
+
newVersionId: string;
|
|
126
|
+
}
|
|
127
|
+
export declare class FilesRecoveryHttpError extends Error {
|
|
128
|
+
readonly status: number;
|
|
129
|
+
readonly code?: string | undefined;
|
|
130
|
+
constructor(status: number, message: string, code?: string | undefined);
|
|
131
|
+
}
|
|
132
|
+
/** Render the exact-key version history, including S3 delete markers. */
|
|
133
|
+
export declare function formatFilesVersionsTable(rows: FileVersionRow[]): string;
|
|
134
|
+
/** Render durable delete tombstones without implying the objects still exist. */
|
|
135
|
+
export declare function formatFilesTrashTable(rows: FileTombstoneRow[]): string;
|
|
136
|
+
export declare function runFilesVersions(params: {
|
|
137
|
+
key: string;
|
|
138
|
+
personal: boolean;
|
|
139
|
+
companySlug: string | undefined;
|
|
140
|
+
}): Promise<FilesVersionsResponse>;
|
|
141
|
+
export declare function runFilesRestore(params: {
|
|
142
|
+
key: string;
|
|
143
|
+
versionId?: string;
|
|
144
|
+
yes: boolean;
|
|
145
|
+
personal: boolean;
|
|
146
|
+
companySlug: string | undefined;
|
|
147
|
+
}, deps?: {
|
|
148
|
+
confirm?: ConfirmFn;
|
|
149
|
+
}): Promise<FilesRestoreResponse | undefined>;
|
|
150
|
+
export declare function runFilesTrash(params: {
|
|
151
|
+
prefix: string;
|
|
152
|
+
cursor?: string;
|
|
153
|
+
personal: boolean;
|
|
154
|
+
companySlug: string | undefined;
|
|
155
|
+
}): Promise<FilesTrashResponse>;
|
|
95
156
|
export {};
|
|
96
157
|
//# sourceMappingURL=files.d.ts.map
|
package/dist/commands/files.js
CHANGED
|
@@ -379,6 +379,69 @@ export function registerFilesCommand(program) {
|
|
|
379
379
|
process.exit(1);
|
|
380
380
|
}
|
|
381
381
|
});
|
|
382
|
+
files
|
|
383
|
+
.command("versions <path>")
|
|
384
|
+
.description("List prior content versions and delete markers for one exact vault key. Use --personal for your personal vault.")
|
|
385
|
+
.option("--personal", "Target your own personal vault instead of a company vault (mutually exclusive with --company)")
|
|
386
|
+
.action(async (path, opts) => {
|
|
387
|
+
try {
|
|
388
|
+
const companySlug = files.opts().company;
|
|
389
|
+
const personal = opts.personal === true;
|
|
390
|
+
assertRecoveryScope(personal, companySlug);
|
|
391
|
+
await runFilesVersions({ key: path, personal, companySlug });
|
|
392
|
+
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
395
|
+
process.exit(1);
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
files
|
|
399
|
+
.command("restore <path>")
|
|
400
|
+
.description("Restore a prior version or undelete an exact vault key. Prompts before overwriting unless --yes. Use --personal for your personal vault.")
|
|
401
|
+
.option("--personal", "Target your own personal vault instead of a company vault (mutually exclusive with --company)")
|
|
402
|
+
.option("--version <id>", "Content version ID to restore")
|
|
403
|
+
.option("-y, --yes", "Skip the overwrite confirmation prompt (for scripts)")
|
|
404
|
+
.action(async (path, opts) => {
|
|
405
|
+
try {
|
|
406
|
+
const companySlug = files.opts().company;
|
|
407
|
+
const personal = opts.personal === true;
|
|
408
|
+
assertRecoveryScope(personal, companySlug);
|
|
409
|
+
await runFilesRestore({
|
|
410
|
+
key: path,
|
|
411
|
+
versionId: opts.version,
|
|
412
|
+
yes: opts.yes === true,
|
|
413
|
+
personal,
|
|
414
|
+
companySlug,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
catch (err) {
|
|
418
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
419
|
+
process.exit(1);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
files
|
|
423
|
+
.command("trash")
|
|
424
|
+
.description("List deleted vault keys retained as tombstones. Use --personal for your personal vault.")
|
|
425
|
+
.option("--personal", "Target your own personal vault instead of a company vault (mutually exclusive with --company)")
|
|
426
|
+
.option("--prefix <prefix>", "Literal key prefix to filter tombstones")
|
|
427
|
+
.option("--cursor <cursor>", "Continue from an opaque tombstone cursor")
|
|
428
|
+
.action(async (opts) => {
|
|
429
|
+
try {
|
|
430
|
+
const companySlug = files.opts().company;
|
|
431
|
+
const personal = opts.personal === true;
|
|
432
|
+
assertRecoveryScope(personal, companySlug);
|
|
433
|
+
await runFilesTrash({
|
|
434
|
+
prefix: opts.prefix ?? "",
|
|
435
|
+
cursor: opts.cursor,
|
|
436
|
+
personal,
|
|
437
|
+
companySlug,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
442
|
+
process.exit(1);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
382
445
|
// Return the `files` Commander group so callers (src/index.ts) can attach
|
|
383
446
|
// additional subcommands (e.g. `hq files browse`/`hq files cat` from
|
|
384
447
|
// files-browse.ts) onto the same group without re-creating it.
|
|
@@ -737,4 +800,215 @@ export async function runFilesDelete(params, deps = {}) {
|
|
|
737
800
|
console.log(chalk.dim(`${result.skipped} object(s) under this prefix you can't delete were left untouched.`));
|
|
738
801
|
}
|
|
739
802
|
}
|
|
803
|
+
export class FilesRecoveryHttpError extends Error {
|
|
804
|
+
status;
|
|
805
|
+
code;
|
|
806
|
+
constructor(status, message, code) {
|
|
807
|
+
super(message);
|
|
808
|
+
this.status = status;
|
|
809
|
+
this.code = code;
|
|
810
|
+
this.name = "FilesRecoveryHttpError";
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
function assertRecoveryScope(personal, companySlug) {
|
|
814
|
+
if (personal && companySlug) {
|
|
815
|
+
throw new Error("Pass either --personal or --company, not both.");
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
function assertExactRecoveryKey(key) {
|
|
819
|
+
if (!key || key.includes("*")) {
|
|
820
|
+
throw new Error("Restore and version history require one exact, wildcard-free vault key.");
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
function quoteRecoveryShellArg(value) {
|
|
824
|
+
return "'" + value.replace(/'/g, "'\\''") + "'";
|
|
825
|
+
}
|
|
826
|
+
function formatBytes(bytes) {
|
|
827
|
+
if (bytes < 1024)
|
|
828
|
+
return String(bytes) + " B";
|
|
829
|
+
if (bytes < 1024 * 1024)
|
|
830
|
+
return (bytes / 1024).toFixed(1) + " KiB";
|
|
831
|
+
if (bytes < 1024 * 1024 * 1024) {
|
|
832
|
+
return (bytes / (1024 * 1024)).toFixed(1) + " MiB";
|
|
833
|
+
}
|
|
834
|
+
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + " GiB";
|
|
835
|
+
}
|
|
836
|
+
/** Render the exact-key version history, including S3 delete markers. */
|
|
837
|
+
export function formatFilesVersionsTable(rows) {
|
|
838
|
+
if (rows.length === 0) {
|
|
839
|
+
return "No version history exists for that key.";
|
|
840
|
+
}
|
|
841
|
+
const cols = ["VERSION", "TYPE", "LATEST", "MODIFIED", "SIZE"];
|
|
842
|
+
const data = rows.map((row) => [
|
|
843
|
+
row.versionId,
|
|
844
|
+
row.isDeleteMarker ? "delete marker" : "content",
|
|
845
|
+
row.isLatest ? "yes" : "",
|
|
846
|
+
row.lastModified,
|
|
847
|
+
row.isDeleteMarker ? "—" : formatBytes(row.size),
|
|
848
|
+
]);
|
|
849
|
+
const widths = cols.map((col, index) => Math.max(col.length, ...data.map((row) => row[index].length)));
|
|
850
|
+
const renderRow = (row) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ");
|
|
851
|
+
return [
|
|
852
|
+
chalk.bold(renderRow(cols)),
|
|
853
|
+
chalk.dim(renderRow(widths.map((width) => "─".repeat(width)))),
|
|
854
|
+
...data.map(renderRow),
|
|
855
|
+
].join("\n");
|
|
856
|
+
}
|
|
857
|
+
/** Render durable delete tombstones without implying the objects still exist. */
|
|
858
|
+
export function formatFilesTrashTable(rows) {
|
|
859
|
+
if (rows.length === 0) {
|
|
860
|
+
return "Trash is empty.";
|
|
861
|
+
}
|
|
862
|
+
const cols = ["KEY", "DELETED", "DELETED BY", "DELETE PREFIX"];
|
|
863
|
+
const data = rows.map((row) => [
|
|
864
|
+
row.key,
|
|
865
|
+
row.deletedAt,
|
|
866
|
+
row.deletedBy,
|
|
867
|
+
row.deletedPrefix,
|
|
868
|
+
]);
|
|
869
|
+
const widths = cols.map((col, index) => Math.max(col.length, ...data.map((row) => row[index].length)));
|
|
870
|
+
const renderRow = (row) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ");
|
|
871
|
+
return [
|
|
872
|
+
chalk.bold(renderRow(cols)),
|
|
873
|
+
chalk.dim(renderRow(widths.map((width) => "─".repeat(width)))),
|
|
874
|
+
...data.map(renderRow),
|
|
875
|
+
].join("\n");
|
|
876
|
+
}
|
|
877
|
+
function throwRecoveryHttpError(status, body, statusText) {
|
|
878
|
+
throw new FilesRecoveryHttpError(status, body.message ?? body.error ?? statusText, body.code);
|
|
879
|
+
}
|
|
880
|
+
function formatFilesRecoveryError(err, key) {
|
|
881
|
+
if (err.status === 401) {
|
|
882
|
+
return "Not authenticated — please run hq login";
|
|
883
|
+
}
|
|
884
|
+
if (err.status === 403) {
|
|
885
|
+
return key
|
|
886
|
+
? "Not authorized to modify '" + key + "' — you need write access on it"
|
|
887
|
+
: "Not authorized to view this vault path";
|
|
888
|
+
}
|
|
889
|
+
if (err.code === "FILES_RESTORE_VERSION_NOT_FOUND") {
|
|
890
|
+
return "That version is unavailable or is a delete marker.";
|
|
891
|
+
}
|
|
892
|
+
if (err.code === "FILES_RESTORE_NO_PRIOR_VERSION") {
|
|
893
|
+
return "There is no prior content version to restore.";
|
|
894
|
+
}
|
|
895
|
+
if (err.code === "FILES_RESTORE_NO_RECOVERABLE_VERSION") {
|
|
896
|
+
return "There is no recoverable content version for this key.";
|
|
897
|
+
}
|
|
898
|
+
if (err.status === 400) {
|
|
899
|
+
return "Invalid request: " + err.message;
|
|
900
|
+
}
|
|
901
|
+
if (err.status >= 500) {
|
|
902
|
+
return "Server error: " + err.message;
|
|
903
|
+
}
|
|
904
|
+
return err.message || "Request failed (" + String(err.status) + ")";
|
|
905
|
+
}
|
|
906
|
+
async function resolveRecoveryScope(params) {
|
|
907
|
+
assertRecoveryScope(params.personal, params.companySlug);
|
|
908
|
+
const token = await ensureCognitoToken();
|
|
909
|
+
if (params.personal) {
|
|
910
|
+
return { token, personal: true };
|
|
911
|
+
}
|
|
912
|
+
return {
|
|
913
|
+
token,
|
|
914
|
+
personal: false,
|
|
915
|
+
companyUid: await getCompanyUid(token, params.companySlug),
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
export async function runFilesVersions(params) {
|
|
919
|
+
assertExactRecoveryKey(params.key);
|
|
920
|
+
const scope = await resolveRecoveryScope(params);
|
|
921
|
+
const res = await vaultApiFetch({
|
|
922
|
+
token: scope.token,
|
|
923
|
+
path: "/v1/files/versions",
|
|
924
|
+
query: scope.personal
|
|
925
|
+
? { personal: "1", key: params.key }
|
|
926
|
+
: { company: scope.companyUid, key: params.key },
|
|
927
|
+
});
|
|
928
|
+
if (!res.ok) {
|
|
929
|
+
const body = (await res.json().catch(() => ({})));
|
|
930
|
+
throwRecoveryHttpError(res.status, body, res.statusText);
|
|
931
|
+
}
|
|
932
|
+
const data = (await res.json());
|
|
933
|
+
console.log(chalk.green("Versions for '" + data.key + "':"));
|
|
934
|
+
console.log(formatFilesVersionsTable(data.versions));
|
|
935
|
+
return data;
|
|
936
|
+
}
|
|
937
|
+
export async function runFilesRestore(params, deps = {}) {
|
|
938
|
+
assertExactRecoveryKey(params.key);
|
|
939
|
+
if (params.versionId !== undefined && params.versionId.trim() === "") {
|
|
940
|
+
throw new Error("versionId must not be empty.");
|
|
941
|
+
}
|
|
942
|
+
const confirm = deps.confirm ?? realConfirm;
|
|
943
|
+
if (!params.yes) {
|
|
944
|
+
const ok = await confirm(params.personal
|
|
945
|
+
? "Restore '" + params.key + "' in your personal vault? This overwrites current content."
|
|
946
|
+
: "Restore '" + params.key + "'? This overwrites current content in the shared vault.");
|
|
947
|
+
if (!ok) {
|
|
948
|
+
console.log(chalk.dim("Aborted — nothing was restored."));
|
|
949
|
+
return undefined;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
const scope = await resolveRecoveryScope(params);
|
|
953
|
+
const body = scope.personal
|
|
954
|
+
? { personal: true, key: params.key }
|
|
955
|
+
: { company: scope.companyUid, key: params.key };
|
|
956
|
+
if (params.versionId !== undefined)
|
|
957
|
+
body.versionId = params.versionId;
|
|
958
|
+
const res = await vaultApiFetch({
|
|
959
|
+
token: scope.token,
|
|
960
|
+
path: "/v1/files/restore",
|
|
961
|
+
method: "POST",
|
|
962
|
+
body,
|
|
963
|
+
});
|
|
964
|
+
if (!res.ok) {
|
|
965
|
+
const errBody = (await res.json().catch(() => ({})));
|
|
966
|
+
const err = new FilesRecoveryHttpError(res.status, errBody.message ?? errBody.error ?? res.statusText, errBody.code);
|
|
967
|
+
throw new Error(formatFilesRecoveryError(err, params.key));
|
|
968
|
+
}
|
|
969
|
+
const data = (await res.json());
|
|
970
|
+
const verb = data.wasDeleted ? "Undeleted" : "Restored";
|
|
971
|
+
console.log(chalk.green(verb + " '" + data.key + "' from version '" + data.restoredFromVersionId + "'."));
|
|
972
|
+
console.log(chalk.dim("New version: " + data.newVersionId));
|
|
973
|
+
return data;
|
|
974
|
+
}
|
|
975
|
+
export async function runFilesTrash(params) {
|
|
976
|
+
if (params.prefix.includes("*")) {
|
|
977
|
+
throw new Error("Trash prefix must be literal; wildcard expansion is not supported.");
|
|
978
|
+
}
|
|
979
|
+
const scope = await resolveRecoveryScope(params);
|
|
980
|
+
const query = scope.personal
|
|
981
|
+
? { personal: "1" }
|
|
982
|
+
: { company: scope.companyUid };
|
|
983
|
+
if (params.prefix)
|
|
984
|
+
query.prefix = params.prefix;
|
|
985
|
+
if (params.cursor)
|
|
986
|
+
query.cursor = params.cursor;
|
|
987
|
+
const res = await vaultApiFetch({
|
|
988
|
+
token: scope.token,
|
|
989
|
+
path: "/v1/files/tombstones",
|
|
990
|
+
query,
|
|
991
|
+
});
|
|
992
|
+
if (!res.ok) {
|
|
993
|
+
const body = (await res.json().catch(() => ({})));
|
|
994
|
+
throwRecoveryHttpError(res.status, body, res.statusText);
|
|
995
|
+
}
|
|
996
|
+
const data = (await res.json());
|
|
997
|
+
console.log(formatFilesTrashTable(data.tombstones));
|
|
998
|
+
if (data.truncated && data.cursor) {
|
|
999
|
+
let continuation = "hq files";
|
|
1000
|
+
if (params.companySlug) {
|
|
1001
|
+
continuation += " --company " + quoteRecoveryShellArg(params.companySlug);
|
|
1002
|
+
}
|
|
1003
|
+
continuation += " trash";
|
|
1004
|
+
if (params.personal)
|
|
1005
|
+
continuation += " --personal";
|
|
1006
|
+
if (params.prefix) {
|
|
1007
|
+
continuation += " --prefix " + quoteRecoveryShellArg(params.prefix);
|
|
1008
|
+
}
|
|
1009
|
+
continuation += " --cursor " + quoteRecoveryShellArg(data.cursor);
|
|
1010
|
+
console.log(chalk.dim("More results: " + continuation));
|
|
1011
|
+
}
|
|
1012
|
+
return data;
|
|
1013
|
+
}
|
|
740
1014
|
//# sourceMappingURL=files.js.map
|
|
@@ -31,7 +31,7 @@ import * as yaml from "js-yaml";
|
|
|
31
31
|
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
32
32
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
33
33
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
34
|
-
import { OUTPOST_PRICE_CENTS, confirmChargeOrExit, parseBillingPayload,
|
|
34
|
+
import { OUTPOST_PRICE_CENTS, confirmChargeOrExit, parseBillingPayload, surfaceBillingBlocked, } from "../utils/billing-gate.js";
|
|
35
35
|
/** Decode hq-pro's `{ capped, limit, outposts }` cap envelope, if present. */
|
|
36
36
|
export function parseCappedPayload(body) {
|
|
37
37
|
if (!body || typeof body !== "object")
|
|
@@ -825,7 +825,7 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
|
|
|
825
825
|
if (err instanceof OutpostHttpError &&
|
|
826
826
|
err.status === 402 &&
|
|
827
827
|
err.billing) {
|
|
828
|
-
await
|
|
828
|
+
await surfaceBillingBlocked(token, err.billing, err.message);
|
|
829
829
|
process.exit(1);
|
|
830
830
|
}
|
|
831
831
|
// Already at the per-person cap → say so and name the box they own,
|
package/dist/main.js
CHANGED
|
@@ -67,6 +67,9 @@ import { maybeWarnNewVersion, refreshVersionCache, } from "./utils/version-check
|
|
|
67
67
|
import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
|
|
68
68
|
import { CLI_VERSION } from "./cli-version.js";
|
|
69
69
|
import { emitCliSessionStarted } from "./utils/cli-telemetry.js";
|
|
70
|
+
import { settleWithin } from "./utils/settle-with-timeout.js";
|
|
71
|
+
/** Hard upper bound for non-user-visible release-health finalization. */
|
|
72
|
+
const RELEASE_HEALTH_SETTLE_TIMEOUT_MS = 3_000;
|
|
70
73
|
// Swallow EPIPE when a downstream reader (e.g. `source <(…)`, `| head`) closes
|
|
71
74
|
// the pipe early. This covers the ASYNC path — an 'error' event emitted on the
|
|
72
75
|
// stream. The SYNCHRONOUS path (a `write EPIPE` thrown straight out of
|
|
@@ -306,7 +309,11 @@ export async function runCli() {
|
|
|
306
309
|
finally {
|
|
307
310
|
// Release health: finalize the per-run session before the flush.
|
|
308
311
|
Sentry.endSession();
|
|
309
|
-
|
|
312
|
+
// Neither task may turn a successful command into Node's
|
|
313
|
+
// `unsettled top-level await` exit. They are observability-only after the
|
|
314
|
+
// command has completed, so a bounded best-effort wait is the terminal
|
|
315
|
+
// lifecycle boundary for this invocation.
|
|
316
|
+
await settleWithin([refreshVersionCache(), Sentry.flush(2000)], RELEASE_HEALTH_SETTLE_TIMEOUT_MS);
|
|
310
317
|
}
|
|
311
318
|
}
|
|
312
319
|
//# sourceMappingURL=main.js.map
|
|
@@ -74,4 +74,19 @@ export declare function mintPaymentLink(token: string, setup: BillingSetupAction
|
|
|
74
74
|
* to act on. Never prints tokens or secrets.
|
|
75
75
|
*/
|
|
76
76
|
export declare function surfaceBillingRequired(token: string, billing: BillingErrorPayload): Promise<string | null>;
|
|
77
|
+
/**
|
|
78
|
+
* Status-aware surface for a 402 billing block. hq-pro's envelope carries two
|
|
79
|
+
* distinct remediations that must never be conflated (mirroring the server's
|
|
80
|
+
* own P1-C classification):
|
|
81
|
+
* - `payment_failed` — a card EXISTS and the charge was DECLINED. The
|
|
82
|
+
* server's `message` already carries the friendly decline copy ("Your
|
|
83
|
+
* card was declined…", "insufficient funds", …). Telling this user
|
|
84
|
+
* "No card on file" sends them down the wrong remediation path entirely
|
|
85
|
+
* (observed live 2026-07-20: a declined $80 Outpost proration surfaced
|
|
86
|
+
* as "no card", triggering a hunt for a missing card that existed).
|
|
87
|
+
* The capture link still surfaces — as the way to UPDATE the card.
|
|
88
|
+
* - anything else (`billing_required`) — genuinely no usable card on
|
|
89
|
+
* file; the existing add-a-card copy is correct.
|
|
90
|
+
*/
|
|
91
|
+
export declare function surfaceBillingBlocked(token: string, billing: BillingErrorPayload, serverMessage?: string): Promise<string | null>;
|
|
77
92
|
//# sourceMappingURL=billing-gate.d.ts.map
|
|
@@ -121,4 +121,39 @@ export async function surfaceBillingRequired(token, billing) {
|
|
|
121
121
|
console.log(chalk.dim("Once a card is added, re-run the same command."));
|
|
122
122
|
return url;
|
|
123
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Status-aware surface for a 402 billing block. hq-pro's envelope carries two
|
|
126
|
+
* distinct remediations that must never be conflated (mirroring the server's
|
|
127
|
+
* own P1-C classification):
|
|
128
|
+
* - `payment_failed` — a card EXISTS and the charge was DECLINED. The
|
|
129
|
+
* server's `message` already carries the friendly decline copy ("Your
|
|
130
|
+
* card was declined…", "insufficient funds", …). Telling this user
|
|
131
|
+
* "No card on file" sends them down the wrong remediation path entirely
|
|
132
|
+
* (observed live 2026-07-20: a declined $80 Outpost proration surfaced
|
|
133
|
+
* as "no card", triggering a hunt for a missing card that existed).
|
|
134
|
+
* The capture link still surfaces — as the way to UPDATE the card.
|
|
135
|
+
* - anything else (`billing_required`) — genuinely no usable card on
|
|
136
|
+
* file; the existing add-a-card copy is correct.
|
|
137
|
+
*/
|
|
138
|
+
export async function surfaceBillingBlocked(token, billing, serverMessage) {
|
|
139
|
+
if (billing.status !== "payment_failed") {
|
|
140
|
+
return surfaceBillingRequired(token, billing);
|
|
141
|
+
}
|
|
142
|
+
console.error(chalk.yellow(serverMessage?.trim() ||
|
|
143
|
+
"Your payment failed. Try a different card or contact your bank."));
|
|
144
|
+
if (!billing.setup)
|
|
145
|
+
return null;
|
|
146
|
+
try {
|
|
147
|
+
const url = await mintPaymentLink(token, billing.setup);
|
|
148
|
+
console.log("Update or replace the card here (safe to share with whoever owns billing):\n " +
|
|
149
|
+
chalk.cyan(url));
|
|
150
|
+
console.log(chalk.dim("Once the payment method is sorted, re-run the same command."));
|
|
151
|
+
return url;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Link minting is best-effort on the decline path — the decline reason
|
|
155
|
+
// above is the essential part; the console billing page also works.
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
124
159
|
//# sourceMappingURL=billing-gate.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Await finalization work without allowing a best-effort promise to leave a
|
|
3
|
+
* CLI's top-level await pending forever. The timer intentionally stays
|
|
4
|
+
* referenced: Node must remain alive long enough to settle this race.
|
|
5
|
+
*/
|
|
6
|
+
export declare function settleWithin(promises: readonly Promise<unknown>[], timeoutMs: number): Promise<"settled" | "timed_out">;
|
|
7
|
+
//# sourceMappingURL=settle-with-timeout.d.ts.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Await finalization work without allowing a best-effort promise to leave a
|
|
3
|
+
* CLI's top-level await pending forever. The timer intentionally stays
|
|
4
|
+
* referenced: Node must remain alive long enough to settle this race.
|
|
5
|
+
*/
|
|
6
|
+
export async function settleWithin(promises, timeoutMs) {
|
|
7
|
+
let timer;
|
|
8
|
+
const timedOut = new Promise((resolve) => {
|
|
9
|
+
timer = setTimeout(() => resolve("timed_out"), timeoutMs);
|
|
10
|
+
});
|
|
11
|
+
try {
|
|
12
|
+
return await Promise.race([
|
|
13
|
+
Promise.allSettled(promises).then(() => "settled"),
|
|
14
|
+
timedOut,
|
|
15
|
+
]);
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
if (timer !== undefined)
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=settle-with-timeout.js.map
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -176,6 +176,9 @@ async function resolveCompanyUid(token, ref) {
|
|
|
176
176
|
`is in your namespace. Re-run with --company <uid> to pick one:\n` +
|
|
177
177
|
body.uids.map((u) => ` --company ${u}`).join('\n'));
|
|
178
178
|
}
|
|
179
|
+
if (res.status === 404 && body.error === "Entity not found") {
|
|
180
|
+
throw Object.assign(new Error(`Company slug '${ref}' was not found. Check the slug or run \`hq companies list\`.`), { expected: true });
|
|
181
|
+
}
|
|
179
182
|
throw new Error(`Failed to resolve company slug '${ref}': ${body.error ?? res.statusText}`);
|
|
180
183
|
}
|
|
181
184
|
const data = (await res.json());
|