@vincentt-xr/cli 0.2.0 → 0.4.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 (43) hide show
  1. package/README.md +3 -1
  2. package/dist/cli/args.d.ts +9 -0
  3. package/dist/cli/args.js +20 -0
  4. package/dist/cli/index.js +114 -25
  5. package/dist/lib/backend.d.ts +0 -16
  6. package/dist/lib/backend.js +0 -34
  7. package/dist/lib/initCommand.d.ts +18 -1
  8. package/dist/lib/initCommand.js +70 -7
  9. package/dist/lib/lifecycle.d.ts +12 -4
  10. package/dist/lib/lifecycle.js +36 -30
  11. package/dist/lib/version.d.ts +34 -0
  12. package/dist/lib/version.js +82 -0
  13. package/dist/publish/api.d.ts +67 -0
  14. package/dist/publish/api.js +96 -0
  15. package/dist/publish/bounds.d.ts +25 -0
  16. package/dist/publish/bounds.js +53 -0
  17. package/dist/publish/classify.d.ts +51 -0
  18. package/dist/publish/classify.js +138 -0
  19. package/dist/publish/command.d.ts +27 -0
  20. package/dist/publish/command.js +328 -0
  21. package/dist/publish/consoleVerbs.d.ts +17 -0
  22. package/dist/publish/consoleVerbs.js +26 -0
  23. package/dist/publish/copy.d.ts +180 -0
  24. package/dist/publish/copy.js +606 -0
  25. package/dist/publish/defaults.d.ts +16 -0
  26. package/dist/publish/defaults.js +52 -0
  27. package/dist/publish/manifest.d.ts +27 -0
  28. package/dist/publish/manifest.js +81 -0
  29. package/dist/publish/paths.d.ts +25 -0
  30. package/dist/publish/paths.js +78 -0
  31. package/dist/publish/progress.d.ts +17 -0
  32. package/dist/publish/progress.js +45 -0
  33. package/dist/publish/prompt.d.ts +7 -0
  34. package/dist/publish/prompt.js +43 -0
  35. package/dist/publish/qa.f4.fixtures.d.ts +25 -0
  36. package/dist/publish/qa.f4.fixtures.js +140 -0
  37. package/dist/publish/upload.d.ts +30 -0
  38. package/dist/publish/upload.js +93 -0
  39. package/dist/scaffold/index.d.ts +26 -0
  40. package/dist/scaffold/index.js +43 -1
  41. package/package.json +2 -3
  42. package/dist/mcp/server.d.ts +0 -16
  43. package/dist/mcp/server.js +0 -215
package/README.md CHANGED
@@ -68,7 +68,9 @@ Reverse channel
68
68
 
69
69
  Servers
70
70
  relay Run the diagnostics/annotation relay
71
- mcp Run the MCP adapter (for hosts without a shell)
71
+
72
+ Meta
73
+ version CLI version, and the API this machine resolves to
72
74
 
73
75
  Common options: --dir <path> (project dir, default cwd)
74
76
  ```
@@ -5,6 +5,15 @@
5
5
  * usage instead of running the (side-effectful) verb.
6
6
  */
7
7
  export declare function isHelpInvocation(args: string[]): boolean;
8
+ /**
9
+ * True for `vincentt --version` / `-v` — the flag form, as the FIRST argument
10
+ * only. Unlike `--help`, this is deliberately not honored anywhere in the args:
11
+ * `--help` is safe to obey late because it cancels the action, whereas treating
12
+ * a trailing `-v` on `vincentt publish -v` as "print the version" would silently
13
+ * skip a publish the creator asked for. Anywhere else it stays an unknown flag.
14
+ */
15
+ export declare function isVersionInvocation(args: string[]): boolean;
16
+ export declare function refusedFlag(args: string[]): string | undefined;
8
17
  /**
9
18
  * The bare arguments, with flag VALUES removed. Without this, `vincentt link --dir
