@vincentt-xr/cli 0.1.4 → 0.2.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 (72) hide show
  1. package/README.md +94 -0
  2. package/dist/cli/args.d.ts +13 -0
  3. package/dist/cli/args.js +41 -0
  4. package/dist/cli/index.js +362 -92
  5. package/dist/lib/backend.d.ts +45 -5
  6. package/dist/lib/backend.js +99 -18
  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 +73 -15
  12. package/dist/lib/lifecycle.js +111 -114
  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 +47 -20
  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/relay/server.d.ts +32 -0
  51. package/dist/relay/server.js +80 -12
  52. package/dist/relay/store.d.ts +32 -3
  53. package/dist/relay/store.js +76 -22
  54. package/dist/relay/testers.d.ts +90 -0
  55. package/dist/relay/testers.js +144 -0
  56. package/dist/shared/authError.d.ts +1 -0
  57. package/dist/shared/authError.js +11 -0
  58. package/dist/shared/config.d.ts +73 -5
  59. package/dist/shared/config.js +133 -20
  60. package/package.json +3 -7
  61. package/dist/cli/deprecated/harness-mcp.d.ts +0 -2
  62. package/dist/cli/deprecated/harness-mcp.js +0 -7
  63. package/dist/cli/deprecated/harness-relay.d.ts +0 -2
  64. package/dist/cli/deprecated/harness-relay.js +0 -7
  65. package/dist/cli/deprecated/harness.d.ts +0 -2
  66. package/dist/cli/deprecated/harness.js +0 -7
  67. package/dist/login/login.d.ts +0 -34
  68. package/dist/login/login.js +0 -148
  69. package/dist/preview/cloudflared.d.ts +0 -13
  70. package/dist/preview/cloudflared.js +0 -46
  71. package/dist/preview/tunnel.d.ts +0 -14
  72. package/dist/preview/tunnel.js +0 -28
package/dist/cli/index.js CHANGED
@@ -5,20 +5,34 @@
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";
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";
18
24
  import { diagQuery, diagRead, previewStart, previewStop, previewStopSignal, projectCreate, projectPublish, } from "../lib/lifecycle.js";
19
- import { writeQrPage } from "../preview/qrPage.js";
25
+ import { PreviewUi } from "../preview/command.js";
26
+ import { httpRelayClient } from "../lib/diagnostics.js";
27
+ import { readLivePreview } from "../lib/relayInfo.js";
28
+ import { mintRateLimited, notSignedIn, previewConflict, startBlock, } from "../preview/copy.js";
29
+ import { renderTerminalQr, shouldRenderQr } from "../preview/qr.js";
30
+ import { PreviewConflictError, PreviewRateLimitedError } from "../preview/api.js";
20
31
  import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
21
- const DEFAULT_EDITOR_URL = "https://editor.vincentt.studio";
32
+ import { isHelpInvocation, positionalArgs } from "./args.js";
33
+ // The console origin serves /cli-auth (the browser target for `vincentt login`). VINCENTT_
34
+ // CONSOLE_URL overrides for dev/dogfood; VINCENTT_EDITOR_URL is honored as a legacy alias.
35
+ const DEFAULT_CONSOLE_URL = "https://vincentt.app";
22
36
  const DEFAULT_API_URL = "https://api.vincentt.studio";
23
37
  // ---- arg helpers ------------------------------------------------------------
24
38
  const argv = process.argv.slice(2);
@@ -37,20 +51,54 @@ function projectDir() {
37
51
  const dir = flag("--dir");
38
52
  return dir ? path.resolve(dir) : process.cwd();
39
53
  }
54
+ /** The nth bare argument (`vincentt link <id>`), skipping flags and their values. */
55
+ function positional(n) {
56
+ return positionalArgs(argv)[n];
57
+ }
40
58
  // ---- auth -------------------------------------------------------------------
