@vincentt-xr/cli 0.8.0 → 0.10.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 (64) hide show
  1. package/README.md +40 -13
  2. package/dist/cli/args.d.ts +22 -0
  3. package/dist/cli/args.js +71 -0
  4. package/dist/cli/index.d.ts +1 -1
  5. package/dist/cli/index.js +359 -91
  6. package/dist/cli/usage-text.d.ts +1 -0
  7. package/dist/cli/usage-text.js +97 -0
  8. package/dist/cli/verb-surface.d.ts +24 -0
  9. package/dist/cli/verb-surface.js +49 -0
  10. package/dist/dx/clusters.d.ts +0 -17
  11. package/dist/dx/clusters.js +50 -1
  12. package/dist/dx/skeleton.js +1 -0
  13. package/dist/lib/backend.d.ts +11 -0
  14. package/dist/lib/backend.js +23 -0
  15. package/dist/lib/classifyTarget.d.ts +42 -0
  16. package/dist/lib/classifyTarget.js +83 -0
  17. package/dist/lib/feedback.d.ts +46 -5
  18. package/dist/lib/feedback.js +76 -19
  19. package/dist/lib/feedbackCopy.d.ts +70 -0
  20. package/dist/lib/feedbackCopy.js +163 -0
  21. package/dist/lib/initCommand.d.ts +28 -1
  22. package/dist/lib/initCommand.js +91 -16
  23. package/dist/lib/initFrom.d.ts +70 -0
  24. package/dist/lib/initFrom.js +222 -0
  25. package/dist/lib/lifecycle.d.ts +28 -3
  26. package/dist/lib/lifecycle.js +99 -7
  27. package/dist/lib/listProjects.d.ts +41 -0
  28. package/dist/lib/listProjects.js +77 -0
  29. package/dist/lib/projects.d.ts +64 -8
  30. package/dist/lib/projects.js +316 -24
  31. package/dist/lib/sourceRef.d.ts +46 -0
  32. package/dist/lib/sourceRef.js +107 -0
  33. package/dist/lib/sourceRefCopy.d.ts +30 -0
  34. package/dist/lib/sourceRefCopy.js +116 -0
  35. package/dist/preview/command.d.ts +11 -0
  36. package/dist/preview/command.js +19 -1
  37. package/dist/preview/copy.d.ts +7 -0
  38. package/dist/preview/copy.js +8 -1
  39. package/dist/preview/runner.d.ts +6 -0
  40. package/dist/preview/runner.js +1 -0
  41. package/dist/preview/stream.d.ts +11 -0
  42. package/dist/preview/stream.js +13 -0
  43. package/dist/publish/api.d.ts +20 -0
  44. package/dist/publish/command.d.ts +3 -0
  45. package/dist/publish/command.js +39 -4
  46. package/dist/publish/copy.d.ts +29 -0
  47. package/dist/publish/copy.js +86 -0
  48. package/dist/relay/annotations.d.ts +28 -4
  49. package/dist/relay/annotations.js +23 -7
  50. package/dist/relay/qa.f12.fixtures.d.ts +154 -0
  51. package/dist/relay/qa.f12.fixtures.js +358 -0
  52. package/dist/relay/server.d.ts +41 -3
  53. package/dist/relay/server.js +90 -32
  54. package/dist/scaffold/index.d.ts +9 -0
  55. package/dist/scaffold/index.js +13 -1
  56. package/package.json +2 -2
  57. package/dist/library/qa.f5-b32.spec.d.ts +0 -1
  58. package/dist/library/qa.f5-b32.spec.js +0 -202
  59. package/dist/library/qa.f5-cli.spec.d.ts +0 -1
  60. package/dist/library/qa.f5-cli.spec.js +0 -433
  61. package/dist/library/qa.f5-contract.spec.d.ts +0 -1
  62. package/dist/library/qa.f5-contract.spec.js +0 -206
  63. package/dist/relay/injectors.d.ts +0 -29
  64. package/dist/relay/injectors.js +0 -72
package/dist/cli/index.js CHANGED
@@ -19,7 +19,8 @@ import { httpAuthBackend } from "../login/authBackend.js";
19
19
  import { loginCommand } from "../login/loginCommand.js";
20
20
  import { ensureLoggedIn } from "../login/ensure.js";
