@vincentt-xr/cli 0.15.2 → 0.17.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.
Files changed (83) hide show
  1. package/dist/cli/args.js +1 -0
  2. package/dist/cli/index.js +198 -3
  3. package/dist/cli/usage-text.js +27 -0
  4. package/dist/cli/verb-surface.d.ts +3 -1
  5. package/dist/cli/verb-surface.js +12 -0
  6. package/dist/domain/api.d.ts +51 -0
  7. package/dist/domain/api.js +145 -0
  8. package/dist/domain/command.d.ts +75 -0
  9. package/dist/domain/command.js +467 -0
  10. package/dist/domain/copy.d.ts +334 -0
  11. package/dist/domain/copy.js +952 -0
  12. package/dist/domain/hostname.d.ts +57 -0
  13. package/dist/domain/hostname.js +116 -0
  14. package/dist/domain/names.d.ts +78 -0
  15. package/dist/domain/names.js +113 -0
  16. package/dist/domain/routedVerbs.d.ts +10 -0
  17. package/dist/domain/routedVerbs.js +30 -0
  18. package/dist/domain/wire.d.ts +140 -0
  19. package/dist/domain/wire.js +174 -0
  20. package/dist/dx/command.d.ts +13 -4
  21. package/dist/dx/command.js +26 -5
  22. package/dist/dx/report.d.ts +39 -7
  23. package/dist/dx/report.js +71 -20
  24. package/dist/dx/send.d.ts +22 -4
  25. package/dist/dx/send.js +29 -8
  26. package/dist/dx/skeleton.js +19 -0
  27. package/dist/dx/unbiddenVerbs.d.ts +25 -0
  28. package/dist/dx/unbiddenVerbs.js +39 -0
  29. package/dist/lib/initCommand.d.ts +3 -1
  30. package/dist/lib/initCommand.js +10 -0
  31. package/dist/lib/projects.d.ts +8 -1
  32. package/dist/lib/projects.js +12 -2
  33. package/dist/platform/allowlist.d.ts +36 -0
  34. package/dist/platform/allowlist.js +34 -0
  35. package/dist/platform/behind.d.ts +41 -0
  36. package/dist/platform/behind.js +82 -0
  37. package/dist/platform/command.d.ts +16 -0
  38. package/dist/platform/command.js +90 -0
  39. package/dist/platform/footer.d.ts +60 -0
  40. package/dist/platform/footer.js +101 -0
  41. package/dist/platform/notice.d.ts +29 -0
  42. package/dist/platform/notice.js +66 -0
  43. package/dist/platform/offSwitch.d.ts +19 -0
  44. package/dist/platform/offSwitch.js +21 -0
  45. package/dist/platform/outdated.d.ts +25 -0
  46. package/dist/platform/outdated.js +246 -0
  47. package/dist/platform/releaseCache.d.ts +69 -0
  48. package/dist/platform/releaseCache.js +140 -0
  49. package/dist/platform/releaseFetch.d.ts +51 -0
  50. package/dist/platform/releaseFetch.js +150 -0
  51. package/dist/platform/resolve.d.ts +30 -0
  52. package/dist/platform/resolve.js +128 -0
  53. package/dist/platform/templateUpgrade.d.ts +56 -0
  54. package/dist/platform/templateUpgrade.js +476 -0
  55. package/dist/platform/upgradeReport.d.ts +132 -0
  56. package/dist/platform/upgradeReport.js +258 -0
  57. package/dist/platform/versionSummary.d.ts +82 -0
  58. package/dist/platform/versionSummary.js +181 -0
  59. package/dist/platform/zonePath.d.ts +154 -0
  60. package/dist/platform/zonePath.js +286 -0
  61. package/dist/platform/zones.d.ts +64 -0
  62. package/dist/platform/zones.js +79 -0
  63. package/dist/preview/api.d.ts +19 -0
  64. package/dist/preview/api.js +29 -0
  65. package/dist/publish/api.d.ts +32 -5
  66. package/dist/publish/api.js +19 -5
  67. package/dist/publish/command.d.ts +10 -0
  68. package/dist/publish/command.js +30 -2
  69. package/dist/publish/copy.d.ts +14 -1
  70. package/dist/publish/copy.js +32 -3
  71. package/dist/scaffold/index.d.ts +50 -3
  72. package/dist/scaffold/index.js +61 -1
  73. package/dist/shared/config.d.ts +16 -0
  74. package/dist/shared/config.js +9 -1
  75. package/dist/shared/countLimit.d.ts +109 -0
  76. package/dist/shared/countLimit.js +169 -0
  77. package/package.json +1 -1
  78. package/dist/dx/qa.f7.fixtures.d.ts +0 -73
  79. package/dist/dx/qa.f7.fixtures.js +0 -58
  80. package/dist/publish/qa.f4.fixtures.d.ts +0 -25
  81. package/dist/publish/qa.f4.fixtures.js +0 -140
  82. package/dist/relay/qa.f12.fixtures.d.ts +0 -154
  83. package/dist/relay/qa.f12.fixtures.js +0 -358