10
19
  * /tmp 66f1` reads `/tmp` as the project id — a wrong-but-plausible value, which is
package/dist/cli/args.js CHANGED
@@ -7,6 +7,16 @@
7
7
  export function isHelpInvocation(args) {
8
8
  return args[0] === undefined || args.includes("--help") || args.includes("-h");
9
9
  }
10
+ /**
11
+ * True for `vincentt --version` / `-v` — the flag form, as the FIRST argument
12
+ * only. Unlike `--help`, this is deliberately not honored anywhere in the args:
13
+ * `--help` is safe to obey late because it cancels the action, whereas treating
14
+ * a trailing `-v` on `vincentt publish -v` as "print the version" would silently
15
+ * skip a publish the creator asked for. Anywhere else it stays an unknown flag.
16
+ */
17
+ export function isVersionInvocation(args) {
18
+ return args[0] === "--version" || args[0] === "-v";
19
+ }
10
20
  /** Flags that stand alone; every other `--flag` consumes the next argument. */
11
21
  const BOOLEAN_FLAGS = new Set([
12
22
  "--force",
@@ -18,9 +28,19 @@ const BOOLEAN_FLAGS = new Set([
18
28
  "--stop",
19
29
  "--no-install",
20
30
  "--allow-custom-api",
31
+ "--yes",
21
32
  "--help",
22
33
  "-h",
23
34
  ]);
35
+ /**
36
+ * Flags the CLI deliberately does NOT have. The checklist reports and never
37
+ * writes: a suggested value is one keystroke from a tool that writes it, and
38
+ * writing to the creator's source is a named boundary.
39
+ */
40
+ const REFUSED_FLAGS = new Set(["--fix", "--write-meta"]);
41
+ export function refusedFlag(args) {
42
+ return args.find((a) => REFUSED_FLAGS.has(a.split("=")[0]));
43
+ }
24
44
  /**
25
45
  * The bare arguments, with flag VALUES removed. Without this, `vincentt link --dir
26
46
  * /tmp 66f1` reads `/tmp` as the project id — a wrong-but-plausible value, which is
package/dist/cli/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- // `vincentt` — the unified CLI for the local-first dev loop. A human and a
3
- // bash-capable coding agent drive the same verbs. No MCP registration is required
4
- // for the default path; `vincentt mcp` exists only for hosts without a shell.
2
+ // `vincentt` — the CLI for the local-first dev loop, and the only surface. A human
3
+ // and a bash-capable coding agent drive the same verbs, so there is nothing to
4
+ // register: an agent that can run a shell command already has the whole product.
5
5
  //
6
6
  // Verb groups:
7
7
  // auth login · logout · whoami
@@ -9,7 +9,9 @@
9
9
  // dev loop publish · preview
10
10
  // diagnostics logs · network · trace
11
11
  // reverse feedback (--wait | --watch)
12
- // servers relay · mcp
12
+ // servers relay
13
+ // meta version
14
+ import { createRequire } from "node:module";
13
15
  import os from "node:os";
14
16
  import path from "node:path";
15
17
  import { runPkceLogin } from "../login/pkceLogin.js";
@@ -21,7 +23,11 @@ import { getMe, ProjectUnreachableError } from "../lib/backend.js";
21
23
  import { initCommand } from "../lib/initCommand.js";
22
24
  import { linkCommand, printUnreachable, unlinkCommand } from "../lib/projects.js";
23
25
  import { whoamiCommand } from "../lib/whoami.js";
24
- import { diagQuery, diagRead, previewStart, previewStop, previewStopSignal, projectCreate, projectPublish, } from "../lib/lifecycle.js";
26
+ import { renderVersion, versionReport } from "../lib/version.js";
27
+ import { diagQuery, diagRead, previewStart, previewStop, previewStopSignal, projectCreate, } from "../lib/lifecycle.js";
28
+ import { publishCommand } from "../publish/command.js";
29
+ import { stdinAsk, stdinConfirm } from "../publish/prompt.js";
30
+ import { consoleVerbCommand, isConsoleOnlyVerb } from "../publish/consoleVerbs.js";
25
31
  import { PreviewUi } from "../preview/command.js";
26
32
  import { httpRelayClient } from "../lib/diagnostics.js";
27
33
  import { readLivePreview } from "../lib/relayInfo.js";
@@ -29,7 +35,7 @@ import { mintRateLimited, notSignedIn, previewConflict, startBlock, } from "../p
29
35
  import { renderTerminalQr, shouldRenderQr } from "../preview/qr.js";
30
36
  import { PreviewConflictError, PreviewRateLimitedError } from "../preview/api.js";
31
37
  import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
32
- import { isHelpInvocation, positionalArgs } from "./args.js";
38
+ import { isHelpInvocation, isVersionInvocation, positionalArgs, refusedFlag } from "./args.js";
33
39
  // The console origin serves /cli-auth (the browser target for `vincentt login`). VINCENTT_
34
40
  // CONSOLE_URL overrides for dev/dogfood; VINCENTT_EDITOR_URL is honored as a legacy alias.
35
41
  const DEFAULT_CONSOLE_URL = "https://vincentt.app";
@@ -143,7 +149,20 @@ async function whoami() {
143
149
  }
144
150
  // ---- project ----------------------------------------------------------------
145
151
  async function init() {
146
- process.exitCode = await initCommand({ print: (l) => console.log(l), cwd: process.cwd() }, { dir: positional(1), skipInstall: has("--no-install") });
152
+ // Both prompts are omitted when stdin is not a TTY, and initCommand reads their
153
+ // ABSENCE as "may not ask" — so a piped or CI invocation refuses rather than
154
+ // blocking forever on a question nobody can see.
155
+ const interactive = Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
156
+ process.exitCode = await initCommand({
157
+ print: (l) => console.log(l),
158
+ cwd: process.cwd(),
159
+ ask: interactive ? stdinAsk() : undefined,
160
+ confirm: interactive ? stdinConfirm() : undefined,
161
+ }, {
162
+ dir: positional(1),
163
+ skipInstall: has("--no-install"),
164
+ template: flag("--template"),
165
+ });
147
166
  }
148
167
  async function create() {
149
168
  await requireLogin();
@@ -186,17 +205,54 @@ async function unlink() {
186
205
  config: () => resolveConfig(cwd),
187
206
  });
188
207
  }
208
+ /**
209
+ * `vincentt publish` — Flows L22-L31.
210
+ *
211
+ * The whole gate lives here: the checklist, the permanence disclosure, the
212
+ * confirmation, the upload and the live URL. There is no browser step in
213
+ * publishing, not on the first publish, not ever.
214
+ */
189
215
  async function publish() {
190
- await requireLogin();
191
- const result = await projectPublish(projectDir(), {
192
- distDir: flag("--dist"),
193
- note: flag("--note"),
194
- allowCustomApi: has("--allow-custom-api"),
195
- warn: (l) => console.log(l),
196
- });
197
- console.log(result.message);
198
- if (result.notBound)
216
+ const refused = refusedFlag(argv);
217
+ if (refused) {
218
+ console.log(`✗ There's no \`${refused}\` option.`);
219
+ console.log("");
220
+ console.log(" The link-preview check reports what it found; it never edits your");
221
+ console.log(" source. Fix the tags in your own files, rebuild, then publish again.");
199
222
  process.exitCode = 1;
