@vincentt-xr/cli 0.8.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 +10 -0
- package/dist/cli/args.js +31 -0
- package/dist/cli/index.js +70 -3
- 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/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/scaffold/index.d.ts +9 -0
- package/dist/scaffold/index.js +13 -1
- package/package.json +1 -1
package/dist/cli/args.d.ts
CHANGED
|
@@ -22,6 +22,16 @@ export declare function isVersionInvocation(args: string[]): boolean;
|
|
|
22
22
|
* the moment it is introduced.
|
|
23
23
|
*/
|
|
24
24
|
export declare const BOOLEAN_FLAGS: Set<string>;
|
|
25
|
+
export interface ArgConflict {
|
|
26
|
+
/** The two flags that cannot appear together, in the order the copy names them. */
|
|
27
|
+
flags: [string, string];
|
|
28
|
+
kind: "source" | "install";
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The `--from` exclusivity rules, checked during argument parsing before any
|
|
32
|
+
* work begins. Returns the conflict to report, or undefined.
|
|
33
|
+
*/
|
|
34
|
+
export declare function fromFlagConflict(args: string[]): ArgConflict | undefined;
|
|
25
35
|
export declare function refusedFlag(args: string[]): string | undefined;
|
|
26
36
|
/**
|
|
27
37
|
* The bare arguments, with flag VALUES removed. Without this, `vincentt link --dir
|
package/dist/cli/args.js
CHANGED
|
@@ -44,6 +44,37 @@ export const BOOLEAN_FLAGS = new Set([
|
|
|
44
44
|
"--help",
|
|
45
45
|
"-h",
|
|
46
46
|
]);
|
|
47
|
+
/**
|
|
48
|
+
* Flags that would turn the dependency install back ON. `--from` is mutually
|
|
49
|
+
* exclusive with every one of them, and the refusal lives HERE rather than in a
|
|
50
|
+
* code path (Q-1).
|
|
51
|
+
*
|
|
52
|
+
* Install is opt-out (`if (!opts.skipInstall)`), so the natural implementation of
|
|
53
|
+
* `--from` INHERITS the install — a developer has to actively write code to
|
|
54
|
+
* prevent it, which is the wrong direction for a security control. Making the
|
|
55
|
+
* refusal structural means the rule cannot be lost by forgetting something, only
|
|
56
|
+
* by deleting an explicit refusal, which is a reviewable act. S-2's accepted
|
|
57
|
+
* moved-tag risk is conditional on S-1 holding, so a single flag must not be able
|
|
58
|
+
* to defeat it.
|
|
59
|
+
*/
|
|
60
|
+
const INSTALL_ENABLING_FLAGS = new Set(["--install", "--with-install", "--deps"]);
|
|
61
|
+
/**
|
|
62
|
+
* The `--from` exclusivity rules, checked during argument parsing before any
|
|
63
|
+
* work begins. Returns the conflict to report, or undefined.
|
|
64
|
+
*/
|
|
65
|
+
export function fromFlagConflict(args) {
|
|
66
|
+
const names = new Set(args.filter((a) => a.startsWith("-")).map((a) => a.split("=")[0]));
|
|
67
|
+
if (!names.has("--from"))
|
|
68
|
+
return undefined;
|
|
69
|
+
// Both choose which source to scaffold from; honoring both is undefined.
|
|
70
|
+
if (names.has("--template"))
|
|
71
|
+
return { flags: ["--from", "--template"], kind: "source" };
|
|
72
|
+
for (const f of names) {
|
|
73
|
+
if (INSTALL_ENABLING_FLAGS.has(f))
|
|
74
|
+
return { flags: ["--from", f], kind: "install" };
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
47
78
|
/**
|
|
48
79
|
* Flags the CLI deliberately does NOT have. The checklist reports and never
|
|
49
80
|
* writes: a suggested value is one keystroke from a tool that writes it, and
|
package/dist/cli/index.js
CHANGED
|
@@ -42,7 +42,12 @@ import { journalEnabled, journalPath, readJournal } from "../dx/journal.js";
|
|
|
42
42
|
import { postReport } from "../dx/send.js";
|
|
43
43
|
import { nudgeFor } from "../dx/nudge.js";
|
|
44
44
|
import { verbOf } from "../dx/skeleton.js";
|
|
45
|
-
import { isHelpInvocation, isVersionInvocation, positionalArgs, refusedFlag, } from "./args.js";
|
|
45
|
+
import { fromFlagConflict, isHelpInvocation, isVersionInvocation, positionalArgs, refusedFlag, } from "./args.js";
|
|
46
|
+
import { parseSourceRef } from "../lib/sourceRef.js";
|
|
47
|
+
import { printFlagConflict, printSourceRefFailure } from "../lib/sourceRefCopy.js";
|
|
48
|
+
import { registerAfterScaffold } from "../lib/initFrom.js";
|
|
49
|
+
import { createCommand } from "../lib/projects.js";
|
|
50
|
+
import { resolveOrgForCommand } from "../lib/lifecycle.js";
|
|
46
51
|
// The console origin serves /cli-auth (the browser target for `vincentt login`). VINCENTT_
|
|
47
52
|
// CONSOLE_URL overrides for dev/dogfood; VINCENTT_EDITOR_URL is honored as a legacy alias.
|
|
48
53
|
// This is the prod console; vincentt.app is the published-PROJECT apex, not the console, so a
|
|
@@ -164,17 +169,75 @@ async function init() {
|
|
|
164
169
|
// ABSENCE as "may not ask" — so a piped or CI invocation refuses rather than
|
|
165
170
|
// blocking forever on a question nobody can see.
|
|
166
171
|
const interactive = Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
|
|
172
|
+
const print = (l) => console.log(l);
|
|
173
|
+
const dir = positional(1);
|
|
174
|
+
const dirLabel = dir ?? "<dir>";
|
|
175
|
+
// Refused during ARGUMENT PARSING, before anything is written (Q-1).
|
|
176
|
+
const conflict = fromFlagConflict(argv);
|
|
177
|
+
if (conflict) {
|
|
178
|
+
printFlagConflict(print, conflict, { dir: dirLabel });
|
|
179
|
+
process.exitCode = 1;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const rawFrom = flag("--from");
|
|
183
|
+
let from;
|
|
184
|
+
if (rawFrom !== undefined) {
|
|
185
|
+
const parsed = parseSourceRef(rawFrom);
|
|
186
|
+
if (!parsed.ok) {
|
|
187
|
+
printSourceRefFailure(print, parsed.failure, { dir: dirLabel });
|
|
188
|
+
process.exitCode = 1;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
from = parsed.ref;
|
|
192
|
+
}
|
|
167
193
|
process.exitCode = await initCommand({
|
|
168
|
-
print
|
|
194
|
+
print,
|
|
169
195
|
cwd: process.cwd(),
|
|
170
196
|
ask: interactive ? stdinAsk() : undefined,
|
|
171
197
|
confirm: interactive ? stdinConfirm() : undefined,
|
|
198
|
+
register: from
|
|
199
|
+
? (rd, o) => registerAfterScaffold({
|
|
200
|
+
...rd,
|
|
201
|
+
currentToken,
|
|
202
|
+
runLogin: async () => {
|
|
203
|
+
await login();
|
|
204
|
+
},
|
|
205
|
+
resolveOrg: async () => {
|
|
206
|
+
const cfg = await resolveConfig(rd.targetDir, { warn: print });
|
|
207
|
+
const resolved = await resolveOrgForCommand(cfg, { flag: flag("--org") });
|
|
208
|
+
if (!resolved.ok)
|
|
209
|
+
return resolved;
|
|
210
|
+
return {
|
|
211
|
+
ok: true,
|
|
212
|
+
name: resolved.org.name,
|
|
213
|
+
source: orgSource(),
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
create: async ({ name, slug }) => createCommand({
|
|
217
|
+
print,
|
|
218
|
+
cwd: rd.targetDir,
|
|
219
|
+
config: () => resolveConfig(rd.targetDir, { warn: print }),
|
|
220
|
+
org: async () => resolveOrgForCommand(await resolveConfig(rd.targetDir, { warn: print }), { flag: flag("--org") }),
|
|
221
|
+
}, { name, slug }),
|
|
222
|
+
}, o)
|
|
223
|
+
: undefined,
|
|
172
224
|
}, {
|
|
173
|
-
dir
|
|
225
|
+
dir,
|
|
174
226
|
skipInstall: has("--no-install"),
|
|
175
227
|
template: flag("--template"),
|
|
228
|
+
from,
|
|
229
|
+
name: flag("--name"),
|
|
230
|
+
slug: flag("--slug"),
|
|
176
231
|
});
|
|
177
232
|
}
|
|
233
|
+
/** Where the resolved org came from, so a surprising value is diagnosable (AF-3). */
|
|
234
|
+
function orgSource() {
|
|
235
|
+
if (flag("--org"))
|
|
236
|
+
return "flag";
|
|
237
|
+
if (process.env.VINCENTT_ORG)
|
|
238
|
+
return "env";
|
|
239
|
+
return "active";
|
|
240
|
+
}
|
|
178
241
|
async function create() {
|
|
179
242
|
await requireLogin();
|
|
180
243
|
const result = await projectCreate(projectDir(), {
|
|
@@ -254,6 +317,8 @@ async function publish() {
|
|
|
254
317
|
now: () => Date.now(),
|
|
255
318
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
256
319
|
orgName: activeOrgName,
|
|
320
|
+
orgSlug: async () => (await activeOrgName()) || undefined,
|
|
321
|
+
consoleUrl: flag("--console") ?? process.env.VINCENTT_CONSOLE_URL,
|
|
257
322
|
}, { distDir: flag("--dist"), note: flag("--note"), yes: has("--yes") });
|
|
258
323
|
}
|
|
259
324
|
/** Flow L30 — the six verbs that are console actions, answered rather than rejected. */
|
|
@@ -756,6 +821,8 @@ function usage() {
|
|
|
756
821
|
`Project\n` +
|
|
757
822
|
` init [dir] Scaffold a Vincentt XR app — no account needed\n` +
|
|
758
823
|
` [--no-install] [--template <version>]\n` +
|
|
824
|
+
` [--from <owner>/<repo>@<tag>] Clone a gallery template\n` +
|
|
825
|
+
` and register it [--name <s>] [--slug <s>]\n` +
|
|
759
826
|
` create Register a project and reserve its address\n` +
|
|
760
827
|
` [--name <s>] [--slug <s>] [--org <s>] [--preview] [--force]\n` +
|
|
761
828
|
` link <project-id> Link this folder to an existing project\n` +
|
package/dist/dx/skeleton.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { RegisterDeps } from "./initFrom.js";
|
|
2
|
+
import { type SourceRef } from "./sourceRef.js";
|
|
1
3
|
import type { Ask, Confirm } from "../publish/prompt.js";
|
|
2
4
|
export interface InitDeps {
|
|
3
5
|
print: (line: string) => void;
|
|
4
6
|
cwd: string;
|
|
5
|
-
scaffold?: (targetDir: string,
|
|
7
|
+
scaffold?: (targetDir: string, opts: ScaffoldCall) => Promise<void>;
|
|
6
8
|
install?: (targetDir: string) => Promise<string>;
|
|
7
9
|
isEmpty?: (targetDir: string) => Promise<boolean>;
|
|
8
10
|
/** Free-text prompt. Absent → not a TTY, so nothing may be asked. */
|
|
@@ -11,6 +13,23 @@ export interface InitDeps {
|
|
|
11
13
|
confirm?: Confirm;
|
|
12
14
|
/** Non-empty check for the resolved target; injected in tests. */
|
|
13
15
|
isNonEmpty?: (targetDir: string) => Promise<boolean>;
|
|
16
|
+
/**
|
|
17
|
+
* The registration half of `--from`. Injected whole so the combined path is
|
|
18
|
+
* drivable without a real API, a real browser, or a real HOME.
|
|
19
|
+
*/
|
|
20
|
+
register?: (deps: Pick<RegisterDeps, "print" | "targetDir" | "dirLabel" | "ask">, opts: {
|
|
21
|
+
name?: string;
|
|
22
|
+
slug?: string;
|
|
23
|
+
}) => Promise<number>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* What `InitDeps.scaffold` is called with. An OBJECT rather than a positional
|
|
27
|
+
* ref: `--from` needs to name a repo too, and a positional-to-object change
|
|
28
|
+
* compiles silently in a fake that ignores its second argument.
|
|
29
|
+
*/
|
|
30
|
+
export interface ScaffoldCall {
|
|
31
|
+
repo?: string;
|
|
32
|
+
ref?: string;
|
|
14
33
|
}
|
|
15
34
|
/** The folder name offered when `init` is run bare and the creator just hits Enter. */
|
|
16
35
|
export declare const DEFAULT_APP_NAME = "my-ar-app";
|
|
@@ -27,5 +46,13 @@ export interface InitOptions {
|
|
|
27
46
|
skipInstall?: boolean;
|
|
28
47
|
/** Template version to scaffold from; defaults to the latest release. */
|
|
29
48
|
template?: string;
|
|
49
|
+
/**
|
|
50
|
+
* A gallery source, already parsed. The combined path (f6): clone this repo at
|
|
51
|
+
* this tag, then register the project. NEVER installs dependencies (S-1).
|
|
52
|
+
*/
|
|
53
|
+
from?: SourceRef;
|
|
54
|
+
/** `--name` / `--slug`, accepted on `init` only when `--from` is present. */
|
|
55
|
+
name?: string;
|
|
56
|
+
slug?: string;
|
|
30
57
|
}
|
|
31
58
|
export declare function initCommand(deps: InitDeps, opts?: InitOptions): Promise<number>;
|
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
|
+
}
|
package/dist/publish/api.d.ts
CHANGED
|
@@ -46,6 +46,26 @@ export interface ApiErrorDetail {
|
|
|
46
46
|
mismatched?: number;
|
|
47
47
|
reason?: string;
|
|
48
48
|
path?: string;
|
|
49
|
+
/**
|
|
50
|
+
* f10's plan-limit refusal. Bytes are DECIMAL on the wire (1 GB = 10^9), the
|
|
51
|
+
* same units formatBytes already renders.
|
|
52
|
+
*/
|
|
53
|
+
figureBytes?: number;
|
|
54
|
+
allowanceBytes?: number;
|
|
55
|
+
cutOffAt?: string;
|
|
56
|
+
from?: string;
|
|
57
|
+
to?: string;
|
|
58
|
+
/** The tier in force, when the API names one. */
|
|
59
|
+
planTier?: string;
|
|
60
|
+
planName?: string;
|
|
61
|
+
/**
|
|
62
|
+
* The org that owns THIS project, as the API names it. It is not the machine's
|
|
63
|
+
* configured active org and must win over it: a project can belong to an org
|
|
64
|
+
* the CLI is not currently pointed at, and naming the wrong one in a refusal
|
|
65
|
+
* tells the creator's agent to go look at an org that was never cut off.
|
|
66
|
+
*/
|
|
67
|
+
orgName?: string;
|
|
68
|
+
orgSlug?: string;
|
|
49
69
|
}
|
|
50
70
|
export declare class PublishApiError extends Error {
|
|
51
71
|
readonly status: number;
|
|
@@ -18,6 +18,9 @@ export interface PublishDeps {
|
|
|
18
18
|
sleep: (ms: number) => Promise<void>;
|
|
19
19
|
fetchImpl?: typeof fetch;
|
|
20
20
|
orgName?: () => Promise<string>;
|
|
21
|
+
/** The active org's slug, for the usage deep link on a plan-limit refusal. */
|
|
22
|
+
orgSlug?: () => Promise<string | undefined>;
|
|
23
|
+
consoleUrl?: string;
|
|
21
24
|
}
|
|
22
25
|
export interface PublishOptions {
|
|
23
26
|
distDir?: string;
|
package/dist/publish/command.js
CHANGED
|
@@ -148,6 +148,11 @@ export async function publishCommand(deps, opts = {}) {
|
|
|
148
148
|
address,
|
|
149
149
|
liveVersion: project.liveVersion ?? undefined,
|
|
150
150
|
fetchImpl,
|
|
151
|
+
org: {
|
|
152
|
+
name: orgName || undefined,
|
|
153
|
+
slug: (await deps.orgSlug?.()) || undefined,
|
|
154
|
+
consoleUrl: deps.consoleUrl,
|
|
155
|
+
},
|
|
151
156
|
});
|
|
152
157
|
}
|
|
153
158
|
async function runUpload(deps, cfg, i) {
|
|
@@ -156,7 +161,7 @@ async function runUpload(deps, cfg, i) {
|
|
|
156
161
|
opened = await openUpload(cfg, i.projectId, i.manifest.files, i.note, i.fetchImpl);
|
|
157
162
|
}
|
|
158
163
|
catch (err) {
|
|
159
|
-
for (const l of openErrorLines(err, i.address, i.liveVersion))
|
|
164
|
+
for (const l of openErrorLines(err, i.address, i.liveVersion, i.org))
|
|
160
165
|
deps.print(l);
|
|
161
166
|
return 1;
|
|
162
167
|
}
|
|
@@ -221,6 +226,9 @@ async function commitWithHealing(deps, cfg, i, uploadId) {
|
|
|
221
226
|
catch (err) {
|
|
222
227
|
if (!(err instanceof PublishApiError))
|
|
223
228
|
throw err;
|
|
229
|
+
// The plan limit is a 409 and falls past this branch deliberately. It is
|
|
230
|
+
// the interlock refusing, not the flip failing, and retrying it three
|
|
231
|
+
// times would be the retry loop the refusal exists to stop.
|
|
224
232
|
if (err.code === "publish_flip_failed" || err.status === 502) {
|
|
225
233
|
if (attempt === COMMIT_RETRIES) {
|
|
226
234
|
for (const l of copy.flipFailed(i.address, i.liveVersion))
|
|
@@ -236,15 +244,39 @@ async function commitWithHealing(deps, cfg, i, uploadId) {
|
|
|
236
244
|
await deps.sleep(COMMIT_RETRY_DELAY_MS);
|
|
237
245
|
continue;
|
|
238
246
|
}
|
|
239
|
-
for (const l of commitErrorLines(err, i.address, i.liveVersion))
|
|
247
|
+
for (const l of commitErrorLines(err, i.address, i.liveVersion, i.org))
|
|
240
248
|
deps.print(l);
|
|
241
249
|
return 1;
|
|
242
250
|
}
|
|
243
251
|
}
|
|
244
252
|
return 1;
|
|
245
253
|
}
|
|
246
|
-
|
|
254
|
+
/**
|
|
255
|
+
* The plan-limit refusal, from the detail the API sends. It is reachable from
|
|
256
|
+
* open AND from commit, because the interlock lives at the port and every flip
|
|
257
|
+
* path folds through the same 409.
|
|
258
|
+
*
|
|
259
|
+
* The figures come off the wire and are never computed here: a CLI that derived
|
|
260
|
+
* an allowance would be guessing at another service's state.
|
|
261
|
+
*/
|
|
262
|
+
function planLimitLines(err, org) {
|
|
263
|
+
return copy.planLimitRefused({
|
|
264
|
+
// THE WIRE WINS over the machine's configured active org. The API names the
|
|
265
|
+
// org that owns this project; the local config names whichever org the CLI
|
|
266
|
+
// was last pointed at, and they are not always the same one.
|
|
267
|
+
orgName: err.detail.orgName ?? org?.name,
|
|
268
|
+
orgSlug: err.detail.orgSlug ?? org?.slug,
|
|
269
|
+
planName: err.detail.planName,
|
|
270
|
+
figureBytes: err.detail.figureBytes ?? 0,
|
|
271
|
+
allowanceBytes: err.detail.allowanceBytes ?? 0,
|
|
272
|
+
cutOffAt: err.detail.cutOffAt,
|
|
273
|
+
consoleUrl: org?.consoleUrl,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
function commitErrorLines(err, address, liveVersion, org) {
|
|
247
277
|
switch (err.code) {
|
|
278
|
+
case "unavailable_plan_limit":
|
|
279
|
+
return planLimitLines(err, org);
|
|
248
280
|
case "publish_expired":
|
|
249
281
|
return copy.publishExpired(address, liveVersion);
|
|
250
282
|
case "publish_contended":
|
|
@@ -267,7 +299,7 @@ function commitErrorLines(err, address, liveVersion) {
|
|
|
267
299
|
];
|
|
268
300
|
}
|
|
269
301
|
}
|
|
270
|
-
function openErrorLines(err, address, liveVersion) {
|
|
302
|
+
function openErrorLines(err, address, liveVersion, org) {
|
|
271
303
|
if (!(err instanceof PublishApiError)) {
|
|
272
304
|
return [
|
|
273
305
|
"",
|
|
@@ -277,6 +309,9 @@ function openErrorLines(err, address, liveVersion) {
|
|
|
277
309
|
"",
|
|
278
310
|
];
|
|
279
311
|
}
|
|
312
|
+
if (err.code === "unavailable_plan_limit") {
|
|
313
|
+
return planLimitLines(err, org);
|
|
314
|
+
}
|
|
280
315
|
if (err.code === "publish_too_large") {
|
|
281
316
|
return copy.tooLargeFromServer(err.detail.observed ?? 0, err.detail.limit ?? 0);
|
|
282
317
|
}
|
package/dist/publish/copy.d.ts
CHANGED
|
@@ -132,6 +132,35 @@ export declare function finishing(): string;
|
|
|
132
132
|
export declare function liveAt(liveUrl: string, version: number): string[];
|
|
133
133
|
/** Prints only if a retry is needed. It says nothing about a failure, because nothing has failed yet. */
|
|
134
134
|
export declare function takingLonger(): string;
|
|
135
|
+
export interface PlanLimitInput {
|
|
136
|
+
/** The org's display name, and its slug when the CLI holds one. */
|
|
137
|
+
orgName?: string;
|
|
138
|
+
orgSlug?: string;
|
|
139
|
+
/** The plan in force. Absent when the API did not name one — the line is then dropped. */
|
|
140
|
+
planName?: string;
|
|
141
|
+
figureBytes: number;
|
|
142
|
+
allowanceBytes: number;
|
|
143
|
+
/** RFC3339, from the cutoff marker. */
|
|
144
|
+
cutOffAt?: string;
|
|
145
|
+
consoleUrl?: string;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Flow L32 — the org is cut off and open-publish refused before a byte moved.
|
|
149
|
+
*
|
|
150
|
+
* This is NOT flipFailed and must never be folded into it. The API gives the two
|
|
151
|
+
* distinct wire codes because one means the system working correctly and the
|
|
152
|
+
* other means the ledger is broken; a creator told "the address didn't switch
|
|
153
|
+
* over" when they hit a plan limit has been told the platform is malfunctioning.
|
|
154
|
+
*
|
|
155
|
+
* The three facts are a STACKED ALIGNED BLOCK because they are a comparison —
|
|
156
|
+
* the figure against the allowance. Inline they are a sentence to parse; stacked
|
|
157
|
+
* an agent and a human read them the same way.
|
|
158
|
+
*
|
|
159
|
+
* "cannot be done from the CLI" is load-bearing: an agent's default response to a
|
|
160
|
+
* failure is to retry, and naming the remedy as a human action outside the tool
|
|
161
|
+
* is the only thing that stops the loop.
|
|
162
|
+
*/
|
|
163
|
+
export declare function planLimitRefused(i: PlanLimitInput): string[];
|
|
135
164
|
/**
|
|
136
165
|
* The cause is NOT named. Every non-healing arm of the commit state machine
|
|
137
166
|
* returns this identically by design, and the CLI must not invent a distinction
|
package/dist/publish/copy.js
CHANGED
|
@@ -440,6 +440,92 @@ export function liveAt(liveUrl, version) {
|
|
|
440
440
|
export function takingLonger() {
|
|
441
441
|
return "! Taking a moment longer than usual…";
|
|
442
442
|
}
|
|
443
|
+
const SUPPORT_EMAIL = "hello@vincentt.studio";
|
|
444
|
+
/**
|
|
445
|
+
* Flow L32 — the org is cut off and open-publish refused before a byte moved.
|
|
446
|
+
*
|
|
447
|
+
* This is NOT flipFailed and must never be folded into it. The API gives the two
|
|
448
|
+
* distinct wire codes because one means the system working correctly and the
|
|
449
|
+
* other means the ledger is broken; a creator told "the address didn't switch
|
|
450
|
+
* over" when they hit a plan limit has been told the platform is malfunctioning.
|
|
451
|
+
*
|
|
452
|
+
* The three facts are a STACKED ALIGNED BLOCK because they are a comparison —
|
|
453
|
+
* the figure against the allowance. Inline they are a sentence to parse; stacked
|
|
454
|
+
* an agent and a human read them the same way.
|
|
455
|
+
*
|
|
456
|
+
* "cannot be done from the CLI" is load-bearing: an agent's default response to a
|
|
457
|
+
* failure is to retry, and naming the remedy as a human action outside the tool
|
|
458
|
+
* is the only thing that stops the loop.
|
|
459
|
+
*/
|
|
460
|
+
export function planLimitRefused(i) {
|
|
461
|
+
const rows = [];
|
|
462
|
+
if (i.orgName || i.orgSlug)
|
|
463
|
+
rows.push(["Organization", orgLabel(i.orgName, i.orgSlug)]);
|
|
464
|
+
if (i.planName) {
|
|
465
|
+
rows.push(["Plan", `${i.planName} — ${formatBytes(i.allowanceBytes)} traffic per month`]);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
rows.push(["Allowance", `${formatBytes(i.allowanceBytes)} traffic per month`]);
|
|
469
|
+
}
|
|
470
|
+
rows.push(["Served", `${formatBytes(i.figureBytes)} this month`]);
|
|
471
|
+
const when = i.cutOffAt ? shortDate(i.cutOffAt) : "";
|
|
472
|
+
const takenOn = when ? ` on ${when}` : "";
|
|
473
|
+
return [
|
|
474
|
+
"",
|
|
475
|
+
"✗ Vincentt has taken this organization's published addresses offline.",
|
|
476
|
+
"",
|
|
477
|
+
...alignedRows(rows),
|
|
478
|
+
"",
|
|
479
|
+
` Published addresses were taken offline${takenOn} because the organization`,
|
|
480
|
+
" went over its monthly traffic allowance. Restoring this is a manual action",
|
|
481
|
+
" by a Vincentt operator and cannot be done from the CLI.",
|
|
482
|
+
"",
|
|
483
|
+
" Every version is still stored and the address is still yours.",
|
|
484
|
+
"",
|
|
485
|
+
...remedyLines(i.orgSlug, i.consoleUrl),
|
|
486
|
+
" Nothing was uploaded.",
|
|
487
|
+
"",
|
|
488
|
+
];
|
|
489
|
+
}
|
|
490
|
+
function orgLabel(name, slug) {
|
|
491
|
+
if (name && slug && name !== slug)
|
|
492
|
+
return `${name} (${slug})`;
|
|
493
|
+
return name || slug || "";
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Left-aligned label column. Distinct from alignedPair, which right-aligns two
|
|
497
|
+
* values so magnitudes compare — here the values are prose of different kinds.
|
|
498
|
+
*/
|
|
499
|
+
function alignedRows(rows) {
|
|
500
|
+
const width = Math.max(...rows.map((r) => r[0].length));
|
|
501
|
+
return rows.map(([label, value]) => ` ${label.padEnd(width)} ${value}`);
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* f4's rule: where a verb does not exist because the act belongs to another
|
|
505
|
+
* surface, say where it lives and link to it — and if the link cannot be built,
|
|
506
|
+
* drop the link and keep the explanation.
|
|
507
|
+
*/
|
|
508
|
+
function remedyLines(orgSlug, consoleUrl) {
|
|
509
|
+
if (!orgSlug)
|
|
510
|
+
return [` Email ${SUPPORT_EMAIL}.`, ""];
|
|
511
|
+
const base = (consoleUrl ?? "https://console.vincentt.studio").replace(/\/$/, "");
|
|
512
|
+
return [
|
|
513
|
+
` See ${base}/orgs/${orgSlug}/usage, or email`,
|
|
514
|
+
` ${SUPPORT_EMAIL}.`,
|
|
515
|
+
"",
|
|
516
|
+
];
|
|
517
|
+
}
|
|
518
|
+
const MONTHS = [
|
|
519
|
+
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
520
|
+
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
521
|
+
];
|
|
522
|
+
/** `29 Aug` — the date the creator reads, in UTC, matching the marker's own day. */
|
|
523
|
+
function shortDate(iso) {
|
|
524
|
+
const d = new Date(iso);
|
|
525
|
+
if (Number.isNaN(d.getTime()))
|
|
526
|
+
return "";
|
|
527
|
+
return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`;
|
|
528
|
+
}
|
|
443
529
|
// ---- Flow L29, the server-side failures ---------------------------------------
|
|
444
530
|
/**
|
|
445
531
|
* The cause is NOT named. Every non-healing arm of the commit state machine
|
package/dist/scaffold/index.d.ts
CHANGED
|
@@ -30,6 +30,15 @@ export declare function needsScaffold(dir: string): Promise<boolean>;
|
|
|
30
30
|
* Dotfiles count. A folder holding only `.git` is a repo someone cares about.
|
|
31
31
|
*/
|
|
32
32
|
export declare function isNonEmptyDir(dir: string): Promise<boolean>;
|
|
33
|
+
/**
|
|
34
|
+
* The clone failed because the REF does not exist, as opposed to the repo being
|
|
35
|
+
* unreachable. The two remedies are opposite, and a caller cloning a gallery
|
|
36
|
+
* source words both differently — so the split is carried by the error's type
|
|
37
|
+
* rather than by re-matching git's text downstream.
|
|
38
|
+
*/
|
|
39
|
+
export declare class ScaffoldRefError extends Error {
|
|
40
|
+
constructor(message: string);
|
|
41
|
+
}
|
|
33
42
|
export interface ScaffoldOptions {
|
|
34
43
|
repo?: string;
|
|
35
44
|
/** Template ref (tag) to scaffold from. Defaults to `DEFAULT_TEMPLATE_REF`. */
|
package/dist/scaffold/index.js
CHANGED
|
@@ -59,6 +59,18 @@ export async function isNonEmptyDir(dir) {
|
|
|
59
59
|
return false;
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* The clone failed because the REF does not exist, as opposed to the repo being
|
|
64
|
+
* unreachable. The two remedies are opposite, and a caller cloning a gallery
|
|
65
|
+
* source words both differently — so the split is carried by the error's type
|
|
66
|
+
* rather than by re-matching git's text downstream.
|
|
67
|
+
*/
|
|
68
|
+
export class ScaffoldRefError extends Error {
|
|
69
|
+
constructor(message) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = "ScaffoldRefError";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
62
74
|
/**
|
|
63
75
|
* Scaffold the template into `targetDir`. Clones to a temp dir, strips `.git`,
|
|
64
76
|
* copies the files in (never overwriting anything already there), then inits a
|
|
@@ -80,7 +92,7 @@ export async function scaffoldFromTemplate(targetDir, opts = {}) {
|
|
|
80
92
|
// real cause is a typo'd version). git names the ref in this specific case,
|
|
81
93
|
// so split on that rather than reporting one cause for both.
|
|
82
94
|
if (/(remote branch|Remote branch).*not found|couldn't find remote ref/i.test(msg)) {
|
|
83
|
-
throw new
|
|
95
|
+
throw new ScaffoldRefError(`The template has no version "${ref}". ` +
|
|
84
96
|
`Check the tag exists, or omit --template to use the latest release.`);
|
|
85
97
|
}
|
|
86
98
|
throw new Error(`Could not clone the ${repo} template (check your git access): ${msg}`);
|
package/package.json
CHANGED