package/dist/cli/args.js CHANGED
@@ -41,6 +41,7 @@ export const BOOLEAN_FLAGS = new Set([
41
41
  "--allow-custom-api",
42
42
  "--yes",
43
43
  "--download",
44
+ "--no-context",
44
45
  "--help",
45
46
  "-h",
46
47
  ]);
package/dist/cli/index.js CHANGED
@@ -38,7 +38,8 @@ import { httpRelayClient } from "../lib/diagnostics.js";
38
38
  import { readLivePreview, relayInfoDir } from "../lib/relayInfo.js";
39
39
  import { attachedToRunning, mintRateLimited, notSignedIn, previewConflict, startBlock, } from "../preview/copy.js";
40
40
  import { renderTerminalQr, shouldRenderQr } from "../preview/qr.js";
41
- import { PreviewConflictError, PreviewRateLimitedError } from "../preview/api.js";
41
+ import { PreviewConflictError, PreviewLimitError, PreviewRateLimitedError, } from "../preview/api.js";
42
+ import { previewLimitRefused, resolveCountLimit, } from "../shared/countLimit.js";
42
43
  import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
43
44
  import * as feedbackCopy from "../lib/feedbackCopy.js";
44
45
  import { record as recordInvocation } from "../dx/journal.js";
@@ -51,6 +52,10 @@ import { nudgeFor } from "../dx/nudge.js";
51
52
  import { FETCH_TIMEOUT_MS, isFresh, updateNotice } from "../dx/updateCheck.js";
52
53
  import { fetchLatest, loadCache, saveCache } from "../dx/updateCheckIo.js";
53
54
  import { verbOf } from "../dx/skeleton.js";
55
+ import { outdatedCommand } from "../platform/command.js";
56
+ import { behindFooterLines, needsRefresh, refreshReleaseCache } from "../platform/footer.js";
57
+ import { loadReleaseCache } from "../platform/releaseCache.js";
58
+ import { resolveBuiltWith } from "../platform/resolve.js";
54
59
  import { createDirCollision, createSourceFlagRefusal, fromFlagConflict, isHelpInvocation, isVersionInvocation, positionalArgs, refusedFlag, } from "./args.js";
55
60
  import { parseSourceRef } from "../lib/sourceRef.js";
56
61
  import { printCreateSourceFlagRefusal, printFlagConflict, printSourceRefFailure, } from "../lib/sourceRefCopy.js";
@@ -401,6 +406,29 @@ async function projects() {
401
406
  store: fileLocationStore,
402
407
  }, { json: has("--json") });
403
408
  }
409
+ /**
410
+ * `vincentt outdated` — what this project is behind on.
411
+ *
412
+ * NEEDS NO ACCOUNT and never prompts for one: the endpoint it reads is public,
413
+ * and a creator with no login is exactly the population this verb exists to
414
+ * serve. It is a listing verb, so it reports and never asks, and its output is
415
+ * routinely piped.
416
+ */
417
+ async function outdated() {
418
+ const cwd = projectDir();
419
+ process.exitCode = await outdatedCommand({
420
+ print: (l) => console.log(l),
421
+ cwd,
422
+ now: () => Date.now(),
423
+ apiUrl: async () => process.env.VINCENTT_API_URL ?? (await loadMachineConfig()).apiUrl ?? DEFAULT_API_URL,
424
+ inProject: async () => (await locateProjectBinding(cwd)) !== undefined,
425
+ projectLine: async () => {
426
+ const name = await projectDisplayName(cwd);
427
+ const org = await activeOrgName();
428
+ return org ? `${name} · ${org}` : name;
429
+ },
430
+ });
431
+ }
404
432
  /**
405
433
  * `vincentt publish` — Flows L22-L31.
406
434
  *
@@ -557,7 +585,11 @@ async function preview() {
557
585
  }
558
586
  }
559
587
  catch (err) {
560
- for (const l of previewFailureLines(err))
588
+ // The local org is the FALLBACK only. A count refusal names the org that
589
+ // owns this project, and the wire's name wins over this one wherever both
590
+ // exist — see resolveCountLimit.
591
+ const localOrg = await localOrgIdentity();
592
+ for (const l of previewFailureLines(err, localOrg))
561
593
  console.log(l);
562
594
  process.exitCode = 1;
563
595
  return;
@@ -679,6 +711,18 @@ async function activeOrgName() {
679
711
  (await loadMachineConfig()).activeOrg ??
680
712
  "");
681
713
  }
714
+ /**
715
+ * The org this machine is pointed at, for a refusal whose body named none.
716
+ *
717
+ * `activeOrg` is a SLUG, so it fills the slug slot and not the name slot. The
718
+ * display name is the API's to give; inventing one from the slug would put a
719
+ * fabricated workspace name on a refusal.
720
+ */
721
+ async function localOrgIdentity() {
722
+ const slug = await activeOrgName();
723
+ const { consoleUrl } = await resolveAuthUrls();
724
+ return { slug: slug || undefined, consoleUrl };
725
+ }
682
726
  /** The binding's name is a HINT for display only; the API is the authority. */