223
+ return;
224
+ }
225
+ const cwd = projectDir();
226
+ process.exitCode = await publishCommand({
227
+ print: (l) => console.log(l),
228
+ write: (s) => process.stdout.write(s),
229
+ confirm: stdinConfirm(),
230
+ cwd,
231
+ config: () => resolveConfig(cwd, {
232
+ allowCustomApi: has("--allow-custom-api"),
233
+ warn: (l) => console.log(l),
234
+ }),
235
+ binding: async () => {
236
+ const located = await locateProjectBinding(cwd);
237
+ return located
238
+ ? { projectId: located.binding.projectId, slug: located.binding.slug }
239
+ : undefined;
240
+ },
241
+ token: currentToken,
242
+ isTTY: Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY),
243
+ now: () => Date.now(),
244
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
245
+ orgName: activeOrgName,
246
+ }, { distDir: flag("--dist"), note: flag("--note"), yes: has("--yes") });
247
+ }
248
+ /** Flow L30 — the six verbs that are console actions, answered rather than rejected. */
249
+ async function consoleOnly(verb) {
250
+ const cwd = projectDir();
251
+ process.exitCode = await consoleVerbCommand({
252
+ print: (l) => console.log(l),
253
+ projectId: async () => (await locateProjectBinding(cwd))?.binding.projectId,
254
+ consoleUrl: flag("--console") ?? process.env.VINCENTT_CONSOLE_URL,
255
+ }, verb);
200
256
  }
