@vincentt-xr/cli 0.15.1 → 0.16.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 (84) hide show
  1. package/dist/cli/args.js +1 -0
  2. package/dist/cli/index.js +177 -4
  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/clusters.js +10 -1
  21. package/dist/dx/command.d.ts +13 -4
  22. package/dist/dx/command.js +26 -5
  23. package/dist/dx/report.d.ts +39 -7
  24. package/dist/dx/report.js +71 -20
  25. package/dist/dx/send.d.ts +22 -4
  26. package/dist/dx/send.js +29 -8
  27. package/dist/dx/skeleton.js +19 -0
  28. package/dist/dx/unbiddenVerbs.d.ts +25 -0
  29. package/dist/dx/unbiddenVerbs.js +39 -0
  30. package/dist/lib/exitCodes.d.ts +24 -0
  31. package/dist/lib/exitCodes.js +28 -0
  32. package/dist/lib/initCommand.d.ts +3 -1
  33. package/dist/lib/initCommand.js +10 -0
  34. package/dist/lib/initFrom.js +12 -2
  35. package/dist/lib/lifecycle.d.ts +5 -1
  36. package/dist/lib/projects.d.ts +8 -1
  37. package/dist/lib/projects.js +21 -4
  38. package/dist/platform/allowlist.d.ts +36 -0
  39. package/dist/platform/allowlist.js +34 -0
  40. package/dist/platform/behind.d.ts +41 -0
  41. package/dist/platform/behind.js +82 -0
  42. package/dist/platform/command.d.ts +16 -0
  43. package/dist/platform/command.js +90 -0
  44. package/dist/platform/footer.d.ts +60 -0
  45. package/dist/platform/footer.js +101 -0
  46. package/dist/platform/notice.d.ts +29 -0
  47. package/dist/platform/notice.js +66 -0
  48. package/dist/platform/offSwitch.d.ts +19 -0
  49. package/dist/platform/offSwitch.js +21 -0
  50. package/dist/platform/outdated.d.ts +25 -0
  51. package/dist/platform/outdated.js +246 -0
  52. package/dist/platform/releaseCache.d.ts +69 -0
  53. package/dist/platform/releaseCache.js +140 -0
  54. package/dist/platform/releaseFetch.d.ts +51 -0
  55. package/dist/platform/releaseFetch.js +150 -0
  56. package/dist/platform/resolve.d.ts +30 -0
  57. package/dist/platform/resolve.js +128 -0
  58. package/dist/platform/templateUpgrade.d.ts +56 -0
  59. package/dist/platform/templateUpgrade.js +476 -0
  60. package/dist/platform/upgradeReport.d.ts +132 -0
  61. package/dist/platform/upgradeReport.js +258 -0
  62. package/dist/platform/versionSummary.d.ts +82 -0
  63. package/dist/platform/versionSummary.js +181 -0
  64. package/dist/platform/zonePath.d.ts +154 -0
  65. package/dist/platform/zonePath.js +286 -0
  66. package/dist/platform/zones.d.ts +64 -0
  67. package/dist/platform/zones.js +79 -0
  68. package/dist/publish/api.d.ts +15 -4
  69. package/dist/publish/api.js +19 -5
  70. package/dist/publish/command.d.ts +10 -0
  71. package/dist/publish/command.js +7 -1
  72. package/dist/publish/copy.d.ts +14 -1
  73. package/dist/publish/copy.js +32 -3
  74. package/dist/scaffold/index.d.ts +50 -3
  75. package/dist/scaffold/index.js +61 -1
  76. package/dist/shared/config.d.ts +16 -0
  77. package/dist/shared/config.js +9 -1
  78. package/package.json +1 -1
  79. package/dist/dx/qa.f7.fixtures.d.ts +0 -73
  80. package/dist/dx/qa.f7.fixtures.js +0 -58
  81. package/dist/publish/qa.f4.fixtures.d.ts +0 -25
  82. package/dist/publish/qa.f4.fixtures.js +0 -140
  83. package/dist/relay/qa.f12.fixtures.d.ts +0 -154
  84. 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
@@ -21,6 +21,7 @@ import { ensureLoggedIn } from "../login/ensure.js";
21
21
  import { clearMachineToken, loadMachineConfig, locateProjectBinding, resolveConfig, writeDxEnablement, MACHINE_CONFIG_PATH, } from "../shared/config.js";
22
22
  import { fileLocationStore } from "../shared/locationStore.js";
23
23
  import { getMe, listProjects, ProjectUnreachableError } from "../lib/backend.js";
