@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/src/commands/files.ts
CHANGED
|
@@ -512,6 +512,100 @@ export function registerFilesCommand(program: Command): Command {
|
|
|
512
512
|
},
|
|
513
513
|
);
|
|
514
514
|
|
|
515
|
+
files
|
|
516
|
+
.command("versions <path>")
|
|
517
|
+
.description(
|
|
518
|
+
"List prior content versions and delete markers for one exact vault key. Use --personal for your personal vault.",
|
|
519
|
+
)
|
|
520
|
+
.option(
|
|
521
|
+
"--personal",
|
|
522
|
+
"Target your own personal vault instead of a company vault (mutually exclusive with --company)",
|
|
523
|
+
)
|
|
524
|
+
.action(async (path: string, opts: { personal?: boolean }) => {
|
|
525
|
+
try {
|
|
526
|
+
const companySlug = files.opts().company as string | undefined;
|
|
527
|
+
const personal = opts.personal === true;
|
|
528
|
+
assertRecoveryScope(personal, companySlug);
|
|
529
|
+
await runFilesVersions({ key: path, personal, companySlug });
|
|
530
|
+
} catch (err) {
|
|
531
|
+
console.error(
|
|
532
|
+
chalk.red("Error:"),
|
|
533
|
+
err instanceof Error ? err.message : String(err),
|
|
534
|
+
);
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
files
|
|
540
|
+
.command("restore <path>")
|
|
541
|
+
.description(
|
|
542
|
+
"Restore a prior version or undelete an exact vault key. Prompts before overwriting unless --yes. Use --personal for your personal vault.",
|
|
543
|
+
)
|
|
544
|
+
.option(
|
|
545
|
+
"--personal",
|
|
546
|
+
"Target your own personal vault instead of a company vault (mutually exclusive with --company)",
|
|
547
|
+
)
|
|
548
|
+
.option("--version <id>", "Content version ID to restore")
|
|
549
|
+
.option("-y, --yes", "Skip the overwrite confirmation prompt (for scripts)")
|
|
550
|
+
.action(
|
|
551
|
+
async (
|
|
552
|
+
path: string,
|
|
553
|
+
opts: { personal?: boolean; yes?: boolean; version?: string },
|
|
554
|
+
) => {
|
|
555
|
+
try {
|
|
556
|
+
const companySlug = files.opts().company as string | undefined;
|
|
557
|
+
const personal = opts.personal === true;
|
|
558
|
+
assertRecoveryScope(personal, companySlug);
|
|
559
|
+
await runFilesRestore({
|
|
560
|
+
key: path,
|
|
561
|
+
versionId: opts.version,
|
|
562
|
+
yes: opts.yes === true,
|
|
563
|
+
personal,
|
|
564
|
+
companySlug,
|
|
565
|
+
});
|
|
566
|
+
} catch (err) {
|
|
567
|
+
console.error(
|
|
568
|
+
chalk.red("Error:"),
|
|
569
|
+
err instanceof Error ? err.message : String(err),
|
|
570
|
+
);
|
|
571
|
+
process.exit(1);
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
files
|
|
577
|
+
.command("trash")
|
|
578
|
+
.description(
|
|
579
|
+
"List deleted vault keys retained as tombstones. Use --personal for your personal vault.",
|
|
580
|
+
)
|
|
581
|
+
.option(
|
|
582
|
+
"--personal",
|
|
583
|
+
"Target your own personal vault instead of a company vault (mutually exclusive with --company)",
|
|
584
|
+
)
|
|
585
|
+
.option("--prefix <prefix>", "Literal key prefix to filter tombstones")
|
|
586
|
+
.option("--cursor <cursor>", "Continue from an opaque tombstone cursor")
|
|
587
|
+
.action(
|
|
588
|
+
async (opts: { personal?: boolean; prefix?: string; cursor?: string }) => {
|
|
589
|
+
try {
|
|
590
|
+
const companySlug = files.opts().company as string | undefined;
|
|
591
|
+
const personal = opts.personal === true;
|
|
592
|
+
assertRecoveryScope(personal, companySlug);
|
|
593
|
+
await runFilesTrash({
|
|
594
|
+
prefix: opts.prefix ?? "",
|
|
595
|
+
cursor: opts.cursor,
|
|
596
|
+
personal,
|
|
597
|
+
companySlug,
|
|
598
|
+
});
|
|
599
|
+
} catch (err) {
|
|
600
|
+
console.error(
|
|
601
|
+
chalk.red("Error:"),
|
|
602
|
+
err instanceof Error ? err.message : String(err),
|
|
603
|
+
);
|
|
604
|
+
process.exit(1);
|
|
605
|
+
}
|
|
606
|
+
},
|
|
607
|
+
);
|
|
608
|
+
|
|
515
609
|
// Return the `files` Commander group so callers (src/index.ts) can attach
|
|
516
610
|
// additional subcommands (e.g. `hq files browse`/`hq files cat` from
|
|
517
611
|
// files-browse.ts) onto the same group without re-creating it.
|
|
@@ -1034,3 +1128,319 @@ export async function runFilesDelete(
|
|
|
1034
1128
|
);
|
|
1035
1129
|
}
|
|
1036
1130
|
}
|
|
1131
|
+
|
|
1132
|
+
// ---------------------------------------------------------------------------
|
|
1133
|
+
// Version history + recovery commands
|
|
1134
|
+
// ---------------------------------------------------------------------------
|
|
1135
|
+
|
|
1136
|
+
export interface FileVersionRow {
|
|
1137
|
+
versionId: string;
|
|
1138
|
+
isLatest: boolean;
|
|
1139
|
+
lastModified: string;
|
|
1140
|
+
size: number;
|
|
1141
|
+
isDeleteMarker?: boolean;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
export interface FilesVersionsResponse {
|
|
1145
|
+
key: string;
|
|
1146
|
+
versions: FileVersionRow[];
|
|
1147
|
+
computedAt: string;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
export interface FileTombstoneRow {
|
|
1151
|
+
key: string;
|
|
1152
|
+
deletedAt: string;
|
|
1153
|
+
deletedBy: string;
|
|
1154
|
+
deletedPrefix: string;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
export interface FilesTrashResponse {
|
|
1158
|
+
companyUid?: string;
|
|
1159
|
+
personal?: true;
|
|
1160
|
+
tombstones: FileTombstoneRow[];
|
|
1161
|
+
cursor?: string | null;
|
|
1162
|
+
truncated: boolean;
|
|
1163
|
+
computedAt: string;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
export interface FilesRestoreResponse {
|
|
1167
|
+
key: string;
|
|
1168
|
+
wasDeleted: boolean;
|
|
1169
|
+
restoredFromVersionId: string;
|
|
1170
|
+
newVersionId: string;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
export class FilesRecoveryHttpError extends Error {
|
|
1174
|
+
constructor(
|
|
1175
|
+
public readonly status: number,
|
|
1176
|
+
message: string,
|
|
1177
|
+
public readonly code?: string,
|
|
1178
|
+
) {
|
|
1179
|
+
super(message);
|
|
1180
|
+
this.name = "FilesRecoveryHttpError";
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function assertRecoveryScope(personal: boolean, companySlug: string | undefined): void {
|
|
1185
|
+
if (personal && companySlug) {
|
|
1186
|
+
throw new Error("Pass either --personal or --company, not both.");
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function assertExactRecoveryKey(key: string): void {
|
|
1191
|
+
if (!key || key.includes("*")) {
|
|
1192
|
+
throw new Error(
|
|
1193
|
+
"Restore and version history require one exact, wildcard-free vault key.",
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function quoteRecoveryShellArg(value: string): string {
|
|
1199
|
+
return "'" + value.replace(/'/g, "'\\''") + "'";
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function formatBytes(bytes: number): string {
|
|
1203
|
+
if (bytes < 1024) return String(bytes) + " B";
|
|
1204
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KiB";
|
|
1205
|
+
if (bytes < 1024 * 1024 * 1024) {
|
|
1206
|
+
return (bytes / (1024 * 1024)).toFixed(1) + " MiB";
|
|
1207
|
+
}
|
|
1208
|
+
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + " GiB";
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/** Render the exact-key version history, including S3 delete markers. */
|
|
1212
|
+
export function formatFilesVersionsTable(rows: FileVersionRow[]): string {
|
|
1213
|
+
if (rows.length === 0) {
|
|
1214
|
+
return "No version history exists for that key.";
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const cols = ["VERSION", "TYPE", "LATEST", "MODIFIED", "SIZE"];
|
|
1218
|
+
const data = rows.map((row) => [
|
|
1219
|
+
row.versionId,
|
|
1220
|
+
row.isDeleteMarker ? "delete marker" : "content",
|
|
1221
|
+
row.isLatest ? "yes" : "",
|
|
1222
|
+
row.lastModified,
|
|
1223
|
+
row.isDeleteMarker ? "—" : formatBytes(row.size),
|
|
1224
|
+
]);
|
|
1225
|
+
const widths = cols.map((col, index) =>
|
|
1226
|
+
Math.max(col.length, ...data.map((row) => row[index].length)),
|
|
1227
|
+
);
|
|
1228
|
+
const renderRow = (row: string[]): string =>
|
|
1229
|
+
row.map((cell, index) => cell.padEnd(widths[index])).join(" ");
|
|
1230
|
+
return [
|
|
1231
|
+
chalk.bold(renderRow(cols)),
|
|
1232
|
+
chalk.dim(renderRow(widths.map((width) => "─".repeat(width)))),
|
|
1233
|
+
...data.map(renderRow),
|
|
1234
|
+
].join("\n");
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/** Render durable delete tombstones without implying the objects still exist. */
|
|
1238
|
+
export function formatFilesTrashTable(rows: FileTombstoneRow[]): string {
|
|
1239
|
+
if (rows.length === 0) {
|
|
1240
|
+
return "Trash is empty.";
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const cols = ["KEY", "DELETED", "DELETED BY", "DELETE PREFIX"];
|
|
1244
|
+
const data = rows.map((row) => [
|
|
1245
|
+
row.key,
|
|
1246
|
+
row.deletedAt,
|
|
1247
|
+
row.deletedBy,
|
|
1248
|
+
row.deletedPrefix,
|
|
1249
|
+
]);
|
|
1250
|
+
const widths = cols.map((col, index) =>
|
|
1251
|
+
Math.max(col.length, ...data.map((row) => row[index].length)),
|
|
1252
|
+
);
|
|
1253
|
+
const renderRow = (row: string[]): string =>
|
|
1254
|
+
row.map((cell, index) => cell.padEnd(widths[index])).join(" ");
|
|
1255
|
+
return [
|
|
1256
|
+
chalk.bold(renderRow(cols)),
|
|
1257
|
+
chalk.dim(renderRow(widths.map((width) => "─".repeat(width)))),
|
|
1258
|
+
...data.map(renderRow),
|
|
1259
|
+
].join("\n");
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function throwRecoveryHttpError(
|
|
1263
|
+
status: number,
|
|
1264
|
+
body: Record<string, string>,
|
|
1265
|
+
statusText: string,
|
|
1266
|
+
): never {
|
|
1267
|
+
throw new FilesRecoveryHttpError(
|
|
1268
|
+
status,
|
|
1269
|
+
body.message ?? body.error ?? statusText,
|
|
1270
|
+
body.code,
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function formatFilesRecoveryError(err: FilesRecoveryHttpError, key?: string): string {
|
|
1275
|
+
if (err.status === 401) {
|
|
1276
|
+
return "Not authenticated — please run hq login";
|
|
1277
|
+
}
|
|
1278
|
+
if (err.status === 403) {
|
|
1279
|
+
return key
|
|
1280
|
+
? "Not authorized to modify '" + key + "' — you need write access on it"
|
|
1281
|
+
: "Not authorized to view this vault path";
|
|
1282
|
+
}
|
|
1283
|
+
if (err.code === "FILES_RESTORE_VERSION_NOT_FOUND") {
|
|
1284
|
+
return "That version is unavailable or is a delete marker.";
|
|
1285
|
+
}
|
|
1286
|
+
if (err.code === "FILES_RESTORE_NO_PRIOR_VERSION") {
|
|
1287
|
+
return "There is no prior content version to restore.";
|
|
1288
|
+
}
|
|
1289
|
+
if (err.code === "FILES_RESTORE_NO_RECOVERABLE_VERSION") {
|
|
1290
|
+
return "There is no recoverable content version for this key.";
|
|
1291
|
+
}
|
|
1292
|
+
if (err.status === 400) {
|
|
1293
|
+
return "Invalid request: " + err.message;
|
|
1294
|
+
}
|
|
1295
|
+
if (err.status >= 500) {
|
|
1296
|
+
return "Server error: " + err.message;
|
|
1297
|
+
}
|
|
1298
|
+
return err.message || "Request failed (" + String(err.status) + ")";
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
interface RecoveryScope {
|
|
1302
|
+
token: string;
|
|
1303
|
+
companyUid?: string;
|
|
1304
|
+
personal: boolean;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
async function resolveRecoveryScope(params: {
|
|
1308
|
+
personal: boolean;
|
|
1309
|
+
companySlug: string | undefined;
|
|
1310
|
+
}): Promise<RecoveryScope> {
|
|
1311
|
+
assertRecoveryScope(params.personal, params.companySlug);
|
|
1312
|
+
const token = await ensureCognitoToken();
|
|
1313
|
+
if (params.personal) {
|
|
1314
|
+
return { token, personal: true };
|
|
1315
|
+
}
|
|
1316
|
+
return {
|
|
1317
|
+
token,
|
|
1318
|
+
personal: false,
|
|
1319
|
+
companyUid: await getCompanyUid(token, params.companySlug),
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
export async function runFilesVersions(params: {
|
|
1324
|
+
key: string;
|
|
1325
|
+
personal: boolean;
|
|
1326
|
+
companySlug: string | undefined;
|
|
1327
|
+
}): Promise<FilesVersionsResponse> {
|
|
1328
|
+
assertExactRecoveryKey(params.key);
|
|
1329
|
+
const scope = await resolveRecoveryScope(params);
|
|
1330
|
+
const res = await vaultApiFetch({
|
|
1331
|
+
token: scope.token,
|
|
1332
|
+
path: "/v1/files/versions",
|
|
1333
|
+
query: scope.personal
|
|
1334
|
+
? { personal: "1", key: params.key }
|
|
1335
|
+
: { company: scope.companyUid!, key: params.key },
|
|
1336
|
+
});
|
|
1337
|
+
if (!res.ok) {
|
|
1338
|
+
const body = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
1339
|
+
throwRecoveryHttpError(res.status, body, res.statusText);
|
|
1340
|
+
}
|
|
1341
|
+
const data = (await res.json()) as FilesVersionsResponse;
|
|
1342
|
+
console.log(chalk.green("Versions for '" + data.key + "':"));
|
|
1343
|
+
console.log(formatFilesVersionsTable(data.versions));
|
|
1344
|
+
return data;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
export async function runFilesRestore(
|
|
1348
|
+
params: {
|
|
1349
|
+
key: string;
|
|
1350
|
+
versionId?: string;
|
|
1351
|
+
yes: boolean;
|
|
1352
|
+
personal: boolean;
|
|
1353
|
+
companySlug: string | undefined;
|
|
1354
|
+
},
|
|
1355
|
+
deps: { confirm?: ConfirmFn } = {},
|
|
1356
|
+
): Promise<FilesRestoreResponse | undefined> {
|
|
1357
|
+
assertExactRecoveryKey(params.key);
|
|
1358
|
+
if (params.versionId !== undefined && params.versionId.trim() === "") {
|
|
1359
|
+
throw new Error("versionId must not be empty.");
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
const confirm = deps.confirm ?? realConfirm;
|
|
1363
|
+
if (!params.yes) {
|
|
1364
|
+
const ok = await confirm(
|
|
1365
|
+
params.personal
|
|
1366
|
+
? "Restore '" + params.key + "' in your personal vault? This overwrites current content."
|
|
1367
|
+
: "Restore '" + params.key + "'? This overwrites current content in the shared vault.",
|
|
1368
|
+
);
|
|
1369
|
+
if (!ok) {
|
|
1370
|
+
console.log(chalk.dim("Aborted — nothing was restored."));
|
|
1371
|
+
return undefined;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
const scope = await resolveRecoveryScope(params);
|
|
1376
|
+
const body: Record<string, unknown> = scope.personal
|
|
1377
|
+
? { personal: true, key: params.key }
|
|
1378
|
+
: { company: scope.companyUid, key: params.key };
|
|
1379
|
+
if (params.versionId !== undefined) body.versionId = params.versionId;
|
|
1380
|
+
const res = await vaultApiFetch({
|
|
1381
|
+
token: scope.token,
|
|
1382
|
+
path: "/v1/files/restore",
|
|
1383
|
+
method: "POST",
|
|
1384
|
+
body,
|
|
1385
|
+
});
|
|
1386
|
+
if (!res.ok) {
|
|
1387
|
+
const errBody = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
1388
|
+
const err = new FilesRecoveryHttpError(
|
|
1389
|
+
res.status,
|
|
1390
|
+
errBody.message ?? errBody.error ?? res.statusText,
|
|
1391
|
+
errBody.code,
|
|
1392
|
+
);
|
|
1393
|
+
throw new Error(formatFilesRecoveryError(err, params.key));
|
|
1394
|
+
}
|
|
1395
|
+
const data = (await res.json()) as FilesRestoreResponse;
|
|
1396
|
+
const verb = data.wasDeleted ? "Undeleted" : "Restored";
|
|
1397
|
+
console.log(
|
|
1398
|
+
chalk.green(
|
|
1399
|
+
verb + " '" + data.key + "' from version '" + data.restoredFromVersionId + "'.",
|
|
1400
|
+
),
|
|
1401
|
+
);
|
|
1402
|
+
console.log(chalk.dim("New version: " + data.newVersionId));
|
|
1403
|
+
return data;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
export async function runFilesTrash(params: {
|
|
1407
|
+
prefix: string;
|
|
1408
|
+
cursor?: string;
|
|
1409
|
+
personal: boolean;
|
|
1410
|
+
companySlug: string | undefined;
|
|
1411
|
+
}): Promise<FilesTrashResponse> {
|
|
1412
|
+
if (params.prefix.includes("*")) {
|
|
1413
|
+
throw new Error("Trash prefix must be literal; wildcard expansion is not supported.");
|
|
1414
|
+
}
|
|
1415
|
+
const scope = await resolveRecoveryScope(params);
|
|
1416
|
+
const query: Record<string, string> = scope.personal
|
|
1417
|
+
? { personal: "1" }
|
|
1418
|
+
: { company: scope.companyUid! };
|
|
1419
|
+
if (params.prefix) query.prefix = params.prefix;
|
|
1420
|
+
if (params.cursor) query.cursor = params.cursor;
|
|
1421
|
+
const res = await vaultApiFetch({
|
|
1422
|
+
token: scope.token,
|
|
1423
|
+
path: "/v1/files/tombstones",
|
|
1424
|
+
query,
|
|
1425
|
+
});
|
|
1426
|
+
if (!res.ok) {
|
|
1427
|
+
const body = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
1428
|
+
throwRecoveryHttpError(res.status, body, res.statusText);
|
|
1429
|
+
}
|
|
1430
|
+
const data = (await res.json()) as FilesTrashResponse;
|
|
1431
|
+
console.log(formatFilesTrashTable(data.tombstones));
|
|
1432
|
+
if (data.truncated && data.cursor) {
|
|
1433
|
+
let continuation = "hq files";
|
|
1434
|
+
if (params.companySlug) {
|
|
1435
|
+
continuation += " --company " + quoteRecoveryShellArg(params.companySlug);
|
|
1436
|
+
}
|
|
1437
|
+
continuation += " trash";
|
|
1438
|
+
if (params.personal) continuation += " --personal";
|
|
1439
|
+
if (params.prefix) {
|
|
1440
|
+
continuation += " --prefix " + quoteRecoveryShellArg(params.prefix);
|
|
1441
|
+
}
|
|
1442
|
+
continuation += " --cursor " + quoteRecoveryShellArg(data.cursor);
|
|
1443
|
+
console.log(chalk.dim("More results: " + continuation));
|
|
1444
|
+
}
|
|
1445
|
+
return data;
|
|
1446
|
+
}
|
|
@@ -365,6 +365,43 @@ describe("hq outposts provision (billing gate)", () => {
|
|
|
365
365
|
const printed = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
366
366
|
expect(printed).toContain("https://checkout.stripe.com/outpost");
|
|
367
367
|
});
|
|
368
|
+
|
|
369
|
+
it("surfaces the DECLINE reason on 402 payment_failed — never the 'No card on file' copy", async () => {
|
|
370
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
371
|
+
// hq-pro's payment_failed envelope: a card EXISTS and was declined. The
|
|
372
|
+
// server's `message` carries the friendly decline copy; telling this user
|
|
373
|
+
// "No card on file" sends them down the wrong remediation path entirely.
|
|
374
|
+
fetchSpy
|
|
375
|
+
.mockResolvedValueOnce(
|
|
376
|
+
jsonResponse(402, {
|
|
377
|
+
error: "payment required",
|
|
378
|
+
message: "Your card was declined. Try a different card or contact your bank.",
|
|
379
|
+
billing: {
|
|
380
|
+
status: "payment_failed",
|
|
381
|
+
setup: {
|
|
382
|
+
payerType: "person",
|
|
383
|
+
path: "/v1/billing/checkout/person",
|
|
384
|
+
method: "POST",
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
}),
|
|
388
|
+
)
|
|
389
|
+
.mockResolvedValueOnce(
|
|
390
|
+
jsonResponse(200, { url: "https://checkout.stripe.com/update-card" }),
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
await expect(run(["outposts", "provision", "--yes"])).rejects.toThrow(
|
|
394
|
+
"process.exit(1)",
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
const printed = [...errSpy.mock.calls, ...logSpy.mock.calls]
|
|
398
|
+
.map((c) => c.map(String).join(" "))
|
|
399
|
+
.join("\n");
|
|
400
|
+
expect(printed).toContain("declined");
|
|
401
|
+
expect(printed).not.toContain("No card on file");
|
|
402
|
+
// The card-capture link still surfaces — as the way to UPDATE the card.
|
|
403
|
+
expect(printed).toContain("https://checkout.stripe.com/update-card");
|
|
404
|
+
});
|
|
368
405
|
});
|
|
369
406
|
|
|
370
407
|
describe("hq outposts exec", () => {
|
package/src/commands/outposts.ts
CHANGED
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
OUTPOST_PRICE_CENTS,
|
|
38
38
|
confirmChargeOrExit,
|
|
39
39
|
parseBillingPayload,
|
|
40
|
-
|
|
40
|
+
surfaceBillingBlocked,
|
|
41
41
|
type BillingErrorPayload,
|
|
42
42
|
} from "../utils/billing-gate.js";
|
|
43
43
|
|
|
@@ -1288,7 +1288,7 @@ export function registerOutpostsCommand(
|
|
|
1288
1288
|
err.status === 402 &&
|
|
1289
1289
|
err.billing
|
|
1290
1290
|
) {
|
|
1291
|
-
await
|
|
1291
|
+
await surfaceBillingBlocked(token, err.billing, err.message);
|
|
1292
1292
|
process.exit(1);
|
|
1293
1293
|
}
|
|
1294
1294
|
// Already at the per-person cap → say so and name the box they own,
|
package/src/main.ts
CHANGED
|
@@ -75,6 +75,10 @@ import {
|
|
|
75
75
|
} from "./utils/version-gate.js";
|
|
76
76
|
import { CLI_VERSION } from "./cli-version.js";
|
|
77
77
|
import { emitCliSessionStarted } from "./utils/cli-telemetry.js";
|
|
78
|
+
import { settleWithin } from "./utils/settle-with-timeout.js";
|
|
79
|
+
|
|
80
|
+
/** Hard upper bound for non-user-visible release-health finalization. */
|
|
81
|
+
const RELEASE_HEALTH_SETTLE_TIMEOUT_MS = 3_000;
|
|
78
82
|
|
|
79
83
|
// Swallow EPIPE when a downstream reader (e.g. `source <(…)`, `| head`) closes
|
|
80
84
|
// the pipe early. This covers the ASYNC path — an 'error' event emitted on the
|
|
@@ -351,6 +355,13 @@ export async function runCli(): Promise<void> {
|
|
|
351
355
|
} finally {
|
|
352
356
|
// Release health: finalize the per-run session before the flush.
|
|
353
357
|
Sentry.endSession();
|
|
354
|
-
|
|
358
|
+
// Neither task may turn a successful command into Node's
|
|
359
|
+
// `unsettled top-level await` exit. They are observability-only after the
|
|
360
|
+
// command has completed, so a bounded best-effort wait is the terminal
|
|
361
|
+
// lifecycle boundary for this invocation.
|
|
362
|
+
await settleWithin(
|
|
363
|
+
[refreshVersionCache(), Sentry.flush(2000)],
|
|
364
|
+
RELEASE_HEALTH_SETTLE_TIMEOUT_MS,
|
|
365
|
+
);
|
|
355
366
|
}
|
|
356
367
|
}
|
|
@@ -180,3 +180,49 @@ export async function surfaceBillingRequired(
|
|
|
180
180
|
console.log(chalk.dim("Once a card is added, re-run the same command."));
|
|
181
181
|
return url;
|
|
182
182
|
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Status-aware surface for a 402 billing block. hq-pro's envelope carries two
|
|
186
|
+
* distinct remediations that must never be conflated (mirroring the server's
|
|
187
|
+
* own P1-C classification):
|
|
188
|
+
* - `payment_failed` — a card EXISTS and the charge was DECLINED. The
|
|
189
|
+
* server's `message` already carries the friendly decline copy ("Your
|
|
190
|
+
* card was declined…", "insufficient funds", …). Telling this user
|
|
191
|
+
* "No card on file" sends them down the wrong remediation path entirely
|
|
192
|
+
* (observed live 2026-07-20: a declined $80 Outpost proration surfaced
|
|
193
|
+
* as "no card", triggering a hunt for a missing card that existed).
|
|
194
|
+
* The capture link still surfaces — as the way to UPDATE the card.
|
|
195
|
+
* - anything else (`billing_required`) — genuinely no usable card on
|
|
196
|
+
* file; the existing add-a-card copy is correct.
|
|
197
|
+
*/
|
|
198
|
+
export async function surfaceBillingBlocked(
|
|
199
|
+
token: string,
|
|
200
|
+
billing: BillingErrorPayload,
|
|
201
|
+
serverMessage?: string,
|
|
202
|
+
): Promise<string | null> {
|
|
203
|
+
if (billing.status !== "payment_failed") {
|
|
204
|
+
return surfaceBillingRequired(token, billing);
|
|
205
|
+
}
|
|
206
|
+
console.error(
|
|
207
|
+
chalk.yellow(
|
|
208
|
+
serverMessage?.trim() ||
|
|
209
|
+
"Your payment failed. Try a different card or contact your bank.",
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
if (!billing.setup) return null;
|
|
213
|
+
try {
|
|
214
|
+
const url = await mintPaymentLink(token, billing.setup);
|
|
215
|
+
console.log(
|
|
216
|
+
"Update or replace the card here (safe to share with whoever owns billing):\n " +
|
|
217
|
+
chalk.cyan(url),
|
|
218
|
+
);
|
|
219
|
+
console.log(
|
|
220
|
+
chalk.dim("Once the payment method is sorted, re-run the same command."),
|
|
221
|
+
);
|
|
222
|
+
return url;
|
|
223
|
+
} catch {
|
|
224
|
+
// Link minting is best-effort on the decline path — the decline reason
|
|
225
|
+
// above is the essential part; the console billing page also works.
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { settleWithin } from "./settle-with-timeout.js";
|
|
4
|
+
|
|
5
|
+
describe("settleWithin", () => {
|
|
6
|
+
it("settles normally when all finalizers finish", async () => {
|
|
7
|
+
await expect(settleWithin([Promise.resolve(), Promise.reject(new Error("ignored"))], 100))
|
|
8
|
+
.resolves.toBe("settled");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("settles the caller when a best-effort finalizer never resolves", async () => {
|
|
12
|
+
vi.useFakeTimers();
|
|
13
|
+
try {
|
|
14
|
+
const result = settleWithin([new Promise<void>(() => {})], 1_000);
|
|
15
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
16
|
+
await expect(result).resolves.toBe("timed_out");
|
|
17
|
+
} finally {
|
|
18
|
+
vi.useRealTimers();
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -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(
|
|
7
|
+
promises: readonly Promise<unknown>[],
|
|
8
|
+
timeoutMs: number,
|
|
9
|
+
): Promise<"settled" | "timed_out"> {
|
|
10
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
11
|
+
const timedOut = new Promise<"timed_out">((resolve) => {
|
|
12
|
+
timer = setTimeout(() => resolve("timed_out"), timeoutMs);
|
|
13
|
+
});
|
|
14
|
+
try {
|
|
15
|
+
return await Promise.race([
|
|
16
|
+
Promise.allSettled(promises).then(() => "settled" as const),
|
|
17
|
+
timedOut,
|
|
18
|
+
]);
|
|
19
|
+
} finally {
|
|
20
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -8,6 +8,7 @@ import { Sentry } from '../sentry.js';
|
|
|
8
8
|
import { getCompanyUid, getEntityUid, resolveCallerPersonUid, vaultApiFetch } from './vault-api.js';
|
|
9
9
|
import { isAuthError } from './auth-error.js';
|
|
10
10
|
import { isCompanySelectionError } from './company-selection-error.js';
|
|
11
|
+
import { isExpectedUserError } from './expected-cli-error.js';
|
|
11
12
|
|
|
12
13
|
const fetchMock = vi.fn();
|
|
13
14
|
const originalFetch = globalThis.fetch;
|
|
@@ -142,6 +143,19 @@ describe('getEntityUid', () => {
|
|
|
142
143
|
expect(fetchMock.mock.calls[1][0]).toMatch(/\/entity\/by-slug\/company\/acme/);
|
|
143
144
|
});
|
|
144
145
|
|
|
146
|
+
it('classifies a global by-slug entity miss as an expected user error (HQ-CLI-8)', async () => {
|
|
147
|
+
fetchMock
|
|
148
|
+
.mockResolvedValueOnce(mockResponse(200, { available: true }))
|
|
149
|
+
.mockResolvedValueOnce(mockResponse(404, { error: 'Entity not found' }));
|
|
150
|
+
|
|
151
|
+
const err = await getEntityUid('tok', { companySlug: 'missing-co' }).catch(
|
|
152
|
+
(e: unknown) => e,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(isExpectedUserError(err)).toBe(true);
|
|
156
|
+
expect((err as Error).message).toMatch(/Company slug 'missing-co' was not found/);
|
|
157
|
+
});
|
|
158
|
+
|
|
145
159
|
it('on residual global ambiguity (not in namespace) gives actionable --company <uid> guidance', async () => {
|
|
146
160
|
// Caller belongs to no "acme"; the slug matches multiple strangers'
|
|
147
161
|
// companies. The server 409s with the colliding uids and the CLI must tell
|
package/src/utils/vault-api.ts
CHANGED
|
@@ -217,6 +217,14 @@ async function resolveCompanyUid(token: string, ref: string): Promise<string> {
|
|
|
217
217
|
body.uids.map((u) => ` --company ${u}`).join('\n'),
|
|
218
218
|
);
|
|
219
219
|
}
|
|
220
|
+
if (res.status === 404 && body.error === "Entity not found") {
|
|
221
|
+
throw Object.assign(
|
|
222
|
+
new Error(
|
|
223
|
+
`Company slug '${ref}' was not found. Check the slug or run \`hq companies list\`.`,
|
|
224
|
+
),
|
|
225
|
+
{ expected: true as const },
|
|
226
|
+
);
|
|
227
|
+
}
|
|
220
228
|
throw new Error(
|
|
221
229
|
`Failed to resolve company slug '${ref}': ${body.error ?? res.statusText}`,
|
|
222
230
|
);
|