683
727
  async function projectDisplayName(cwd) {
684
728
  const located = await locateProjectBinding(cwd);
@@ -717,9 +761,15 @@ async function projectConsoleUrl(cwd, sessionId) {
717
761
  * creator never has to read a second, and none of them names the edge, the API
718
762
  * or an outage — a creator cannot act on that.
719
763
  */
720
- function previewFailureLines(err) {
764
+ function previewFailureLines(err, local) {
721
765
  if (err instanceof PreviewRateLimitedError)
722
766
  return mintRateLimited(err.retryAfterSec);
767
+ // The ceiling is the second 409 on the mint route, and it is a DIFFERENT
768
+ // creator situation from the conflict below: nothing is running, nothing was
769
+ // taken down, and the organization is full.
770
+ if (err instanceof PreviewLimitError) {
771
+ return previewLimitRefused(resolveCountLimit(err.detail, local));
772
+ }
723
773
  if (err instanceof PreviewConflictError)
724
774
  return previewConflict();
725
775
  if (err instanceof ProjectUnreachableError) {
@@ -1032,6 +1082,9 @@ async function dx() {
1032
1082
  // first two have not already allowed the send, and allowed to throw — a /me that
1033
1083
  // is unreachable falls back to the other two arms rather than blocking.
1034
1084
  accountConsent: (auth) => httpConsentBackend().read(auth.apiUrl, auth.pat, now),
1085
+ // `--dir`-or-cwd, the same resolution every other verb uses, so `--dir`
1086
+ // works on a send as it does everywhere else.
1087
+ cwd: projectDir(),
1035
1088
  }, options);
1036
1089
  }
1037
1090
  // ---- report -----------------------------------------------------------------
@@ -1070,6 +1123,9 @@ async function report() {
1070
1123
  interactive,
1071
1124
  enablement: () => enablement,
1072
1125
  accountConsent: (auth) => httpConsentBackend().read(auth.apiUrl, auth.pat, now),
1126
+ // `--dir`-or-cwd, the same resolution every other verb uses, so `--dir`
1127
+ // works on a report as it does everywhere else.
1128
+ cwd: projectDir(),
1073
1129
  }, parseReportOptions(argv.slice(1)));
1074
1130
  }
1075
1131
  // ---- assets (f5) ------------------------------------------------------------
@@ -1153,6 +1209,77 @@ async function assets() {
1153
1209
  dir: flag("--dir"),
1154
1210
  });
1155
1211
  }
1212
+ // ---- custom domains (f16) ---------------------------------------------------
1213
+ /**
1214
+ * `vincentt domain <attach|list|verify|detach>` — Flows C1-C7.
1215
+ *
1216
+ * The aliases `add` and `remove` resolve inside `resolveSub` and NOTHING IS
1217
+ * PRINTED ABOUT THEM: no "did you mean", no deprecation, no echo of the canonical
1218
+ * name. `resolveSub` returns only the canonical verb, so this function cannot
1219
+ * tell an alias was used — which is the structural form of the rule.
1220
+ *
1221
+ * `renew` gets a FACT rather than a redirect, because the honest answer is that
1222
+ * the act does not exist: certificates renew on their own.
1223
+ */
1224
+ async function domain() {
1225
+ const { domainCommand, resolveSub, } = await import("../domain/command.js");
1226
+ const domainCopy = await import("../domain/copy.js");
1227
+ const api = await import("../domain/api.js");
1228
+ const sub = positional(1);
1229
+ if (sub === undefined) {
1230
+ console.error("Usage: vincentt domain <attach|list|verify|detach> [domain]");
1231
+ process.exitCode = 1;
1232
+ return;
1233
+ }
1234
+ if (sub === "renew") {
1235
+ for (const l of domainCopy.noRenewVerb())
1236
+ console.log(l);
1237
+ process.exitCode = 1;
1238
+ return;
1239
+ }
1240
+ const resolved = resolveSub(sub);
1241
+ if (resolved === undefined) {
1242
+ for (const l of domainCopy.unknownDomainSub(sub))
1243
+ console.log(l);
1244
+ process.exitCode = 1;
1245
+ return;
1246
+ }
1247
+ const cwd = projectDir();
1248
+ const machine = await loadMachineConfig();
1249
+ const config = async () => {
1250
+ const apiUrl = process.env.VINCENTT_API_URL ?? machine.apiUrl ?? DEFAULT_API_URL;
1251
+ const pat = process.env.VINCENTT_PAT ?? machine.pat;
1252
+ return pat ? { apiUrl: apiUrl.replace(/\/$/, ""), pat } : undefined;
1253
+ };
1254
+ process.exitCode = await domainCommand({
1255
+ print: (l) => console.log(l),
1256
+ config,
1257
+ binding: async () => {
1258
+ const located = await locateProjectBinding(cwd);
1259
+ return located
1260
+ ? { projectId: located.binding.projectId, slug: located.binding.slug }
1261
+ : undefined;
1262
+ },
1263
+ org: async () => {
1264
+ const cfg = await config();
1265
+ if (!cfg)
1266
+ return { ok: false, message: "Not signed in. Run `vincentt login`." };
1267
+ return resolveOrgForCommand(cfg, { flag: flag("--org") });
1268
+ },
1269
+ attach: api.attachDomain,
1270
+ verify: api.verifyDomain,
1271
+ listOrg: api.listOrgDomains,
1272
+ detach: api.detachDomain,
1273
+ confirm: stdinConfirm(),
1274
+ isTTY: Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY),
1275
+ }, resolved, {
1276
+ // The hostname is the bare argument after the subcommand. `list` takes
1277
+ // none and ignores it rather than erroring: a listing verb reports.
1278
+ hostname: positional(2),
1279
+ json: has("--json"),
1280
+ yes: has("--yes"),
1281
+ });
1282
+ }
1156
1283
  // ---- servers ----------------------------------------------------------------