24
+ import { EXIT } from "../lib/exitCodes.js";
24
25
  import { listProjectsCommand } from "../lib/listProjects.js";
25
26
  import { initCommand } from "../lib/initCommand.js";
26
27
  import { linkCommand, printUnreachable, unlinkCommand } from "../lib/projects.js";
@@ -50,6 +51,10 @@ import { nudgeFor } from "../dx/nudge.js";
50
51
  import { FETCH_TIMEOUT_MS, isFresh, updateNotice } from "../dx/updateCheck.js";
51
52
  import { fetchLatest, loadCache, saveCache } from "../dx/updateCheckIo.js";
52
53
  import { verbOf } from "../dx/skeleton.js";
54
+ import { outdatedCommand } from "../platform/command.js";
55
+ import { behindFooterLines, needsRefresh, refreshReleaseCache } from "../platform/footer.js";
56
+ import { loadReleaseCache } from "../platform/releaseCache.js";
57
+ import { resolveBuiltWith } from "../platform/resolve.js";
53
58
  import { createDirCollision, createSourceFlagRefusal, fromFlagConflict, isHelpInvocation, isVersionInvocation, positionalArgs, refusedFlag, } from "./args.js";
54
59
  import { parseSourceRef } from "../lib/sourceRef.js";
55
60
  import { printCreateSourceFlagRefusal, printFlagConflict, printSourceRefFailure, } from "../lib/sourceRefCopy.js";
@@ -400,6 +405,29 @@ async function projects() {
400
405
  store: fileLocationStore,
401
406
  }, { json: has("--json") });
402
407
  }
