@vincentt-xr/cli 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +40 -13
  2. package/dist/cli/args.d.ts +12 -0
  3. package/dist/cli/args.js +40 -0
  4. package/dist/cli/index.d.ts +1 -1
  5. package/dist/cli/index.js +296 -95
  6. package/dist/cli/usage-text.d.ts +1 -0
  7. package/dist/cli/usage-text.js +97 -0
  8. package/dist/cli/verb-surface.d.ts +24 -0
  9. package/dist/cli/verb-surface.js +49 -0
  10. package/dist/dx/clusters.d.ts +0 -17
  11. package/dist/dx/clusters.js +50 -1
  12. package/dist/lib/backend.d.ts +11 -0
  13. package/dist/lib/backend.js +23 -0
  14. package/dist/lib/classifyTarget.d.ts +42 -0
  15. package/dist/lib/classifyTarget.js +83 -0
  16. package/dist/lib/feedback.d.ts +46 -5
  17. package/dist/lib/feedback.js +76 -19
  18. package/dist/lib/feedbackCopy.d.ts +70 -0
  19. package/dist/lib/feedbackCopy.js +163 -0
  20. package/dist/lib/initFrom.d.ts +26 -4
  21. package/dist/lib/initFrom.js +65 -41
  22. package/dist/lib/lifecycle.d.ts +28 -3
  23. package/dist/lib/lifecycle.js +99 -7
  24. package/dist/lib/listProjects.d.ts +41 -0
  25. package/dist/lib/listProjects.js +77 -0
  26. package/dist/lib/projects.d.ts +64 -8
  27. package/dist/lib/projects.js +316 -24
  28. package/dist/lib/sourceRefCopy.d.ts +19 -0
  29. package/dist/lib/sourceRefCopy.js +36 -0
  30. package/dist/preview/command.d.ts +11 -0
  31. package/dist/preview/command.js +19 -1
  32. package/dist/preview/copy.d.ts +7 -0
  33. package/dist/preview/copy.js +8 -1
  34. package/dist/preview/runner.d.ts +6 -0
  35. package/dist/preview/runner.js +1 -0
  36. package/dist/preview/stream.d.ts +11 -0
  37. package/dist/preview/stream.js +13 -0
  38. package/dist/relay/annotations.d.ts +28 -4
  39. package/dist/relay/annotations.js +23 -7
  40. package/dist/relay/qa.f12.fixtures.d.ts +154 -0
  41. package/dist/relay/qa.f12.fixtures.js +358 -0
  42. package/dist/relay/server.d.ts +41 -3
  43. package/dist/relay/server.js +90 -32
  44. package/package.json +2 -2
  45. package/dist/library/qa.f5-b32.spec.d.ts +0 -1
  46. package/dist/library/qa.f5-b32.spec.js +0 -202
  47. package/dist/library/qa.f5-cli.spec.d.ts +0 -1
  48. package/dist/library/qa.f5-cli.spec.js +0 -433
  49. package/dist/library/qa.f5-contract.spec.d.ts +0 -1
  50. package/dist/library/qa.f5-contract.spec.js +0 -206
  51. package/dist/relay/injectors.d.ts +0 -29
  52. package/dist/relay/injectors.js +0 -72
