@vincentt-xr/cli 0.1.5 → 0.3.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 (92) hide show
  1. package/README.md +94 -0
  2. package/dist/cli/args.d.ts +14 -0
  3. package/dist/cli/args.js +51 -0
  4. package/dist/cli/index.js +416 -100
  5. package/dist/lib/backend.d.ts +44 -20
  6. package/dist/lib/backend.js +98 -51
  7. package/dist/lib/diagnostics.d.ts +27 -2
  8. package/dist/lib/diagnostics.js +73 -8
  9. package/dist/lib/initCommand.d.ts +14 -0
  10. package/dist/lib/initCommand.js +75 -0
  11. package/dist/lib/lifecycle.d.ts +85 -19
  12. package/dist/lib/lifecycle.js +133 -130
  13. package/dist/lib/org.d.ts +29 -0
  14. package/dist/lib/org.js +45 -0
  15. package/dist/lib/projects.d.ts +82 -0
  16. package/dist/lib/projects.js +217 -0
  17. package/dist/lib/relayInfo.d.ts +41 -3
  18. package/dist/lib/relayInfo.js +94 -0
  19. package/dist/lib/whoami.d.ts +11 -0
  20. package/dist/lib/whoami.js +38 -0
  21. package/dist/login/authBackend.d.ts +30 -0
  22. package/dist/login/authBackend.js +80 -0
  23. package/dist/login/guard.d.ts +13 -0
  24. package/dist/login/guard.js +17 -0
  25. package/dist/login/loginCommand.d.ts +39 -0
  26. package/dist/login/loginCommand.js +72 -0
  27. package/dist/login/pkce.d.ts +47 -0
  28. package/dist/login/pkce.js +106 -0
  29. package/dist/login/pkceLogin.d.ts +53 -0
  30. package/dist/login/pkceLogin.js +152 -0
  31. package/dist/mcp/server.js +52 -25
  32. package/dist/preview/api.d.ts +84 -0
  33. package/dist/preview/api.js +174 -0
  34. package/dist/preview/command.d.ts +96 -0
  35. package/dist/preview/command.js +276 -0
  36. package/dist/preview/copy.d.ts +209 -0
  37. package/dist/preview/copy.js +444 -0
  38. package/dist/preview/index.d.ts +2 -3
  39. package/dist/preview/index.js +5 -6
  40. package/dist/preview/proxy.d.ts +12 -1
  41. package/dist/preview/proxy.js +17 -7
  42. package/dist/preview/qr.d.ts +30 -0
  43. package/dist/preview/qr.js +89 -0
  44. package/dist/preview/runner.d.ts +155 -30
  45. package/dist/preview/runner.js +219 -68
  46. package/dist/preview/stream.d.ts +52 -0
  47. package/dist/preview/stream.js +171 -0
  48. package/dist/preview/viewers.d.ts +113 -0
  49. package/dist/preview/viewers.js +242 -0
  50. package/dist/publish/api.d.ts +67 -0
  51. package/dist/publish/api.js +96 -0
  52. package/dist/publish/bounds.d.ts +25 -0
  53. package/dist/publish/bounds.js +53 -0
  54. package/dist/publish/classify.d.ts +51 -0
  55. package/dist/publish/classify.js +138 -0
  56. package/dist/publish/command.d.ts +27 -0
  57. package/dist/publish/command.js +328 -0
  58. package/dist/publish/consoleVerbs.d.ts +17 -0
  59. package/dist/publish/consoleVerbs.js +26 -0
  60. package/dist/publish/copy.d.ts +180 -0
  61. package/dist/publish/copy.js +606 -0
  62. package/dist/publish/defaults.d.ts +16 -0
  63. package/dist/publish/defaults.js +52 -0
  64. package/dist/publish/manifest.d.ts +27 -0
  65. package/dist/publish/manifest.js +81 -0
  66. package/dist/publish/paths.d.ts +25 -0
  67. package/dist/publish/paths.js +78 -0
  68. package/dist/publish/progress.d.ts +17 -0
  69. package/dist/publish/progress.js +45 -0
  70. package/dist/publish/prompt.d.ts +4 -0
  71. package/dist/publish/prompt.js +31 -0
  72. package/dist/publish/qa.f4.fixtures.d.ts +25 -0
  73. package/dist/publish/qa.f4.fixtures.js +140 -0
  74. package/dist/publish/upload.d.ts +30 -0
  75. package/dist/publish/upload.js +93 -0
  76. package/dist/relay/server.d.ts +32 -0
  77. package/dist/relay/server.js +80 -12
  78. package/dist/relay/store.d.ts +32 -3
  79. package/dist/relay/store.js +76 -22
  80. package/dist/relay/testers.d.ts +90 -0
  81. package/dist/relay/testers.js +144 -0
  82. package/dist/shared/authError.d.ts +1 -0
  83. package/dist/shared/authError.js +11 -0
  84. package/dist/shared/config.d.ts +73 -5
  85. package/dist/shared/config.js +133 -20
  86. package/package.json +2 -3
  87. package/dist/login/login.d.ts +0 -34
  88. package/dist/login/login.js +0 -148
  89. package/dist/preview/cloudflared.d.ts +0 -13
  90. package/dist/preview/cloudflared.js +0 -46
  91. package/dist/preview/tunnel.d.ts +0 -14
  92. package/dist/preview/tunnel.js +0 -28