1157
1284
  async function relay() {
1158
1285
  const { startRelay } = await import("../relay/server.js");
@@ -1189,6 +1316,19 @@ async function main() {
1189
1316
  // to answer a question it knows the answer to.
1190
1317
  if (cmd !== undefined && isConsoleOnlyVerb(cmd))
1191
1318
  return consoleOnly(cmd);
1319
+ // f16: `domains`, `dns`, `custom-domain` are the plausible spellings of the
1320
+ // real verb. An agent that gets "unknown command" retries or guesses a
1321
+ // neighbour; naming the command terminates.
1322
+ if (cmd !== undefined) {
1323
+ const { isDomainRoutedVerb } = await import("../domain/routedVerbs.js");
1324
+ if (isDomainRoutedVerb(cmd)) {
1325
+ const { domainVerbRedirect } = await import("../domain/copy.js");
1326
+ for (const l of domainVerbRedirect(cmd))
1327
+ console.log(l);
1328
+ process.exitCode = 1;
1329
+ return;
1330
+ }
1331
+ }
1192
1332
  console.error(`Unknown command: ${cmd}\n`);
1193
1333
  usage();
1194
1334
  process.exitCode = 1;
@@ -1233,6 +1373,10 @@ async function main() {
1233
1373
  return dx();
1234
1374
  case "report":
1235
1375
  return report();
1376
+ case "outdated":
1377
+ return outdated();
1378
+ case "domain":
1379
+ return domain();
1236
1380
  }
1237
1381
  }
1238
1382
  // Every invocation is journaled here, at the one site that sees all of them —
@@ -1283,6 +1427,57 @@ function journal(exitCode, error) {
1283
1427
  catch {
1284
1428
  // Same rule as the nudge: never break a run over a footnote.
1285
1429
  }
1430
+ // The behind-notice, on the same terms as the two footnotes above: stderr,
1431
+ // after the run it is a footnote to, from the PREVIOUS run's cache, and never
1432
+ // awaited. It rides only on commands a creator initiated — there is no timer
1433
+ // and no background poll anywhere in this feature.
1434
+ //
1435
+ // `preview` is the carrier that matters: it is the command a creator runs many
1436
+ // times a day for the life of a project, which closes the gap where someone
1437
+ // iterates for weeks without publishing and hears nothing.
1438
+ //
1439
+ // NEVER ON `outdated` ITSELF, for the reason the nudge is never printed on
1440
+ // `dx`: appending an unbidden summary of what the creator just asked for and
1441
+ // read in full is noise, and worse than noise here — it would BURN the
1442
+ // once-per-release announcement on a run where the creator was already told
1443
+ // everything, so the next `preview` would stay silent about a release they
1444
+ // saw only because they went looking.
1445
+ if (verbOf(argv) !== "outdated")
1446
+ void announceBehind();
1447
+ }
1448
+ /**
1449
+ * Print the behind-notice and schedule the refresh.
1450
+ *
1451
+ * Its own function because it needs an await (the version resolve reads a few
1452
+ * small files) while `journal` is synchronous, and because every fault here must
1453
+ * be swallowed: the worst outcome of this whole surface going dark is that
1454
+ * nobody is told they are behind, which is exactly where they were before it
1455
+ * existed. `preview` must not acquire a new way to look broken.
1456
+ */
1457
+ async function announceBehind() {
1458
+ try {
1459
+ const cwd = projectDir();
1460
+ const builtWith = await resolveBuiltWith(cwd);
1461
+ if (builtWith !== undefined) {
1462
+ for (const l of behindFooterLines({
1463
+ installed: { packages: builtWith.packages, template: builtWith.template },
1464
+ now: () => Date.now(),
1465
+ })) {
1466
+ console.error(l);
1467
+ }
1468
+ }
1469
+ if (needsRefresh(loadReleaseCache(), Date.now())) {
1470
+ refreshReleaseCache({
1471
+ apiUrl: process.env.VINCENTT_API_URL ??
1472
+ (await loadMachineConfig()).apiUrl ??
1473
+ DEFAULT_API_URL,
1474
+ now: () => Date.now(),
1475
+ });
1476
+ }
1477
+ }
1478
+ catch {
1479
+ // A footnote that can break a publish is not worth having.
1480
+ }
1286
1481
  }
1287
1482
  /**
1288
1483
  * Refresh the cached `latest` without holding the process open.
@@ -41,6 +41,28 @@ export const USAGE_TEXT = `Vincentt CLI — local-first AR dev loop\n\n` +
41
41
  ` preview Serve the app at an https link a phone can open\n` +
42
42
  ` [--port <n>] [--tester <id>] [--no-qr] [--stop]\n` +
43
43
  ` Re-running prints the live link; --restart mints a new one.\n\n` +
44
+ // f16. HELP TEACHES THE CANONICAL VERBS ONLY: `add` and `remove` are accepted
45
+ // as silent aliases and are deliberately ABSENT here, because help, docs and
46
+ // every error string are where the vocabulary is actually taught. Listing the
47
+ // aliases would teach two words for one act, which is the thing D-45 refuses.
48
+ //
49
+ // "Detaching stops it serving; the name stays yours" is the load-bearing
50
+ // clause: a detached custom domain is NOT burned, and a reader who assumed
51
+ // f4's permanence rule applied would never try the verb.
52
+ `Custom domains (a paid-plan capability)\n` +
53
+ ` domain attach <domain> Claim a domain for this folder's project and print\n` +
54
+ ` the three DNS records to create. Prints each name\n` +
55
+ ` in both the short and the full form, because\n` +
56
+ ` registrars disagree about which they want.\n` +
57
+ ` domain list This workspace's domains and the count its plan\n` +
58
+ ` carries [--org <s>] [--json]\n` +
59
+ ` domain verify <domain> Check the DNS now and report what was OBSERVED\n` +
60
+ ` beside what was expected [--json]\n` +
61
+ ` Exit 0 serving (findings that block nothing may\n` +
62
+ ` still be reported); exit 1 not serving yet\n` +
63
+ ` domain detach <domain> Stop serving the domain. Asks first; [--yes] to\n` +
64
+ ` skip the question. The name stays yours and the\n` +
65
+ ` project's vincentt.app address is unaffected.\n\n` +
44
66
  `Diagnostics (need a running preview)\n` +
45
67
  ` logs [--errors] Console logs from the device [--json] [--tester <id>]\n` +
46
68
  ` network fetch/XHR requests [--json] [--tester <id>]\n` +
@@ -92,6 +114,11 @@ export const USAGE_TEXT = `Vincentt CLI — local-first AR dev loop\n\n` +
92
114
  ` relay Run the diagnostics/annotation relay\n\n` +
93
115
  `Meta\n` +
94
116
  ` version CLI version, and the API this machine resolves to\n` +
117
+ ` outdated What this project is behind on, and what Vincentt currently\n` +
118
+ ` publishes. Reads a public endpoint; no account needed.\n` +
119
+ ` Prints the installed and current version of each Vincentt\n` +
120
+ ` package, the string to place in package.json, and any\n` +
121
+ ` migration note the platform published. Writes nothing.\n` +
95
122
  ` dx Where this dev loop fought you [--days <n>] [--json]\n` +
96
123
  ` Reads a local journal of your own runs. Local by default.\n` +
97
124
  ` --send reports the findings to the Vincentt team; it\n` +
@@ -1,4 +1,4 @@
1
- export declare const PINNED_VERBS: readonly ["version", "login", "logout", "whoami", "init", "create", "link", "unlink", "publish", "preview", "logs", "network", "trace", "feedback", "relay", "assets", "dx", "projects", "report"];
1
+ export declare const PINNED_VERBS: readonly ["version", "login", "logout", "whoami", "init", "create", "link", "unlink", "publish", "preview", "logs", "network", "trace", "feedback", "relay", "assets", "dx", "projects", "report", "outdated", "domain"];
2
2
  export type PinnedVerb = (typeof PINNED_VERBS)[number];
3
3
  export declare const VERB_HANDLERS: {
4
4
  readonly version: true;
@@ -20,6 +20,8 @@ export declare const VERB_HANDLERS: {
20
20
  readonly dx: true;
21
21
  readonly projects: true;
22
22
  readonly report: true;
23
+ readonly outdated: true;
24
+ readonly domain: true;
23
25
  };
24
26
  export type DispatchVerb = keyof typeof VERB_HANDLERS;
25
27
  export declare function resolveVerb(cmd: string | undefined): DispatchVerb | undefined;
@@ -16,6 +16,16 @@ export const PINNED_VERBS = [
16
16
  // and refuses when it finds none; this carries what a creator TYPED, which is
17
17
  // the half a journal keyed on exit codes cannot see. Also an addition.
18
18
  "report",
19
+ // Appended: what this project is behind on, and what the platform currently
20
+ // publishes. Named for the QUESTION and never for the act — `update` and
21
+ // `upgrade` would imply the CLI performs the upgrade, which it does not, and
22
+ // an agent reading `vincentt update` in any text will run it.
23
+ "outdated",
24
+ // Appended (f16): custom domains. SINGULAR, because every subcommand acts on
25
+ // one domain (`domain attach ar.acme.com`) and the plural would read as a
26
+ // collection verb. `domains` is routed to an answer, not accepted as a verb —
27
+ // see domainVerbRedirect. An addition, so B-F3-8 permits it.
28
+ "domain",
19
29
  ];
20
30
  // The dispatch's verb set, as data. `main()` switches over this, so the two can
21
31
  // never disagree: a verb added to the CLI is a key added here, and QA-AHG-01
@@ -40,6 +50,8 @@ export const VERB_HANDLERS = {
40
50
  dx: true,
41
51
  projects: true,
42
52
  report: true,
53
+ outdated: true,
54
+ domain: true,
43
55
  };
44
56
  // Answers "is this a verb this CLI accepts" without running it. The
45
57
  // console-redirect strings are NOT accepted here: they reach their explanation
@@ -0,0 +1,51 @@
1
+ import type { ResolvedConfig } from "../shared/config.js";
2
+ import { type DomainListView, type DomainView } from "./wire.js";
3
+ export interface DomainApiErrorDetail {
4
+ /** The plan's ceiling, on a ceiling refusal. */
5
+ limit?: number;
6
+ /** The plan in force, when the API names one. */
7
+ planName?: string;
8
+ planTier?: string;
9
+ }
10
+ export declare class DomainApiError extends Error {
11
+ readonly status: number;
12
+ readonly code: string;
13
+ readonly detail: DomainApiErrorDetail;
14
+ readonly retryAfterSec?: number | undefined;
15
+ constructor(status: number, code: string, message: string, detail?: DomainApiErrorDetail, retryAfterSec?: number | undefined);
16
+ }
17
+ /**
18
+ * The record's role, when the API names one, parallel to `records`.
19
+ *
20
+ * The CLI does NOT decide which record is the certificate-validation one: the
21
+ * caption whose absence is expensive (D-44) must not depend on a client-side
22
+ * heuristic over a CA hostname. When the API sends no roles the array is empty
23
+ * and the caption is simply absent — visibly missing rather than wrongly placed.
24
+ */
25
+ export interface AttachResult {
26
+ domain: DomainView;
27
+ roles: (string | undefined)[];
28
+ droppedFindings: number;
29
+ }
30
+ /** POST /projects/{id}/domains — returns the records to publish. Rate-limited. */
31
+ export declare function attachDomain(cfg: ResolvedConfig, projectId: string, domain: string, fetchImpl?: typeof fetch): Promise<AttachResult>;
32
+ /**
33
+ * POST /projects/{id}/domains/{domain}/verify — the on-demand re-check.
34
+ *
35
+ * Rate-limited per `(org, domain)`, so a 429 here is expected traffic rather than
36
+ * a defect, and its copy names no limit family.
37
+ */
38
+ export declare function verifyDomain(cfg: ResolvedConfig, projectId: string, domain: string, fetchImpl?: typeof fetch): Promise<AttachResult>;
39
+ /** GET /projects/{id}/domains — this project's domains with state + findings. */
40
+ export declare function listProjectDomains(cfg: ResolvedConfig, projectId: string, fetchImpl?: typeof fetch): Promise<DomainListView>;
41
+ /**
42
+ * GET /orgs/{id}/domains — the workspace's whole fleet and the count against the
43
+ * limit.
44
+ *
45
+ * This is what `vincentt domain list` reads, and it is why the anti-oracle
46
+ * collision copy is safe: a creator who genuinely holds a contested name finds it
47
+ * here, and this route only ever returns their own.
48
+ */
49
+ export declare function listOrgDomains(cfg: ResolvedConfig, orgSlug: string, fetchImpl?: typeof fetch): Promise<DomainListView>;
50
+ /** DELETE /projects/{id}/domains/{domain} — detach. The row is never deleted server-side. */
51
+ export declare function detachDomain(cfg: ResolvedConfig, projectId: string, domain: string, fetchImpl?: typeof fetch): Promise<void>;
@@ -0,0 +1,145 @@
1
+ // The f16 domain calls against the Go API, built against implementation.md
2
+ // § API surface.
3
+ //
4
+ // THE ROUTES ARE HOSTNAME-KEYED and normalized in the path. A hostname path
5
+ // segment CONTAINS DOTS, which some routers treat as a file extension — the API
6
+ // side pins a four-label round trip for exactly that reason. Every hostname is
7
+ // `encodeURIComponent`d here, which is what keeps a name with an unexpected
8
+ // character from changing the shape of the path.
9
+ //
10
+ // The project is resolved and authorized FIRST, by the same class check every
11
+ // other project route uses, so a caller who does not hold the project gets the
12
+ // ordinary indistinguishable 404 before the hostname is read at all. The CLI
13
+ // therefore cannot distinguish "no such project" from "not your project" — and
14
+ // must not try.
15
+ import { clientHeaders } from "../shared/clientHeader.js";
16
+ import { parseDomain, parseDomainList, } from "./wire.js";
17
+ export class DomainApiError extends Error {
18
+ status;
19
+ code;
20
+ detail;
21
+ retryAfterSec;
22
+ constructor(status, code, message, detail = {}, retryAfterSec) {
23
+ super(message);
24
+ this.status = status;
25
+ this.code = code;
26
+ this.detail = detail;
27
+ this.retryAfterSec = retryAfterSec;
28
+ this.name = "DomainApiError";
29
+ }
30
+ }
31
+ async function readError(res) {
32
+ let body = {};
33
+ try {
34
+ body = (await res.json());
35
+ }
36
+ catch {
37
+ // A non-JSON error body is still an error; the status carries the meaning.
38
+ }
39
+ const retryAfter = res.headers.get("retry-after");
40
+ return new DomainApiError(res.status, body.code ?? `http_${res.status}`, body.message ?? `request failed (${res.status})`, body.detail ?? {}, retryAfter ? Number(retryAfter) : undefined);
41
+ }
42
+ function authHeaders(cfg) {
43
+ return { ...clientHeaders(), authorization: `Bearer ${cfg.pat}` };
44
+ }
45
+ function rolesOf(raw) {
46
+ const o = typeof raw === "object" && raw !== null ? raw : {};
47
+ const rows = Array.isArray(o.records) ? o.records : [];
48
+ return rows.map((r) => {
49
+ const rec = typeof r === "object" && r !== null ? r : {};
50
+ return typeof rec.role === "string" ? rec.role : undefined;
51
+ });
52
+ }
53
+ /** POST /projects/{id}/domains — returns the records to publish. Rate-limited. */
54
+ export async function attachDomain(cfg, projectId, domain, fetchImpl = fetch) {
55
+ const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/domains`, {
56
+ method: "POST",
57
+ headers: { ...authHeaders(cfg), "content-type": "application/json" },
58
+ body: JSON.stringify({ domain }),
59
+ });
60
+ if (!res.ok)
61
+ throw await readError(res);
62
+ const raw = await res.json();
63
+ const parsed = parseDomain(unwrap(raw));
64
+ if (!parsed) {
65
+ throw new DomainApiError(res.status, "malformed_response", "attach: response carried no domain");
66
+ }
67
+ return {
68
+ domain: parsed.domain,
69
+ roles: rolesOf(unwrap(raw)),
70
+ droppedFindings: parsed.droppedFindings,
71
+ };
72
+ }
73
+ /**
74
+ * POST /projects/{id}/domains/{domain}/verify — the on-demand re-check.
75
+ *
76
+ * Rate-limited per `(org, domain)`, so a 429 here is expected traffic rather than
77
+ * a defect, and its copy names no limit family.
78
+ */
79
+ export async function verifyDomain(cfg, projectId, domain, fetchImpl = fetch) {
80
+ const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/domains/${encodeURIComponent(domain)}/verify`, { method: "POST", headers: authHeaders(cfg) });
81
+ if (!res.ok)
82
+ throw await readError(res);
83
+ const raw = await res.json();
84
+ const parsed = parseDomain(unwrap(raw));
85
+ if (!parsed) {
86
+ throw new DomainApiError(res.status, "malformed_response", "verify: response carried no domain");
87
+ }
88
+ return {
89
+ domain: parsed.domain,
90
+ roles: rolesOf(unwrap(raw)),
91
+ droppedFindings: parsed.droppedFindings,
92
+ };
93
+ }
94
+ /** GET /projects/{id}/domains — this project's domains with state + findings. */
95
+ export async function listProjectDomains(cfg, projectId, fetchImpl = fetch) {
96
+ const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/domains`, { headers: authHeaders(cfg) });
97
+ if (!res.ok)
98
+ throw await readError(res);
99
+ return parseDomainList(await res.json());
100
+ }
101
+ /**
102
+ * GET /orgs/{id}/domains — the workspace's whole fleet and the count against the
103
+ * limit.
104
+ *
105
+ * This is what `vincentt domain list` reads, and it is why the anti-oracle
106
+ * collision copy is safe: a creator who genuinely holds a contested name finds it
107
+ * here, and this route only ever returns their own.
108
+ */
109
+ export async function listOrgDomains(cfg, orgSlug, fetchImpl = fetch) {
110
+ const res = await fetchImpl(`${cfg.apiUrl}/orgs/${encodeURIComponent(orgSlug)}/domains`, { headers: authHeaders(cfg) });
111
+ if (!res.ok)
112
+ throw await readError(res);
113
+ return parseDomainList(await res.json());
114
+ }
115
+ /** DELETE /projects/{id}/domains/{domain} — detach. The row is never deleted server-side. */
116
+ export async function detachDomain(cfg, projectId, domain, fetchImpl = fetch) {
117
+ const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/domains/${encodeURIComponent(domain)}`, { method: "DELETE", headers: authHeaders(cfg) });
118
+ // 404 is a SILENT RESOLVE on detach: the creator asked for a state the world is
119
+ // already in. It is not distinguishable from "not your project", and it does
120
+ // not need to be — either way this project is not serving that name.
121
+ if (res.status === 404)
122
+ return;
123
+ if (!res.ok)
124
+ throw await readError(res);
125
+ }
126
+ /**
127
+ * Unwrap a `{domain: {...}}` envelope if the API sends one, else treat the body as
128
+ * the row.
129
+ *
130
+ * Tolerating both is NOT the legacy-tolerance anti-pattern `backend.ts` removed:
131
+ * that one accepted two spellings of a FIELD (`_id` vs `id`), where silently
132
+ * accepting the old shape hides a contract break. This tolerates an ENVELOPE,
133
+ * where both readings name the same field set and the ambiguity is resolved by
134
+ * looking for `domain` as a string — the row's own required field.
135
+ */
136
+ function unwrap(raw) {
137
+ if (typeof raw !== "object" || raw === null)
138
+ return raw;
139
+ const o = raw;
140
+ if (typeof o.domain === "string")
141
+ return o;
142
+ if (typeof o.domain === "object" && o.domain !== null)
143
+ return o.domain;
144
+ return o;
145
+ }
@@ -0,0 +1,75 @@
1
+ import type { ResolvedConfig } from "../shared/config.js";
2
+ import { type DomainListView, type DomainView } from "./wire.js";
3
+ import type { AttachResult } from "./api.js";
4
+ /** The canonical subcommands. */
5
+ export declare const DOMAIN_SUBCOMMANDS: readonly ["attach", "list", "verify", "detach"];
6
+ export type DomainSub = (typeof DOMAIN_SUBCOMMANDS)[number];
7
+ /**
8
+ * The silent aliases. `add`→`attach`, `remove`→`detach`.
9
+ *
10
+ * A map, not a chain of `if`s, so the pair is data a test can enumerate and the
11
+ * set cannot drift from what `resolveSub` accepts.
12
+ */
13
+ export declare const DOMAIN_ALIASES: Readonly<Record<string, DomainSub>>;
14
+ /**
15
+ * Resolve a typed subcommand to its canonical verb, or undefined.
16
+ *
17
+ * RETURNS ONLY THE CANONICAL VERB. It deliberately does not report whether an
18
+ * alias was used: a caller that cannot tell cannot print a correction, which is
19
+ * the structural form of "the aliases are silent". Making the alias invisible at
20
+ * the type level is stronger than remembering not to print it.
21
+ */
22
+ export declare function resolveSub(typed: string | undefined): DomainSub | undefined;
23
+ export interface DomainDeps {
24
+ print: (line: string) => void;
25
+ config: () => Promise<ResolvedConfig | undefined>;
26
+ /** The linked project for this folder, or undefined when nothing is linked. */
27
+ binding: () => Promise<{
28
+ projectId: string;
29
+ slug: string;
30
+ } | undefined>;
31
+ /** The workspace to list. Resolved by the caller's own precedence chain. */
32
+ org: () => Promise<{
33
+ ok: true;
34
+ org: {
35
+ slug: string;
36
+ name: string;
37
+ };
38
+ } | {
39
+ ok: false;
40
+ message: string;
41
+ }>;
42
+ attach: (cfg: ResolvedConfig, projectId: string, domain: string) => Promise<AttachResult>;
43
+ verify: (cfg: ResolvedConfig, projectId: string, domain: string) => Promise<AttachResult>;
44
+ listOrg: (cfg: ResolvedConfig, orgSlug: string) => Promise<DomainListView>;
45
+ detach: (cfg: ResolvedConfig, projectId: string, domain: string) => Promise<void>;
46
+ confirm: (prompt: string, defaultYes: boolean) => Promise<boolean>;
47
+ isTTY: boolean;
48
+ }
49
+ export interface DomainOptions {
50
+ /** The hostname argument, as typed. */
51
+ hostname?: string;
52
+ json?: boolean;
53
+ /** `--yes` — the explicit non-interactive consent for detach. */
54
+ yes?: boolean;
55
+ }
56
+ /**
57
+ * The dispatch. Returns the process exit code.
58
+ *
59
+ * Every arm returns explicitly rather than falling through to a shared tail: an
60
+ * exit code assembled at the end of a long function is an exit code a later
61
+ * branch changes by accident, and the exit code is the thing an agent branches
62
+ * on.
63
+ */
64
+ export declare function domainCommand(deps: DomainDeps, sub: DomainSub, opts?: DomainOptions): Promise<number>;
65
+ /**
66
+ * The verify render, split out so the state→transcript mapping is one readable
67
+ * table and the exit code is computed in exactly one place per arm.
68
+ */
69
+ export declare function renderVerify(deps: Pick<DomainDeps, "print">, view: DomainView, roles: readonly (string | undefined)[]): number;
70
+ /**
71
+ * The states where a creator returns to re-read the record values: pending,
72
+ * failing and held (QA-F16-C1). A creator whose brand is dark should not have to
73
+ * navigate to find the values again.
74
+ */
75
+ export declare function needsRecordsReprinted(view: Pick<DomainView, "state">): boolean;