@@ -0,0 +1,41 @@
1
+ import type { ProjectView } from "./backend.js";
2
+ import type { ResolvedConfig } from "../shared/config.js";
3
+ export interface ListProjectsDeps {
4
+ print: (line: string) => void;
5
+ /** The machine's API host + PAT, or undefined when nothing is signed in. */
6
+ config: () => Promise<ResolvedConfig | undefined>;
7
+ /** The workspace to list. Resolved by the caller's own precedence chain. */
8
+ org: () => Promise<{
9
+ ok: true;
10
+ org: {
11
+ slug: string;
12
+ name: string;
13
+ };
14
+ } | {
15
+ ok: false;
16
+ message: string;
17
+ }>;
18
+ list: (cfg: ResolvedConfig, orgSlug: string) => Promise<ProjectView[]>;
19
+ }
20
+ export interface ListProjectsOptions {
21
+ json?: boolean;
22
+ }
23
+ /**
24
+ * The columns are `id`, `slug`, `name` and where it stands.
25
+ *
26
+ * THE ID IS FIRST AND IS NEVER ELIDED. It is the whole reason the verb exists:
27
+ * the next command a reader runs is `vincentt link <project-id>`, and a listing
28
+ * that truncated the id would send them to the console for the one value the
29
+ * command needs — which is the gap this closes.
30
+ */
31
+ export declare function listProjectsCommand(deps: ListProjectsDeps, opts?: ListProjectsOptions): Promise<number>;
32
+ /**
33
+ * Aligned on the widest id and slug so the block reads as a table in a terminal
34
+ * and stays greppable in an agent's transcript.
35
+ *
36
+ * The state column reads from `publishState` and NEVER computes a URL from the
37
+ * slug: `liveUrl` is server-computed because the apex differs per environment,
38
+ * so a client that assembled `<slug>.vincentt.app` itself would print the wrong
39
+ * address on staging and dev.
40
+ */
41
+ export declare function projectRows(projects: ProjectView[]): string[];
@@ -0,0 +1,77 @@
1
+ // `vincentt projects` — which projects does this workspace have?
2
+ //
3
+ // A READ-ONLY verb, and an APPEND to the verb surface (B-F3-8 permits additions;
4
+ // nothing is renamed or removed). It is NEUTRAL in the sense the feature
5
+ // requires: a human typing it gets exactly what an agent running it gets, and
6
+ // nothing about it exists for a plugin.
7
+ //
8
+ // It answers the one question the neutral surface could not. An agent asked to
9
+ // resume a project in an UNBOUND folder previously had three options and all
10
+ // were bad: guess an id, tell the creator to open the console and copy one, or
11
+ // fail. `link` needs an id, and until now nothing printed one.
12
+ //
13
+ // The IO boundary (print, backend, org) is injected, so every branch is testable
14
+ // with no API, no HOME, and no network.
15
+ /**
16
+ * The columns are `id`, `slug`, `name` and where it stands.
17
+ *
18
+ * THE ID IS FIRST AND IS NEVER ELIDED. It is the whole reason the verb exists:
19
+ * the next command a reader runs is `vincentt link <project-id>`, and a listing
20
+ * that truncated the id would send them to the console for the one value the
21
+ * command needs — which is the gap this closes.
22
+ */
23
+ export async function listProjectsCommand(deps, opts = {}) {
24
+ const cfg = await deps.config();
25
+ if (!cfg) {
26
+ deps.print("Not signed in. Run `vincentt login`.");
27
+ return 1;
28
+ }
29
+ const org = await deps.org();
30
+ if (!org.ok) {
31
+ deps.print(org.message);
32
+ return 1;
33
+ }
34
+ const projects = await deps.list(cfg, org.org.slug);
35
+ if (opts.json) {
36
+ // `{projects: [...]}` — the server's own envelope, holding the SAME
37
+ // `ProjectView` every other verb in this CLI parses a project into. It is a
38
+ // projection of the wire row (the API also sends version/timestamp fields),
39
+ // and it is deliberately the shared one rather than a listing-specific
40
+ // shape: a second projection here would be a second thing that can drift.
41
+ deps.print(JSON.stringify({ projects }, null, 2));
42
+ return 0;
43
+ }
44
+ if (projects.length === 0) {
45
+ // Not a failure, so no ✗ glyph: an empty workspace is a fact. The remedy is
46
+ // named because a reader who ran this verb was looking for something to act
47
+ // on and found nothing.
48
+ deps.print(`No projects in ${org.org.name}.`);
49
+ deps.print("");
50
+ deps.print(" vincentt create <name> Register one for a new folder");
51
+ return 0;
52
+ }
53
+ deps.print(`${projects.length} project${projects.length === 1 ? "" : "s"} in ${org.org.name}`);
54
+ deps.print("");
55
+ for (const line of projectRows(projects))
56
+ deps.print(line);
57
+ deps.print("");
58
+ deps.print(" Link a folder to one: vincentt link <project-id>");
59
+ return 0;
60
+ }
61
+ /**
62
+ * Aligned on the widest id and slug so the block reads as a table in a terminal
63
+ * and stays greppable in an agent's transcript.
64
+ *
65
+ * The state column reads from `publishState` and NEVER computes a URL from the
66
+ * slug: `liveUrl` is server-computed because the apex differs per environment,
67
+ * so a client that assembled `<slug>.vincentt.app` itself would print the wrong
68
+ * address on staging and dev.
69
+ */
70
+ export function projectRows(projects) {
71
+ const idWidth = Math.max(...projects.map((p) => p.id.length));
72
+ const slugWidth = Math.max(...projects.map((p) => p.slug.length));
73
+ return projects.map((p) => {
74
+ const state = p.publishState === "live" && p.liveUrl ? p.liveUrl : p.publishState;
75
+ return ` ${p.id.padEnd(idWidth)} ${p.slug.padEnd(slugWidth)} ${p.name} ${state}`;
76
+ });
77
+ }
@@ -1,3 +1,7 @@
1
+ import { type FolderCase } from "./classifyTarget.js";
2
+ import { type OrgSource } from "./initFrom.js";
3
+ import type { ScaffoldCall } from "./initCommand.js";
4
+ import type { Ask } from "../publish/prompt.js";
1
5
  import { type ResolvedConfig } from "../shared/config.js";