408
+ /**
409
+ * `vincentt outdated` — what this project is behind on.
410
+ *
411
+ * NEEDS NO ACCOUNT and never prompts for one: the endpoint it reads is public,
412
+ * and a creator with no login is exactly the population this verb exists to
413
+ * serve. It is a listing verb, so it reports and never asks, and its output is
414
+ * routinely piped.
415
+ */
416
+ async function outdated() {
417
+ const cwd = projectDir();
418
+ process.exitCode = await outdatedCommand({
419
+ print: (l) => console.log(l),
420
+ cwd,
421
+ now: () => Date.now(),
422
+ apiUrl: async () => process.env.VINCENTT_API_URL ?? (await loadMachineConfig()).apiUrl ?? DEFAULT_API_URL,
423
+ inProject: async () => (await locateProjectBinding(cwd)) !== undefined,
424
+ projectLine: async () => {
425
+ const name = await projectDisplayName(cwd);
426
+ const org = await activeOrgName();
427
+ return org ? `${name} · ${org}` : name;
428
+ },
429
+ });
430
+ }
403
431
  /**
404
432
  * `vincentt publish` — Flows L22-L31.
405
433
  *
@@ -813,7 +841,7 @@ async function feedbackWait(cwd, timeoutMs, verbose) {
813
841
  break;
814
842
  for (const l of feedbackCopy.windowElapsed(timeoutMs))
815
843
  console.error(l);
816
- return 64;
844
+ return EXIT.WINDOW_ELAPSED;
817
845
  }
818
846
  if (result.kind === "closed") {
819
847
  if (handed > 0)
@@ -825,7 +853,7 @@ async function feedbackWait(cwd, timeoutMs, verbose) {
825
853
  console.error(l);
826
854
  }
827
855
  }
828
- return 65;
856
+ return EXIT.CHANNEL_ENDED;
829
857
  }
830
858
  await handOver(cwd, result.annotation);
831
859
  after = result.annotation.seq;
@@ -882,7 +910,7 @@ async function feedbackWatch(cwd, timeoutMs, verbose) {
882
910
  console.error(l);
883
911
  }
884
912
  }
885
- return 65;
913
+ return EXIT.CHANNEL_ENDED;
886
914
  }
887
915
  const a = result.annotation;
888
916
  await handOver(cwd, a);
@@ -1031,6 +1059,9 @@ async function dx() {
1031
1059
  // first two have not already allowed the send, and allowed to throw — a /me that
1032
1060
  // is unreachable falls back to the other two arms rather than blocking.
1033
1061
  accountConsent: (auth) => httpConsentBackend().read(auth.apiUrl, auth.pat, now),
1062
+ // `--dir`-or-cwd, the same resolution every other verb uses, so `--dir`
1063
+ // works on a send as it does everywhere else.
1064
+ cwd: projectDir(),
1034
1065
  }, options);
1035
1066
  }
1036
1067
  // ---- report -----------------------------------------------------------------
@@ -1069,6 +1100,9 @@ async function report() {
1069
1100
  interactive,
1070
1101
  enablement: () => enablement,
1071
1102
  accountConsent: (auth) => httpConsentBackend().read(auth.apiUrl, auth.pat, now),
1103
+ // `--dir`-or-cwd, the same resolution every other verb uses, so `--dir`
1104
+ // works on a report as it does everywhere else.
1105
+ cwd: projectDir(),
1072
1106
  }, parseReportOptions(argv.slice(1)));
1073
1107
  }
1074
1108
  // ---- assets (f5) ------------------------------------------------------------
@@ -1110,7 +1144,7 @@ async function assets() {
1110
1144
  for (const l of unreachableLines("auth", 'vincentt assets search "<what you want>"')) {
1111
1145
  console.error(l);
1112
1146
  }
1113
- process.exitCode = 4;
1147
+ process.exitCode = EXIT.NOT_AUTHENTICATED;
1114
1148
  return;
1115
1149
  }
1116
1150
  const deps = {
@@ -1152,6 +1186,77 @@ async function assets() {
1152
1186
  dir: flag("--dir"),
1153
1187
  });
1154
1188
  }
1189
+ // ---- custom domains (f16) ---------------------------------------------------
1190
+ /**
1191
+ * `vincentt domain <attach|list|verify|detach>` — Flows C1-C7.
1192
+ *
1193
+ * The aliases `add` and `remove` resolve inside `resolveSub` and NOTHING IS
1194
+ * PRINTED ABOUT THEM: no "did you mean", no deprecation, no echo of the canonical
1195
+ * name. `resolveSub` returns only the canonical verb, so this function cannot
1196
+ * tell an alias was used — which is the structural form of the rule.
1197
+ *
1198
+ * `renew` gets a FACT rather than a redirect, because the honest answer is that
1199
+ * the act does not exist: certificates renew on their own.
1200
+ */
1201
+ async function domain() {
1202
+ const { domainCommand, resolveSub, } = await import("../domain/command.js");
1203
+ const domainCopy = await import("../domain/copy.js");
1204
+ const api = await import("../domain/api.js");
1205
+ const sub = positional(1);
1206
+ if (sub === undefined) {
1207
+ console.error("Usage: vincentt domain <attach|list|verify|detach> [domain]");
1208
+ process.exitCode = 1;
1209
+ return;
1210
+ }
1211
+ if (sub === "renew") {
1212
+ for (const l of domainCopy.noRenewVerb())
1213
+ console.log(l);
1214
+ process.exitCode = 1;
1215
+ return;
1216
+ }
1217
+ const resolved = resolveSub(sub);
1218
+ if (resolved === undefined) {
1219
+ for (const l of domainCopy.unknownDomainSub(sub))
1220
+ console.log(l);
1221
+ process.exitCode = 1;
1222
+ return;
1223
+ }
1224
+ const cwd = projectDir();
1225
+ const machine = await loadMachineConfig();
1226
+ const config = async () => {
1227
+ const apiUrl = process.env.VINCENTT_API_URL ?? machine.apiUrl ?? DEFAULT_API_URL;
1228
+ const pat = process.env.VINCENTT_PAT ?? machine.pat;
1229
+ return pat ? { apiUrl: apiUrl.replace(/\/$/, ""), pat } : undefined;
1230
+ };
1231
+ process.exitCode = await domainCommand({
1232
+ print: (l) => console.log(l),
1233
+ config,
1234
+ binding: async () => {
1235
+ const located = await locateProjectBinding(cwd);
1236
+ return located
1237
+ ? { projectId: located.binding.projectId, slug: located.binding.slug }
1238
+ : undefined;
1239
+ },
1240
+ org: async () => {
1241
+ const cfg = await config();
1242
+ if (!cfg)
1243
+ return { ok: false, message: "Not signed in. Run `vincentt login`." };
1244
+ return resolveOrgForCommand(cfg, { flag: flag("--org") });
1245
+ },
1246
+ attach: api.attachDomain,
1247
+ verify: api.verifyDomain,
1248
+ listOrg: api.listOrgDomains,
1249
+ detach: api.detachDomain,
1250
+ confirm: stdinConfirm(),
1251
+ isTTY: Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY),
1252
+ }, resolved, {
1253
+ // The hostname is the bare argument after the subcommand. `list` takes
1254
+ // none and ignores it rather than erroring: a listing verb reports.
1255
+ hostname: positional(2),
1256
+ json: has("--json"),
1257
+ yes: has("--yes"),
1258
+ });
1259
+ }
1155
1260
  // ---- servers ----------------------------------------------------------------