41
- async function login() {
42
- const editorUrl = flag("--editor") ?? process.env.VINCENTT_EDITOR_URL ?? DEFAULT_EDITOR_URL;
59
+ function resolveAuthUrls() {
60
+ const consoleUrl = flag("--console") ??
61
+ flag("--editor") ??
62
+ process.env.VINCENTT_CONSOLE_URL ??
63
+ process.env.VINCENTT_EDITOR_URL ??
64
+ DEFAULT_CONSOLE_URL;
43
65
  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,
66
+ return { consoleUrl, apiUrl };
67
+ }
68
+ /** The PKCE loopback + code-exchange login (f1). The raw PAT never rides a redirect query. */
69
+ async function runLoginFlow() {
70
+ const { consoleUrl, apiUrl } = resolveAuthUrls();
71
+ return runPkceLogin({
72
+ consoleUrl,
48
73
  apiUrl,
49
- tokenName,
50
- onUrl: (url) => console.log(`\nIf your browser didn't open, visit:\n ${url}\n`),
74
+ onProgress: (line) => console.log(line),
51
75
  });
52
- console.log(`\n✓ Signed in. Access token saved to ${configPath}`);
53
- console.log(` API: ${saved}`);
76
+ }
77
+ /** `vincentt login` [--force] — the L1/L2/L4 transcripts. */
78
+ async function login() {
79
+ const { apiUrl } = resolveAuthUrls();
80
+ const code = await loginCommand({
81
+ print: (line) => console.log(line),
82
+ currentToken,
83
+ whoAmI: async () => {
84
+ const pat = await currentToken();
85
+ if (!pat)
86
+ return undefined;
87
+ try {
88
+ return await httpAuthBackend(apiUrl).me(pat);
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ },
94
+ runLogin: runLoginFlow,
95
+ clearToken: async () => {
96
+ await clearMachineToken();
97
+ },
98
+ home: os.homedir(),
99
+ configPath: MACHINE_CONFIG_PATH,
100
+ }, { force: has("--force") });
101
+ process.exitCode = code;
54
102
  }
55
103
  /** The token that would authenticate a machine call, if any (env wins over file). */
56
104
  async function currentToken() {
@@ -68,41 +116,47 @@ async function logout() {
68
116
  const { path: p, hadToken } = await clearMachineToken();
69
117
  console.log(hadToken ? `✓ Signed out. Token removed from ${p}` : "Already signed out.");
70
118
  }
119
+ /**
120
+ * `whoami` reads the MACHINE config's API host, never the folder's binding — it
121
+ * answers "who am I", and reading the binding would make it answer a question
122
+ * about the folder instead.
123
+ */
71
124
  async function whoami() {
72
125
  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
- }
126
+ process.exitCode = await whoamiCommand({
127
+ print: (l) => console.log(l),
128
+ config: async () => {
129
+ const apiUrl = process.env.VINCENTT_API_URL ?? machine.apiUrl ?? DEFAULT_API_URL;
130
+ const pat = process.env.VINCENTT_PAT ?? machine.pat;
131
+ if (!pat)
132
+ return undefined;
133
+ const cfg = { apiUrl: apiUrl.replace(/\/$/, ""), pat };
134
+ return cfg;
135
+ },
136
+ getMe,
137
+ org: {
138
+ flag: flag("--org"),
139
+ env: process.env.VINCENTT_ORG,
140
+ config: machine.activeOrg,
141
+ },
142
+ });
143
+ }
144
+ // ---- project ----------------------------------------------------------------
145
+ async function init() {
146
+ process.exitCode = await initCommand({ print: (l) => console.log(l), cwd: process.cwd() }, { dir: positional(1), skipInstall: has("--no-install") });
92
147
  }
93
- // ---- dev loop ---------------------------------------------------------------
94
148
  async function create() {
95
149
  await requireLogin();
96
150
  const result = await projectCreate(projectDir(), {
97
151
  name: flag("--name"),
98
152
  slug: flag("--slug"),
99
- scaffold: has("--no-scaffold") ? false : undefined,
153
+ org: flag("--org"),
100
154
  force: has("--force"),
101
- onProgress: (m) => console.error(` ${m}`),
155
+ allowCustomApi: has("--allow-custom-api"),
156
+ onLine: (l) => console.log(l),
102
157
  });
103
- console.log(result.message);
104
- if (result.alreadyBound) {
105
- process.exitCode = 1;
158
+ if (result.exitCode !== 0) {
159
+ process.exitCode = result.exitCode;
106
160
  return;
107
161
  }
108
162
  // Chain straight into the dev loop when asked.
@@ -111,68 +165,264 @@ async function create() {
111
165
  await preview();
112
166
  }
113
167
  }
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.");
168
+ async function link() {
169
+ await requireLogin();
170
+ const cwd = projectDir();
171
+ process.exitCode = await linkCommand({
172
+ print: (l) => console.log(l),
173
+ cwd,
174
+ config: () => resolveConfig(cwd, {
175
+ allowCustomApi: has("--allow-custom-api"),
176
+ warn: (l) => console.log(l),
177
+ }),
178
+ }, positional(1));
179
+ }
180
+ async function unlink() {
181
+ const cwd = projectDir();
182
+ process.exitCode = await unlinkCommand({
183
+ print: (l) => console.log(l),
184
+ cwd,
185
+ // unlink makes no server call, so this is never invoked.
186
+ config: () => resolveConfig(cwd),
187
+ });
117
188
  }
118
189
  async function publish() {
119
190
  await requireLogin();
120
191
  const result = await projectPublish(projectDir(), {
121
192
  distDir: flag("--dist"),
122
193
  note: flag("--note"),
194
+ allowCustomApi: has("--allow-custom-api"),
195
+ warn: (l) => console.log(l),
123
196
  });
124
197
  console.log(result.message);
125
198
  if (result.notBound)
126
199
  process.exitCode = 1;
127
200
  }
201
+ /**
202
+ * `vincentt preview` — Flows L13-L21.
203
+ *
204
+ * The verb does something a creator could easily misread, so the FOLLOW path
205
+ * says what it is doing in its first line: in a folder with a live preview,
206
+ * `vincentt preview` ATTACHES to it and follows. It does not mint, does not
207
+ * print a QR, and does not supersede. A second preview for the same tree is
208
+ * created only when the working tree differs.
209
+ */
128
210
  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.
211
+ const cwd = projectDir();
131
212
  if (has("--stop")) {
132
- const { message } = await previewStopSignal(projectDir());
213
+ const { message } = await previewStopSignal(cwd);
133
214
  console.log(message);
134
215
  return;
135
216
  }
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}`);
217
+ // The gate is LOCAL and NOTHING IS ATTEMPTED: no spinner, no partial mint, no
218
+ // session left in `pending`. The command stops before it touches the network.
219
+ if (!(await currentToken())) {
220
+ for (const l of notSignedIn())
221
+ console.log(l);
222
+ process.exitCode = 1;
140
223
  return;
141
224
  }
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.
225
+ const filterViewer = flag("--viewer");
226
+ const filterTester = flag("--tester");
227
+ const color = Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined;
228
+ const ui = new PreviewUi({
229
+ print: (l) => console.log(l),
230
+ now: () => Date.now(),
231
+ targetUrl: `http://localhost:${numFlag("--port") ?? 5173}`,
232
+ color,
233
+ filterTester,
234
+ filterViewer,
235
+ });
236
+ // A preview already running for this folder — in ANOTHER process — is
237
+ // FOLLOWED, not superseded. `vincentt preview` in a folder with a live
238
+ // preview attaches to it: it does not mint, does not print a QR, and does not
239
+ // take the link away from whoever is holding a phone.
240
+ //
241
+ // The marker is VALIDATED first. It only ever proved a preview was STARTED,
242
+ // and a crashed CLI, a kill -9 or a reboot all leave it behind — an unvalidated
243
+ // one short-circuited the mint into a dead end whose only cure was deleting a
244
+ // dotfile the creator does not know exists. A stale marker is cleared and
245
+ // treated as absent, which lands on the mint the creator actually asked for.
246
+ const existing = await readLivePreview(cwd);
247
+ if (existing !== undefined) {
248
+ await followPreview(existing, ui, filterTester, filterViewer);
249
+ return;
250
+ }
251
+ let ending = null;
252
+ let resolveDone;
253
+ const done = new Promise((r) => {
254
+ resolveDone = r;
255
+ });
256
+ let running;
147
257
  try {
148
- const qrUrl = await writeQrPage(projectDir(), p.url);
149
- console.log(` Show QR (desktop): ${qrUrl}`);
258
+ const started = await previewStart(cwd, (m) => console.error(`[preview] ${m}`), {
259
+ appPort: numFlag("--port"),
260
+ appStdio: has("--verbose") ? "inherit" : "ignore",
261
+ onViewer: (label, uaClass) => ui.viewerSeen(label, uaClass),
262
+ onViewerGone: (label) => ui.viewerGone(label),
263
+ onEvents: (events, tags) => ui.events(events, tags),
264
+ onCapReached: () => ui.capReached(),
265
+ onTunnelState: (state) => ui.tunnel(state),
266
+ onOriginError: () => ui.originError(),
267
+ onOriginRecovered: () => ui.originRecovered(),
268
+ onExpiring: () => ui.checkHardMax(running?.expiresAt ?? ""),
269
+ onEnded: (reason) => {
270
+ ending = reason;
271
+ resolveDone?.();
272
+ },
273
+ });
274
+ running = started.preview;
275
+ if (!started.started) {
276
+ // Already running IN THIS PROCESS. The mint path never reaches here.
277
+ console.log(`A preview is already running:\n${running.url}`);
278
+ return;
279
+ }
150
280
  }