package/dist/cli/index.js CHANGED
@@ -5,20 +5,37 @@
5
5
  //
6
6
  // Verb groups:
7
7
  // auth login · logout · whoami
8
- // dev loop create · publish · preview
8
+ // project init · create · link · unlink
9
+ // dev loop publish · preview
9
10
  // diagnostics logs · network · trace
10
11
  // reverse feedback (--wait | --watch)
11
12
  // servers relay · mcp
12
13
  import os from "node:os";
13
14
  import path from "node:path";
14
- import { runLogin } from "../login/login.js";
15
+ import { runPkceLogin } from "../login/pkceLogin.js";
16
+ import { httpAuthBackend } from "../login/authBackend.js";
17
+ import { loginCommand } from "../login/loginCommand.js";
15
18
  import { ensureLoggedIn } from "../login/ensure.js";
16
- import { clearMachineToken, clearProjectBinding, loadMachineConfig, } from "../shared/config.js";
17
- import { getMe } from "../lib/backend.js";
18
- import { diagQuery, diagRead, previewStart, previewStop, previewStopSignal, projectCreate, projectPublish, } from "../lib/lifecycle.js";
19
- import { writeQrPage } from "../preview/qrPage.js";
19
+ import { clearMachineToken, loadMachineConfig, locateProjectBinding, resolveConfig, MACHINE_CONFIG_PATH, } from "../shared/config.js";
20
+ import { getMe, ProjectUnreachableError } from "../lib/backend.js";
21
+ import { initCommand } from "../lib/initCommand.js";
22
+ import { linkCommand, printUnreachable, unlinkCommand } from "../lib/projects.js";
23
+ import { whoamiCommand } from "../lib/whoami.js";
24
+ import { diagQuery, diagRead, previewStart, previewStop, previewStopSignal, projectCreate, } from "../lib/lifecycle.js";
25
+ import { publishCommand } from "../publish/command.js";
26
+ import { stdinConfirm } from "../publish/prompt.js";
27
+ import { consoleVerbCommand, isConsoleOnlyVerb } from "../publish/consoleVerbs.js";
28
+ import { PreviewUi } from "../preview/command.js";
29
+ import { httpRelayClient } from "../lib/diagnostics.js";
30
+ import { readLivePreview } from "../lib/relayInfo.js";
31
+ import { mintRateLimited, notSignedIn, previewConflict, startBlock, } from "../preview/copy.js";
32
+ import { renderTerminalQr, shouldRenderQr } from "../preview/qr.js";
33
+ import { PreviewConflictError, PreviewRateLimitedError } from "../preview/api.js";
20
34
  import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
21
- const DEFAULT_EDITOR_URL = "https://editor.vincentt.studio";
35
+ import { isHelpInvocation, positionalArgs, refusedFlag } from "./args.js";
36
+ // The console origin serves /cli-auth (the browser target for `vincentt login`). VINCENTT_
37
+ // CONSOLE_URL overrides for dev/dogfood; VINCENTT_EDITOR_URL is honored as a legacy alias.
38
+ const DEFAULT_CONSOLE_URL = "https://vincentt.app";
22
39
  const DEFAULT_API_URL = "https://api.vincentt.studio";
23
40
  // ---- arg helpers ------------------------------------------------------------
24
41
  const argv = process.argv.slice(2);
@@ -37,20 +54,54 @@ function projectDir() {
37
54
  const dir = flag("--dir");
38
55
  return dir ? path.resolve(dir) : process.cwd();
39
56
  }