2
6
  export interface ProjectCommandDeps {
3
7
  /** Print a transcript line. */
@@ -23,22 +27,70 @@ export interface CreateCommandDeps extends ProjectCommandDeps {
23
27
  ok: false;
24
28
  message: string;
25
29
  }>;
30
+ /** Which of the three folder cases the target is in. Defaults to `classifyTarget`. */
31
+ classify?: (dir: string) => Promise<FolderCase>;
32
+ /** Create the target directory. See the writer's guards below. */
33
+ mkdir?: (dir: string) => Promise<void>;
34
+ /** Resolve a path through its symlinks, for the classify→write re-check. */
35
+ realpath?: (dir: string) => Promise<string>;
36
+ scaffold?: (dir: string, opts: ScaffoldCall) => Promise<void>;
37
+ install?: (dir: string) => Promise<string>;
38
+ detectPm?: (dir: string) => Promise<string>;
39
+ /** Free-text prompt. Absent → not a TTY, so nothing may be asked. */
40
+ ask?: Ask;
41
+ /** The PAT that would authenticate a call, if any. Never triggers a login itself. */
42
+ currentToken?: () => Promise<string | undefined>;
43
+ /** The interactive browser login. Only ever reached on a TTY. */
44
+ runLogin?: () => Promise<void>;
45
+ /** Where the resolved org came from, so a surprising value is diagnosable (AF-3). */
46
+ orgSource?: () => OrgSource;
26
47
  }
27
48
  export interface CreateCommandOptions {
28
49
  name?: string;
29
50
  slug?: string;
30
51
  /** Replace a binding this folder already has. */
31
52
  force?: boolean;
53
+ /** The positional, already through `sanitizeAppName`. Resolves against `cwd`. */
54
+ dir?: string;
55
+ /** `--no-install`, the same meaning it has on `init`. */
56
+ skipInstall?: boolean;
57
+ /**
58
+ * Let a `409 slug_taken` propagate instead of printing here. Set by the callers
59
+ * that own their own taken-address copy (`registerAfterScaffold`, which has the
60
+ * bounded re-ask). See the throw site.
61
+ */
62
+ rethrowSlugTaken?: boolean;
32
63
  }
33
64
  /**
34
- * `vincentt create` — register a project and link this folder to it.
65
+ * `vincentt create` — a TWO-HALF verb with one seam.
66
+ *
67
+ * Above the seam is local and permanent (a directory, source on disk); below it is
68
+ * a network attempt (a session, an org, a slug claim, a binding). Which half
69
+ * failed is readable from the transcript's shape, which is what lets a creator
70
+ * answer "did I lose my work?" without reading the error twice.
71
+ *
72
+ * In an EMPTY folder it scaffolds the standard starter, installs, and then
73
+ * registers. In a folder that is already a Vincentt app it only registers. In any
74
+ * other non-empty folder it REFUSES and names `vincentt init .`, which is the
75
+ * escape hatch with its own confirm.
35
76
  *
36
- * It does NOT scaffold. An unscaffolded folder gets a `!` advisory and the command
37
- * PROCEEDS: the server accepts a create from any folder or none, and blocking here
38
- * would put the open template behind the auth gate, which is the one thing the
39
- * init/create split exists to prevent.
77
+ * `requireLogin()` is deliberately NOT called above this: the scaffold half must
78
+ * never depend on a session or on our availability, and a session check at the top
79
+ * of the verb would put the open template behind the auth gate. A "cleanup" that
80
+ * restores it silently reverts the whole design — `link()` three lines below in
81
+ * cli/index.ts does exactly that, which is what a dev writes by copying a
82
+ * neighbour.
40
83
  */
41
84
  export declare function createCommand(deps: CreateCommandDeps, opts?: CreateCommandOptions): Promise<number>;
85
+ /**
86
+ * The registration half — the binding guard's remainder, the slug derivation, the
87
+ * round trip, and the binding write. Extracted from `createCommand` so
88
+ * `registerAfterScaffold` can call it without a cycle (that orchestrator IS the
89
+ * below-the-seam half of `create`, and it calls this as its `create` dep).
90
+ *
91
+ * Pure refactor of what shipped: no behavior change on f6's path.
92
+ */
93
+ export declare function registerProject(deps: CreateCommandDeps, opts?: CreateCommandOptions): Promise<number>;
42
94
  /**
43
95
  * `vincentt link <project-id>` — one verb for four situations (console-first
44
96
  * birth, a fresh clone, a folder that lost its binding, a re-link after
@@ -70,9 +122,13 @@ export declare function printUnreachable(print: (line: string) => void, message:
70
122
  */
71
123
  export declare function unlinkCommand(deps: ProjectCommandDeps): Promise<number>;