201
257
  /**
202
258
  * `vincentt preview` — Flows L13-L21.
@@ -458,11 +514,32 @@ async function feedback() {
458
514
  await writeCursor(cwd, annotation.seq);
459
515
  } while (watch);
460
516
  }
461
- // ---- servers ----------------------------------------------------------------
462
- async function mcp() {
463
- const { runStdio } = await import("../mcp/server.js");
464
- await runStdio({ relayUrl: flag("--relay-url") });
517
+ // ---- meta -------------------------------------------------------------------
518
+ /**
519
+ * The running package's version, read from the manifest rather than baked in by
520
+ * the build — a hardcoded string is one forgotten edit away from a CLI that
521
+ * misreports itself, which is worse than not having the command at all.
522
+ * `src/cli/` and the emitted `dist/cli/` sit at the same depth, so one relative
523
+ * path serves both.
524
+ */
525
+ function cliVersion() {
526
+ try {
527
+ const manifest = createRequire(import.meta.url)("../../package.json");
528
+ return manifest.version ?? "unknown";
529
+ }
530
+ catch {
531
+ return "unknown";
532
+ }
465
533
  }
534
+ async function version() {
535
+ const report = await versionReport(projectDir(), {
536
+ cliVersion: cliVersion(),
537
+ allowCustomApi: has("--allow-custom-api"),
538
+ });
539
+ for (const line of renderVersion(report))
540
+ console.log(line);
541
+ }
542
+ // ---- servers ----------------------------------------------------------------
466
543
  async function relay() {
467
544
  const { startRelay } = await import("../relay/server.js");
468
545
  const port = numFlag("--port") ?? 7331;
@@ -490,13 +567,14 @@ function usage() {
490
567
  ` whoami Show the signed-in account\n\n` +
491
568
  `Project\n` +
492
569
  ` init [dir] Scaffold a Vincentt XR app — no account needed\n` +
493
- ` [--no-install]\n` +
570
+ ` [--no-install] [--template <version>]\n` +
494
571
  ` create Register a project and reserve its address\n` +
495
572
  ` [--name <s>] [--slug <s>] [--org <s>] [--preview] [--force]\n` +
496
573
  ` link <project-id> Link this folder to an existing project\n` +
497
574
  ` unlink Disconnect this folder from its project\n\n` +
498
575
  `Dev loop\n` +
499
576
  ` publish Upload the built dist; go live at <slug>.vincentt.app\n` +
577
+ ` [--dist <path>] [--note <s>] [--yes]\n` +
500
578
  ` preview Serve the app at an https link a phone can open\n` +
501
579
  ` [--port <n>] [--tester <id>] [--no-qr] [--stop]\n\n` +
502
580
  `Diagnostics (need a running preview)\n` +
@@ -510,13 +588,19 @@ function usage() {
510
588
  ` feedback --wait Block for the next phone annotation, print it, exit\n` +
511
589
  ` feedback --watch Stream annotations as they arrive\n\n` +
512
590
  `Servers\n` +
513
- ` relay Run the diagnostics/annotation relay\n` +
514
- ` mcp Run the MCP adapter (for hosts without a shell)\n\n` +
591
+ ` relay Run the diagnostics/annotation relay\n\n` +
592
+ `Meta\n` +
593
+ ` version CLI version, and the API this machine resolves to\n\n` +
515
594
  `Common options: --dir <path> (project dir, default cwd)\n` +
516
595
  ` --allow-custom-api honor a binding's apiUrl override\n`);
517
596
  }
518
597
  async function main() {
519
598
  const cmd = argv[0];
599
+ // Checked BEFORE the help gate: `--version` is a lone flag with no verb, which
600
+ // the help gate would otherwise answer with usage — the one response that does
601
+ // not contain the version.
602
+ if (isVersionInvocation(argv))
603
+ return version();
520
604
  // `--help`/`-h` anywhere means "explain, don't act" — never let it fall through to
521
605
  // a verb. Otherwise `login --help` runs login, `create --help` scaffolds, etc.
522
606
  if (isHelpInvocation(argv)) {
@@ -524,6 +608,8 @@ async function main() {
524
608
  return;
525
609
  }
526
610
  switch (cmd) {
611
+ case "version":
612
+ return version();
527
613
  case "login":
528
614
  return login();
529
615
  case "logout":
@@ -550,11 +636,14 @@ async function main() {
550
636
  return diag("trace");
551
637
  case "feedback":
552
638
  return feedback();
553
- case "mcp":
554
- return mcp();
555
639
  case "relay":
556
640
  return relay();
557
641
  default:
642
+ // Flow L30: rolling back and taking an address offline are console
643
+ // actions, and a bare "unknown command" here would be the product failing
644
+ // to answer a question it knows the answer to.
645
+ if (cmd !== undefined && isConsoleOnlyVerb(cmd))
646
+ return consoleOnly(cmd);
558
647
  console.error(`Unknown command: ${cmd}\n`);
559
648
  usage();
560
649
  process.exitCode = 1;
@@ -19,13 +19,6 @@ export interface CreatedProject {
19
19
  orgId: string;
20
20
  liveUrl: string;
21
21
  }
22
- export interface PublishResult {
23
- version: number;
24
- /** Immutable versioned URL (<slug>.<apex>/v<n>/). */
25
- url: string;
26
- /** Stable live alias (<slug>.<apex>/). */
27
- liveUrl: string;
28
- }
29
22
  export declare class BackendError extends Error {
30
23
  readonly status?: number | undefined;
31
24
  constructor(message: string, status?: number | undefined);
@@ -70,15 +63,6 @@ export declare function reapTunnel(cfg: ResolvedConfig, projectId: string, ref:
70
63
  }): Promise<void>;
71
64
  /** Recursively list files under `dir`, returning dir-relative POSIX paths. */
72
65
  export declare function walkDir(dir: string, base?: string): Promise<string[]>;
73
- /**
74
- * Upload a locally-built dist and make it live. Each file rides as a multipart
75
- * part whose filename is its dist-relative path (the backend reconstructs the
76
- * tree). Requires an index.html at the dist root.
77
- */
78
- export declare function publishUpload(cfg: ResolvedConfig, projectId: string, distDir: string, opts?: {
79
- note?: string;
80
- commitSha?: string;
81
- }): Promise<PublishResult>;
82
66
  export interface MeMembership {
83
67
  orgId: string;
84
68
  orgSlug: string;
@@ -178,40 +178,6 @@ export async function walkDir(dir, base = dir) {
178
178
  }
179
179
  return out;
180
180
  }
181
- /**
182
- * Upload a locally-built dist and make it live. Each file rides as a multipart
183
- * part whose filename is its dist-relative path (the backend reconstructs the
184
- * tree). Requires an index.html at the dist root.
185
- */
186
- export async function publishUpload(cfg, projectId, distDir, opts = {}) {
187
- const files = await walkDir(distDir);
188
- if (!files.includes("index.html")) {
189
- throw new BackendError(`dist at ${distDir} has no index.html at its root — did the build run?`);
190
- }
191
- const form = new FormData();
192
- // The real dist-relative paths ride as an ordered manifest — a multipart filename
193
- // can't carry a directory (FormData strips it to a basename), so the backend maps
194
- // paths[i] → files[i] to rebuild the tree.
195
- form.append("paths", JSON.stringify(files));
196
- for (const rel of files) {
197
- const bytes = await fs.readFile(path.join(distDir, rel));
198
- // Uint8Array (Buffer) is a valid BlobPart; the filename is cosmetic (the backend
199
- // uses the manifest), but pass the basename so server logs read sensibly.
200
- form.append("files", new Blob([bytes]), path.basename(rel));
201
- }
202
- if (opts.note)
203
- form.append("note", opts.note);
204
- if (opts.commitSha)
205
- form.append("commitSha", opts.commitSha);
206
- const res = await authFetch(cfg, `/projects/${projectId}/publish/upload`, {
207
- method: "POST",
208
- body: form,
209
- });
210
- if (!res.ok) {
211
- throw new BackendError(authFailureMessage("publish failed", res.status, await safeText(res)), res.status);
212
- }
213
- return (await res.json());
214
- }
215
181
  /** Resolve the signed-in identity for `vincentt whoami`. Throws on any non-2xx so
216
182
  * the caller can degrade to "token present, unverified". */
217
183
  export async function getMe(cfg) {
@@ -1,14 +1,31 @@
1
+ import type { Ask, Confirm } from "../publish/prompt.js";
1
2
  export interface InitDeps {
2
3
  print: (line: string) => void;
3
4
  cwd: string;
4
- scaffold?: (targetDir: string) => Promise<void>;
5
+ scaffold?: (targetDir: string, ref?: string) => Promise<void>;
5
6
  install?: (targetDir: string) => Promise<string>;
6
7
  isEmpty?: (targetDir: string) => Promise<boolean>;
8
+ /** Free-text prompt. Absent → not a TTY, so nothing may be asked. */
9
+ ask?: Ask;
10
+ /** y/N prompt. Absent → not a TTY. */
11
+ confirm?: Confirm;
12
+ /** Non-empty check for the resolved target; injected in tests. */
13
+ isNonEmpty?: (targetDir: string) => Promise<boolean>;
7
14
  }
15
+ /** The folder name offered when `init` is run bare and the creator just hits Enter. */
16
+ export declare const DEFAULT_APP_NAME = "my-ar-app";
17
+ /**
18
+ * A folder name safe to create from a typed answer. Keeps this from becoming a
19
+ * path-traversal seam: the answer names a child of the cwd, never `../` upward and
20
+ * never an absolute path.
21
+ */
22
+ export declare function sanitizeAppName(answer: string): string | null;
8
23
  export interface InitOptions {
9
24
  /** Positional target directory; defaults to the cwd. */
10
25
  dir?: string;
11
26
  /** Skip the dependency install (CI, or a creator who wants to pick a manager). */
12
27
  skipInstall?: boolean;
28
+ /** Template version to scaffold from; defaults to the latest release. */
29
+ template?: string;
13
30
  }
14
31
  export declare function initCommand(deps: InitDeps, opts?: InitOptions): Promise<number>;
@@ -6,10 +6,49 @@
6
6
  // does need an account) is offered at the end as the next thing AVAILABLE, never as
7
7
  // a requirement.
8
8
  import path from "node:path";
9
- import { detectPackageManager, installDependencies, needsScaffold, scaffoldFromTemplate, } from "../scaffold/index.js";
9
+ import { detectPackageManager, installDependencies, isNonEmptyDir, needsScaffold, scaffoldFromTemplate, } from "../scaffold/index.js";
10
+ /** The folder name offered when `init` is run bare and the creator just hits Enter. */
11
+ export const DEFAULT_APP_NAME = "my-ar-app";
12
+ /**
13
+ * A folder name safe to create from a typed answer. Keeps this from becoming a
14
+ * path-traversal seam: the answer names a child of the cwd, never `../` upward and
15
+ * never an absolute path.
16
+ */
17
+ export function sanitizeAppName(answer) {
18
+ const trimmed = answer.trim();
19
+ if (trimmed === "" || trimmed === "." || trimmed === "..")
20
+ return null;
21
+ if (trimmed.includes("/") || trimmed.includes("\\"))
22
+ return null;
23
+ if (trimmed.startsWith("-"))
24
+ return null;
25
+ return trimmed;
26
+ }
10
27
  export async function initCommand(deps, opts = {}) {
11
- const target = path.resolve(deps.cwd, opts.dir ?? ".");
12
- const label = opts.dir ? `./${opts.dir}` : ".";
28
+ // Resolve WHERE before checking what is there. Bare `init` used to mean "here",
29
+ // which is the surprising reading — a creator typing it in their home directory
30
+ // meant "make me an app", not "fill this folder with one".
31
+ let dir = opts.dir;
32
+ if (dir === undefined) {
33
+ if (!deps.ask) {
34
+ deps.print("✗ init needs a target directory when it can't ask.");
35
+ deps.print("");
36
+ deps.print(" vincentt init <dir> Scaffold into a new folder");
37
+ deps.print(" vincentt init . Scaffold into the current folder");
38
+ return 1;
39
+ }
40
+ const answer = await deps.ask(`Project name: (${DEFAULT_APP_NAME}) `, DEFAULT_APP_NAME);
41
+ const name = sanitizeAppName(answer);
42
+ if (name === null) {
43
+ deps.print(`✗ "${answer.trim()}" is not a folder name.`);
44
+ deps.print("");
45
+ deps.print(" Use a plain name (my-ar-app), or `vincentt init .` for this folder.");
46
+ return 1;
47
+ }
48
+ dir = name;
49
+ }
50
+ const target = path.resolve(deps.cwd, dir);
51
+ const label = dir === "." ? "." : `./${dir}`;
13
52
  const isEmpty = deps.isEmpty ?? needsScaffold;
14
53
  if (!(await isEmpty(target))) {
15
54
  deps.print(`✗ ${label} already contains an app (package.json).`);
@@ -18,11 +57,34 @@ export async function initCommand(deps, opts = {}) {
18
57
  deps.print(" vincentt create Register THIS folder as a project");
19
58
  return 1;
20
59
  }
60
+ // No package.json, but not empty either — the case the old guard waved through.
61
+ const isNonEmpty = deps.isNonEmpty ?? isNonEmptyDir;
62
+ if (await isNonEmpty(target)) {
63
+ if (!deps.confirm) {
64
+ deps.print(`✗ ${label} is not empty, and I can't ask whether to add to it.`);
65
+ deps.print("");
66
+ deps.print(" vincentt init <dir> Scaffold into a new folder instead");
67
+ return 1;
68
+ }
69
+ deps.print(`! ${label} is not empty. Scaffolding adds the template's files alongside what's there.`);
70
+ deps.print(" Existing files are never overwritten.");
71
+ if (!(await deps.confirm("? Continue? (y/N) ", false))) {
72
+ deps.print("");
73
+ deps.print(" Nothing was written.");
74
+ return 1;
75
+ }
76
+ }
21
77
  deps.print(`→ Scaffolding a Vincentt XR app in ${label}…`);
22
78
  deps.print(" · React + react-three-fiber + @vincentt-xr/sdk");
23
- const scaffold = deps.scaffold ?? ((dir) => scaffoldFromTemplate(dir));
79
+ // Only announce the version when it was asked for. On the default path the ref
80
+ // is an implementation detail, and naming it would invite creators to pin a
81
+ // version they have no reason to care about.
82
+ if (opts.template)
83
+ deps.print(` · template ${opts.template}`);
84
+ const scaffold = deps.scaffold ??
85
+ ((dir, ref) => scaffoldFromTemplate(dir, { ref }));
24
86
  try {
25
- await scaffold(target);
87
+ await scaffold(target, opts.template);
26
88
  }
27
89
  catch (err) {
28
90
  deps.print("");
@@ -50,8 +112,9 @@ export async function initCommand(deps, opts = {}) {
50
112
  deps.print(`✓ Created ${label}`);
51
113
  deps.print("");
52
114
  const steps = [];
53
- if (opts.dir)
54
- steps.push([`cd ${opts.dir}`, ""]);
115
+ // Keyed off the RESOLVED dir, not the flag — a prompted name needs the cd too.
116
+ if (dir !== ".")
117
+ steps.push([`cd ${dir}`, ""]);
55
118
  if (!installed || opts.skipInstall) {
56
119
  steps.push([`${pm} install`, "Install dependencies"]);
57
120
  }
@@ -59,14 +59,22 @@ export interface PublishOptions {
59
59
  * has already happened (or thrown).
60
60
  */
61
61
  warn?: (line: string) => void;
62
+ /** Skip the confirmation. REFUSED on a first publish; see publishCommand. */
63
+ yes?: boolean;
62
64
  }
63
65
  export interface PublishOutcome {
66
+ /** The full transcript, exactly as the CLI prints it. */
64
67
  message: string;
65
- notBound: boolean;
66
- version?: number;
67
- liveUrl?: string;
68
- url?: string;
68
+ exitCode: number;
69
69
  }
70
+ /**
71
+ * The shared `publish` orchestration behind both `vincentt publish` and the MCP
72
+ * `project_publish` tool, so the two surfaces can never diverge.
73
+ *
74
+ * MCP has no terminal, so it is a non-TTY caller: the command refuses without
75
+ * `--yes` rather than assuming consent, and refuses `--yes` outright on a first
76
+ * publish. An agent cannot make an address permanent.
77
+ */
70
78
  export declare function projectPublish(projectCwd: string, opts?: PublishOptions): Promise<PublishOutcome>;
71
79
  export declare function getActivePreview(): RunningPreview | null;
72
80
  export interface PreviewStartResult {
@@ -3,9 +3,9 @@
3
3
  // lower-level pieces (backend calls, scaffold, preview, diagnostics); no CLI or
4
4
  // MCP SDK concerns leak in here. Both surfaces render the returned data their
5
5
  // own way.
6
- import path from "node:path";
7
- import { getMe, gitHeadSha, publishUpload } from "./backend.js";
6
+ import { getMe } from "./backend.js";
8
7
  import { createCommand } from "./projects.js";
8
+ import { publishCommand } from "../publish/command.js";
9
9
  import { activeOrgSlug, resolveActiveOrg } from "./org.js";
10
10
  import { filterErrors, httpRelayClient, renderResult, } from "./diagnostics.js";
11
11
  import { clearRelayInfo, readLivePreview, readRelayInfo, writeRelayInfo, } from "./relayInfo.js";
@@ -68,36 +68,42 @@ export async function resolveOrgForCommand(cfg, input) {
68
68
  : "✗ No active organization.\n\n Run `vincentt whoami` to see which organizations you belong to,\n then re-run with `--org <slug>`.",
69
69
  };
70
70
  }
71
+ /**
72
+ * The shared `publish` orchestration behind both `vincentt publish` and the MCP
73
+ * `project_publish` tool, so the two surfaces can never diverge.
74
+ *
75
+ * MCP has no terminal, so it is a non-TTY caller: the command refuses without
76
+ * `--yes` rather than assuming consent, and refuses `--yes` outright on a first
77
+ * publish. An agent cannot make an address permanent.
78
+ */
71
79
  export async function projectPublish(projectCwd, opts = {}) {
72
- const cfg = await resolveConfig(projectCwd, {
73
- allowCustomApi: opts.allowCustomApi,
74
- warn: opts.warn,
75
- });
76
- const located = await locateProjectBinding(projectCwd);
77
- if (!located) {
78
- return {
79
- message: [
80
- "✗ This folder isn't linked to a project.",
81
- "",
82
- " vincentt link <project-id> (copy it from the console)",
83
- " vincentt create Register a new project for this folder",
84
- ].join("\n"),
85
- notBound: true,
86
- };
87
- }
88
- const dist = path.resolve(projectCwd, opts.distDir ?? "dist");
89
- const commitSha = await gitHeadSha(projectCwd);
90
- const result = await publishUpload(cfg, located.binding.projectId, dist, {
91
- note: opts.note,
92
- commitSha,
93
- });
94
- return {
95
- message: `Published v${result.version}. Live at ${result.liveUrl}`,
96
- notBound: false,
97
- version: result.version,
98
- liveUrl: result.liveUrl,
99
- url: result.url,
80
+ const lines = [];
81
+ const print = (line) => {
82
+ lines.push(line);
100
83
  };
84
+ const exitCode = await publishCommand({
85
+ print,
86
+ write: () => {
87
+ // No redraw off a terminal; the degraded path emits whole lines.
88
+ },
89
+ confirm: async () => false,
90
+ cwd: projectCwd,
91
+ config: () => resolveConfig(projectCwd, {
92
+ allowCustomApi: opts.allowCustomApi,
93
+ warn: opts.warn ?? print,
94
+ }),
95
+ binding: async () => {
96
+ const located = await locateProjectBinding(projectCwd);
97
+ return located
98
+ ? { projectId: located.binding.projectId, slug: located.binding.slug }
99
+ : undefined;
100
+ },
101
+ token: async () => process.env.VINCENTT_PAT ?? (await loadMachineConfig()).pat,
102
+ isTTY: false,
103
+ now: () => Date.now(),
104
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
105
+ }, { distDir: opts.distDir, note: opts.note, yes: opts.yes });
106
+ return { message: lines.join("\n"), exitCode };
101
107
  }
102
108
  // ---- preview (one per process) ----------------------------------------------
103
109
  let activePreview = null;
@@ -0,0 +1,34 @@
1
+ export interface VersionReport {
2
+ cliVersion: string;
3
+ nodeVersion: string;
4
+ apiUrl: string;
5
+ /** Where apiUrl came from, in creator-facing words. */
6
+ apiSource: string;
7
+ /** Set when a binding names a different API that was NOT used. */
8
+ ignoredBindingApiUrl?: string;
9
+ /** True when the resolved host is the built-in fallback (nothing configured). */
10
+ isDefault: boolean;
11
+ }
12
+ export interface VersionEnv {
13
+ envApiUrl?: string;
14
+ machineApiUrl?: string;
15
+ bindingApiUrl?: string;
16
+ allowCustomApi?: boolean;
17
+ nodeVersion: string;
18
+ cliVersion: string;
19
+ }
20
+ /**
21
+ * Decide what to report. Pure, so the transcript is testable without a home
22
+ * directory, an env var, or a built package.
23
+ */
24
+ export declare function buildVersionReport(env: VersionEnv): VersionReport;
25
+ /**
26
+ * The transcript. Two aligned columns so the host and its provenance read as one
27
+ * fact, not two.
28
+ */
29
+ export declare function renderVersion(r: VersionReport): string[];
30
+ /** Read the machine's real state and produce the report. */
31
+ export declare function versionReport(cwd: string, opts: {
32
+ cliVersion: string;
33
+ allowCustomApi?: boolean;
34
+ }): Promise<VersionReport>;