@vincentt-xr/cli 0.7.0 → 0.9.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/dist/cli/args.d.ts +18 -1
- package/dist/cli/args.js +40 -1
- package/dist/cli/index.js +208 -10
- package/dist/dx/command.d.ts +59 -2
- package/dist/dx/command.js +150 -11
- package/dist/dx/enablement.d.ts +51 -0
- package/dist/dx/enablement.js +97 -0
- package/dist/dx/qa.f7.fixtures.d.ts +73 -0
- package/dist/dx/qa.f7.fixtures.js +58 -0
- package/dist/dx/skeleton.js +1 -0
- package/dist/lib/initCommand.d.ts +28 -1
- package/dist/lib/initCommand.js +91 -16
- package/dist/lib/initFrom.d.ts +48 -0
- package/dist/lib/initFrom.js +198 -0
- package/dist/lib/sourceRef.d.ts +46 -0
- package/dist/lib/sourceRef.js +107 -0
- package/dist/lib/sourceRefCopy.d.ts +11 -0
- package/dist/lib/sourceRefCopy.js +80 -0
- package/dist/lib/version.d.ts +20 -1
- package/dist/lib/version.js +41 -5
- package/dist/library/assetsCommand.d.ts +32 -0
- package/dist/library/assetsCommand.js +264 -0
- package/dist/library/client.d.ts +42 -0
- package/dist/library/client.js +137 -0
- package/dist/library/download.d.ts +46 -0
- package/dist/library/download.js +98 -0
- package/dist/library/parse.d.ts +43 -0
- package/dist/library/parse.js +109 -0
- package/dist/library/qa.f5-b32.spec.d.ts +1 -0
- package/dist/library/qa.f5-b32.spec.js +202 -0
- package/dist/library/qa.f5-cli.spec.d.ts +1 -0
- package/dist/library/qa.f5-cli.spec.js +433 -0
- package/dist/library/qa.f5-contract.spec.d.ts +1 -0
- package/dist/library/qa.f5-contract.spec.js +206 -0
- package/dist/library/render.d.ts +39 -0
- package/dist/library/render.js +305 -0
- package/dist/library/wire.d.ts +96 -0
- package/dist/library/wire.js +167 -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/publish/prompt.js +17 -2
- package/dist/scaffold/index.d.ts +9 -0
- package/dist/scaffold/index.js +13 -1
- package/dist/shared/config.d.ts +27 -0
- package/dist/shared/config.js +64 -9
- package/package.json +1 -1
package/dist/lib/initCommand.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
// `vincentt init [dir]` — scaffold a Vincentt XR app.
|
|
2
|
-
// to the API, NO binding written.
|
|
1
|
+
// `vincentt init [dir]` — scaffold a Vincentt XR app. On the BARE path: NO
|
|
2
|
+
// account, NO network call to the API, NO binding written.
|
|
3
3
|
//
|
|
4
4
|
// This is the acquisition wedge: local-first is a smaller room, not a wall, so the
|
|
5
5
|
// template must be reachable without ever touching the auth gate. `create` (which
|
|
6
6
|
// does need an account) is offered at the end as the next thing AVAILABLE, never as
|
|
7
7
|
// a requirement.
|
|
8
|
+
//
|
|
9
|
+
// `--from owner/repo@tag` (f6) adds a SECOND path that clones a gallery template
|
|
10
|
+
// and then registers the project, so it does call the API — for its second half,
|
|
11
|
+
// after the source is on disk. The claim above is scoped to the bare path and
|
|
12
|
+
// stays true of it: no flag added to `init` may make the bare path touch the
|
|
13
|
+
// network (A-5.3).
|
|
8
14
|
import path from "node:path";
|
|
9
|
-
import { detectPackageManager, installDependencies, isNonEmptyDir, needsScaffold, scaffoldFromTemplate, } from "../scaffold/index.js";
|
|
15
|
+
import { ScaffoldRefError, detectPackageManager, installDependencies, isNonEmptyDir, needsScaffold, scaffoldFromTemplate, } from "../scaffold/index.js";
|
|
16
|
+
import { formatSourceRef, templateCloneURL } from "./sourceRef.js";
|
|
10
17
|
/** The folder name offered when `init` is run bare and the creator just hits Enter. */
|
|
11
18
|
export const DEFAULT_APP_NAME = "my-ar-app";
|
|
12
19
|
/**
|
|
@@ -81,20 +88,36 @@ export async function initCommand(deps, opts = {}) {
|
|
|
81
88
|
// version they have no reason to care about.
|
|
82
89
|
if (opts.template)
|
|
83
90
|
deps.print(` · template ${opts.template}`);
|
|
91
|
+
if (opts.from)
|
|
92
|
+
deps.print(` · from ${formatSourceRef(opts.from)}`);
|
|
84
93
|
const scaffold = deps.scaffold ??
|
|
85
|
-
((dir,
|
|
94
|
+
((dir, o) => scaffoldFromTemplate(dir, o));
|
|
86
95
|
try {
|
|
87
|
-
await scaffold(target, opts.
|
|
96
|
+
await scaffold(target, opts.from
|
|
97
|
+
? { repo: templateCloneURL(opts.from.owner, opts.from.repo), ref: opts.from.tag }
|
|
98
|
+
: { ref: opts.template });
|
|
88
99
|
}
|
|
89
100
|
catch (err) {
|
|
101
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
90
102
|
deps.print("");
|
|
91
|
-
|
|
103
|
+
if (opts.from) {
|
|
104
|
+
printFromScaffoldFailure(deps.print, opts.from, isRefFailure(err, msg));
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
deps.print(`✗ ${msg}`);
|
|
92
108
|
deps.print("");
|
|
93
109
|
deps.print(" Run `vincentt init` again once that is resolved.");
|
|
94
110
|
return 1;
|
|
95
111
|
}
|
|
112
|
+
// S-1: the combined path CLONES AND STOPS. No package manager is spawned, and
|
|
113
|
+
// the refusal is structural — `--from` is rejected in the argument parser
|
|
114
|
+
// alongside any install-enabling flag, so this branch cannot be re-enabled by
|
|
115
|
+
// a caller.
|
|
96
116
|
let installed = true;
|
|
97
|
-
if (
|
|
117
|
+
if (opts.from) {
|
|
118
|
+
installed = false;
|
|
119
|
+
}
|
|
120
|
+
else if (!opts.skipInstall) {
|
|
98
121
|
deps.print(" · installing dependencies");
|
|
99
122
|
const install = deps.install ?? ((dir) => installDependencies(dir));
|
|
100
123
|
try {
|
|
@@ -110,7 +133,6 @@ export async function initCommand(deps, opts = {}) {
|
|
|
110
133
|
const pm = await detectPackageManager(target).catch(() => "npm");
|
|
111
134
|
deps.print("");
|
|
112
135
|
deps.print(`✓ Created ${label}`);
|
|
113
|
-
deps.print("");
|
|
114
136
|
const steps = [];
|
|
115
137
|
// Keyed off the RESOLVED dir, not the flag — a prompted name needs the cd too.
|
|
116
138
|
if (dir !== ".")
|
|
@@ -119,18 +141,71 @@ export async function initCommand(deps, opts = {}) {
|
|
|
119
141
|
steps.push([`${pm} install`, "Install dependencies"]);
|
|
120
142
|
}
|
|
121
143
|
steps.push([`${pm} run dev`, "Start the local dev server"]);
|
|
122
|
-
|
|
123
|
-
deps.print(
|
|
144
|
+
if (!opts.from) {
|
|
145
|
+
deps.print("");
|
|
146
|
+
for (const line of alignedSteps(steps))
|
|
147
|
+
deps.print(line);
|
|
148
|
+
deps.print("");
|
|
149
|
+
deps.print(" No account needed. When you're ready to put it online:");
|
|
150
|
+
for (const line of alignedSteps([
|
|
151
|
+
["vincentt login", "Connect this machine to Vincentt"],
|
|
152
|
+
["vincentt create", "Register the project and reserve its address"],
|
|
153
|
+
])) {
|
|
154
|
+
deps.print(line);
|
|
155
|
+
}
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
// S-1's whole UX: two sentences, no apology and no threat model. The install
|
|
159
|
+
// appears below as an ordinary step, never as a remedy — a creator should
|
|
160
|
+
// never learn that it is unusual.
|
|
161
|
+
deps.print(" The source is yours. Dependencies are not installed yet.");
|
|
162
|
+
// TRAP 4: the scaffold's success is already in the output, and the exit code
|
|
163
|
+
// still reports the registration half. The `✗` is the last glyph, always.
|
|
164
|
+
const register = deps.register;
|
|
165
|
+
if (!register)
|
|
166
|
+
return 0;
|
|
167
|
+
const code = await register({ print: deps.print, targetDir: target, dirLabel: label, ask: deps.ask }, { name: opts.name, slug: opts.slug });
|
|
168
|
+
if (code !== 0)
|
|
169
|
+
return code;
|
|
124
170
|
deps.print("");
|
|
125
|
-
deps.print("
|
|
126
|
-
for (const line of alignedSteps(
|
|
127
|
-
["vincentt login", "Connect this machine to Vincentt"],
|
|
128
|
-
["vincentt create", "Register the project and reserve its address"],
|
|
129
|
-
])) {
|
|
171
|
+
deps.print(" Next:");
|
|
172
|
+
for (const line of alignedSteps(steps))
|
|
130
173
|
deps.print(line);
|
|
131
|
-
}
|
|
132
174
|
return 0;
|
|
133
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* The scaffold half's failure copy for `--from`, which branches on WHICH repo
|
|
178
|
+
* was cloned. The shipped "omit --template to use the latest release" advice is
|
|
179
|
+
* right for the built-in template and meaningless for a gallery source.
|
|
180
|
+
*
|
|
181
|
+
* Nothing was written — no `else` — because there is no second half above this.
|
|
182
|
+
*/
|
|
183
|
+
function isRefFailure(err, message) {
|
|
184
|
+
return (err instanceof ScaffoldRefError ||
|
|
185
|
+
/has no version|remote branch|couldn't find remote ref/i.test(message));
|
|
186
|
+
}
|
|
187
|
+
function printFromScaffoldFailure(print, from, refMissing) {
|
|
188
|
+
const named = `${from.owner}/${from.repo}`;
|
|
189
|
+
if (refMissing) {
|
|
190
|
+
print(`✗ The repository ${named} has no tag "${from.tag}"`);
|
|
191
|
+
print("");
|
|
192
|
+
print(" The gallery card always shows the current reference. If you pasted this command");
|
|
193
|
+
print(" from somewhere else, it may be out of date.");
|
|
194
|
+
print("");
|
|
195
|
+
print(" Nothing was written.");
|
|
196
|
+
print("");
|
|
197
|
+
print(" Copy the command again from the gallery and run it.");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
print(`✗ Could not reach github.com/${named}`);
|
|
201
|
+
print("");
|
|
202
|
+
print(" The repository may have been renamed or made private, or your network may be");
|
|
203
|
+
print(" blocking github.com.");
|
|
204
|
+
print("");
|
|
205
|
+
print(" Nothing was written.");
|
|
206
|
+
print("");
|
|
207
|
+
print(" Check the command on the gallery card and run it again.");
|
|
208
|
+
}
|
|
134
209
|
/** Two columns, aligned on the widest command, so the block reads as a table. */
|
|
135
210
|
function alignedSteps(steps) {
|
|
136
211
|
const width = Math.max(...steps.map(([cmd]) => cmd.length));
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type SourceRef } from "./sourceRef.js";
|
|
2
|
+
/** How many times the address prompt re-asks before falling back to the printed block. */
|
|
3
|
+
export declare const ADDRESS_REASK_LIMIT = 3;
|
|
4
|
+
export interface RegisterDeps {
|
|
5
|
+
print: (line: string) => void;
|
|
6
|
+
/** The scaffolded directory — the cwd the registration acts in. */
|
|
7
|
+
targetDir: string;
|
|
8
|
+
/** Label for the resume command (`cd <label>`). */
|
|
9
|
+
dirLabel: string;
|
|
10
|
+
/** Free-text prompt. Absent → not a TTY, so nothing may be asked. */
|
|
11
|
+
ask?: (prompt: string, fallback: string) => Promise<string>;
|
|
12
|
+
/** Resolve the current PAT, or undefined. Never triggers a login by itself. */
|
|
13
|
+
currentToken: () => Promise<string | undefined>;
|
|
14
|
+
/** Run the interactive browser login. Only ever called on a TTY. */
|
|
15
|
+
runLogin: () => Promise<void>;
|
|
16
|
+
/** Resolve which org to register in, and where that choice came from. */
|
|
17
|
+
resolveOrg: () => Promise<{
|
|
18
|
+
ok: true;
|
|
19
|
+
name: string;
|
|
20
|
+
source: OrgSource;
|
|
21
|
+
} | {
|
|
22
|
+
ok: false;
|
|
23
|
+
message: string;
|
|
24
|
+
}>;
|
|
25
|
+
/**
|
|
26
|
+
* f2's shipped `createCommand`, injected. Returns the exit code; it owns the
|
|
27
|
+
* binding write, which happens ONLY after the 201.
|
|
28
|
+
*/
|
|
29
|
+
create: (opts: {
|
|
30
|
+
name: string;
|
|
31
|
+
slug: string;
|
|
32
|
+
}) => Promise<number>;
|
|
33
|
+
}
|
|
34
|
+
export type OrgSource = "active" | "env" | "flag";
|
|
35
|
+
export interface RegisterOptions {
|
|
36
|
+
/** `--name`, read before prompting. */
|
|
37
|
+
name?: string;
|
|
38
|
+
/** `--slug`, read before prompting. */
|
|
39
|
+
slug?: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The registration half. Runs ONLY after the scaffold reported success, so the
|
|
43
|
+
* source is already on disk in every branch below — which is why every failure
|
|
44
|
+
* closes with "Nothing else was written." rather than "Nothing was written."
|
|
45
|
+
*/
|
|
46
|
+
export declare function registerAfterScaffold(deps: RegisterDeps, opts?: RegisterOptions): Promise<number>;
|
|
47
|
+
/** The `· from owner/repo@tag` bullet in the start block. */
|
|
48
|
+
export declare function sourceBullet(ref: SourceRef): string;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// `vincentt init <dir> --from owner/repo@tag` — the combined clone path (f6).
|
|
2
|
+
//
|
|
3
|
+
// One command, TWO failure domains. Everything above the seam is on disk and
|
|
4
|
+
// permanent; everything below it is a network attempt. The transcript's shape is
|
|
5
|
+
// what lets a creator answer "did I lose my work?" without reading the error.
|
|
6
|
+
//
|
|
7
|
+
// This file is an ORCHESTRATOR plus the bounded re-ask loop plus the transition
|
|
8
|
+
// copy, and nothing else. `ensureLoggedIn` and `createCommand` are called
|
|
9
|
+
// verbatim — a second implementation of f2's rules is a second place for them to
|
|
10
|
+
// be wrong.
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { SlugTakenError } from "./backend.js";
|
|
13
|
+
import { slugify } from "./slug.js";
|
|
14
|
+
import { formatSourceRef } from "./sourceRef.js";
|
|
15
|
+
/** How many times the address prompt re-asks before falling back to the printed block. */
|
|
16
|
+
export const ADDRESS_REASK_LIMIT = 3;
|
|
17
|
+
const ORG_SOURCE_LABEL = {
|
|
18
|
+
active: "from your active workspace",
|
|
19
|
+
env: "from VINCENTT_ORG",
|
|
20
|
+
flag: "from --org",
|
|
21
|
+
};
|
|
22
|
+
/** The one sentence every registration-half failure opens with. Byte-identical across arms. */
|
|
23
|
+
function reassure(print, dirLabel) {
|
|
24
|
+
print(` Your source is fine — ${dirLabel} is on disk and it's yours.`);
|
|
25
|
+
}
|
|
26
|
+
/** The resume block: `cd <dir>` then the exact create command. */
|
|
27
|
+
function resume(print, dirLabel, command) {
|
|
28
|
+
print(` cd ${stripLeadingDotSlash(dirLabel)}`);
|
|
29
|
+
print(` ${command}`);
|
|
30
|
+
}
|
|
31
|
+
function stripLeadingDotSlash(label) {
|
|
32
|
+
return label.startsWith("./") ? label.slice(2) : label;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The registration half. Runs ONLY after the scaffold reported success, so the
|
|
36
|
+
* source is already on disk in every branch below — which is why every failure
|
|
37
|
+
* closes with "Nothing else was written." rather than "Nothing was written."
|
|
38
|
+
*/
|
|
39
|
+
export async function registerAfterScaffold(deps, opts = {}) {
|
|
40
|
+
const defaultName = opts.name ?? path.basename(path.resolve(deps.targetDir));
|
|
41
|
+
const defaultSlug = opts.slug ?? slugify(defaultName);
|
|
42
|
+
const resumeCommand = `vincentt create --name "${defaultName}" --slug ${defaultSlug || "<address>"}`;
|
|
43
|
+
// TRAP 2 inverted deliberately: the session is checked AFTER the scaffold, and
|
|
44
|
+
// `ensureLoggedIn` is only reachable on a TTY because it opens a browser.
|
|
45
|
+
const token = await deps.currentToken();
|
|
46
|
+
if (!token) {
|
|
47
|
+
if (!deps.ask) {
|
|
48
|
+
deps.print("");
|
|
49
|
+
deps.print("✗ Not signed in, and signing in needs a browser.");
|
|
50
|
+
deps.print("");
|
|
51
|
+
reassure(deps.print, deps.dirLabel);
|
|
52
|
+
deps.print(" No address is reserved yet.");
|
|
53
|
+
deps.print("");
|
|
54
|
+
deps.print(" On a machine with a browser:");
|
|
55
|
+
deps.print(" vincentt login");
|
|
56
|
+
deps.print("");
|
|
57
|
+
deps.print(" Then finish:");
|
|
58
|
+
resume(deps.print, deps.dirLabel, resumeCommand);
|
|
59
|
+
deps.print("");
|
|
60
|
+
deps.print(" Nothing else was written.");
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
deps.print("");
|
|
64
|
+
deps.print("→ Now registering the project so it gets an address.");
|
|
65
|
+
deps.print(" You're not signed in yet — opening the browser to authorize this device…");
|
|
66
|
+
try {
|
|
67
|
+
await deps.runLogin();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
deps.print("");
|
|
71
|
+
deps.print("✗ Sign-in was not completed.");
|
|
72
|
+
deps.print("");
|
|
73
|
+
reassure(deps.print, deps.dirLabel);
|
|
74
|
+
deps.print(" The project was not registered, so no address is reserved yet.");
|
|
75
|
+
deps.print("");
|
|
76
|
+
deps.print(" Finish when you're ready:");
|
|
77
|
+
resume(deps.print, deps.dirLabel, "vincentt create");
|
|
78
|
+
deps.print("");
|
|
79
|
+
deps.print(" Nothing else was written.");
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
if (!(await deps.currentToken())) {
|
|
83
|
+
deps.print("");
|
|
84
|
+
deps.print("✗ Sign-in was not completed.");
|
|
85
|
+
deps.print("");
|
|
86
|
+
reassure(deps.print, deps.dirLabel);
|
|
87
|
+
deps.print(" The project was not registered, so no address is reserved yet.");
|
|
88
|
+
deps.print("");
|
|
89
|
+
deps.print(" Finish when you're ready:");
|
|
90
|
+
resume(deps.print, deps.dirLabel, "vincentt create");
|
|
91
|
+
deps.print("");
|
|
92
|
+
deps.print(" Nothing else was written.");
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// A non-TTY caller must supply both, and the tool never invents an address.
|
|
97
|
+
if (!deps.ask && (opts.name === undefined || opts.slug === undefined)) {
|
|
98
|
+
deps.print("");
|
|
99
|
+
deps.print("✗ Can't register the project without a name and an address, and I can't ask.");
|
|
100
|
+
deps.print("");
|
|
101
|
+
reassure(deps.print, deps.dirLabel);
|
|
102
|
+
deps.print(" No address is reserved yet.");
|
|
103
|
+
deps.print("");
|
|
104
|
+
deps.print(" Finish it:");
|
|
105
|
+
resume(deps.print, deps.dirLabel, resumeCommand);
|
|
106
|
+
deps.print("");
|
|
107
|
+
deps.print(" Nothing else was written.");
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
const org = await deps.resolveOrg();
|
|
111
|
+
if (!org.ok) {
|
|
112
|
+
deps.print("");
|
|
113
|
+
deps.print(org.message);
|
|
114
|
+
deps.print("");
|
|
115
|
+
reassure(deps.print, deps.dirLabel);
|
|
116
|
+
deps.print(" No address is reserved yet.");
|
|
117
|
+
deps.print("");
|
|
118
|
+
deps.print(" Check who you are: vincentt whoami");
|
|
119
|
+
deps.print(" Then finish:");
|
|
120
|
+
deps.print(` cd ${stripLeadingDotSlash(deps.dirLabel)}`);
|
|
121
|
+
deps.print(" vincentt create --org <workspace>");
|
|
122
|
+
deps.print("");
|
|
123
|
+
deps.print(" Nothing else was written.");
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
126
|
+
// AF-3: name the org AND where it came from, before the address prompt. The
|
|
127
|
+
// card's command carries no --org, so a stale active workspace would otherwise
|
|
128
|
+
// claim an address in a team org silently.
|
|
129
|
+
deps.print("");
|
|
130
|
+
deps.print(`→ Registering in ${org.name} (${ORG_SOURCE_LABEL[org.source]})`);
|
|
131
|
+
let name = opts.name;
|
|
132
|
+
let slug = opts.slug;
|
|
133
|
+
if (deps.ask) {
|
|
134
|
+
if (name === undefined) {
|
|
135
|
+
const answer = await deps.ask(`? Project name: (${defaultName}) `, defaultName);
|
|
136
|
+
name = answer.trim() === "" ? defaultName : answer.trim();
|
|
137
|
+
}
|
|
138
|
+
if (slug === undefined) {
|
|
139
|
+
const fallback = slugify(name);
|
|
140
|
+
const answer = await deps.ask(`? Address: (${fallback}) `, fallback);
|
|
141
|
+
slug = answer.trim() === "" ? fallback : answer.trim();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
let attempts = 0;
|
|
145
|
+
for (;;) {
|
|
146
|
+
try {
|
|
147
|
+
return await deps.create({ name: name, slug: slug });
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
if (!(err instanceof SlugTakenError))
|
|
151
|
+
throw err;
|
|
152
|
+
attempts++;
|
|
153
|
+
// TRAP 3: never auto-pick suggestions[0]. On a non-TTY this refuses; on a
|
|
154
|
+
// TTY it re-asks with NO default, because there is no safe default left.
|
|
155
|
+
const canReask = Boolean(deps.ask) && attempts < ADDRESS_REASK_LIMIT;
|
|
156
|
+
if (!canReask) {
|
|
157
|
+
deps.print("");
|
|
158
|
+
deps.print("✗ That address is taken.");
|
|
159
|
+
deps.print("");
|
|
160
|
+
reassure(deps.print, deps.dirLabel);
|
|
161
|
+
deps.print(" No address is reserved yet.");
|
|
162
|
+
if (err.suggestions.length > 0) {
|
|
163
|
+
deps.print("");
|
|
164
|
+
deps.print(" Available alternatives:");
|
|
165
|
+
for (const s of err.suggestions)
|
|
166
|
+
deps.print(` ${s}`);
|
|
167
|
+
}
|
|
168
|
+
deps.print("");
|
|
169
|
+
deps.print(deps.ask
|
|
170
|
+
? " Finish with an address of your own:"
|
|
171
|
+
: " Finish with one of those, or your own:");
|
|
172
|
+
deps.print(` cd ${stripLeadingDotSlash(deps.dirLabel)}`);
|
|
173
|
+
deps.print(` vincentt create --name "${name}" --slug <address>`);
|
|
174
|
+
deps.print("");
|
|
175
|
+
deps.print(" Nothing else was written.");
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
// A routine, expected collision mid-flow: `!` and a re-ask, not `✗` and an
|
|
179
|
+
// exit. The command DOES proceed, so a `✗` would announce a stop that is
|
|
180
|
+
// not happening.
|
|
181
|
+
deps.print("");
|
|
182
|
+
deps.print("! That address is taken. Your source is fine — pick another.");
|
|
183
|
+
if (err.suggestions.length > 0) {
|
|
184
|
+
deps.print("");
|
|
185
|
+
deps.print(" Available alternatives:");
|
|
186
|
+
for (const s of err.suggestions)
|
|
187
|
+
deps.print(` ${s}`);
|
|
188
|
+
}
|
|
189
|
+
deps.print("");
|
|
190
|
+
const answer = await deps.ask("? Address: ", "");
|
|
191
|
+
slug = answer.trim();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** The `· from owner/repo@tag` bullet in the start block. */
|
|
196
|
+
export function sourceBullet(ref) {
|
|
197
|
+
return ` · from ${formatSourceRef(ref)}`;
|
|
198
|
+
}
|
|
@@ -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,11 @@
|
|
|
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
|
+
/** `--from` with `--template`, or with an install-enabling flag. */
|
|
8
|
+
export declare function printFlagConflict(print: (line: string) => void, conflict: {
|
|
9
|
+
flags: [string, string];
|
|
10
|
+
kind: "source" | "install";
|
|
11
|
+
}, ctx: FromFailureContext): void;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// The refusal copy for a `--from` reference that does not parse. Separate from
|
|
2
|
+
// the parser so the grammar stays pure, and separate from the dispatcher so the
|
|
3
|
+
// copy is assertable.
|
|
4
|
+
//
|
|
5
|
+
// Every arm closes with "Nothing was written." — no `else` — because a parse
|
|
6
|
+
// refusal happens before git is ever reached.
|
|
7
|
+
import { formatSourceRef } from "./sourceRef.js";
|
|
8
|
+
export function printSourceRefFailure(print, failure, ctx) {
|
|
9
|
+
switch (failure.kind) {
|
|
10
|
+
case "looks_like_url": {
|
|
11
|
+
print("✗ --from takes owner/repo@tag, not a URL.");
|
|
12
|
+
print("");
|
|
13
|
+
if (failure.converted) {
|
|
14
|
+
const short = formatSourceRef(failure.converted);
|
|
15
|
+
print(` From that URL: ${short}`);
|
|
16
|
+
print("");
|
|
17
|
+
print(` vincentt init ${ctx.dir} --from ${short}`);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
print(" The gallery card shows the reference in that form.");
|
|
21
|
+
}
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
case "missing_tag": {
|
|
25
|
+
print("✗ --from needs a version: owner/repo@tag");
|
|
26
|
+
print("");
|
|
27
|
+
print(` You gave: ${failure.value}`);
|
|
28
|
+
print(` Try: ${failure.value}@<tag>`);
|
|
29
|
+
print("");
|
|
30
|
+
print(" The gallery card's command already includes the tag.");
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
case "missing_repo": {
|
|
34
|
+
print("✗ --from needs a repository: owner/repo@tag");
|
|
35
|
+
print("");
|
|
36
|
+
print(` You gave: ${failure.value}`);
|
|
37
|
+
print("");
|
|
38
|
+
print(" The gallery card's command already includes the owner and the repository.");
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
case "bad_owner": {
|
|
42
|
+
print(`✗ "${failure.value}" is not a valid GitHub owner.`);
|
|
43
|
+
print("");
|
|
44
|
+
print(" --from takes owner/repo@tag.");
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
case "bad_repo": {
|
|
48
|
+
print(`✗ "${failure.value}" is not a valid repository name.`);
|
|
49
|
+
print("");
|
|
50
|
+
print(" --from takes owner/repo@tag.");
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
case "bad_tag": {
|
|
54
|
+
print(`✗ "${failure.value}" is not a valid tag.`);
|
|
55
|
+
print("");
|
|
56
|
+
print(" --from takes owner/repo@tag.");
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
print("");
|
|
61
|
+
print(" Nothing was written.");
|
|
62
|
+
}
|
|
63
|
+
/** `--from` with `--template`, or with an install-enabling flag. */
|
|
64
|
+
export function printFlagConflict(print, conflict, ctx) {
|
|
65
|
+
const [a, b] = conflict.flags;
|
|
66
|
+
print(`✗ ${a} and ${b} can't be used together.`);
|
|
67
|
+
print("");
|
|
68
|
+
if (conflict.kind === "source") {
|
|
69
|
+
print(" Both choose which source to scaffold from.");
|
|
70
|
+
print("");
|
|
71
|
+
print(` vincentt init ${ctx.dir} --from owner/repo@tag Start from a gallery template`);
|
|
72
|
+
print(` vincentt init ${ctx.dir} --template 2.1.0 Start from the standard template`);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
print(" A gallery template is cloned without installing its dependencies.");
|
|
76
|
+
print(" The install is printed as a next step, to run once you've looked at the source.");
|
|
77
|
+
}
|
|
78
|
+
print("");
|
|
79
|
+
print(" Nothing was written.");
|
|
80
|
+
}
|