72
124
  /**
73
- * The advisory's trigger: a package.json that depends on @vincentt-xr/sdk. It is a
74
- * HINT only — create proceeds either way — so a false negative costs one extra
75
- * line of output, never a refused command.
125
+ * A package.json that depends on `@vincentt-xr/*`.
126
+ *
127
+ * This is a GATE, not a hint. It is `classifyTarget`'s first and unconditional
128
+ * question, and it decides whether `create` registers only (true) or goes on to ask
129
+ * whether the folder is empty (false). A false negative therefore costs a refused
130
+ * command on a non-empty folder and a SCAFFOLD on an empty one — never one extra
131
+ * line of output, which is what this used to say and is no longer true.
76
132
  */
77
133
  export declare function looksLikeVincenttApp(cwd: string): Promise<boolean>;
78
134
  /** Resolve the bound project for a project-scoped verb, or explain why not. */
@@ -7,18 +7,51 @@
7
7
  import { promises as fs } from "node:fs";
8
8
  import path from "node:path";
9
9
  import { BackendError, SlugTakenError, createProject, getProject, } from "./backend.js";
10
+ import { classifyTarget } from "./classifyTarget.js";
11
+ import { registerAfterScaffold } from "./initFrom.js";
10
12
  import { slugify } from "./slug.js";
13
+ import { detectPackageManager, installDependencies, scaffoldFromTemplate, } from "../scaffold/index.js";
11
14
  import { clearProjectBinding, loadProjectBinding, locateProjectBinding, writeProjectBinding, } from "../shared/config.js";
12
15
  /**
13
- * `vincentt create` — register a project and link this folder to it.
16
+ * `vincentt create` — a TWO-HALF verb with one seam.
14
17
  *
15
- * It does NOT scaffold. An unscaffolded folder gets a `!` advisory and the command
16
- * PROCEEDS: the server accepts a create from any folder or none, and blocking here
17
- * would put the open template behind the auth gate, which is the one thing the
18
- * init/create split exists to prevent.
18
+ * Above the seam is local and permanent (a directory, source on disk); below it is
19
+ * a network attempt (a session, an org, a slug claim, a binding). Which half
20
+ * failed is readable from the transcript's shape, which is what lets a creator
21
+ * answer "did I lose my work?" without reading the error twice.
22
+ *
23
+ * In an EMPTY folder it scaffolds the standard starter, installs, and then
24
+ * registers. In a folder that is already a Vincentt app it only registers. In any
25
+ * other non-empty folder it REFUSES and names `vincentt init .`, which is the
26
+ * escape hatch with its own confirm.
27
+ *
28
+ * `requireLogin()` is deliberately NOT called above this: the scaffold half must
29
+ * never depend on a session or on our availability, and a session check at the top
30
+ * of the verb would put the open template behind the auth gate. A "cleanup" that
31
+ * restores it silently reverts the whole design — `link()` three lines below in
32
+ * cli/index.ts does exactly that, which is what a dev writes by copying a
33
+ * neighbour.
19
34
  */