1156
1261
  async function relay() {
1157
1262
  const { startRelay } = await import("../relay/server.js");
@@ -1188,6 +1293,19 @@ async function main() {
1188
1293
  // to answer a question it knows the answer to.
1189
1294
  if (cmd !== undefined && isConsoleOnlyVerb(cmd))
1190
1295
  return consoleOnly(cmd);
1296
+ // f16: `domains`, `dns`, `custom-domain` are the plausible spellings of the
1297
+ // real verb. An agent that gets "unknown command" retries or guesses a
1298
+ // neighbour; naming the command terminates.
1299
+ if (cmd !== undefined) {
1300
+ const { isDomainRoutedVerb } = await import("../domain/routedVerbs.js");
1301
+ if (isDomainRoutedVerb(cmd)) {
1302
+ const { domainVerbRedirect } = await import("../domain/copy.js");
1303
+ for (const l of domainVerbRedirect(cmd))
1304
+ console.log(l);
1305
+ process.exitCode = 1;
1306
+ return;
1307
+ }
1308
+ }
1191
1309
  console.error(`Unknown command: ${cmd}\n`);
1192
1310
  usage();
1193
1311
  process.exitCode = 1;
@@ -1232,6 +1350,10 @@ async function main() {
1232
1350
  return dx();
1233
1351
  case "report":
1234
1352
  return report();
1353
+ case "outdated":
1354
+ return outdated();
1355
+ case "domain":
1356
+ return domain();
1235
1357
  }
1236
1358
  }
1237
1359
  // Every invocation is journaled here, at the one site that sees all of them —
@@ -1282,6 +1404,57 @@ function journal(exitCode, error) {
1282
1404
  catch {
1283
1405
  // Same rule as the nudge: never break a run over a footnote.
1284
1406
  }
1407
+ // The behind-notice, on the same terms as the two footnotes above: stderr,
1408
+ // after the run it is a footnote to, from the PREVIOUS run's cache, and never
1409
+ // awaited. It rides only on commands a creator initiated — there is no timer
1410
+ // and no background poll anywhere in this feature.
1411
+ //
1412
+ // `preview` is the carrier that matters: it is the command a creator runs many
1413
+ // times a day for the life of a project, which closes the gap where someone
1414
+ // iterates for weeks without publishing and hears nothing.
1415
+ //
1416
+ // NEVER ON `outdated` ITSELF, for the reason the nudge is never printed on
1417
+ // `dx`: appending an unbidden summary of what the creator just asked for and
1418
+ // read in full is noise, and worse than noise here — it would BURN the
1419
+ // once-per-release announcement on a run where the creator was already told
1420
+ // everything, so the next `preview` would stay silent about a release they
1421
+ // saw only because they went looking.
1422
+ if (verbOf(argv) !== "outdated")
1423
+ void announceBehind();
1424
+ }
1425
+ /**
1426
+ * Print the behind-notice and schedule the refresh.
1427
+ *
1428
+ * Its own function because it needs an await (the version resolve reads a few
1429
+ * small files) while `journal` is synchronous, and because every fault here must
1430
+ * be swallowed: the worst outcome of this whole surface going dark is that
1431
+ * nobody is told they are behind, which is exactly where they were before it
1432
+ * existed. `preview` must not acquire a new way to look broken.
1433
+ */
1434
+ async function announceBehind() {
1435
+ try {
1436
+ const cwd = projectDir();
1437
+ const builtWith = await resolveBuiltWith(cwd);
1438
+ if (builtWith !== undefined) {
1439
+ for (const l of behindFooterLines({
1440
+ installed: { packages: builtWith.packages, template: builtWith.template },
1441
+ now: () => Date.now(),
1442
+ })) {
1443
+ console.error(l);
1444
+ }
1445
+ }
1446
+ if (needsRefresh(loadReleaseCache(), Date.now())) {
1447
+ refreshReleaseCache({
1448
+ apiUrl: process.env.VINCENTT_API_URL ??
1449
+ (await loadMachineConfig()).apiUrl ??
1450
+ DEFAULT_API_URL,
1451
+ now: () => Date.now(),
1452
+ });
1453
+ }
1454
+ }
1455
+ catch {
1456
+ // A footnote that can break a publish is not worth having.
1457
+ }
1285
1458
  }
1286
1459
  /**
1287
1460
  * 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;