@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
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
/**
|
|
64
|
+
* `--template` / `--from` on `create` — the parser refusal, and it is a security
|
|
65
|
+
* control rather than a scope note. The basis for installing dependencies on the
|
|
66
|
+
* unauthenticated scaffold path is that `create` can only ever clone our own
|
|
67
|
+
* hardcoded template at a release-gated ref; this screen is what makes that
|
|
68
|
+
* structurally true rather than conventional.
|
|
69
|
+
*
|
|
70
|
+
* "on `create`" is REQUIRED on line 1 and is not required by the shipped
|
|
71
|
+
* `printFlagConflict` precedent: `--template` really does exist, on `init`, and the
|
|
72
|
+
* remedy three lines below depends on that being true. An unqualified claim is one
|
|
73
|
+
* a creator disproves in one command.
|
|
74
|
+
*
|
|
75
|
+
* The creator's own typed arguments are carried through verbatim, so all three
|
|
76
|
+
* remedy lines are copy-pasteable with no placeholder to guess.
|
|
77
|
+
*/
|
|
78
|
+
export function printCreateSourceFlagRefusal(print, flagName, ctx) {
|
|
79
|
+
const dir = ctx.dir;
|
|
80
|
+
print(`✗ There's no \`${flagName}\` option on \`create\`.`);
|
|
81
|
+
print("");
|
|
82
|
+
if (flagName === "--from") {
|
|
83
|
+
print(" `create` scaffolds the standard starter and nothing else. To start from");
|
|
84
|
+
print(" a gallery template, scaffold first and register after:");
|
|
85
|
+
print("");
|
|
86
|
+
print(` vincentt init ${dir} --from ${ctx.value ?? "owner/repo@tag"}`);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
print(" `create` scaffolds the standard starter and nothing else. To choose a");
|
|
90
|
+
print(" template or a version, scaffold first and register after:");
|
|
91
|
+
print("");
|
|
92
|
+
print(` vincentt init ${dir} --template ${ctx.value ?? "<version>"}`);
|
|
93
|
+
}
|
|
94
|
+
print(` cd ${dir}`);
|
|
95
|
+
print(" vincentt create");
|
|
96
|
+
print("");
|
|
97
|
+
print(" Nothing was written.");
|
|
98
|
+
}
|
|
99
|
+
/** `--from` with `--template`, or with an install-enabling flag. */
|
|
100
|
+
export function printFlagConflict(print, conflict, ctx) {
|
|
101
|
+
const [a, b] = conflict.flags;
|
|
102
|
+
print(`✗ ${a} and ${b} can't be used together.`);
|
|
103
|
+
print("");
|
|
104
|
+
if (conflict.kind === "source") {
|
|
105
|
+
print(" Both choose which source to scaffold from.");
|
|
106
|
+
print("");
|
|
107
|
+
print(` vincentt init ${ctx.dir} --from owner/repo@tag Start from a gallery template`);
|
|
108
|
+
print(` vincentt init ${ctx.dir} --template 2.1.0 Start from the standard template`);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
print(" A gallery template is cloned without installing its dependencies.");
|
|
112
|
+
print(" The install is printed as a next step, to run once you've looked at the source.");
|
|
113
|
+
}
|
|
114
|
+
print("");
|
|
115
|
+
print(" Nothing was written.");
|
|
116
|
+
}
|
|
@@ -65,6 +65,17 @@ export declare class PreviewUi {
|
|
|
65
65
|
events(evs: DiagEvent[], tags: EventTags, opts?: {
|
|
66
66
|
attribute?: boolean;
|
|
67
67
|
}): void;
|
|
68
|
+
/**
|
|
69
|
+
* An annotation arrived from the phone. One line per annotation — it is an
|
|
70
|
+
* event, not a condition, so the once-per-stretch rule that governs advisories
|
|
71
|
+
* does not apply.
|
|
72
|
+
*
|
|
73
|
+
* An un-attributed send is dropped from the terminal for the same reason an
|
|
74
|
+
* un-attributed event is: column one is the tester, and inventing a
|
|
75
|
+
* placeholder would read as a real one. The annotation itself is still stored
|
|
76
|
+
* and still handed over by `vincentt feedback`.
|
|
77
|
+
*/
|
|
78
|
+
annotation(tags: EventTags): void;
|
|
68
79
|
/**
|
|
69
80
|
* L18. Recovery is announced by the 200 that follows and by NOTHING else — a
|
|
70
81
|
* "recovered!" line would be a second interruption for a non-event.
|
package/dist/preview/command.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// file works, and an agent reading the transcript gets the same information a
|
|
13
13
|
// human does.
|
|
14
14
|
import * as copy from "./copy.js";
|
|
15
|
-
import { PerfThrottle, palette, renderEvent, uaLabel } from "./stream.js";
|
|
15
|
+
import { PerfThrottle, palette, renderAnnotation, renderEvent, uaLabel, } from "./stream.js";
|
|
16
16
|
import { ViewerRoster } from "./viewers.js";
|
|
17
17
|
import { MAX_TESTERS } from "../relay/testers.js";
|
|
18
18
|
/**
|
|
@@ -151,6 +151,24 @@ export class PreviewUi {
|
|
|
151
151
|
this.line(` ${rendered}`);
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* An annotation arrived from the phone. One line per annotation — it is an
|
|
156
|
+
* event, not a condition, so the once-per-stretch rule that governs advisories
|
|
157
|
+
* does not apply.
|
|
158
|
+
*
|
|
159
|
+
* An un-attributed send is dropped from the terminal for the same reason an
|
|
160
|
+
* un-attributed event is: column one is the tester, and inventing a
|
|
161
|
+
* placeholder would read as a real one. The annotation itself is still stored
|
|
162
|
+
* and still handed over by `vincentt feedback`.
|
|
163
|
+
*/
|
|
164
|
+
annotation(tags) {
|
|
165
|
+
const { tester } = tags;
|
|
166
|
+
if (tester === undefined)
|
|
167
|
+
return;
|
|
168
|
+
if (this.deps.filterTester !== undefined && tester !== this.deps.filterTester)
|
|
169
|
+
return;
|
|
170
|
+
this.line(` ${renderAnnotation(tester)}`);
|
|
171
|
+
}
|
|
154
172
|
// ---- advisories -----------------------------------------------------------
|
|
155
173
|
/**
|
|
156
174
|
* L18. Recovery is announced by the 200 that follows and by NOTHING else — a
|
package/dist/preview/copy.d.ts
CHANGED
|
@@ -5,6 +5,13 @@ export interface StartBlockInput {
|
|
|
5
5
|
/** The forward target, printed BEFORE the URL so the 502 advisory matches it. */
|
|
6
6
|
targetUrl: string;
|
|
7
7
|
url: string;
|
|
8
|
+
/**
|
|
9
|
+
* The project's console page, or undefined for a folder with no binding. An
|
|
10
|
+
* unlinked folder gets NO console line rather than a guessed one — the same rule
|
|
11
|
+
* `consoleVerbCommand` follows, and for the same reason: a link to the wrong
|
|
12
|
+
* project is worse than no link.
|
|
13
|
+
*/
|
|
14
|
+
consoleUrl?: string;
|
|
8
15
|
/** Pre-rendered QR rows, or undefined for non-TTY / piped / --no-qr. */
|
|
9
16
|
qr?: string[];
|
|
10
17
|
expiresInHours: number;
|
package/dist/preview/copy.js
CHANGED
|
@@ -38,7 +38,14 @@ export function startBlock(i) {
|
|
|
38
38
|
if (i.qr && i.qr.length > 0) {
|
|
39
39
|
out.push(...i.qr.map((row) => ` ${row}`), "");
|
|
40
40
|
}
|
|
41
|
-
out.push(" Scan it, or open the link on any phone. No sign-in needed on the phone.", ` Expires in ${i.expiresInHours}h · Ctrl-C to stop`, ""
|
|
41
|
+
out.push(" Scan it, or open the link on any phone. No sign-in needed on the phone.", ` Expires in ${i.expiresInHours}h · Ctrl-C to stop`, "");
|
|
42
|
+
// BELOW the phone URL and its instruction, never above. The phone address is the
|
|
43
|
+
// line the creator is waiting on; a second URL printed first is a second thing to
|
|
44
|
+
// read before the one that matters. Dropped entirely when the folder is unlinked.
|
|
45
|
+
if (i.consoleUrl) {
|
|
46
|
+
out.push(" In the console:", "", ` ${i.consoleUrl}`, "");
|
|
47
|
+
}
|
|
48
|
+
out.push(" Waiting for a device…", "");
|
|
42
49
|
return out;
|
|
43
50
|
}
|
|
44
51
|
/**
|
package/dist/preview/runner.d.ts
CHANGED
|
@@ -96,6 +96,12 @@ export interface StartPreviewOptions {
|
|
|
96
96
|
* line; the connection's events are still stored, just un-attributed.
|
|
97
97
|
*/
|
|
98
98
|
onCapReached?: () => void;
|
|
99
|
+
/**
|
|
100
|
+
* An annotation arrived from the phone, tagged with the tester that sent it.
|
|
101
|
+
* Carries nothing about its content — the terminal states arrival, and the
|
|
102
|
+
* record itself goes to whoever is consuming the reverse channel.
|
|
103
|
+
*/
|
|
104
|
+
onAnnotation?: (tags: EventTags) => void;
|
|
99
105
|
/** The tunnel's own state — what drives the L19 reconnect advisories. */
|
|
100
106
|
onTunnelState?: (state: TunnelState) => void;
|
|
101
107
|
/** The creator's dev server is not answering (L18). */
|
package/dist/preview/runner.js
CHANGED
|
@@ -83,6 +83,7 @@ export async function startPreview(opts) {
|
|
|
83
83
|
onEvents: opts.onEvents,
|
|
84
84
|
onAttribute: opts.onAttribute,
|
|
85
85
|
onCapReached: opts.onCapReached,
|
|
86
|
+
onAnnotation: opts.onAnnotation,
|
|
86
87
|
});
|
|
87
88
|
front = createServer((req, res) => {
|
|
88
89
|
const toRelay = req.url?.startsWith(harnessPath) === true;
|
package/dist/preview/stream.d.ts
CHANGED
|
@@ -34,6 +34,17 @@ export declare function renderNetworkWithSize(label: string, e: NetworkEvent, by
|
|
|
34
34
|
export declare function displayPath(url: string): string;
|
|
35
35
|
export declare function renderLog(label: string, e: LogEvent, p: Palette): string;
|
|
36
36
|
export declare function renderTrace(label: string, e: TraceEvent): string;
|
|
37
|
+
/**
|
|
38
|
+
* An annotation arrived from the phone. Same column grammar as every other
|
|
39
|
+
* stream line — tester in column one, kind in the status column, then the fact.
|
|
40
|
+
*
|
|
41
|
+
* It states THAT one arrived and nothing else: no message, no id, no screenshot
|
|
42
|
+
* path, no count, and no command to run. The message is viewer-authored and
|
|
43
|
+
* hostile, and this line lands in the creator's scrollback and, pasted, in their
|
|
44
|
+
* agent's context. It is an event and not a condition, so one line per
|
|
45
|
+
* annotation is correct here where an advisory would print once per stretch.
|
|
46
|
+
*/
|
|
47
|
+
export declare function renderAnnotation(label: string): string;
|
|
37
48
|
/** The dispatcher. Returns null for an event kind the terminal does not print. */
|
|
38
49
|
export declare function renderEvent(label: string, e: DiagEvent, p: Palette): string | null;
|
|
39
50
|
/**
|
package/dist/preview/stream.js
CHANGED
|
@@ -134,6 +134,19 @@ export function renderTrace(label, e) {
|
|
|
134
134
|
const fps = e.fps.toFixed(0);
|
|
135
135
|
return `${labelCol(label)}${padEnd("perf", STATUS_COL)} ${fps} fps · ${e.longTaskCount} long tasks · ${e.longestTaskMs}ms peak`;
|
|
136
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* An annotation arrived from the phone. Same column grammar as every other
|
|
139
|
+
* stream line — tester in column one, kind in the status column, then the fact.
|
|
140
|
+
*
|
|
141
|
+
* It states THAT one arrived and nothing else: no message, no id, no screenshot
|
|
142
|
+
* path, no count, and no command to run. The message is viewer-authored and
|
|
143
|
+
* hostile, and this line lands in the creator's scrollback and, pasted, in their
|
|
144
|
+
* agent's context. It is an event and not a condition, so one line per
|
|
145
|
+
* annotation is correct here where an advisory would print once per stretch.
|
|
146
|
+
*/
|
|
147
|
+
export function renderAnnotation(label) {
|
|
148
|
+
return `${labelCol(label)}${padEnd("feedback", STATUS_COL)} annotation received`;
|
|
149
|
+
}
|
|
137
150
|
/** The dispatcher. Returns null for an event kind the terminal does not print. */
|
|
138
151
|
export function renderEvent(label, e, p) {
|
|
139
152
|
if (e.kind === "network")
|
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
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import type { Annotation, AnnotationInput } from "@vincentt-xr/harness/events";
|
|
2
2
|
export declare const ANNOTATIONS_DIR = ".vincentt/annotations";
|
|
3
3
|
export declare function annotationsDir(projectDir: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* What ended a long-poll. A timeout and a teardown used to be the same value
|
|
6
|
+
* (`null`), which made a dead session byte-identical to a quiet one on the wire
|
|
7
|
+
* — so a parked consumer waited forever on a channel that could never speak.
|
|
8
|
+
*/
|
|
9
|
+
export type NextOutcome = {
|
|
10
|
+
kind: "annotation";
|
|
11
|
+
annotation: Annotation;
|
|
12
|
+
} | {
|
|
13
|
+
kind: "timeout";
|
|
14
|
+
} | {
|
|
15
|
+
kind: "closed";
|
|
16
|
+
};
|
|
4
17
|
export declare class AnnotationStore {
|
|
5
18
|
private readonly projectDir;
|
|
6
19
|
private readonly dir;
|
|
@@ -8,6 +21,7 @@ export declare class AnnotationStore {
|
|
|
8
21
|
private seq;
|
|
9
22
|
private waiters;
|
|
10
23
|
private loaded;
|
|
24
|
+
private closed;
|
|
11
25
|
constructor(projectDir: string, dir?: string);
|
|
12
26
|
/** Load persisted annotations so seq continues across preview restarts. */
|
|
13
27
|
init(): Promise<void>;
|
|
@@ -18,13 +32,23 @@ export declare class AnnotationStore {
|
|
|
18
32
|
}): Promise<Annotation>;
|
|
19
33
|
/** The first annotation with seq greater than `after`, if one is already held. */
|
|
20
34
|
peekAfter(after: number): Annotation | undefined;
|
|
35
|
+
/** Every held annotation with seq greater than `after`, in seq order. */
|
|
36
|
+
listAfter(after: number): Annotation[];
|
|
21
37
|
/**
|
|
22
38
|
* Long-poll: resolve with the first annotation whose seq is greater than
|
|
23
|
-
* `after`, either immediately (already held) or as soon as one arrives.
|
|
24
|
-
*
|
|
39
|
+
* `after`, either immediately (already held) or as soon as one arrives.
|
|
40
|
+
* Resolves `timeout` if none arrives within `timeoutMs`, and `closed` if the
|
|
41
|
+
* store tore down while the caller was parked.
|
|
42
|
+
*
|
|
43
|
+
* `timeoutMs` of 0 means "answer now": nothing held resolves `timeout` on the
|
|
44
|
+
* next tick rather than parking.
|
|
25
45
|
*/
|
|
26
|
-
nextAfter(after: number, timeoutMs: number): Promise<
|
|
46
|
+
nextAfter(after: number, timeoutMs: number): Promise<NextOutcome>;
|
|
27
47
|
private wake;
|
|
28
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Release any parked waiters on teardown, telling them the channel ENDED. A
|
|
50
|
+
* teardown that resolved them the same way a timeout does left a parked
|
|
51
|
+
* consumer unable to tell a dead session from a quiet one.
|
|
52
|
+
*/
|
|
29
53
|
close(): void;
|
|
30
54
|
}
|
|
@@ -29,6 +29,7 @@ export class AnnotationStore {
|
|
|
29
29
|
seq = 0;
|
|
30
30
|
waiters = [];
|
|
31
31
|
loaded = false;
|
|
32
|
+
closed = false;
|
|
32
33
|
constructor(projectDir, dir = annotationsDir(projectDir)) {
|
|
33
34
|
this.projectDir = projectDir;
|
|
34
35
|
this.dir = dir;
|
|
@@ -80,22 +81,32 @@ export class AnnotationStore {
|
|
|
80
81
|
peekAfter(after) {
|
|
81
82
|
return this.items.find((a) => a.seq > after);
|
|
82
83
|
}
|
|
84
|
+
/** Every held annotation with seq greater than `after`, in seq order. */
|
|
85
|
+
listAfter(after) {
|
|
86
|
+
return this.items.filter((a) => a.seq > after);
|
|
87
|
+
}
|
|
83
88
|
/**
|
|
84
89
|
* Long-poll: resolve with the first annotation whose seq is greater than
|
|
85
|
-
* `after`, either immediately (already held) or as soon as one arrives.
|
|
86
|
-
*
|
|
90
|
+
* `after`, either immediately (already held) or as soon as one arrives.
|
|
91
|
+
* Resolves `timeout` if none arrives within `timeoutMs`, and `closed` if the
|
|
92
|
+
* store tore down while the caller was parked.
|
|
93
|
+
*
|
|
94
|
+
* `timeoutMs` of 0 means "answer now": nothing held resolves `timeout` on the
|
|
95
|
+
* next tick rather than parking.
|
|
87
96
|
*/
|
|
88
97
|
nextAfter(after, timeoutMs) {
|
|
89
98
|
const held = this.peekAfter(after);
|
|
90
99
|
if (held)
|
|
91
|
-
return Promise.resolve(held);
|
|
100
|
+
return Promise.resolve({ kind: "annotation", annotation: held });
|
|
101
|
+
if (this.closed)
|
|
102
|
+
return Promise.resolve({ kind: "closed" });
|
|
92
103
|
return new Promise((resolve) => {
|
|
93
104
|
const waiter = {
|
|
94
105
|
after,
|
|
95
106
|
resolve,
|
|
96
107
|
timer: setTimeout(() => {
|
|
97
108
|
this.waiters = this.waiters.filter((w) => w !== waiter);
|
|
98
|
-
resolve(
|
|
109
|
+
resolve({ kind: "timeout" });
|
|
99
110
|
}, timeoutMs),
|
|
100
111
|
};
|
|
101
112
|
waiter.timer.unref?.();
|
|
@@ -107,7 +118,7 @@ export class AnnotationStore {
|
|
|
107
118
|
for (const w of this.waiters) {
|
|
108
119
|
if (a.seq > w.after) {
|
|
109
120
|
clearTimeout(w.timer);
|
|
110
|
-
w.resolve(a);
|
|
121
|
+
w.resolve({ kind: "annotation", annotation: a });
|
|
111
122
|
}
|
|
112
123
|
else {
|
|
113
124
|
remaining.push(w);
|
|
@@ -115,11 +126,16 @@ export class AnnotationStore {
|
|
|
115
126
|
}
|
|
116
127
|
this.waiters = remaining;
|
|
117
128
|
}
|
|
118
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* Release any parked waiters on teardown, telling them the channel ENDED. A
|
|
131
|
+
* teardown that resolved them the same way a timeout does left a parked
|
|
132
|
+
* consumer unable to tell a dead session from a quiet one.
|
|
133
|
+
*/
|
|
119
134
|
close() {
|
|
135
|
+
this.closed = true;
|
|
120
136
|
for (const w of this.waiters) {
|
|
121
137
|
clearTimeout(w.timer);
|
|
122
|
-
w.resolve(
|
|
138
|
+
w.resolve({ kind: "closed" });
|
|
123
139
|
}
|
|
124
140
|
this.waiters = [];
|
|
125
141
|
}
|