@vincentt-xr/cli 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -13
- package/dist/cli/args.d.ts +22 -0
- package/dist/cli/args.js +71 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +359 -91
- package/dist/cli/usage-text.d.ts +1 -0
- package/dist/cli/usage-text.js +97 -0
- package/dist/cli/verb-surface.d.ts +24 -0
- package/dist/cli/verb-surface.js +49 -0
- package/dist/dx/clusters.d.ts +0 -17
- package/dist/dx/clusters.js +50 -1
- package/dist/dx/skeleton.js +1 -0
- package/dist/lib/backend.d.ts +11 -0
- package/dist/lib/backend.js +23 -0
- package/dist/lib/classifyTarget.d.ts +42 -0
- package/dist/lib/classifyTarget.js +83 -0
- package/dist/lib/feedback.d.ts +46 -5
- package/dist/lib/feedback.js +76 -19
- package/dist/lib/feedbackCopy.d.ts +70 -0
- package/dist/lib/feedbackCopy.js +163 -0
- package/dist/lib/initCommand.d.ts +28 -1
- package/dist/lib/initCommand.js +91 -16
- package/dist/lib/initFrom.d.ts +70 -0
- package/dist/lib/initFrom.js +222 -0
- package/dist/lib/lifecycle.d.ts +28 -3
- package/dist/lib/lifecycle.js +99 -7
- package/dist/lib/listProjects.d.ts +41 -0
- package/dist/lib/listProjects.js +77 -0
- package/dist/lib/projects.d.ts +64 -8
- package/dist/lib/projects.js +316 -24
- package/dist/lib/sourceRef.d.ts +46 -0
- package/dist/lib/sourceRef.js +107 -0
- package/dist/lib/sourceRefCopy.d.ts +30 -0
- package/dist/lib/sourceRefCopy.js +116 -0
- package/dist/preview/command.d.ts +11 -0
- package/dist/preview/command.js +19 -1
- package/dist/preview/copy.d.ts +7 -0
- package/dist/preview/copy.js +8 -1
- package/dist/preview/runner.d.ts +6 -0
- package/dist/preview/runner.js +1 -0
- package/dist/preview/stream.d.ts +11 -0
- package/dist/preview/stream.js +13 -0
- package/dist/publish/api.d.ts +20 -0
- package/dist/publish/command.d.ts +3 -0
- package/dist/publish/command.js +39 -4
- package/dist/publish/copy.d.ts +29 -0
- package/dist/publish/copy.js +86 -0
- package/dist/relay/annotations.d.ts +28 -4
- package/dist/relay/annotations.js +23 -7
- package/dist/relay/qa.f12.fixtures.d.ts +154 -0
- package/dist/relay/qa.f12.fixtures.js +358 -0
- package/dist/relay/server.d.ts +41 -3
- package/dist/relay/server.js +90 -32
- package/dist/scaffold/index.d.ts +9 -0
- package/dist/scaffold/index.js +13 -1
- package/package.json +2 -2
- package/dist/library/qa.f5-b32.spec.d.ts +0 -1
- package/dist/library/qa.f5-b32.spec.js +0 -202
- package/dist/library/qa.f5-cli.spec.d.ts +0 -1
- package/dist/library/qa.f5-cli.spec.js +0 -433
- package/dist/library/qa.f5-contract.spec.d.ts +0 -1
- package/dist/library/qa.f5-contract.spec.js +0 -206
- package/dist/relay/injectors.d.ts +0 -29
- package/dist/relay/injectors.js +0 -72
package/dist/lib/projects.js
CHANGED
|
@@ -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` —
|
|
16
|
+
* `vincentt create` — a TWO-HALF verb with one seam.
|
|
14
17
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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(
|
|
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
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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 {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface SourceRef {
|
|
2
|
+
owner: string;
|
|
3
|
+
repo: string;
|
|
4
|
+
tag: string;
|
|
5
|
+
}
|
|
6
|
+
export type ParseFailure = {
|
|
7
|
+
kind: "missing_tag";
|
|
8
|
+
value: string;
|
|
9
|
+
} | {
|
|
10
|
+
kind: "missing_repo";
|
|
11
|
+
value: string;
|
|
12
|
+
} | {
|
|
13
|
+
kind: "bad_owner";
|
|
14
|
+
value: string;
|
|
15
|
+
} | {
|
|
16
|
+
kind: "bad_repo";
|
|
17
|
+
value: string;
|
|
18
|
+
} | {
|
|
19
|
+
kind: "bad_tag";
|
|
20
|
+
value: string;
|
|
21
|
+
} | {
|
|
22
|
+
kind: "looks_like_url";
|
|
23
|
+
value: string;
|
|
24
|
+
converted?: SourceRef;
|
|
25
|
+
};
|
|
26
|
+
export type ParseResult = {
|
|
27
|
+
ok: true;
|
|
28
|
+
ref: SourceRef;
|
|
29
|
+
} | {
|
|
30
|
+
ok: false;
|
|
31
|
+
failure: ParseFailure;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Parse `owner/repo@tag`. Pure: no filesystem, no network, no process.
|
|
35
|
+
*
|
|
36
|
+
* A bare `owner/repo` is REFUSED, never defaulted to a branch — a default would
|
|
37
|
+
* point the command at a moving target (S-2).
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseSourceRef(input: string): ParseResult;
|
|
40
|
+
/**
|
|
41
|
+
* The clone URL's ONLY composition point. The host is a compile-time constant —
|
|
42
|
+
* never configuration, never derived from stored data (S-6).
|
|
43
|
+
*/
|
|
44
|
+
export declare function templateCloneURL(owner: string, repo: string): string;
|
|
45
|
+
/** `owner/repo@tag`, the form the creator typed and the card shows. */
|
|
46
|
+
export declare function formatSourceRef(ref: SourceRef): string;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// `--from owner/repo@tag` — the CLI's only writer of the values handed to git.
|
|
2
|
+
//
|
|
3
|
+
// THE PATTERN IS THE SPECIFICATION. Where a comment here and a pattern below
|
|
4
|
+
// disagree, the pattern is the design (f6 S-5).
|
|
5
|
+
//
|
|
6
|
+
// Three parsers accept this grammar (the Go write-boundary validator, this one,
|
|
7
|
+
// and the console's paste parser, which deliberately accepts MORE). The shared
|
|
8
|
+
// row list is `features/f6-gallery/fixtures/source-refs.json`, and every row
|
|
9
|
+
// names the parsers it applies to.
|
|
10
|
+
const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
11
|
+
const REPO = /^(?!\.)(?!.*\.\.)[A-Za-z0-9._-]{1,100}$/;
|
|
12
|
+
const TAG = /^(?!.*\.\.)(?!.*\/\/)[A-Za-z0-9._][A-Za-z0-9._/-]{0,127}$/;
|
|
13
|
+
/** A trailing `.git` is refused outright rather than stripped (S-5 fixture row). */
|
|
14
|
+
function validRepo(repo) {
|
|
15
|
+
return REPO.test(repo) && !repo.endsWith(".git");
|
|
16
|
+
}
|
|
17
|
+
/** git's own check-ref-format rules, on top of the character grammar. */
|
|
18
|
+
function validTag(tag) {
|
|
19
|
+
return TAG.test(tag) && !tag.endsWith("/") && !tag.endsWith(".lock");
|
|
20
|
+
}
|
|
21
|
+
const URL_LIKE = /^(https?:\/\/|git@|ssh:\/\/|github\.com[/:])/i;
|
|
22
|
+
/**
|
|
23
|
+
* Best-effort conversion of a pasted GitHub URL to the shorthand, used ONLY to
|
|
24
|
+
* show the creator the form they should have typed. It never feeds git — a URL
|
|
25
|
+
* is always a refusal, and this is the remedy line.
|
|
26
|
+
*/
|
|
27
|
+
function convertUrl(input) {
|
|
28
|
+
const withoutScheme = input.replace(/^(https?:\/\/|ssh:\/\/|git@)/i, "").replace(/^git@/i, "");
|
|
29
|
+
const path = withoutScheme.replace(/^github\.com[/:]/i, "");
|
|
30
|
+
if (path === withoutScheme && !/^github\.com/i.test(withoutScheme))
|
|
31
|
+
return undefined;
|
|
32
|
+
const parts = path.split("/").filter((p) => p !== "");
|
|
33
|
+
if (parts.length < 2)
|
|
34
|
+
return undefined;
|
|
35
|
+
const owner = parts[0];
|
|
36
|
+
const repo = parts[1].replace(/\.git$/i, "");
|
|
37
|
+
// /releases/tag/<tag> and /tree/<tag> are the two forms a creator copies from
|
|
38
|
+
// a repo page; the tag may itself contain slashes, so take everything after.
|
|
39
|
+
let tag;
|
|
40
|
+
if (parts[2] === "releases" && parts[3] === "tag" && parts.length > 4) {
|
|
41
|
+
tag = parts.slice(4).join("/");
|
|
42
|
+
}
|
|
43
|
+
else if (parts[2] === "tree" && parts.length > 3) {
|
|
44
|
+
tag = parts.slice(3).join("/");
|
|
45
|
+
}
|
|
46
|
+
else if (parts.length === 2 && repo.includes("@")) {
|
|
47
|
+
const [r, t] = repo.split("@");
|
|
48
|
+
return OWNER.test(owner) && validRepo(r) && validTag(t)
|
|
49
|
+
? { owner, repo: r, tag: t }
|
|
50
|
+
: undefined;
|
|
51
|
+
}
|
|
52
|
+
if (tag === undefined)
|
|
53
|
+
return undefined;
|
|
54
|
+
if (!OWNER.test(owner) || !validRepo(repo) || !validTag(tag))
|
|
55
|
+
return undefined;
|
|
56
|
+
return { owner, repo, tag };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Parse `owner/repo@tag`. Pure: no filesystem, no network, no process.
|
|
60
|
+
*
|
|
61
|
+
* A bare `owner/repo` is REFUSED, never defaulted to a branch — a default would
|
|
62
|
+
* point the command at a moving target (S-2).
|
|
63
|
+
*/
|
|
64
|
+
export function parseSourceRef(input) {
|
|
65
|
+
const s = input.trim();
|
|
66
|
+
if (URL_LIKE.test(s)) {
|
|
67
|
+
return { ok: false, failure: { kind: "looks_like_url", value: s, converted: convertUrl(s) } };
|
|
68
|
+
}
|
|
69
|
+
const at = s.indexOf("@");
|
|
70
|
+
const slash = s.indexOf("/");
|
|
71
|
+
if (at === -1) {
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
failure: slash === -1 ? { kind: "missing_repo", value: s } : { kind: "missing_tag", value: s },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const left = s.slice(0, at);
|
|
78
|
+
const tag = s.slice(at + 1);
|
|
79
|
+
if (!left.includes("/"))
|
|
80
|
+
return { ok: false, failure: { kind: "missing_repo", value: s } };
|
|
81
|
+
const firstSlash = left.indexOf("/");
|
|
82
|
+
const owner = left.slice(0, firstSlash);
|
|
83
|
+
const repo = left.slice(firstSlash + 1);
|
|
84
|
+
if (!OWNER.test(owner))
|
|
85
|
+
return { ok: false, failure: { kind: "bad_owner", value: owner } };
|
|
86
|
+
if (!validRepo(repo))
|
|
87
|
+
return { ok: false, failure: { kind: "bad_repo", value: repo } };
|
|
88
|
+
if (!validTag(tag))
|
|
89
|
+
return { ok: false, failure: { kind: "bad_tag", value: tag } };
|
|
90
|
+
return { ok: true, ref: { owner, repo, tag } };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The clone URL's ONLY composition point. The host is a compile-time constant —
|
|
94
|
+
* never configuration, never derived from stored data (S-6).
|
|
95
|
+
*/
|
|
96
|
+
export function templateCloneURL(owner, repo) {
|
|
97
|
+
const url = `https://github.com/${owner}/${repo}.git`;
|
|
98
|
+
const parsed = new URL(url);
|
|
99
|
+
if (parsed.host !== "github.com" || parsed.protocol !== "https:") {
|
|
100
|
+
throw new Error(`Refusing to clone from ${parsed.host}`);
|
|
101
|
+
}
|
|
102
|
+
return url;
|
|
103
|
+
}
|
|
104
|
+
/** `owner/repo@tag`, the form the creator typed and the card shows. */
|
|
105
|
+
export function formatSourceRef(ref) {
|
|
106
|
+
return `${ref.owner}/${ref.repo}@${ref.tag}`;
|
|
107
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type ParseFailure } from "./sourceRef.js";
|
|
2
|
+
export interface FromFailureContext {
|
|
3
|
+
/** The target directory as typed, for the runnable remedy line. */
|
|
4
|
+
dir: string;
|
|
5
|
+
}
|
|
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;
|
|
26
|
+
/** `--from` with `--template`, or with an install-enabling flag. */
|
|
27
|
+
export declare function printFlagConflict(print: (line: string) => void, conflict: {
|
|
28
|
+
flags: [string, string];
|
|
29
|
+
kind: "source" | "install";
|
|
30
|
+
}, ctx: FromFailureContext): void;
|