21
21
  import { clearMachineToken, loadMachineConfig, locateProjectBinding, resolveConfig, writeDxEnablement, MACHINE_CONFIG_PATH, } from "../shared/config.js";
22
- import { getMe, ProjectUnreachableError } from "../lib/backend.js";
22
+ import { getMe, listProjects, ProjectUnreachableError } from "../lib/backend.js";
23
+ import { listProjectsCommand } from "../lib/listProjects.js";
23
24
  import { initCommand } from "../lib/initCommand.js";
24
25
  import { linkCommand, printUnreachable, unlinkCommand } from "../lib/projects.js";
25
26
  import { whoamiCommand } from "../lib/whoami.js";
@@ -28,13 +29,16 @@ import { diagQuery, diagRead, previewStart, previewStop, previewStopSignal, proj
28
29
  import { publishCommand } from "../publish/command.js";
29
30
  import { stdinAsk, stdinConfirm } from "../publish/prompt.js";
30
31
  import { consoleVerbCommand, isConsoleOnlyVerb } from "../publish/consoleVerbs.js";
32
+ import { resolveVerb } from "./verb-surface.js";
33
+ import { USAGE_TEXT } from "./usage-text.js";
31
34
  import { PreviewUi } from "../preview/command.js";
32
35
  import { httpRelayClient } from "../lib/diagnostics.js";
33
- import { readLivePreview } from "../lib/relayInfo.js";
36
+ import { readLivePreview, relayInfoDir } from "../lib/relayInfo.js";
34
37
  import { attachedToRunning, mintRateLimited, notSignedIn, previewConflict, startBlock, } from "../preview/copy.js";
35
38
  import { renderTerminalQr, shouldRenderQr } from "../preview/qr.js";
36
39
  import { PreviewConflictError, PreviewRateLimitedError } from "../preview/api.js";
37
40
  import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
41
+ import * as feedbackCopy from "../lib/feedbackCopy.js";
38
42
  import { record as recordInvocation } from "../dx/journal.js";
39
43
  import { dxDisableCommand, dxEnableCommand, dxSendCommand, parseDxOptions, runDx, } from "../dx/command.js";
40
44
  import { DX_UNATTENDED_SUNSET, loadEnablement } from "../dx/enablement.js";
@@ -42,7 +46,13 @@ import { journalEnabled, journalPath, readJournal } from "../dx/journal.js";
42
46
  import { postReport } from "../dx/send.js";
43
47
  import { nudgeFor } from "../dx/nudge.js";
44
48
  import { verbOf } from "../dx/skeleton.js";
45
- import { isHelpInvocation, isVersionInvocation, positionalArgs, refusedFlag, } from "./args.js";
49
+ import { createSourceFlagRefusal, fromFlagConflict, isHelpInvocation, isVersionInvocation, positionalArgs, refusedFlag, } from "./args.js";
50
+ import { parseSourceRef } from "../lib/sourceRef.js";
51
+ import { printCreateSourceFlagRefusal, printFlagConflict, printSourceRefFailure, } from "../lib/sourceRefCopy.js";
52
+ import { registerAfterScaffold } from "../lib/initFrom.js";
53
+ import { registerProject } from "../lib/projects.js";
54
+ import { sanitizeAppName } from "../lib/initCommand.js";
55
+ import { resolveOrgForCommand } from "../lib/lifecycle.js";
46
56
  // The console origin serves /cli-auth (the browser target for `vincentt login`). VINCENTT_
47
57
  // CONSOLE_URL overrides for dev/dogfood; VINCENTT_EDITOR_URL is honored as a legacy alias.
48
58
  // This is the prod console; vincentt.app is the published-PROJECT apex, not the console, so a
@@ -70,6 +80,15 @@ function projectDir() {
70
80
  function positional(n) {
71
81
  return positionalArgs(argv)[n];
72
82
  }
83
+ /**
84
+ * The value of a `--flag=value` spelling, which `flag()` cannot see at all (it is
85
+ * a bare `argv.indexOf`). Only used to echo the creator's own argument back into a
86
+ * refusal's remedy, so both spellings produce a copy-pasteable line.
87
+ */
88
+ function valueOfEqualsFlag(args, name) {
89
+ const hit = args.find((a) => a.startsWith(`${name}=`));
90
+ return hit?.slice(name.length + 1) || undefined;
91
+ }
73
92
  // ---- auth -------------------------------------------------------------------
74
93
  async function resolveAuthUrls() {
75
94
  const machine = await loadMachineConfig();
@@ -164,25 +183,129 @@ async function init() {
164
183
  // ABSENCE as "may not ask" — so a piped or CI invocation refuses rather than
165
184
  // blocking forever on a question nobody can see.
166
185
  const interactive = Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
186
+ const print = (l) => console.log(l);
187
+ const dir = positional(1);
188
+ const dirLabel = dir ?? "<dir>";
189
+ // Refused during ARGUMENT PARSING, before anything is written (Q-1).
190
+ const conflict = fromFlagConflict(argv);
191
+ if (conflict) {
192
+ printFlagConflict(print, conflict, { dir: dirLabel });
193
+ process.exitCode = 1;
194
+ return;
195
+ }
196
+ const rawFrom = flag("--from");
197
+ let from;
198
+ if (rawFrom !== undefined) {
199
+ const parsed = parseSourceRef(rawFrom);
200
+ if (!parsed.ok) {
201
+ printSourceRefFailure(print, parsed.failure, { dir: dirLabel });
202
+ process.exitCode = 1;
203
+ return;
204
+ }
205
+ from = parsed.ref;
206
+ }
167
207
  process.exitCode = await initCommand({
168
- print: (l) => console.log(l),
208
+ print,
169
209
  cwd: process.cwd(),
170
210
  ask: interactive ? stdinAsk() : undefined,
171
211
  confirm: interactive ? stdinConfirm() : undefined,
212
+ register: from
213
+ ? (rd, o) => registerAfterScaffold({
214
+ ...rd,
215
+ currentToken,
216
+ runLogin: async () => {
217
+ await login();
218
+ },
219
+ resolveOrg: async () => {
220
+ const cfg = await resolveConfig(rd.targetDir, { warn: print });
221
+ const resolved = await resolveOrgForCommand(cfg, {
222
+ flag: flag("--org"),
223
+ });
224
+ if (!resolved.ok)
225
+ return resolved;
226
+ return {
227
+ ok: true,
228
+ name: resolved.org.name,
229
+ source: orgSource(),
230
+ };
231
+ },
232
+ // registerProject, NOT createCommand: createCommand is now the
233
+ // whole two-half verb, so calling it here would classify and
234
+ // scaffold a second time inside the half that already cloned.
235
+ create: async ({ name, slug }) => registerProject({
236
+ print,
237
+ cwd: rd.targetDir,
238
+ config: () => resolveConfig(rd.targetDir, { warn: print }),
239
+ org: async () => resolveOrgForCommand(await resolveConfig(rd.targetDir, { warn: print }), { flag: flag("--org") }),
240
+ }, { name, slug }),
241
+ }, o)
242
+ : undefined,
172
243
  }, {
173
- dir: positional(1),
244
+ dir,
174
245
  skipInstall: has("--no-install"),
175
246
  template: flag("--template"),
247
+ from,
248
+ name: flag("--name"),
249
+ slug: flag("--slug"),
176
250
  });
177
251
  }
252
+ /** Where the resolved org came from, so a surprising value is diagnosable (AF-3). */
253
+ function orgSource() {
254
+ if (flag("--org"))
255
+ return "flag";
256
+ if (process.env.VINCENTT_ORG)
257
+ return "env";
258
+ return "active";
259
+ }
178
260
  async function create() {
179
- await requireLogin();
261
+ // NO requireLogin() here, deliberately. The scaffold half must not depend on a
262
+ // session: `create` in an empty folder writes the open template, and a gate at
263
+ // the top of the verb would put it behind the auth gate. The session is
264
+ // consulted BELOW the seam, inside the verb. `link()` three lines down still
265
+ // calls requireLogin, correctly — do not copy it up here.
266
+ const print = (l) => console.log(l);
267
+ const dirLabel = positional(1) ?? ".";
268
+ // The parser refusal runs FIRST, before anything is read or written. `flag()` is
269
+ // a global argv scan with no per-verb gate, so a single natural copy of
270
+ // `template: flag("--template")` into this function would make `create` an
271
+ // unauthenticated arbitrary-ref clone-and-execute, and nothing would go red.
272
+ const refusedSource = createSourceFlagRefusal(argv);
273
+ if (refusedSource) {
274
+ printCreateSourceFlagRefusal(print, refusedSource, {
275
+ dir: dirLabel,
276
+ value: flag(refusedSource) ?? valueOfEqualsFlag(argv, refusedSource),
277
+ });
278
+ process.exitCode = 1;
279
+ return;
280
+ }
281
+ const raw = positional(1);
282
+ let dir;
283
+ if (raw !== undefined) {
284
+ const safe = sanitizeAppName(raw);
285
+ if (safe === null) {
286
+ print(`✗ "${raw.trim()}" is not a folder name.`);
287
+ print("");
288
+ print(" Use a plain name (my-ar-app), or `vincentt create` for this folder.");
289
+ process.exitCode = 1;
290
+ return;
291
+ }
292
+ dir = safe;
293
+ }
294
+ const interactive = Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
180
295
  const result = await projectCreate(projectDir(), {
181
296
  name: flag("--name"),
182
297
  slug: flag("--slug"),
183
298
  org: flag("--org"),
184
299
  force: has("--force"),
185
300
  allowCustomApi: has("--allow-custom-api"),
301
+ dir,
302
+ skipInstall: has("--no-install"),
303
+ ask: interactive ? stdinAsk() : undefined,
304
+ currentToken,
305
+ runLogin: async () => {
306
+ await login();
307
+ },
308
+ orgSource,
186
309
  onLine: (l) => console.log(l),
187
310
  });
188
311
  if (result.exitCode !== 0) {
@@ -216,6 +339,34 @@ async function unlink() {
216
339
  config: () => resolveConfig(cwd),
217
340
  });
218
341
  }
342
+ /**
343
+ * `vincentt projects` — which projects does this workspace have?
344
+ *
345
+ * Reads the MACHINE config and the org precedence chain, never the folder's
346
+ * binding, for the same reason `whoami` does: it answers a question about the
347
+ * workspace. A folder-derived default would make the one verb a creator runs
348
+ * BECAUSE they are in an unbound folder depend on that folder having a binding.
349
+ */
350
+ async function projects() {
351
+ await requireLogin();
352
+ const machine = await loadMachineConfig();
353
+ const config = async () => {
354
+ const apiUrl = process.env.VINCENTT_API_URL ?? machine.apiUrl ?? DEFAULT_API_URL;
355
+ const pat = process.env.VINCENTT_PAT ?? machine.pat;
356
+ return pat ? { apiUrl: apiUrl.replace(/\/$/, ""), pat } : undefined;
357
+ };
358
+ process.exitCode = await listProjectsCommand({
359
+ print: (l) => console.log(l),
360
+ config,
361
+ org: async () => {
362
+ const cfg = await config();
363
+ if (!cfg)
364
+ return { ok: false, message: "Not signed in. Run `vincentt login`." };
365
+ return resolveOrgForCommand(cfg, { flag: flag("--org") });
366
+ },
367
+ list: listProjects,
368
+ }, { json: has("--json") });
369
+ }
219
370
  /**
220
371
  * `vincentt publish` — Flows L22-L31.
221
372
  *
@@ -254,6 +405,8 @@ async function publish() {
254
405
  now: () => Date.now(),
255
406
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
256
407
  orgName: activeOrgName,
408
+ orgSlug: async () => (await activeOrgName()) || undefined,
409
+ consoleUrl: flag("--console") ?? process.env.VINCENTT_CONSOLE_URL,
257
410
  }, { distDir: flag("--dist"), note: flag("--note"), yes: has("--yes") });
258
411
  }
259
412
  /** Flow L30 — the six verbs that are console actions, answered rather than rejected. */
@@ -352,6 +505,7 @@ async function preview() {
352
505
  }
353
506
  },
354
507
  onCapReached: () => ui.capReached(),
508
+ onAnnotation: (tags) => ui.annotation(tags),
355
509
  onTunnelState: (state) => ui.tunnel(state),
356
510
  onOriginError: () => ui.originError(),
357
511
  onOriginRecovered: () => ui.originRecovered(),
@@ -386,6 +540,7 @@ async function preview() {
386
540
  orgName: await activeOrgName(),
387
541
  targetUrl: `http://localhost:${running.appPort}`,
388
542
  url: running.url,
543
+ consoleUrl: await projectConsoleUrl(cwd),
389
544
  qr,
390
545
  expiresInHours: 12,
391
546
  })) {
@@ -495,6 +650,22 @@ async function projectDisplayName(cwd) {
495
650
  const located = await locateProjectBinding(cwd);
496
651
  return located?.binding.slug ?? path.basename(cwd);
497
652
  }
653
+ /**
654
+ * The project's page in the console, or undefined for an unlinked folder.
655
+ *
656
+ * The path is the BARE-ID route (`/projects/<id>`), the same shape
657
+ * `consoleVerbCommand` deep-links to. The console redirects it to the org-scoped
658
+ * address, so the CLI does not need the org slug and cannot print a stale one — a
659
+ * folder can outlive the org binding the machine config happens to hold.
660
+ */
661
+ async function projectConsoleUrl(cwd) {
662
+ const located = await locateProjectBinding(cwd);
663
+ if (!located)
664
+ return undefined;
665
+ const cfg = await loadMachineConfig();
666
+ const base = (cfg.consoleUrl ?? DEFAULT_CONSOLE_URL).replace(/\/$/, "");
667
+ return `${base}/projects/${located.binding.projectId}`;
668
+ }
498
669
  /**
499
670
  * Map a mint failure to its screen. Each cause has a DISTINCT first line so the
500
671
  * creator never has to read a second, and none of them names the edge, the API
@@ -533,28 +704,179 @@ async function diag(kind) {
533
704
  }
534
705
  }
535
706
  // ---- reverse channel --------------------------------------------------------
707
+ /**
708
+ * `vincentt feedback` — the reverse channel's consumer, and the one verb whose
709
+ * EXIT CODE is the contract.
710
+ *
711
+ * | exit | meaning | stdout | cursor |
712
+ * |------|------------------------------------------|-----------|-----------|
713
+ * | 0 | one or more annotations handed over | NDJSON | advanced |
714
+ * | 64 | the bounded window elapsed, nothing new | *(empty)* | untouched |
715
+ * | 65 | the preview session has ended | *(empty)* | untouched |
716
+ * | 1 | usage error, or an unexpected fault | *(empty)* | untouched |
717
+ *
718
+ * A caller never parses stdout to decide whether to re-park: empty stdout is
719
+ * never ambiguous, because 64 and 65 differ. 64 and 65 are NOT errors — a
720
+ * recipe must be able to loop on 64 without also swallowing real failures.
721
+ *
722
+ * 64/65 sit above the band generic tooling overloads, below the shell-reserved
723
+ * 126, and deliberately avoid 124: GNU `timeout` emits 124, and a recipe that
724
+ * wraps this command in `timeout` would otherwise be unable to tell its own
725
+ * timeout from ours.
726
+ *
727
+ * The contract is APPEND-ONLY, permanently: a code may be added, an existing
728
+ * code's meaning never changes, a flag once accepted stays accepted, and
729
+ * stdout's one-JSON-per-line grammar never changes shape.
730
+ */
536
731
  async function feedback() {
537
732
  const cwd = projectDir();
538
733
  const timeoutMs = (numFlag("--timeout") ?? 300) * 1000;
734
+ const verbose = has("--verbose");
539
735
  const watch = has("--watch");
540
736
  if (!watch && !has("--wait")) {
541
- console.error("Usage: vincentt feedback --wait | --watch [--timeout <seconds>]");
737
+ console.error(feedbackCopy.USAGE);
542
738
  process.exitCode = 1;
543
739
  return;
544
740
  }
545
- do {
546
- const after = await readCursor(cwd);
547
- const annotation = await fetchNext(cwd, { after, timeoutMs });
548
- if (!annotation) {
549
- // 204 re-arm signal: --wait exits empty, --watch loops.
550
- if (!watch)
551
- return;
741
+ // process.exit() would truncate a pending pipe write, so every path here
742
+ // assigns exitCode and RETURNS — the loop drains on its own.
743
+ process.exitCode = watch
744
+ ? await feedbackWatch(cwd, timeoutMs, verbose)
745
+ : await feedbackWait(cwd, timeoutMs, verbose);
746
+ }
747
+ /**
748
+ * The drain. `--wait` hands over EVERY pending annotation and then exits, not
749
+ * exactly one: a creator who walked the room and sent three is one hand-off, and
750
+ * three round trips would be three wakes for one thought.
751
+ *
752
+ * The first park uses the full window; every subsequent probe asks "is there
753
+ * another RIGHT NOW" with `timeoutMs: 0` and must not block, or the batch
754
+ * silently costs one window per annotation.
755
+ */
756
+ async function feedbackWait(cwd, timeoutMs, verbose) {
757
+ for (const l of feedbackCopy.waiting())
758
+ console.error(l);
759
+ let after = await readCursor(cwd);
760
+ let handed = 0;
761
+ for (;;) {
762
+ const result = await fetchNext(cwd, {
763
+ after,
764
+ timeoutMs: handed === 0 ? timeoutMs : 0,
765
+ });
766
+ if (result.kind === "empty") {
767
+ if (handed > 0)
768
+ break;
769
+ for (const l of feedbackCopy.windowElapsed(timeoutMs))
770
+ console.error(l);
771
+ return 64;
772
+ }
773
+ if (result.kind === "closed") {
774
+ if (handed > 0)
775
+ break;
776
+ for (const l of feedbackCopy.sessionEnded(handed))
777
+ console.error(l);
778
+ if (verbose) {
779
+ for (const l of feedbackCopy.sessionEndedDetail(result.reason, await relayInfoDir(cwd), after)) {
780
+ console.error(l);
781
+ }
782
+ }
783
+ return 65;
784
+ }
785
+ await handOver(cwd, result.annotation);
786
+ after = result.annotation.seq;
787
+ handed += 1;
788
+ }
789
+ // A session that ended MID-BATCH is still a successful hand-off: exiting 65
790
+ // here would tell a polling host to stop after it had just been given work.
791
+ // The un-drained tail is not skipped — the cursor names only what was printed.
792
+ for (const l of feedbackCopy.handedOver(handed))
793
+ console.error(l);
794
+ return 0;
795
+ }
796
+ /**
797
+ * `--watch` streams for a human and does not return on an elapsed window — it
798
+ * re-arms. It DOES return 65 when the session ends, rather than looping forever
799
+ * against a channel that can never speak again.
800
+ *
801
+ * `--timeout` is accepted here (a flag once accepted is accepted forever) and
802
+ * names the internal re-arm interval rather than a bounded window.
803
+ */
804
+ async function feedbackWatch(cwd, timeoutMs, verbose) {
805
+ for (const l of feedbackCopy.watching())
806
+ console.error(l);
807
+ // A human stopping a watch is not a failure, so it ends 0 — and the ending
808
+ // says the PREVIEW is still live, because what stopped is this command.
809
+ //
810
+ // An interrupt between a completed write and its cursor advance leaves the
811
+ // cursor behind, which re-delivers that annotation on the next run. That is
812
+ // at-least-once working as designed, and the safe direction.
813
+ process.on("SIGINT", () => {
814
+ for (const l of feedbackCopy.stoppedWatching())
815
+ console.error(l);
816
+ process.exit(0);
817
+ });
818
+ let after = await readCursor(cwd);
819
+ let handed = 0;
820
+ // A `--timeout 0` here would make the re-arm a busy loop against the relay
821
+ // rather than a parked request. The flag stays ACCEPTED (it always has been)
822
+ // and the floor is applied silently: a watch's window is an implementation
823
+ // detail of its re-arm, not a contract the caller reads back.
824
+ const armMs = Math.max(timeoutMs, 1_000);
825
+ for (;;) {
826
+ const result = await fetchNext(cwd, { after, timeoutMs: armMs });
827
+ if (result.kind === "empty")
552
828
  continue;
829
+ if (result.kind === "closed") {
830
+ // `{N}` is what THIS invocation handed over, which for a watch that ran
831
+ // for an hour is not 0. The ending is C4's, exit code included: a watch
832
+ // against a dead channel would otherwise loop forever.
833
+ for (const l of feedbackCopy.sessionEnded(handed))
834
+ console.error(l);
835
+ if (verbose) {
836
+ for (const l of feedbackCopy.sessionEndedDetail(result.reason, await relayInfoDir(cwd), after)) {
837
+ console.error(l);
838
+ }
839
+ }
840
+ return 65;
841
+ }
842
+ const a = result.annotation;
843
+ await handOver(cwd, a);
844
+ // The human's copy, on stderr, in the second fidelity. stdout stays the
845
+ // exact NDJSON, so `feedback --watch > out.ndjson` is still clean.
846
+ console.error("");
847
+ for (const l of feedbackCopy.watchEntry({
848
+ createdAt: a.createdAt,
849
+ tester: a.sessionId,
850
+ message: a.message,
851
+ screenshotPath: a.screenshotPath,
852
+ }, process.stderr.columns)) {
853
+ console.error(l);
553
854
  }
554
- // Print BEFORE advancing the cursor → at-least-once (a crash re-delivers).
555
- console.log(JSON.stringify(annotation));
556
- await writeCursor(cwd, annotation.seq);
557
- } while (watch);
855
+ after = a.seq;
856
+ handed += 1;
857
+ }
858
+ }
859
+ /**
860
+ * THE ORDERING THAT MAKES DELIVERY AT-LEAST-ONCE.
861
+ *
862
+ * The cursor advances only inside write's completion callback, having fired
863
+ * without error. On a PIPE — the consumer case this channel exists for —
864
+ * `write()` is ASYNCHRONOUS and returns before the bytes reach the OS.
865
+ * Advancing on return and then being killed loses the annotation with the
866
+ * cursor already past it: at-most-once, silently, with no way for the creator to
867
+ * learn their words were gone.
868
+ *
869
+ * `console.log` is barred from this path for the same reason — it offers no
870
+ * completion callback, so there is no point at which the cursor may legally
871
+ * advance. There is no `stdout.flush()`; Node streams have no such method, and
872
+ * the callback is the only correct signal.
873
+ */
874
+ async function handOver(cwd, a) {
875
+ const line = JSON.stringify(a) + "\n";
876
+ await new Promise((resolve, reject) => {
877
+ process.stdout.write(line, (err) => (err ? reject(err) : resolve()));
878
+ });
879
+ await writeCursor(cwd, a.seq);
558
880
  }
559
881
  // ---- meta -------------------------------------------------------------------
560
882
  /**
@@ -681,7 +1003,7 @@ async function assets() {
681
1003
  // Not signed in — the exit-4 screen, because we did not answer. The library is
682
1004
  // a platform call, and the creator's PROJECT is unaffected either way.
683
1005
  const { unreachableLines } = await import("../library/render.js");
684
- for (const l of unreachableLines("auth", "vincentt assets search \"<what you want>\"")) {
1006
+ for (const l of unreachableLines("auth", 'vincentt assets search "<what you want>"')) {
685
1007
  console.error(l);
686
1008
  }
687
1009
  process.exitCode = 4;
@@ -738,68 +1060,9 @@ async function relay() {
738
1060
  });
739
1061
  }
740
1062
  // ---- dispatch ---------------------------------------------------------------
741
- function usage() {
742
- console.log(`Vincentt CLI — local-first AR dev loop\n\n` +
743
- `Usage: vincentt <command> [options]\n\n` +
744
- `Getting started (building a new app with a coding agent)\n` +
745
- ` 1. Confirm a project name with the developer first.\n` +
746
- ` 2. Scaffold: vincentt init <dir> (no account needed)\n` +
747
- ` 3. Register: vincentt create --name "<name>"\n` +
748
- ` 4. Read AGENTS.md, confirm it is ready, and stop — do NOT invent an app.\n` +
749
- ` Wait for the developer to describe what to build.\n` +
750
- ` Full guide: npm view @vincentt-xr/cli readme\n\n` +
751
- `Auth\n` +
752
- ` login Sign in; save an access token to ~/.vincentt/config.json\n` +
753
- ` [--api <url>] [--console <url>] to target a non-prod env\n` +
754
- ` logout Remove the saved access token\n` +
755
- ` whoami Show the signed-in account\n\n` +
756
- `Project\n` +
757
- ` init [dir] Scaffold a Vincentt XR app — no account needed\n` +
758
- ` [--no-install] [--template <version>]\n` +
759
- ` create Register a project and reserve its address\n` +
760
- ` [--name <s>] [--slug <s>] [--org <s>] [--preview] [--force]\n` +
761
- ` link <project-id> Link this folder to an existing project\n` +
762
- ` unlink Disconnect this folder from its project\n\n` +
763
- `Dev loop\n` +
764
- ` publish Upload the built dist; go live at <slug>.vincentt.app\n` +
765
- ` [--dist <path>] [--note <s>] [--yes]\n` +
766
- ` preview Serve the app at an https link a phone can open\n` +
767
- ` [--port <n>] [--tester <id>] [--no-qr] [--stop]\n` +
768
- ` Re-running prints the live link; --restart mints a new one.\n\n` +
769
- `Diagnostics (need a running preview)\n` +
770
- ` logs [--errors] Console logs from the device [--json] [--tester <id>]\n` +
771
- ` network fetch/XHR requests [--json] [--tester <id>]\n` +
772
- ` trace Performance samples [--json] [--tester <id>]\n` +
773
- ` --tester t1 filters to one tester (one tab, across\n` +
774
- ` its refreshes). --viewer <label> filters to a single\n` +
775
- ` connection; one tab opens several.\n\n` +
776
- `Asset library\n` +
777
- ` assets search "<query>" Find curated assets [--kind prop|frame]\n` +
778
- ` [--render 3d-clay] [--palette sage] [--limit 20] [--json]\n` +
779
- ` assets show <id> [<id>…] Everything known about an asset [--json]\n` +
780
- ` assets add <id> [<id>…] Print the URLs to reference from your source\n` +
781
- ` Writes nothing. [--download [--dir <path>] [--force]]\n` +
782
- ` [--json]\n` +
783
- ` Ids look like lib_7f3a2c67d4e5b6a7; search returns\n` +
784
- ` them. Reference only ids the tool returned.\n\n` +
785
- `Reverse channel\n` +
786
- ` feedback --wait Block for the next phone annotation, print it, exit\n` +
787
- ` feedback --watch Stream annotations as they arrive\n\n` +
788
- `Servers\n` +
789
- ` relay Run the diagnostics/annotation relay\n\n` +
790
- `Meta\n` +
791
- ` version CLI version, and the API this machine resolves to\n` +
792
- ` dx Where this dev loop fought you [--days <n>] [--json]\n` +
793
- ` Reads a local journal of your own runs. Local by default.\n` +
794
- ` --send reports the findings to the Vincentt team; it\n` +
795
- ` shows the exact payload and asks first. [--note <text>]\n` +
796
- ` dx enable Allow reports to be sent from this machine without asking.\n` +
797
- ` Asks you to confirm in your terminal. Stays on until you\n` +
798
- ` disable it, and is shown on \`vincentt dx\` and\n` +
799
- ` \`vincentt version\` while on.\n` +
800
- ` dx disable Turn that off again.\n\n` +
801
- `Common options: --dir <path> (project dir, default cwd)\n` +
802
- ` --allow-custom-api honor a binding's apiUrl override\n`);
1063
+ export function usage() {
1064
+ console.log(USAGE_TEXT);
1065
+ return USAGE_TEXT;
803
1066
  }
804
1067
  async function main() {
805
1068
  const cmd = argv[0];
@@ -814,7 +1077,19 @@ async function main() {
814
1077
  usage();
815
1078
  return;
816
1079
  }
817
- switch (cmd) {
1080
+ const verb = resolveVerb(cmd);
1081
+ if (verb === undefined) {
1082
+ // Flow L30: rolling back and taking an address offline are console
1083
+ // actions, and a bare "unknown command" here would be the product failing
1084
+ // to answer a question it knows the answer to.
1085
+ if (cmd !== undefined && isConsoleOnlyVerb(cmd))
1086
+ return consoleOnly(cmd);
1087
+ console.error(`Unknown command: ${cmd}\n`);
1088
+ usage();
1089
+ process.exitCode = 1;
1090
+ return;
1091
+ }
1092
+ switch (verb) {
818
1093
  case "version":
819
1094
  return version();
820
1095
  case "login":
@@ -831,6 +1106,8 @@ async function main() {
831
1106
  return link();
832
1107
  case "unlink":
833
1108
  return unlink();
1109
+ case "projects":
1110
+ return projects();
834
1111
  case "publish":
835
1112
  return publish();
836
1113
  case "preview":
@@ -849,15 +1126,6 @@ async function main() {
849
1126
  return assets();
850
1127
  case "dx":
851
1128
  return dx();
852
- default:
853
- // Flow L30: rolling back and taking an address offline are console
854
- // actions, and a bare "unknown command" here would be the product failing
855
- // to answer a question it knows the answer to.
856
- if (cmd !== undefined && isConsoleOnlyVerb(cmd))
857
- return consoleOnly(cmd);
858
- console.error(`Unknown command: ${cmd}\n`);
859
- usage();
860
- process.exitCode = 1;
861
1129
  }
862
1130
  }
863
1131
  // Every invocation is journaled here, at the one site that sees all of them —
@@ -0,0 +1 @@
1
+ export declare const USAGE_TEXT: string;