57
+ /** The nth bare argument (`vincentt link <id>`), skipping flags and their values. */
58
+ function positional(n) {
59
+ return positionalArgs(argv)[n];
60
+ }
40
61
  // ---- auth -------------------------------------------------------------------
41
- async function login() {
42
- const editorUrl = flag("--editor") ?? process.env.VINCENTT_EDITOR_URL ?? DEFAULT_EDITOR_URL;
62
+ function resolveAuthUrls() {
63
+ const consoleUrl = flag("--console") ??
64
+ flag("--editor") ??
65
+ process.env.VINCENTT_CONSOLE_URL ??
66
+ process.env.VINCENTT_EDITOR_URL ??
67
+ DEFAULT_CONSOLE_URL;
43
68
  const apiUrl = flag("--api") ?? process.env.VINCENTT_API_URL ?? DEFAULT_API_URL;
44
- const tokenName = flag("--name") ?? `Vincentt CLI (${os.hostname()})`;
45
- console.log("Opening your browser to authorize this device…");
46
- const { apiUrl: saved, configPath } = await runLogin({
47
- editorUrl,
69
+ return { consoleUrl, apiUrl };
70
+ }
71
+ /** The PKCE loopback + code-exchange login (f1). The raw PAT never rides a redirect query. */
72
+ async function runLoginFlow() {
73
+ const { consoleUrl, apiUrl } = resolveAuthUrls();
74
+ return runPkceLogin({
75
+ consoleUrl,
48
76
  apiUrl,
49
- tokenName,
50
- onUrl: (url) => console.log(`\nIf your browser didn't open, visit:\n ${url}\n`),
77
+ onProgress: (line) => console.log(line),
51
78
  });
52
- console.log(`\n✓ Signed in. Access token saved to ${configPath}`);
53
- console.log(` API: ${saved}`);
79
+ }
80
+ /** `vincentt login` [--force] — the L1/L2/L4 transcripts. */
81
+ async function login() {
82
+ const { apiUrl } = resolveAuthUrls();
83
+ const code = await loginCommand({
84
+ print: (line) => console.log(line),
85
+ currentToken,
86
+ whoAmI: async () => {
87
+ const pat = await currentToken();
88
+ if (!pat)
89
+ return undefined;
90
+ try {
91
+ return await httpAuthBackend(apiUrl).me(pat);
92
+ }
93
+ catch {
94
+ return undefined;
95
+ }
96
+ },
97
+ runLogin: runLoginFlow,
98
+ clearToken: async () => {
99
+ await clearMachineToken();
100
+ },
101
+ home: os.homedir(),
102
+ configPath: MACHINE_CONFIG_PATH,
103
+ }, { force: has("--force") });
104
+ process.exitCode = code;
54
105
  }
55
106
  /** The token that would authenticate a machine call, if any (env wins over file). */
56
107
  async function currentToken() {
@@ -68,41 +119,47 @@ async function logout() {
68
119
  const { path: p, hadToken } = await clearMachineToken();
69
120
  console.log(hadToken ? `✓ Signed out. Token removed from ${p}` : "Already signed out.");
70
121
  }
122
+ /**
123
+ * `whoami` reads the MACHINE config's API host, never the folder's binding — it
124
+ * answers "who am I", and reading the binding would make it answer a question
125
+ * about the folder instead.
126
+ */
71
127
  async function whoami() {
72
128
  const machine = await loadMachineConfig();
73
- const apiUrl = process.env.VINCENTT_API_URL ?? machine.apiUrl ?? DEFAULT_API_URL;
74
- const pat = process.env.VINCENTT_PAT ?? machine.pat;
75
- if (!pat) {
76
- console.log("Not signed in. Run `vincentt login`.");
77
- process.exitCode = 1;
78
- return;
79
- }
80
- const cfg = { apiUrl: apiUrl.replace(/\/$/, ""), pat };
81
- try {
82
- const me = await getMe(cfg);
83
- const who = me.email ?? me.name ?? me.id ?? "(unknown account)";
84
- console.log(`Signed in as ${who}`);
85
- console.log(` API: ${cfg.apiUrl}`);
86
- }
87
- catch (err) {
88
- // Degrade: offline or a backend without /me still confirms a token is present.
89
- console.log(`API: ${cfg.apiUrl}`);
90
- console.log(`Token present but unverified (${err instanceof Error ? err.message : String(err)}).`);
91
- }
129
+ process.exitCode = await whoamiCommand({
130
+ print: (l) => console.log(l),
131
+ config: async () => {
132
+ const apiUrl = process.env.VINCENTT_API_URL ?? machine.apiUrl ?? DEFAULT_API_URL;
133
+ const pat = process.env.VINCENTT_PAT ?? machine.pat;
134
+ if (!pat)
135
+ return undefined;
136
+ const cfg = { apiUrl: apiUrl.replace(/\/$/, ""), pat };
137
+ return cfg;
138
+ },
139
+ getMe,
140
+ org: {
141
+ flag: flag("--org"),
142
+ env: process.env.VINCENTT_ORG,
143
+ config: machine.activeOrg,
144
+ },
145
+ });
146
+ }
147
+ // ---- project ----------------------------------------------------------------
148
+ async function init() {
149
+ process.exitCode = await initCommand({ print: (l) => console.log(l), cwd: process.cwd() }, { dir: positional(1), skipInstall: has("--no-install") });
92
150
  }
93
- // ---- dev loop ---------------------------------------------------------------
94
151
  async function create() {
95
152
  await requireLogin();
96
153
  const result = await projectCreate(projectDir(), {
97
154
  name: flag("--name"),
98
155
  slug: flag("--slug"),
99
- scaffold: has("--no-scaffold") ? false : undefined,
156
+ org: flag("--org"),
100
157
  force: has("--force"),
101
- onProgress: (m) => console.error(` ${m}`),
158
+ allowCustomApi: has("--allow-custom-api"),
159
+ onLine: (l) => console.log(l),
102
160
  });
103
- console.log(result.message);
104
- if (result.alreadyBound) {
105
- process.exitCode = 1;
161
+ if (result.exitCode !== 0) {
162
+ process.exitCode = result.exitCode;
106
163
  return;
107
164
  }
108
165
  // Chain straight into the dev loop when asked.
@@ -111,68 +168,301 @@ async function create() {
111
168
  await preview();
112
169
  }
113
170
  }
114
- async function unbind() {
115
- const { path: p, hadBinding } = await clearProjectBinding(projectDir());
116
- console.log(hadBinding ? `✓ Unbound. Removed ${p}` : "This directory has no project binding.");
117
- }
118
- async function publish() {
171
+ async function link() {
119
172
  await requireLogin();
120
- const result = await projectPublish(projectDir(), {
121
- distDir: flag("--dist"),
122
- note: flag("--note"),
173
+ const cwd = projectDir();
174
+ process.exitCode = await linkCommand({
175
+ print: (l) => console.log(l),
176
+ cwd,
177
+ config: () => resolveConfig(cwd, {
178
+ allowCustomApi: has("--allow-custom-api"),
179
+ warn: (l) => console.log(l),
180
+ }),
181
+ }, positional(1));
182
+ }
183
+ async function unlink() {
184
+ const cwd = projectDir();
185
+ process.exitCode = await unlinkCommand({
186
+ print: (l) => console.log(l),
187
+ cwd,
188
+ // unlink makes no server call, so this is never invoked.
189
+ config: () => resolveConfig(cwd),
123
190
  });
124
- console.log(result.message);
125
- if (result.notBound)
191
+ }
192
+ /**
193
+ * `vincentt publish` — Flows L22-L31.
194
+ *
195
+ * The whole gate lives here: the checklist, the permanence disclosure, the
196
+ * confirmation, the upload and the live URL. There is no browser step in
197
+ * publishing, not on the first publish, not ever.
198
+ */
199
+ async function publish() {
200
+ const refused = refusedFlag(argv);
201
+ if (refused) {
202
+ console.log(`✗ There's no \`${refused}\` option.`);
203
+ console.log("");
204
+ console.log(" The link-preview check reports what it found; it never edits your");
205
+ console.log(" source. Fix the tags in your own files, rebuild, then publish again.");
126
206
  process.exitCode = 1;
207
+ return;
208
+ }
209
+ const cwd = projectDir();
210
+ process.exitCode = await publishCommand({
211
+ print: (l) => console.log(l),
212
+ write: (s) => process.stdout.write(s),
213
+ confirm: stdinConfirm(),
214
+ cwd,
215
+ config: () => resolveConfig(cwd, {
216
+ allowCustomApi: has("--allow-custom-api"),
217
+ warn: (l) => console.log(l),
218
+ }),
219
+ binding: async () => {
220
+ const located = await locateProjectBinding(cwd);
221
+ return located
222
+ ? { projectId: located.binding.projectId, slug: located.binding.slug }
223
+ : undefined;
224
+ },
225
+ token: currentToken,
226
+ isTTY: Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY),
227
+ now: () => Date.now(),
228
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
229
+ orgName: activeOrgName,
230
+ }, { distDir: flag("--dist"), note: flag("--note"), yes: has("--yes") });
127
231
  }
232
+ /** Flow L30 — the six verbs that are console actions, answered rather than rejected. */
233
+ async function consoleOnly(verb) {
234
+ const cwd = projectDir();
235
+ process.exitCode = await consoleVerbCommand({
236
+ print: (l) => console.log(l),
237
+ projectId: async () => (await locateProjectBinding(cwd))?.binding.projectId,
238
+ consoleUrl: flag("--console") ?? process.env.VINCENTT_CONSOLE_URL,
239
+ }, verb);
240
+ }
241
+ /**
242
+ * `vincentt preview` — Flows L13-L21.
243
+ *
244
+ * The verb does something a creator could easily misread, so the FOLLOW path
245
+ * says what it is doing in its first line: in a folder with a live preview,
246
+ * `vincentt preview` ATTACHES to it and follows. It does not mint, does not
247
+ * print a QR, and does not supersede. A second preview for the same tree is
248
+ * created only when the working tree differs.
249
+ */
128
250
  async function preview() {
129
- // `--stop` reaps a preview started by another process (e.g. a backgrounded run)
130
- // via its pidfile — no need to hunt for the cloudflared pid by hand.
251
+ const cwd = projectDir();
131
252
  if (has("--stop")) {
132
- const { message } = await previewStopSignal(projectDir());
253
+ const { message } = await previewStopSignal(cwd);
133
254
  console.log(message);
134
255
  return;
135
256
  }
136
- await requireLogin();
137
- const { preview: p, started } = await previewStart(projectDir(), (m) => console.error(`[preview] ${m}`));
138
- if (!started) {
139
- console.log(`A preview is already running:\n${p.url}`);
257
+ // The gate is LOCAL and NOTHING IS ATTEMPTED: no spinner, no partial mint, no
258
+ // session left in `pending`. The command stops before it touches the network.
259
+ if (!(await currentToken())) {
260
+ for (const l of notSignedIn())
261
+ console.log(l);
262
+ process.exitCode = 1;
263
+ return;
264
+ }
265
+ const filterViewer = flag("--viewer");
266
+ const filterTester = flag("--tester");
267
+ const color = Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined;
268
+ const ui = new PreviewUi({
269
+ print: (l) => console.log(l),
270
+ now: () => Date.now(),
271
+ targetUrl: `http://localhost:${numFlag("--port") ?? 5173}`,
272
+ color,
273
+ filterTester,
274
+ filterViewer,
275
+ });
276
+ // A preview already running for this folder — in ANOTHER process — is
277
+ // FOLLOWED, not superseded. `vincentt preview` in a folder with a live
278
+ // preview attaches to it: it does not mint, does not print a QR, and does not
279
+ // take the link away from whoever is holding a phone.
280
+ //
281
+ // The marker is VALIDATED first. It only ever proved a preview was STARTED,
282
+ // and a crashed CLI, a kill -9 or a reboot all leave it behind — an unvalidated
283
+ // one short-circuited the mint into a dead end whose only cure was deleting a
284
+ // dotfile the creator does not know exists. A stale marker is cleared and
285
+ // treated as absent, which lands on the mint the creator actually asked for.
286
+ const existing = await readLivePreview(cwd);
287
+ if (existing !== undefined) {
288
+ await followPreview(existing, ui, filterTester, filterViewer);
140
289
  return;
141
290
  }
142
- console.log("\nLive preview:");
143
- console.log(` Open on your phone: ${p.url}`);
144
- // A scannable QR in the terminal is unreliable across emulators, so hand back a
145
- // desktop QR page to open and scan instead. Best-effort — a preview is still
146
- // usable via the URL above if writing the page fails.
291
+ let ending = null;
292
+ let resolveDone;
293
+ const done = new Promise((r) => {
294
+ resolveDone = r;
295
+ });
296
+ let running;
147
297
  try {
148
- const qrUrl = await writeQrPage(projectDir(), p.url);
149
- console.log(` Show QR (desktop): ${qrUrl}`);
298
+ const started = await previewStart(cwd, (m) => console.error(`[preview] ${m}`), {
299
+ appPort: numFlag("--port"),
300
+ appStdio: has("--verbose") ? "inherit" : "ignore",
301
+ onViewer: (label, uaClass) => ui.viewerSeen(label, uaClass),
302
+ onViewerGone: (label) => ui.viewerGone(label),
303
+ onEvents: (events, tags) => ui.events(events, tags),
304
+ onCapReached: () => ui.capReached(),
305
+ onTunnelState: (state) => ui.tunnel(state),
306
+ onOriginError: () => ui.originError(),
307
+ onOriginRecovered: () => ui.originRecovered(),
308
+ onExpiring: () => ui.checkHardMax(running?.expiresAt ?? ""),
309
+ onEnded: (reason) => {
310
+ ending = reason;
311
+ resolveDone?.();
312
+ },
313
+ });
314
+ running = started.preview;
315
+ if (!started.started) {
316
+ // Already running IN THIS PROCESS. The mint path never reaches here.
317
+ console.log(`A preview is already running:\n${running.url}`);
318
+ return;
319
+ }
320
+ }
321
+ catch (err) {
322
+ for (const l of previewFailureLines(err))
323
+ console.log(l);
324
+ process.exitCode = 1;
325
+ return;
326
+ }
327
+ const qr = shouldRenderQr({
328
+ isTTY: Boolean(process.stdout.isTTY),
329
+ noQr: has("--no-qr"),
330
+ columns: process.stdout.columns,
331
+ })
332
+ ? await renderTerminalQr(running.url).catch(() => undefined)
333
+ : undefined;
334
+ for (const l of startBlock({
335
+ projectName: await projectDisplayName(cwd),
336
+ orgName: await activeOrgName(),
337
+ targetUrl: `http://localhost:${running.appPort}`,
338
+ url: running.url,
339
+ qr,
340
+ expiresInHours: 12,
341
+ })) {
342
+ console.log(l);
343
+ }
344
+ // The hard-max warning is checked on a slow tick rather than scheduled at a
345
+ // fixed offset: a laptop that slept through the scheduled instant would never
346
+ // fire a one-shot timer, and the 30-minute warning is the one the creator
347
+ // plans a demo around.
348
+ const ceiling = setInterval(() => ui.checkHardMax(running.expiresAt), 60_000);
349
+ ceiling.unref?.();
350
+ const stop = () => {
351
+ ui.stopping();
352
+ void previewStop().then(() => {
353
+ ui.stopped();
354
+ resolveDone?.();
355
+ });
356
+ };
357
+ process.on("SIGINT", stop);
358
+ process.on("SIGTERM", stop);
359
+ await done;
360
+ clearInterval(ceiling);
361
+ if (ending !== null) {
362
+ ui.endedBy(ending, (await activeOrgName()) || "your organization");
363
+ await previewStop();
364
+ process.exitCode = 1;
150
365
  }
151
- catch {
152
- /* non-fatal: the URL above is enough to preview */
366
+ }
367
+ /**
368
+ * Flow L16 — follow a preview running in another terminal.
369
+ *
370
+ * It says what it is doing in its FIRST line, because this is the one flow
371
+ * where the CLI's verb does something the creator could easily misread. It
372
+ * polls the relay rather than opening a second tunnel: a second tunnel would be
373
+ * a second session, which is the "two live URLs, one stale" the design forbids.
374
+ */
375
+ async function followPreview(info, ui, filterTester, filterViewer) {
376
+ console.log("→ Attaching to the preview running in this folder");
377
+ const following = filterTester ?? filterViewer;
378
+ if (following !== undefined) {
379
+ console.log(`✓ Following ${following}`);
153
380
  }
154
- console.log("\nDiagnostics: vincentt logs | network | trace");
155
- console.log("Feedback: vincentt feedback --wait");
156
- console.log("\nStop: vincentt preview --stop (or Ctrl-C here).");
157
- if (!p.appReady) {
158
- console.log(`\nNote: the app on :${p.appPort} isn't responding yet — the URL may be blank until its build finishes.`);
381
+ else {
382
+ console.log("✓ Following this preview");
159
383
  }
160
- // Keep the process alive until interrupted; reap the tunnel/dev-server on exit.
161
- await new Promise((resolve) => {
162
- const stop = async () => {
163
- console.error("\nStopping preview…");
164
- await previewStop();
165
- resolve();
166
- };
167
- process.on("SIGINT", stop);
168
- process.on("SIGTERM", stop);
384
+ console.log("");
385
+ const relay = httpRelayClient(`http://localhost:${info.relayPort}`);
386
+ let since = -1;
387
+ let warnedMissing = false;
388
+ let stop = false;
389
+ process.on("SIGINT", () => {
390
+ stop = true;
169
391
  });
392
+ while (!stop) {
393
+ try {
394
+ const result = await relay.query({ since, tester: filterTester, viewer: filterViewer });
395
+ // An unknown id is a FACT, not an error — and the remedy goes on the same
396
+ // line. Said once rather than on every poll, or a two-second loop becomes
397
+ // a wall of identical advisories.
398
+ if (!warnedMissing &&
399
+ filterTester !== undefined &&
400
+ result.testers.length > 0 &&
401
+ !result.testers.includes(filterTester)) {
402
+ warnedMissing = true;
403
+ console.log(` ! No ${filterTester} on this preview right now. Connected: ${result.testers.join(", ")}.`);
404
+ }
405
+ // Both tags ride ALONGSIDE the events, index-parallel, so an unattributed
406
+ // event is dropped from the terminal rather than printed under an
407
+ // invented id that would read as a real tester.
408
+ result.events.forEach((e, i) => ui.events([e], { tester: result.eventTesters?.[i], viewer: result.eventViewers?.[i] }, { attribute: false }));
409
+ if (result.latestSeq > since)
410
+ since = result.latestSeq;
411
+ }
412
+ catch {
413
+ // The preview stopped, or its relay is gone. Either way the follower has
414
+ // nothing left to follow.
415
+ console.log("");
416
+ console.log("✓ The preview is no longer running.");
417
+ return;
418
+ }
419
+ await new Promise((r) => setTimeout(r, 1000));
420
+ }
421
+ console.log("");
422
+ console.log(" Ctrl-C to stop following. The preview keeps running.");
423
+ }
424
+ /**
425
+ * The org the creator is acting in, by the same precedence every other verb
426
+ * uses — flag, then env, then the machine config. Reading only the machine
427
+ * config would print a different org from the one the command acted in.
428
+ */
429
+ async function activeOrgName() {
430
+ return (flag("--org") ??
431
+ process.env.VINCENTT_ORG ??
432
+ (await loadMachineConfig()).activeOrg ??
433
+ "");
434
+ }
435
+ /** The binding's name is a HINT for display only; the API is the authority. */
436
+ async function projectDisplayName(cwd) {
437
+ const located = await locateProjectBinding(cwd);
438
+ return located?.binding.slug ?? path.basename(cwd);
439
+ }
440
+ /**
441
+ * Map a mint failure to its screen. Each cause has a DISTINCT first line so the
442
+ * creator never has to read a second, and none of them names the edge, the API
443
+ * or an outage — a creator cannot act on that.
444
+ */
445
+ function previewFailureLines(err) {
446
+ if (err instanceof PreviewRateLimitedError)
447
+ return mintRateLimited(err.retryAfterSec);
448
+ if (err instanceof PreviewConflictError)
449
+ return previewConflict();
450
+ if (err instanceof ProjectUnreachableError) {
451
+ const out = [];
452
+ printUnreachable((l) => out.push(l), err.message);
453
+ return ["", ...out, ""];
454
+ }
455
+ return ["", `✗ ${err instanceof Error ? err.message : String(err)}`, ""];
170
456
  }
171
457
  // ---- diagnostics ------------------------------------------------------------
172
458
  async function diag(kind) {
173
459
  const cwd = projectDir();
460
+ // There is NO `--session`. It filtered on the raw client-supplied key, which
461
+ // made it the one surface that would teach a creator that key exists — and it
462
+ // was the same value the terminal was printing back. `--tester` replaces it.
174
463
  const opts = {
175
- sessionId: flag("--session"),
464
+ tester: flag("--tester"),
465
+ viewer: flag("--viewer"),
176
466
  since: numFlag("--since"),
177
467
  limit: numFlag("--limit"),
178
468
  errorsOnly: has("--errors"),
@@ -227,31 +517,53 @@ async function relay() {
227
517
  function usage() {
228
518
  console.log(`Vincentt CLI — local-first AR dev loop\n\n` +
229
519
  `Usage: vincentt <command> [options]\n\n` +
520
+ `Getting started (building a new app with a coding agent)\n` +
521
+ ` 1. Confirm a project name with the developer first.\n` +
522
+ ` 2. Scaffold: vincentt init <dir> (no account needed)\n` +
523
+ ` 3. Register: vincentt create --name "<name>"\n` +
524
+ ` 4. Read AGENTS.md, confirm it is ready, and stop — do NOT invent an app.\n` +
525
+ ` Wait for the developer to describe what to build.\n` +
526
+ ` Full guide: npm view @vincentt-xr/cli readme\n\n` +
230
527
  `Auth\n` +
231
528
  ` login Sign in; save an access token to ~/.vincentt/config.json\n` +
232
529
  ` logout Remove the saved access token\n` +
233
- ` whoami Show the signed-in account (via GET /network/me)\n\n` +
530
+ ` whoami Show the signed-in account\n\n` +
531
+ `Project\n` +
532
+ ` init [dir] Scaffold a Vincentt XR app — no account needed\n` +
533
+ ` [--no-install]\n` +
534
+ ` create Register a project and reserve its address\n` +
535
+ ` [--name <s>] [--slug <s>] [--org <s>] [--preview] [--force]\n` +
536
+ ` link <project-id> Link this folder to an existing project\n` +
537
+ ` unlink Disconnect this folder from its project\n\n` +
234
538
  `Dev loop\n` +
235
- ` create Scaffold + bind a project (empty dir → full starter)\n` +
236
- ` [--name <s>] [--slug <s>] [--preview] [--force]\n` +
237
- ` unbind Detach this directory from its project\n` +
238
539
  ` publish Upload the built dist; go live at <slug>.vincentt.app\n` +
239
- ` preview Serve + tunnel the app for on-device preview\n` +
240
- ` (prints a desktop QR page to scan) [--stop]\n\n` +
540
+ ` [--dist <path>] [--note <s>] [--yes]\n` +
541
+ ` preview Serve the app at an https link a phone can open\n` +
542
+ ` [--port <n>] [--tester <id>] [--no-qr] [--stop]\n\n` +
241
543
  `Diagnostics (need a running preview)\n` +
242
- ` logs [--errors] Console logs from the device [--json]\n` +
243
- ` network fetch/XHR requests [--json]\n` +
244
- ` trace Performance samples [--json]\n\n` +
544
+ ` logs [--errors] Console logs from the device [--json] [--tester <id>]\n` +
545
+ ` network fetch/XHR requests [--json] [--tester <id>]\n` +
546
+ ` trace Performance samples [--json] [--tester <id>]\n` +
547
+ ` --tester t1 filters to one tester (one tab, across\n` +
548
+ ` its refreshes). --viewer <label> filters to a single\n` +
549
+ ` connection; one tab opens several.\n\n` +
245
550
  `Reverse channel\n` +
246
551
  ` feedback --wait Block for the next phone annotation, print it, exit\n` +
247
552
  ` feedback --watch Stream annotations as they arrive\n\n` +
248
553
  `Servers\n` +
249
554
  ` relay Run the diagnostics/annotation relay\n` +
250
555
  ` mcp Run the MCP adapter (for hosts without a shell)\n\n` +
251
- `Common options: --dir <path> (project dir, default cwd)\n`);
556
+ `Common options: --dir <path> (project dir, default cwd)\n` +
557
+ ` --allow-custom-api honor a binding's apiUrl override\n`);
252
558
  }
253
559
  async function main() {
254
560
  const cmd = argv[0];
561
+ // `--help`/`-h` anywhere means "explain, don't act" — never let it fall through to
562
+ // a verb. Otherwise `login --help` runs login, `create --help` scaffolds, etc.
563
+ if (isHelpInvocation(argv)) {
564
+ usage();
565
+ return;
566
+ }
255
567
  switch (cmd) {
256
568
  case "login":
257
569
  return login();
@@ -259,10 +571,14 @@ async function main() {
259
571
  return logout();
260
572
  case "whoami":
261
573
  return whoami();
574
+ case "init":
575
+ return init();
262
576
  case "create":
263
577
  return create();
264
- case "unbind":
265
- return unbind();
578
+ case "link":
579
+ return link();
580
+ case "unlink":
581
+ return unlink();
266
582
  case "publish":
267
583
  return publish();
268
584
  case "preview":
@@ -279,12 +595,12 @@ async function main() {
279
595
  return mcp();
280
596
  case "relay":
281
597
  return relay();
282
- case undefined:
283
- case "-h":
284
- case "--help":
285
- usage();
286
- return;
287
598
  default:
599
+ // Flow L30: rolling back and taking an address offline are console
600
+ // actions, and a bare "unknown command" here would be the product failing
601
+ // to answer a question it knows the answer to.
602
+ if (cmd !== undefined && isConsoleOnlyVerb(cmd))
603
+ return consoleOnly(cmd);
288
604
  console.error(`Unknown command: ${cmd}\n`);
289
605
  usage();
290
606
  process.exitCode = 1;