151
- catch {
152
- /* non-fatal: the URL above is enough to preview */
281
+ catch (err) {
282
+ for (const l of previewFailureLines(err))
283
+ console.log(l);
284
+ process.exitCode = 1;
285
+ return;
286
+ }
287
+ const qr = shouldRenderQr({
288
+ isTTY: Boolean(process.stdout.isTTY),
289
+ noQr: has("--no-qr"),
290
+ columns: process.stdout.columns,
291
+ })
292
+ ? await renderTerminalQr(running.url).catch(() => undefined)
293
+ : undefined;
294
+ for (const l of startBlock({
295
+ projectName: await projectDisplayName(cwd),
296
+ orgName: await activeOrgName(),
297
+ targetUrl: `http://localhost:${running.appPort}`,
298
+ url: running.url,
299
+ qr,
300
+ expiresInHours: 12,
301
+ })) {
302
+ console.log(l);
303
+ }
304
+ // The hard-max warning is checked on a slow tick rather than scheduled at a
305
+ // fixed offset: a laptop that slept through the scheduled instant would never
306
+ // fire a one-shot timer, and the 30-minute warning is the one the creator
307
+ // plans a demo around.
308
+ const ceiling = setInterval(() => ui.checkHardMax(running.expiresAt), 60_000);
309
+ ceiling.unref?.();
310
+ const stop = () => {
311
+ ui.stopping();
312
+ void previewStop().then(() => {
313
+ ui.stopped();
314
+ resolveDone?.();
315
+ });
316
+ };
317
+ process.on("SIGINT", stop);
318
+ process.on("SIGTERM", stop);
319
+ await done;
320
+ clearInterval(ceiling);
321
+ if (ending !== null) {
322
+ ui.endedBy(ending, (await activeOrgName()) || "your organization");
323
+ await previewStop();
324
+ process.exitCode = 1;
325
+ }
326
+ }
327
+ /**
328
+ * Flow L16 — follow a preview running in another terminal.
329
+ *
330
+ * It says what it is doing in its FIRST line, because this is the one flow
331
+ * where the CLI's verb does something the creator could easily misread. It
332
+ * polls the relay rather than opening a second tunnel: a second tunnel would be
333
+ * a second session, which is the "two live URLs, one stale" the design forbids.
334
+ */
335
+ async function followPreview(info, ui, filterTester, filterViewer) {
336
+ console.log("→ Attaching to the preview running in this folder");
337
+ const following = filterTester ?? filterViewer;
338
+ if (following !== undefined) {
339
+ console.log(`✓ Following ${following}`);
153
340
  }
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.`);
341
+ else {
342
+ console.log("✓ Following this preview");
159
343
  }
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);
344
+ console.log("");
345
+ const relay = httpRelayClient(`http://localhost:${info.relayPort}`);
346
+ let since = -1;
347
+ let warnedMissing = false;
348
+ let stop = false;
349
+ process.on("SIGINT", () => {
350
+ stop = true;
169
351
  });
352
+ while (!stop) {
353
+ try {
354
+ const result = await relay.query({ since, tester: filterTester, viewer: filterViewer });
355
+ // An unknown id is a FACT, not an error — and the remedy goes on the same
356
+ // line. Said once rather than on every poll, or a two-second loop becomes
357
+ // a wall of identical advisories.
358
+ if (!warnedMissing &&
359
+ filterTester !== undefined &&
360
+ result.testers.length > 0 &&
361
+ !result.testers.includes(filterTester)) {
362
+ warnedMissing = true;
363
+ console.log(` ! No ${filterTester} on this preview right now. Connected: ${result.testers.join(", ")}.`);
364
+ }
365
+ // Both tags ride ALONGSIDE the events, index-parallel, so an unattributed
366
+ // event is dropped from the terminal rather than printed under an
367
+ // invented id that would read as a real tester.
368
+ result.events.forEach((e, i) => ui.events([e], { tester: result.eventTesters?.[i], viewer: result.eventViewers?.[i] }, { attribute: false }));
369
+ if (result.latestSeq > since)
370
+ since = result.latestSeq;
371
+ }
372
+ catch {
373
+ // The preview stopped, or its relay is gone. Either way the follower has
374
+ // nothing left to follow.
375
+ console.log("");
376
+ console.log("✓ The preview is no longer running.");
377
+ return;
378
+ }
379
+ await new Promise((r) => setTimeout(r, 1000));
380
+ }
381
+ console.log("");
382
+ console.log(" Ctrl-C to stop following. The preview keeps running.");
383
+ }
384
+ /**
385
+ * The org the creator is acting in, by the same precedence every other verb
386
+ * uses — flag, then env, then the machine config. Reading only the machine
387
+ * config would print a different org from the one the command acted in.
388
+ */
389
+ async function activeOrgName() {
390
+ return (flag("--org") ??
391
+ process.env.VINCENTT_ORG ??
392
+ (await loadMachineConfig()).activeOrg ??
393
+ "");
394
+ }
395
+ /** The binding's name is a HINT for display only; the API is the authority. */
396
+ async function projectDisplayName(cwd) {
397
+ const located = await locateProjectBinding(cwd);
398
+ return located?.binding.slug ?? path.basename(cwd);
399
+ }
400
+ /**
401
+ * Map a mint failure to its screen. Each cause has a DISTINCT first line so the
402
+ * creator never has to read a second, and none of them names the edge, the API
403
+ * or an outage — a creator cannot act on that.
404
+ */
405
+ function previewFailureLines(err) {
406
+ if (err instanceof PreviewRateLimitedError)
407
+ return mintRateLimited(err.retryAfterSec);
408
+ if (err instanceof PreviewConflictError)
409
+ return previewConflict();
410
+ if (err instanceof ProjectUnreachableError) {
411
+ const out = [];
412
+ printUnreachable((l) => out.push(l), err.message);
413
+ return ["", ...out, ""];
414
+ }
415
+ return ["", `✗ ${err instanceof Error ? err.message : String(err)}`, ""];
170
416
  }