20
35
  export async function createCommand(deps, opts = {}) {
21
- const existing = await loadProjectBinding(deps.cwd);
36
+ // ONE target, resolved as the first statement. `deps.cwd` is never read again
37
+ // and never passed onward: once a positional exists it is the PARENT, and a
38
+ // guard asking its question about the parent lets `--force` overwrite a
39
+ // different project's binding while registering a project for the child.
40
+ //
41
+ // The PARENT is canonicalized here, before anything is classified, so that the
42
+ // writer's realpath re-check below compares like with like. Without this the
43
+ // guard fires on every ordinary run whose path merely passes through a symlink:
44
+ // on macOS `/tmp` is a symlink to `/private/tmp`, so `path.resolve` and
45
+ // `realpath` disagree about a perfectly innocent target and the verb refuses.
46
+ // Found by driving the built CLI, not by a unit test — the unit injected a
47
+ // realpath that was the identity function, which is exactly the shape of a
48
+ // check that passes while the artifact is wrong.
49
+ const realpath = deps.realpath ?? ((dir) => fs.realpath(dir));
50
+ const rawParent = path.resolve(deps.cwd);
51
+ const parent = await realpath(rawParent).catch(() => rawParent);
52
+ const target = opts.dir ? path.join(parent, opts.dir) : parent;
53
+ const dirLabel = opts.dir ? `./${opts.dir}` : ".";
54
+ const existing = await loadProjectBinding(target);
22
55
  if (existing && !opts.force) {
23
56
  deps.print(`✗ This folder is already linked to a project (${existing.slug}).`);
24
57
  deps.print("");
@@ -27,13 +60,259 @@ export async function createCommand(deps, opts = {}) {
27
60
  deps.print(" Replace it: vincentt create --force");
28
61
  return 1;
29
62
  }
30
- if (!(await looksLikeVincenttApp(deps.cwd))) {
31
- deps.print("! This folder doesn't look like a Vincentt app (no package.json with @vincentt-xr/sdk).");
32
- deps.print(" `vincentt create` registers a project — it doesn't scaffold one.");
33
- deps.print(" To start from the template: npx @vincentt-xr/cli init");
63
+ const classify = deps.classify ?? classifyTarget;
64
+ const folderCase = await classify(target);
65
+ if (folderCase === "occupied") {
66
+ // Every case-3 cause prints this exact screen — occupied, unreadable, a
67
+ // symlink to anything, a dangling symlink, a regular file, ENOTDIR, ELOOP, an
68
+ // unforeseen errno. The CLI could not establish that the target is safe to
69
+ // write into; that is one fact with one remedy pair. A per-cause message
70
+ // would advertise which filenames the predicate has opinions about, which is
71
+ // the allowlist this design refuses to open.
72
+ deps.print("✗ This folder isn't a Vincentt app, and it isn't empty.");
73
+ deps.print("");
74
+ deps.print(" `vincentt create` scaffolds an empty folder and registers an existing app.");
75
+ deps.print(" This folder is neither, and adding the template's files here would mix them");
76
+ deps.print(" into what's already present.");
77
+ deps.print("");
78
+ deps.print(" vincentt create <name> Scaffold and register in a new folder");
79
+ deps.print(" vincentt init . Scaffold into THIS folder anyway (asks first)");
80
+ deps.print("");
81
+ deps.print(" Nothing was written.");
82
+ return 1;
83
+ }
84
+ let scaffolded = false;
85
+ let installed = true;
86
+ let pm = "npm";
87
+ let actedOn = dirLabel;
88
+ if (folderCase === "empty") {
89
+ const wrote = await scaffoldInto(deps, target, dirLabel, opts);
90
+ if (!wrote.ok)
91
+ return 1;
92
+ scaffolded = true;
93
+ installed = wrote.installed;
94
+ pm = wrote.pm;
95
+ }
96
+ else {
97
+ // Case 2 writes too — a binding and a .gitignore line — so it runs the same
98
+ // re-check the case-1 writer does. It never calls scaffoldInto, so without
99
+ // this the guard is asymmetric: a symlink whose target holds an
100
+ // @vincentt-xr package.json classifies as `vincentt-app` (looksLikeApp runs
101
+ // first and follows the link), and the binding lands in the link target.
102
+ const resolved = await realpath(target).catch(() => target);
103
+ // Two different collisions land here, and they need different copy. A
104
+ // DIFFERENT PARENT means a symlink pointing out of the folder the creator
105
+ // named — refuse, because the binding would land somewhere they cannot see.
106
+ // The SAME parent with a different basename is the case-insensitive twin
107
+ // (`create myapp` beside `MyApp/` on the default macOS volume): the folder
108
+ // is right there and visible, so it is FORK-C4(ii)'s transparency case, not
109
+ // a refusal — name what it resolved to and carry on.
110
+ if (path.dirname(resolved) !== path.dirname(target)) {
111
+ deps.print("✗ That folder resolves somewhere else.");
112
+ deps.print("");
113
+ deps.print(` ${dirLabel} is a link, and registering would write into its target`);
114
+ deps.print(" rather than into the folder you named.");
115
+ deps.print("");
116
+ deps.print(" Run the command from the real folder instead.");
117
+ deps.print("");
118
+ deps.print(" Nothing was written.");
119
+ return 1;
120
+ }
121
+ const onDisk = path.basename(resolved);
122
+ if (opts.dir && onDisk !== opts.dir)
123
+ actedOn = `./${onDisk}`;
124
+ }
125
+ let cachedOrg;
126
+ const org = async () => (cachedOrg ??= await deps.org());
127
+ const sharedDeps = { ...deps, org };
128
+ // Only when the folder on disk is spelled differently from what was typed.
129
+ // Printing it every time would be noise; printing it here is the one signal
130
+ // that `create myapp` is about to register `MyApp/`.
131
+ if (actedOn !== dirLabel) {
132
+ deps.print(`! Registering ${actedOn}, which is what ${dirLabel} resolves to.`);
133
+ }
134
+ const code = await registerAfterScaffold({
135
+ print: deps.print,
136
+ targetDir: target,
137
+ dirLabel,
138
+ ask: deps.ask,
139
+ scaffolded,
140
+ // Bare `create` acts in the cwd, so a `cd .` in the resume block is noise.
141
+ resumeNeedsCd: Boolean(opts.dir),
142
+ currentToken: deps.currentToken ?? (async () => undefined),
143
+ runLogin: deps.runLogin ?? (async () => undefined),
144
+ resolveOrg: async () => {
145
+ const resolved = await org();
146
+ if (!resolved.ok)
147
+ return resolved;
148
+ return {
149
+ ok: true,
150
+ name: resolved.org.name,
151
+ source: deps.orgSource?.() ?? "active",
152
+ };
153
+ },
154
+ create: async ({ name, slug }) => registerProject(sharedDeps, {
155
+ ...opts,
156
+ dir: opts.dir,
157
+ name,
158
+ slug,
159
+ rethrowSlugTaken: true,
160
+ }),
161
+ },
162
+ // Case 2 keeps the shipped basename default: a creator made that folder and
163
+ // put an app in it. Case 1's folder was created by the CLI from a token an
164
+ // agent passed, so defaulting an address off it would be the CLI claiming a
165
+ // globally-unique public address from a name it generated the path for.
166
+ scaffolded
167
+ ? { name: opts.name, slug: opts.slug }
168
+ : {
169
+ name: opts.name ?? path.basename(target),
170
+ slug: opts.slug ?? slugify(opts.name ?? path.basename(target)),
171
+ });
172
+ if (code !== 0)
173
+ return code;
174
+ if (scaffolded) {
175
+ // This reader has never run the dev server, so the block that fits is the one
176
+ // `init` already ships for exactly this reader — two aligned columns off the
177
+ // DETECTED package manager, never a hardcoded one (the template ships a pnpm
178
+ // lockfile and a hoisted node-linker).
179
+ const steps = [];
180
+ if (opts.dir)
181
+ steps.push([`cd ${opts.dir}`, ""]);
182
+ if (!installed || opts.skipInstall)
183
+ steps.push([`${pm} install`, "Install dependencies"]);
184
+ steps.push([`${pm} run dev`, "Start the local dev server"]);
185
+ steps.push(["vincentt preview", "Try it on your phone"]);
34
186
  deps.print("");
187
+ for (const line of alignedSteps(steps))
188
+ deps.print(line);
189
+ }
190
+ return 0;
191
+ }
192
+ /** Two columns, aligned on the widest command, so the block reads as a table. */
193
+ function alignedSteps(steps) {
194
+ const width = Math.max(...steps.map(([cmd]) => cmd.length));
195
+ return steps.map(([cmd, note]) => note ? ` ${cmd.padEnd(width)} ${note}` : ` ${cmd}`);
196
+ }
197
+ /**
198
+ * The scaffold half — everything above the seam, and the only place this verb
199
+ * writes before a 201.
200
+ *
201
+ * TWO GUARDS LIVE HERE RATHER THAN IN THE CLASSIFIER, because the classifier
202
+ * answers a question at a moment and both of these failures happen after it:
203
+ *
204
+ * 1. `mkdir` with `{recursive: false}`. Measured: `mkdir(p, {recursive: true})`
205
+ * on a path that is an EXISTING SYMLINK TO A DIRECTORY succeeds silently, and
206
+ * the scaffold then writes through the link, outside the parent the creator
207
+ * named. The same call non-recursively throws EEXIST. Without this the
208
+ * classifier's lstat check is defeated by anything that plants a symlink
209
+ * between the classify and the write.
210
+ * 2. `realpath(target)` is compared against the path that was classified, and a
211
+ * mismatch refuses WITHOUT writing. This closes the general
212
+ * classify-then-write window rather than one instance of it.
213
+ *
214
+ * Both also close a collision that needs no attacker at all: `sanitizeAppName`
215
+ * accepts `MyApp` and `myapp` as distinct tokens and the default macOS volume is
216
+ * case-INsensitive, so `create myapp` beside an existing `MyApp/` resolves to one
217
+ * directory and would otherwise bind a project to somebody else's folder while
218
+ * reporting success.
219
+ */
220
+ async function scaffoldInto(deps, target, dirLabel, opts) {
221
+ deps.print(`→ Scaffolding a Vincentt XR app in ${dirLabel}…`);
222
+ deps.print(" · React + react-three-fiber + @vincentt-xr/sdk");
223
+ if (!opts.skipInstall)
224
+ deps.print(" · installing dependencies");
225
+ const mkdir = deps.mkdir ??
226
+ (async (dir) => {
227
+ await fs.mkdir(path.dirname(dir), { recursive: true });
228
+ await fs.mkdir(dir, { recursive: false });
229
+ });
230
+ const realpath = deps.realpath ?? ((dir) => fs.realpath(dir));
231
+ try {
232
+ await mkdir(target);
233
+ }
234
+ catch (err) {
235
+ // EEXIST here is guard 1 firing: something occupies the path that the
236
+ // classifier said was absent or empty. It is a refusal, not a retry.
237
+ const code = err?.code;
238
+ if (code !== "EEXIST") {
239
+ deps.print("");
240
+ // The errno and the label the creator typed, never err.message — that
241
+ // carries the resolved absolute path, and transcripts get pasted into
242
+ // bug reports and agent context.
243
+ deps.print(`✗ Could not create ${dirLabel}${code ? ` (${code})` : ""}.`);
244
+ deps.print("");
245
+ deps.print(" Run `vincentt create` again once that is resolved.");
246
+ return { ok: false };
247
+ }
35
248
  }
36
- const name = opts.name ?? path.basename(path.resolve(deps.cwd));
249
+ let resolved;
250
+ try {
251
+ resolved = await realpath(target);
252
+ }
253
+ catch {
254
+ resolved = "";
255
+ }
256
+ if (resolved !== target) {
257
+ deps.print("");
258
+ deps.print("✗ This folder isn't a Vincentt app, and it isn't empty.");
259
+ deps.print("");
260
+ deps.print(" `vincentt create` scaffolds an empty folder and registers an existing app.");
261
+ deps.print(" This folder is neither, and adding the template's files here would mix them");
262
+ deps.print(" into what's already present.");
263
+ deps.print("");
264
+ deps.print(" vincentt create <name> Scaffold and register in a new folder");
265
+ deps.print(" vincentt init . Scaffold into THIS folder anyway (asks first)");
266
+ deps.print("");
267
+ deps.print(" Nothing was written.");
268
+ return { ok: false };
269
+ }
270
+ // A CONSTANT call: no `repo`, no `ref`, so scaffoldFromTemplate resolves
271
+ // TEMPLATE_REPO at DEFAULT_TEMPLATE_REF. ScaffoldCall's two fields exist for
272
+ // `init --from`; `create` never populates either, and there is no code path by
273
+ // which it could — which is exactly what `--template`/`--from` being refused at
274
+ // the parser makes structurally true, and what the install below rests on.
275
+ const scaffold = deps.scaffold ?? ((dir, o) => scaffoldFromTemplate(dir, o));
276
+ try {
277
+ await scaffold(target, {});
278
+ }
279
+ catch (err) {
280
+ // No `✓ Created`, so no registration is attempted, and this arm carries
281
+ // NEITHER closing string: a partial directory may exist, so "Nothing was
282
+ // written." would be a lie.
283
+ deps.print("");
284
+ deps.print(`✗ ${err instanceof Error ? err.message : String(err)}`);
285
+ deps.print("");
286
+ deps.print(" Run `vincentt create` again once that is resolved.");
287
+ return { ok: false };
288
+ }
289
+ let installed = true;
290
+ if (!opts.skipInstall) {
291
+ const install = deps.install ?? ((dir) => installDependencies(dir));
292
+ try {
293
+ await install(target);
294
+ }
295
+ catch {
296
+ installed = false;
297
+ }
298
+ }
299
+ const detect = deps.detectPm ?? ((dir) => detectPackageManager(dir));
300
+ const pm = await detect(target).catch(() => "npm");
301
+ deps.print("");
302
+ deps.print(`✓ Created ${dirLabel}`);
303
+ return { ok: true, installed, pm };
304
+ }
305
+ /**
306
+ * The registration half — the binding guard's remainder, the slug derivation, the
307
+ * round trip, and the binding write. Extracted from `createCommand` so
308
+ * `registerAfterScaffold` can call it without a cycle (that orchestrator IS the
309
+ * below-the-seam half of `create`, and it calls this as its `create` dep).
310
+ *
311
+ * Pure refactor of what shipped: no behavior change on f6's path.
312
+ */
313
+ export async function registerProject(deps, opts = {}) {
314
+ const target = opts.dir ? path.resolve(deps.cwd, opts.dir) : path.resolve(deps.cwd);
315
+ const name = opts.name ?? path.basename(target);
37
316
  const slug = opts.slug ?? slugify(name);
38
317
  if (!slug) {
39
318
  deps.print("✗ Could not derive an address from that name.");
@@ -56,13 +335,17 @@ export async function createCommand(deps, opts = {}) {
56
335
  created = await createProject(cfg, org.org.slug, name, slug);
57
336
  }
58
337
  catch (err) {
59
- if (err instanceof SlugTakenError) {
338
+ // `registerAfterScaffold` owns the taken-address copy on its own path (it has
339
+ // the bounded re-ask and the reassure block, neither of which exists here), so
340
+ // when it is the caller the error is its to handle. Printing here as well
341
+ // would emit two refusals for one condition AND kill the re-ask loop.
342
+ if (err instanceof SlugTakenError && !opts.rethrowSlugTaken) {
60
343
  printSlugTaken(deps.print, err.suggestions, name);
61
344
  return 1;
62
345
  }
63
346
  throw err;
64
347
  }
65
- await writeProjectBinding(deps.cwd, {
348
+ await writeProjectBinding(target, {
66
349
  projectId: created.projectId,
67
350
  slug: created.slug,
68
351
  });
@@ -77,9 +360,17 @@ export async function createCommand(deps, opts = {}) {
77
360
  /**
78
361
  * Four words, byte-identical to the console's — it must read the same whether the
79
362
  * holder is your own org, a stranger, a tombstone, a burn, or a reserved hostname.
80
- * Suggestions are LISTED, never applied: the remedy restates the whole command so
81
- * it is copy-pasteable, and the closing clause offers the creator's own choice as
82
- * an equal option, so an agent reading this cannot conclude auto-picking is sanctioned.
363
+ *
364
+ * Suggestions are LISTED, never applied, and the remedy carries the `<address>`
365
+ * PLACEHOLDER on both arms. It is the only command in the verb an agent cannot run
366
+ * verbatim, and that is the tool refusing to choose a globally-unique public
367
+ * address on the creator's behalf: a creator who pastes a filled-in command has
368
+ * accepted an address the tool picked. Substituting `suggestions[0]` would make
369
+ * auto-picking look sanctioned, and an agent reading this transcript would learn to
370
+ * do it every time. One verb, one answer — the sibling copy in
371
+ * `registerAfterScaffold` reads the same, and two answers to one condition,
372
+ * reproducible only by standing in the right folder, is the shape of a bug report
373
+ * nobody can reproduce.
83
374
  */
84
375
  function printSlugTaken(print, suggestions, name) {
85
376
  print("✗ That address is taken.");
@@ -89,12 +380,9 @@ function printSlugTaken(print, suggestions, name) {
89
380
  for (const s of suggestions)
90
381
  print(` ${s}`);
91
382
  print("");
92
- print(` Run \`vincentt create --name "${name}" --slug ${suggestions[0]}\` to use one,`);
93
- print(" or pick your own with any --slug.");
94
- }
95
- else {
96
- print(` Run \`vincentt create --name "${name}" --slug <address>\` with another address.`);
383
+ print(" Finish with one of those, or your own:");
97
384
  }
385
+ print(` Run \`vincentt create --name "${name}" --slug <address>\` with another address.`);
98
386
  }
99
387
  // ---- link -------------------------------------------------------------------
100
388
  /**
@@ -188,9 +476,13 @@ export async function unlinkCommand(deps) {
188
476
  }
189
477
  // ---- shared -----------------------------------------------------------------
190
478
  /**
191
- * The advisory's trigger: a package.json that depends on @vincentt-xr/sdk. It is a
192
- * HINT only — create proceeds either way — so a false negative costs one extra
193
- * line of output, never a refused command.
479
+ * A package.json that depends on `@vincentt-xr/*`.
480
+ *
481
+ * This is a GATE, not a hint. It is `classifyTarget`'s first and unconditional
482
+ * question, and it decides whether `create` registers only (true) or goes on to ask
483
+ * whether the folder is empty (false). A false negative therefore costs a refused
484
+ * command on a non-empty folder and a SCAFFOLD on an empty one — never one extra
485
+ * line of output, which is what this used to say and is no longer true.
194
486
  */
195
487
  export async function looksLikeVincenttApp(cwd) {
196
488
  try {
@@ -4,6 +4,25 @@ export interface FromFailureContext {
4
4
  dir: string;
5
5
  }
6
6
  export declare function printSourceRefFailure(print: (line: string) => void, failure: ParseFailure, ctx: FromFailureContext): void;
7
+ /**
8
+ * `--template` / `--from` on `create` — the parser refusal, and it is a security
9
+ * control rather than a scope note. The basis for installing dependencies on the
10
+ * unauthenticated scaffold path is that `create` can only ever clone our own
11
+ * hardcoded template at a release-gated ref; this screen is what makes that
12
+ * structurally true rather than conventional.
13
+ *
14
+ * "on `create`" is REQUIRED on line 1 and is not required by the shipped
15
+ * `printFlagConflict` precedent: `--template` really does exist, on `init`, and the
16
+ * remedy three lines below depends on that being true. An unqualified claim is one
17
+ * a creator disproves in one command.
18
+ *
19
+ * The creator's own typed arguments are carried through verbatim, so all three
20
+ * remedy lines are copy-pasteable with no placeholder to guess.
21
+ */
22
+ export declare function printCreateSourceFlagRefusal(print: (line: string) => void, flagName: string, ctx: {
23
+ dir: string;
24
+ value?: string;
25
+ }): void;
7
26
  /** `--from` with `--template`, or with an install-enabling flag. */
8
27
  export declare function printFlagConflict(print: (line: string) => void, conflict: {
9
28
  flags: [string, string];