171
417
  // ---- diagnostics ------------------------------------------------------------
172
418
  async function diag(kind) {
173
419
  const cwd = projectDir();
420
+ // There is NO `--session`. It filtered on the raw client-supplied key, which
421
+ // made it the one surface that would teach a creator that key exists — and it
422
+ // was the same value the terminal was printing back. `--tester` replaces it.
174
423
  const opts = {
175
- sessionId: flag("--session"),
424
+ tester: flag("--tester"),
425
+ viewer: flag("--viewer"),
176
426
  since: numFlag("--since"),
177
427
  limit: numFlag("--limit"),
178
428
  errorsOnly: has("--errors"),
@@ -227,31 +477,52 @@ async function relay() {
227
477
  function usage() {
228
478
  console.log(`Vincentt CLI — local-first AR dev loop\n\n` +
229
479
  `Usage: vincentt <command> [options]\n\n` +
480
+ `Getting started (building a new app with a coding agent)\n` +
481
+ ` 1. Confirm a project name with the developer first.\n` +
482
+ ` 2. Scaffold: vincentt init <dir> (no account needed)\n` +
483
+ ` 3. Register: vincentt create --name "<name>"\n` +
484
+ ` 4. Read AGENTS.md, confirm it is ready, and stop — do NOT invent an app.\n` +
485
+ ` Wait for the developer to describe what to build.\n` +
486
+ ` Full guide: npm view @vincentt-xr/cli readme\n\n` +
230
487
  `Auth\n` +
231
488
  ` login Sign in; save an access token to ~/.vincentt/config.json\n` +
232
489
  ` logout Remove the saved access token\n` +
233
- ` whoami Show the signed-in account (via GET /network/me)\n\n` +
490
+ ` whoami Show the signed-in account\n\n` +
491
+ `Project\n` +
492
+ ` init [dir] Scaffold a Vincentt XR app — no account needed\n` +
493
+ ` [--no-install]\n` +
494
+ ` create Register a project and reserve its address\n` +
495
+ ` [--name <s>] [--slug <s>] [--org <s>] [--preview] [--force]\n` +
496
+ ` link <project-id> Link this folder to an existing project\n` +
497
+ ` unlink Disconnect this folder from its project\n\n` +
234
498
  `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
499
  ` 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` +
500
+ ` preview Serve the app at an https link a phone can open\n` +
501
+ ` [--port <n>] [--tester <id>] [--no-qr] [--stop]\n\n` +
241
502
  `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` +
503
+ ` logs [--errors] Console logs from the device [--json] [--tester <id>]\n` +
504
+ ` network fetch/XHR requests [--json] [--tester <id>]\n` +
505
+ ` trace Performance samples [--json] [--tester <id>]\n` +
506
+ ` --tester t1 filters to one tester (one tab, across\n` +
507
+ ` its refreshes). --viewer <label> filters to a single\n` +
508
+ ` connection; one tab opens several.\n\n` +
245
509
  `Reverse channel\n` +
246
510
  ` feedback --wait Block for the next phone annotation, print it, exit\n` +
247
511
  ` feedback --watch Stream annotations as they arrive\n\n` +
248
512
  `Servers\n` +
249
513
  ` relay Run the diagnostics/annotation relay\n` +
250
514
  ` mcp Run the MCP adapter (for hosts without a shell)\n\n` +
251
- `Common options: --dir <path> (project dir, default cwd)\n`);
515
+ `Common options: --dir <path> (project dir, default cwd)\n` +
516
+ ` --allow-custom-api honor a binding's apiUrl override\n`);
252
517
  }
253
518
  async function main() {
254
519
  const cmd = argv[0];
520
+ // `--help`/`-h` anywhere means "explain, don't act" — never let it fall through to
521
+ // a verb. Otherwise `login --help` runs login, `create --help` scaffolds, etc.
522
+ if (isHelpInvocation(argv)) {
523
+ usage();
524
+ return;
525
+ }
255
526
  switch (cmd) {
256
527
  case "login":
257
528
  return login();
@@ -259,10 +530,14 @@ async function main() {
259
530
  return logout();
260
531
  case "whoami":
261
532
  return whoami();
533
+ case "init":
534
+ return init();
262
535
  case "create":
263
536
  return create();
264
- case "unbind":
265
- return unbind();
537
+ case "link":
538
+ return link();
539
+ case "unlink":
540
+ return unlink();
266
541
  case "publish":
267
542
  return publish();
268
543
  case "preview":
@@ -279,11 +554,6 @@ async function main() {
279
554
  return mcp();
280
555
  case "relay":
281
556
  return relay();
282
- case undefined:
283
- case "-h":
284
- case "--help":
285
- usage();
286
- return;
287
557
  default:
288
558
  console.error(`Unknown command: ${cmd}\n`);
289
559
  usage();
@@ -1,8 +1,23 @@
1
1
  import type { ResolvedConfig } from "../shared/config.js";
2
+ /**
3
+ * A project as the Go API returns it. `liveUrl` is SERVER-COMPUTED — the apex
4
+ * differs per environment, so a client that builds `<slug>.<apex>` itself would
5
+ * print the wrong address on staging and dev.
6
+ */
7
+ export interface ProjectView {
8
+ id: string;
9
+ slug: string;
10
+ name: string;
11
+ orgId: string;
12
+ liveUrl: string;
13
+ publishState: string;
14
+ }
2
15
  export interface CreatedProject {
3
16
  projectId: string;
4
17
  slug: string;
5
18
  name: string;
19
+ orgId: string;
20
+ liveUrl: string;
6
21
  }
7
22
  export interface PublishResult {
8
23
  version: number;
@@ -15,12 +30,28 @@ export declare class BackendError extends Error {
15
30
  readonly status?: number | undefined;
16
31
  constructor(message: string, status?: number | undefined);
17
32
  }
33
+ /** The address a caller asked for is not available. Carries the server's suggestions. */
34
+ export declare class SlugTakenError extends BackendError {
35
+ readonly suggestions: string[];
36
+ constructor(suggestions: string[]);
37
+ }
38
+ /**
39
+ * The uniform 404 for every unreachable-project cause. The message is the
40
+ * server's, verbatim: the console shows the same six words, so a creator moving
41
+ * between surfaces sees one product and support can match a screenshot to a log
42
+ * line without a translation table.
43
+ */
44
+ export declare class ProjectUnreachableError extends BackendError {
45
+ constructor(message: string);
46
+ }
18
47
  /**
19
- * Create a backend project (reuses POST /projects). A `slug` pins the permanent
20
- * <slug>.vincentt.app host; omit it and the platform assigns a catchy one. A
21
- * taken slug comes back 409, surfaced so the caller can re-ask.
48
+ * Register a project in an org. `slug` is optional and the server NEVER assigns
49
+ * one on a collision — it returns 409 with suggestions and the caller re-asks, so
50
+ * a creator's address is always a name they chose.
22
51
  */
23
- export declare function createProject(cfg: ResolvedConfig, name: string, slug?: string): Promise<CreatedProject>;
52
+ export declare function createProject(cfg: ResolvedConfig, orgSlug: string, name: string, slug?: string): Promise<CreatedProject>;
53
+ /** Read a project by id. Backs `link`; the org is derived server-side from the row. */
54
+ export declare function getProject(cfg: ResolvedConfig, projectId: string): Promise<ProjectView>;
24
55
  export interface MintedTunnel {
25
56
  tunnelId: string;
26
57
  /** The public host the phone opens: <slug>-<token>.<previewApex>. */
@@ -48,11 +79,20 @@ export declare function publishUpload(cfg: ResolvedConfig, projectId: string, di
48
79
  note?: string;
49
80
  commitSha?: string;
50
81
  }): Promise<PublishResult>;
82
+ export interface MeMembership {
83
+ orgId: string;
84
+ orgSlug: string;
85
+ orgName: string;
86
+ orgKind: "personal" | "team";
87
+ role: string;
88
+ status: string;
89
+ }
51
90
  export interface Me {
52
91
  id?: string;
53
92
  email?: string;
54
93
  name?: string;
55
- networkId?: string;
94
+ personalOrgId?: string;
95
+ memberships?: MeMembership[];
56
96
  }
57
97
  /** Resolve the signed-in identity for `vincentt whoami`. Throws on any non-2xx so
58
98
  * the caller can degrade to "token present, unverified". */