@vincentt-xr/cli 0.23.0 → 0.25.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/MIGRATION.md +25 -2
- package/README.md +2 -3
- package/dist/cli/index.js +20 -15
- package/dist/dx/consent.js +1 -1
- package/dist/lib/initCommand.js +10 -5
- package/dist/lib/initFrom.js +4 -4
- package/dist/lib/lifecycle.js +1 -0
- package/dist/lib/projects.js +4 -5
- package/dist/lib/relayInfo.d.ts +6 -0
- package/dist/login/pkceLogin.js +1 -1
- package/dist/preview/command.d.ts +2 -2
- package/dist/preview/command.js +1 -1
- package/dist/preview/copy.d.ts +33 -21
- package/dist/preview/copy.js +51 -39
- package/dist/preview/runner.d.ts +7 -0
- package/dist/preview/runner.js +4 -0
- package/dist/relay/consoleOrigin.d.ts +12 -0
- package/dist/relay/consoleOrigin.js +68 -0
- package/dist/relay/guard.d.ts +3 -2
- package/dist/relay/guard.js +6 -4
- package/dist/relay/server.d.ts +15 -0
- package/dist/relay/server.js +94 -9
- package/dist/relay/specBounds.d.ts +39 -0
- package/dist/relay/specBounds.js +157 -0
- package/dist/shared/upgradeError.d.ts +0 -2
- package/dist/shared/upgradeError.js +2 -3
- package/package.json +2 -2
package/MIGRATION.md
CHANGED
|
@@ -3,16 +3,39 @@
|
|
|
3
3
|
Per version, newest first. Read by the platform's own version surface and served to
|
|
4
4
|
creators' agents. Accurate or absent, never speculative (B-F5-2).
|
|
5
5
|
|
|
6
|
+
## 0.25.0
|
|
7
|
+
|
|
8
|
+
required: false
|
|
9
|
+
No action required. `vincentt preview` leads with the console page and no creator-facing screen prints a localhost address.
|
|
10
|
+
- The start block prints `→ <project> · <workspace>` before the preview is created, then `✓ Preview is live`, then `Open the console:` with the project's console page, then `On a phone:` with the phone link and QR. The `→ Serving http://localhost:<port>` line is gone.
|
|
11
|
+
- Running `vincentt preview` while one is running in the folder prints the same two blocks: the console page, then the phone link.
|
|
12
|
+
- The not-signed-in screen ends with `Sign in: vincentt login` and `Then start it again: vincentt preview`. It no longer names the dev command or a local address. The preview's upgrade refusal likewise ends on the upgrade and `vincentt preview`.
|
|
13
|
+
- When the dev server is down, the advisory reads `! Your dev server isn't responding on port <n>.`
|
|
14
|
+
- `vincentt create` and `vincentt init` next steps no longer list the dev server; `vincentt preview` starts it. A non-interactive `create` that scaffolded without an account ends with `Signing in is what gives it an address a phone can open.`
|
|
15
|
+
- The browser page at the end of `vincentt login` reads `You're Signed In` and `Go back to your AI assistant to continue.` The problem-report consent page reads `Problem Reports Are On` or `Problem Reports Are Off` with the same line. Neither mentions the terminal.
|
|
16
|
+
|
|
17
|
+
## 0.24.0
|
|
18
|
+
|
|
19
|
+
required: false
|
|
20
|
+
No action required. `vincentt preview` accepts feedback from the console the CLI signs in through, and the relay now refuses marks outside fixed bounds.
|
|
21
|
+
- The relay admits one browser origin besides the preview's own: the origin of the console the CLI is configured with (https, or http on this machine's loopback; anything else admits none). It answers that console's preflight and probe only on the annotation route, with that exact origin, never `*` and never with credentials.
|
|
22
|
+
- The diagnostics socket, the diagnostics query and the feedback drain refuse the console origin with 403, like any other browser origin.
|
|
23
|
+
- An annotation from any sender is refused with 400 and stored nowhere when a coordinate is outside [-1, 2], it has more than 50 marks, a pen path has more than 500 points, a note is over 2,000 characters, the message is over 110,000 characters, or `frameInImage` does not fit inside the screenshot's PNG size.
|
|
24
|
+
- Also refused with 400: a mark whose colour is not red, yellow or blue, or whose number is not 1 to 50; more than 50 legacy strokes, pins or labels; a stroke over 500 points or with a colour that is not a short colour token; a pin label or label over 2,000 characters.
|
|
25
|
+
- The stored annotation's `spec` is rebuilt from the fields the relay checked, so a field it does not know is dropped and never reaches the agent.
|
|
26
|
+
- An annotation sent from the console carries no tester.
|
|
27
|
+
- When the configured console is not the production console (a flag, an environment variable or the machine config can change it), the preview start block prints `Feedback is accepted from the console at <origin>`.
|
|
28
|
+
|
|
6
29
|
## 0.23.0
|
|
7
30
|
|
|
8
31
|
required: false
|
|
9
32
|
No action required. New verb `vincentt editor`, which hands the project to the Vincentt Editor desktop app.
|
|
10
|
-
- `vincentt editor` (macOS 14 or later, no account) finds
|
|
33
|
+
- `vincentt editor` (macOS 14 or later, no account) finds the Vincentt Editor app in the Mac's Applications folder, then in the user's own Applications folder, checks it is signed by Vincentt, hands it the project folder and exits 0 with `✓ Sent <name> to Vincentt Editor.` It does not wait for the app. The app is a separate download from https://vincentt.studio/editor; the CLI installs no editor binary and has no port option for it.
|
|
11
34
|
- It exits 1, last line `Nothing was opened.`, when the app is not installed, the installed copy is not signed by Vincentt, the Mac is not on macOS 14 or later, or another copy of the app is running from a different folder.
|
|
12
35
|
- `vincentt editor --json` prints one `vincentt.editor-open/1` object: `{"schema","handedOff":true,"project","app","appVersion"}`, or `handedOff: false` with a `reason` (`not_installed`, `bad_signature`, `unsupported_os`, `other_copy_running`, `open_failed`) and, where known, `app` and `detail`. The sentence goes to stderr.
|
|
13
36
|
- `vincentt editor --selection [--json]` prints what is selected in the editor (`vincentt.editor-selection/1`), and `vincentt editor --status --json` the editor's state for the folder (`vincentt.editor-state/1`, including `app.lastOpen`). Both work on any OS and exit 1, with one JSON object still on stdout, when no editor is running for the folder.
|
|
14
37
|
- In `--status --json`, `app` is always `{"version","lastOpen"}`; when no editor is running, `lastOpen` is reported only for a folder this machine handed over with `vincentt editor`, from a state file written after that hand-off, and is `null` otherwise.
|
|
15
|
-
- The CLI keeps those hand-offs in
|
|
38
|
+
- The CLI keeps those hand-offs in a file in its own settings folder, as a hash of each folder's path and a time, never the path itself. A file written in a newer format than this CLI reads is refused with an upgrade line on stderr and nothing on stdout.
|
|
16
39
|
|
|
17
40
|
## 0.22.0
|
|
18
41
|
|
package/README.md
CHANGED
|
@@ -108,9 +108,8 @@ later verbs run without prompting. `init` never reaches that gate at all.
|
|
|
108
108
|
|
|
109
109
|
## The loop, once a project exists
|
|
110
110
|
|
|
111
|
-
- `
|
|
112
|
-
|
|
113
|
-
URL (and a desktop QR page) to open on a phone. AR needs a secure context for
|
|
111
|
+
- `npx vincentt preview` — start the dev server and tunnel the app, then hand over
|
|
112
|
+
the project's console page, which carries the phone link and its QR. AR needs a secure context for
|
|
114
113
|
camera access, so this is how you test tracking and gestures on a device.
|
|
115
114
|
- `npx vincentt logs | network | trace` — read what the phone reported to the
|
|
116
115
|
preview relay. Debug on-device misbehavior instead of guessing.
|
package/dist/cli/index.js
CHANGED
|
@@ -39,7 +39,7 @@ import { USAGE_TEXT } from "./usage-text.js";
|
|
|
39
39
|
import { PreviewUi } from "../preview/command.js";
|
|
40
40
|
import { httpRelayClient } from "../lib/diagnostics.js";
|
|
41
41
|
import { readLivePreview, relayInfoDir } from "../lib/relayInfo.js";
|
|
42
|
-
import { attachedToRunning, mintRateLimited, noDiagnosticsStatic, notSignedIn, previewConflict, previewUserLimitRefused, settingsBanner, startBlock, } from "../preview/copy.js";
|
|
42
|
+
import { attachedToRunning, bindingLine, mintRateLimited, noDiagnosticsStatic, notSignedIn, previewConflict, previewUserLimitRefused, settingsBanner, startBlock, } from "../preview/copy.js";
|
|
43
43
|
import { chooseDevPort } from "../preview/devPort.js";
|
|
44
44
|
import { isPortListening } from "../preview/net.js";
|
|
45
45
|
import { readSettings, resolveSettings, SettingsError, TEMPLATE_DEFAULTS, validateField, } from "../lib/settings.js";
|
|
@@ -570,9 +570,6 @@ async function preview() {
|
|
|
570
570
|
}
|
|
571
571
|
const devCommand = settings.devCommand.value;
|
|
572
572
|
const { port: devPort, fresh: devPortFresh } = await chooseDevPort(settings);
|
|
573
|
-
// What the creator gets running the dev command by hand, without PORT.
|
|
574
|
-
const handRunUrl = `http://localhost:${settings.devPort.value}`;
|
|
575
|
-
const localUrl = `http://localhost:${devPort}`;
|
|
576
573
|
for (const l of settingsBanner({
|
|
577
574
|
devCommand,
|
|
578
575
|
devCommandSource: sourceWords(settings.devCommand),
|
|
@@ -589,7 +586,7 @@ async function preview() {
|
|
|
589
586
|
// The gate is LOCAL and NOTHING IS ATTEMPTED: no spinner, no partial mint, no
|
|
590
587
|
// session left in `pending`. The command stops before it touches the network.
|
|
591
588
|
if (!(await currentToken())) {
|
|
592
|
-
for (const l of notSignedIn(
|
|
589
|
+
for (const l of notSignedIn())
|
|
593
590
|
console.log(l);
|
|
594
591
|
process.exitCode = EXIT.NOT_AUTHENTICATED;
|
|
595
592
|
return;
|
|
@@ -600,7 +597,7 @@ async function preview() {
|
|
|
600
597
|
const ui = new PreviewUi({
|
|
601
598
|
print: (l) => console.log(l),
|
|
602
599
|
now: () => Date.now(),
|
|
603
|
-
|
|
600
|
+
devPort,
|
|
604
601
|
color,
|
|
605
602
|
filterTester,
|
|
606
603
|
filterViewer,
|
|
@@ -629,8 +626,10 @@ async function preview() {
|
|
|
629
626
|
// No filter means the caller wanted the preview, not a diagnostics stream:
|
|
630
627
|
// hand them the link and exit rather than blocking in the follow loop.
|
|
631
628
|
// Following a live preview is what `vincentt logs`/`network`/`trace` are.
|
|
632
|
-
|
|
629
|
+
const consoleUrl = await projectConsoleUrl(cwd, existing.sessionId);
|
|
630
|
+
for (const l of attachedToRunning(existing.url, consoleUrl, existing.expiresAt)) {
|
|
633
631
|
console.log(l);
|
|
632
|
+
}
|
|
634
633
|
return;
|
|
635
634
|
}
|
|
636
635
|
else {
|
|
@@ -642,11 +641,7 @@ async function preview() {
|
|
|
642
641
|
let endingRefusal;
|
|
643
642
|
let resolveDone;
|
|
644
643
|
const refusedAtStart = (facts) => {
|
|
645
|
-
for (const l of upgradeRefusalLines(facts, {
|
|
646
|
-
kind: "preview_start",
|
|
647
|
-
devCommand,
|
|
648
|
-
localUrl: handRunUrl,
|
|
649
|
-
})) {
|
|
644
|
+
for (const l of upgradeRefusalLines(facts, { kind: "preview_start" })) {
|
|
650
645
|
console.log(l);
|
|
651
646
|
}
|
|
652
647
|
process.exitCode = EXIT.UPGRADE_REQUIRED;
|
|
@@ -654,12 +649,21 @@ async function preview() {
|
|
|
654
649
|
const done = new Promise((r) => {
|
|
655
650
|
resolveDone = r;
|
|
656
651
|
});
|
|
652
|
+
// The binding line prints BEFORE the mint: a wrong binding is cheap to catch
|
|
653
|
+
// only while no session exists. An unlinked folder prints none — previewStart
|
|
654
|
+
// refuses it, and a guessed name would be a line about the wrong project.
|
|
655
|
+
if (await locateProjectBinding(cwd)) {
|
|
656
|
+
for (const l of bindingLine(await projectDisplayName(cwd), await activeOrgName())) {
|
|
657
|
+
console.log(l);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
657
660
|
let running;
|
|
658
661
|
try {
|
|
659
662
|
const started = await previewStart(cwd, (m) => console.error(`[preview] ${m}`), {
|
|
660
663
|
appPort: devPort,
|
|
661
664
|
appPortFresh: devPortFresh,
|
|
662
665
|
devCommand,
|
|
666
|
+
consoleUrl: (await resolveAuthUrls()).consoleUrl,
|
|
663
667
|
onHiddenPath: (p) => ui.hiddenPath(p),
|
|
664
668
|
appStdio: has("--verbose") ? "inherit" : "ignore",
|
|
665
669
|
onViewer: (label, uaClass) => ui.viewerSeen(label, uaClass),
|
|
@@ -722,11 +726,12 @@ async function preview() {
|
|
|
722
726
|
? await renderTerminalQr(running.url).catch(() => undefined)
|
|
723
727
|
: undefined;
|
|
724
728
|
for (const l of startBlock({
|
|
725
|
-
projectName: await projectDisplayName(cwd),
|
|
726
|
-
orgName: await activeOrgName(),
|
|
727
|
-
targetUrl: `http://localhost:${running.appPort}`,
|
|
728
729
|
url: running.url,
|
|
729
730
|
consoleUrl: await projectConsoleUrl(cwd, running.sessionId),
|
|
731
|
+
feedbackOrigin: running.consoleOrigin !== undefined &&
|
|
732
|
+
running.consoleOrigin !== new URL(DEFAULT_CONSOLE_URL).origin
|
|
733
|
+
? running.consoleOrigin
|
|
734
|
+
: undefined,
|
|
730
735
|
qr,
|
|
731
736
|
expiresInHours: 12,
|
|
732
737
|
})) {
|
package/dist/dx/consent.js
CHANGED
|
@@ -195,7 +195,7 @@ export async function runConsentInBrowser(opts) {
|
|
|
195
195
|
value: result.decision,
|
|
196
196
|
page: {
|
|
197
197
|
status: 200,
|
|
198
|
-
html: browserPage(granted, granted ? "Reports
|
|
198
|
+
html: browserPage(granted, granted ? "Problem Reports Are On" : "Problem Reports Are Off", "Go back to your AI assistant to continue."),
|
|
199
199
|
},
|
|
200
200
|
};
|
|
201
201
|
}
|
package/dist/lib/initCommand.js
CHANGED
|
@@ -134,11 +134,13 @@ export async function initCommand(deps, opts = {}) {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
// The template ships a pnpm lockfile and a hoisted node-linker, so telling a
|
|
137
|
-
// creator to `npm
|
|
137
|
+
// creator to `npm install` in it produces a second lockfile and an unproven
|
|
138
138
|
// node_modules layout. Name the manager the scaffold is actually set up for.
|
|
139
139
|
const pm = await detectPackageManager(target).catch(() => "npm");
|
|
140
140
|
deps.print("");
|
|
141
141
|
deps.print(`✓ Created ${label}`);
|
|
142
|
+
// No dev-server step: `vincentt preview` starts it, and the product never
|
|
143
|
+
// points the creator at the local loop (B-F3-6).
|
|
142
144
|
const steps = [];
|
|
143
145
|
// Keyed off the RESOLVED dir, not the flag — a prompted name needs the cd too.
|
|
144
146
|
if (dir !== ".")
|
|
@@ -146,16 +148,18 @@ export async function initCommand(deps, opts = {}) {
|
|
|
146
148
|
if (!installed || opts.skipInstall) {
|
|
147
149
|
steps.push([`${pm} install`, "Install dependencies"]);
|
|
148
150
|
}
|
|
149
|
-
steps.push([`${pm} run dev`, "Start the local dev server"]);
|
|
150
151
|
if (!opts.from) {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
152
|
+
if (steps.length > 0) {
|
|
153
|
+
deps.print("");
|
|
154
|
+
for (const line of alignedSteps(steps))
|
|
155
|
+
deps.print(line);
|
|
156
|
+
}
|
|
154
157
|
deps.print("");
|
|
155
158
|
deps.print(" No account needed. When you're ready to put it online:");
|
|
156
159
|
for (const line of alignedSteps([
|
|
157
160
|
["vincentt login", "Connect this machine to Vincentt"],
|
|
158
161
|
["vincentt create", "Register the project and reserve its address"],
|
|
162
|
+
["vincentt preview", "Try it on your phone"],
|
|
159
163
|
])) {
|
|
160
164
|
deps.print(line);
|
|
161
165
|
}
|
|
@@ -181,6 +185,7 @@ export async function initCommand(deps, opts = {}) {
|
|
|
181
185
|
return code;
|
|
182
186
|
deps.print("");
|
|
183
187
|
deps.print(" Next:");
|
|
188
|
+
steps.push(["vincentt preview", "Try it on your phone"]);
|
|
184
189
|
for (const line of alignedSteps(steps))
|
|
185
190
|
deps.print(line);
|
|
186
191
|
return 0;
|
package/dist/lib/initFrom.js
CHANGED
|
@@ -86,10 +86,10 @@ export async function registerAfterScaffold(deps, opts = {}) {
|
|
|
86
86
|
resume(deps.print, deps.dirLabel, resumeCommand, needsCd);
|
|
87
87
|
if (scaffolded) {
|
|
88
88
|
deps.print("");
|
|
89
|
-
// B-
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
deps.print("
|
|
89
|
+
// B-F3-6 (amended): the product points only at the platform, so this
|
|
90
|
+
// ending names what signing in buys, never the local dev loop. The open
|
|
91
|
+
// route B-F2-9(a) requires stays named by the template's verb list.
|
|
92
|
+
deps.print(" Signing in is what gives it an address a phone can open.");
|
|
93
93
|
}
|
|
94
94
|
deps.print("");
|
|
95
95
|
deps.print(closing);
|
package/dist/lib/lifecycle.js
CHANGED
|
@@ -176,6 +176,7 @@ export async function previewStart(projectCwd, onLog, extra = {}) {
|
|
|
176
176
|
expiresAt: preview.expiresAt,
|
|
177
177
|
pidStartTime: await readProcessStartTime(process.pid),
|
|
178
178
|
token: preview.relayToken,
|
|
179
|
+
sessionId: preview.sessionId,
|
|
179
180
|
});
|
|
180
181
|
return { preview, started: true };
|
|
181
182
|
}
|
package/dist/lib/projects.js
CHANGED
|
@@ -203,16 +203,15 @@ export async function createCommand(deps, opts = {}) {
|
|
|
203
203
|
if (code !== 0 && code !== EXIT.DELIVERED_NOT_REGISTERED)
|
|
204
204
|
return code;
|
|
205
205
|
if (scaffolded) {
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
206
|
+
// Two aligned columns off the DETECTED package manager, never a hardcoded one
|
|
207
|
+
// (the template ships a pnpm lockfile and a hoisted node-linker). No dev-server
|
|
208
|
+
// step: `vincentt preview` starts it, and the product never points the creator
|
|
209
|
+
// at the local loop (B-F3-6).
|
|
210
210
|
const steps = [];
|
|
211
211
|
if (opts.dir)
|
|
212
212
|
steps.push([`cd ${opts.dir}`, ""]);
|
|
213
213
|
if (!installed || opts.skipInstall)
|
|
214
214
|
steps.push([`${pm} install`, "Install dependencies"]);
|
|
215
|
-
steps.push([`${pm} run dev`, "Start the local dev server"]);
|
|
216
215
|
steps.push(["vincentt preview", "Try it on your phone"]);
|
|
217
216
|
deps.print("");
|
|
218
217
|
for (const line of alignedSteps(steps))
|
package/dist/lib/relayInfo.d.ts
CHANGED
|
@@ -30,6 +30,12 @@ export interface RelayInfo {
|
|
|
30
30
|
* whose relay asks for none.
|
|
31
31
|
*/
|
|
32
32
|
token?: string;
|
|
33
|
+
/**
|
|
34
|
+
* The running session's id, so a re-run can hand over the console page that
|
|
35
|
+
* shows THIS preview rather than the bare project page. Never printed on its
|
|
36
|
+
* own; absent from markers written by an older CLI.
|
|
37
|
+
*/
|
|
38
|
+
sessionId?: string;
|
|
33
39
|
}
|
|
34
40
|
export declare function relayInfoPath(cwd: string): string;
|
|
35
41
|
/**
|
package/dist/login/pkceLogin.js
CHANGED
|
@@ -166,7 +166,7 @@ export async function runPkceLogin(opts) {
|
|
|
166
166
|
value: result.code,
|
|
167
167
|
page: {
|
|
168
168
|
status: 200,
|
|
169
|
-
html: browserPage(true, "You're
|
|
169
|
+
html: browserPage(true, "You're Signed In", "Go back to your AI assistant to continue."),
|
|
170
170
|
},
|
|
171
171
|
};
|
|
172
172
|
}
|
|
@@ -6,8 +6,8 @@ import { type UpgradeFacts } from "../shared/upgradeError.js";
|
|
|
6
6
|
export interface PreviewUiDeps {
|
|
7
7
|
print: (line: string) => void;
|
|
8
8
|
now: () => number;
|
|
9
|
-
/** The
|
|
10
|
-
|
|
9
|
+
/** The forwarded dev port, named identically in the settings banner and the 502. */
|
|
10
|
+
devPort: number;
|
|
11
11
|
color: boolean;
|
|
12
12
|
/** Filter the stream to one tester (`--tester`) — one tab, across refreshes. */
|
|
13
13
|
filterTester?: string;
|
package/dist/preview/command.js
CHANGED
|
@@ -238,7 +238,7 @@ export class PreviewUi {
|
|
|
238
238
|
const settings = this.deps.devCommand !== undefined && this.deps.portSource !== undefined
|
|
239
239
|
? { devCommand: this.deps.devCommand, portSource: this.deps.portSource }
|
|
240
240
|
: undefined;
|
|
241
|
-
this.lines(copy.originDown(this.deps.
|
|
241
|
+
this.lines(copy.originDown(this.deps.devPort, settings).map((l) => ` ${l}`));
|
|
242
242
|
}
|
|
243
243
|
originRecovered() {
|
|
244
244
|
this.originDown = false;
|
package/dist/preview/copy.d.ts
CHANGED
|
@@ -1,33 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flow L13 — the binding line, printed BEFORE the mint. The creator sees which
|
|
3
|
+
* project this folder is before anything is minted, the only moment a wrong
|
|
4
|
+
* binding is cheap to catch; the start block follows once the preview is live.
|
|
5
|
+
*/
|
|
6
|
+
export declare function bindingLine(projectName: string, orgName: string): string[];
|
|
7
|
+
/** Flow L13 — the start block, after the binding line. */
|
|
1
8
|
export interface StartBlockInput {
|
|
2
|
-
projectName: string;
|
|
3
|
-
orgName: string;
|
|
4
|
-
/** The forward target, printed BEFORE the URL so the 502 advisory matches it. */
|
|
5
|
-
targetUrl: string;
|
|
6
9
|
url: string;
|
|
7
10
|
/**
|
|
8
|
-
* The project's console page
|
|
9
|
-
* unlinked folder
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* The project's console page. Always set on the start path, because a preview
|
|
12
|
+
* refuses an unlinked folder; kept optional so a missing binding prints NO
|
|
13
|
+
* console line rather than a guessed one — a link to the wrong project is worse
|
|
14
|
+
* than no link.
|
|
12
15
|
*/
|
|
13
16
|
consoleUrl?: string;
|
|
17
|
+
/**
|
|
18
|
+
* The console origin the relay accepts feedback from, passed ONLY when it is
|
|
19
|
+
* not the production console. A flag or environment variable can point the
|
|
20
|
+
* CLI at another console, and the creator sees which one before any feedback
|
|
21
|
+
* arrives from it.
|
|
22
|
+
*/
|
|
23
|
+
feedbackOrigin?: string;
|
|
14
24
|
/** Pre-rendered QR rows, or undefined for non-TTY / piped / --no-qr. */
|
|
15
25
|
qr?: string[];
|
|
16
26
|
expiresInHours: number;
|
|
17
27
|
}
|
|
18
28
|
export declare function startBlock(i: StartBlockInput): string[];
|
|
19
29
|
/**
|
|
20
|
-
* Flow L14 — not signed in.
|
|
21
|
-
* feel like a wall.
|
|
30
|
+
* Flow L14 — not signed in.
|
|
22
31
|
*
|
|
23
32
|
* Line 1 is f1's exact string, so a creator meeting this gate in a second
|
|
24
|
-
* command reads one product. The
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* consolation — no "unfortunately", no "you can still".
|
|
33
|
+
* command reads one product. The second line is the reason, not a rule. No local
|
|
34
|
+
* alternative is offered (B-F3-6 as amended: the local loop keeps working, but
|
|
35
|
+
* no product surface points at it) — an agent reading this screen relays what
|
|
36
|
+
* it says, and the only thing to relay is: sign in.
|
|
29
37
|
*/
|
|
30
|
-
export declare function notSignedIn(
|
|
38
|
+
export declare function notSignedIn(): string[];
|
|
31
39
|
/**
|
|
32
40
|
* Flow P1 — the two settings lines, printed right after resolution and before
|
|
33
41
|
* the sign-in check, the mint and the spawn. They are the ONLY disclosure of the
|
|
@@ -65,9 +73,10 @@ export declare function followingFooter(): string[];
|
|
|
65
73
|
* room over an `npm run dev` they forgot.
|
|
66
74
|
*
|
|
67
75
|
* The middle line splits the wire in two, because the creator's first instinct
|
|
68
|
-
* on a 502 is to suspect us.
|
|
76
|
+
* on a 502 is to suspect us. It names the PORT, matching the settings banner,
|
|
77
|
+
* never a loopback URL an agent could paste.
|
|
69
78
|
*/
|
|
70
|
-
export declare function originDown(
|
|
79
|
+
export declare function originDown(devPort: number, settings?: {
|
|
71
80
|
devCommand: string;
|
|
72
81
|
portSource: "flag" | "vincentt.json" | "default";
|
|
73
82
|
}): string[];
|
|
@@ -190,10 +199,13 @@ export declare function supersededLoser(): string[];
|
|
|
190
199
|
* each of those re-runs superseded the previous session: the link really did
|
|
191
200
|
* keep changing, because the attach never printed one.
|
|
192
201
|
*
|
|
193
|
-
* The
|
|
194
|
-
*
|
|
202
|
+
* The two addresses render in the start block's order and shape (Flow L16a),
|
|
203
|
+
* console page first, so an agent that re-runs preview to recover a link finds
|
|
204
|
+
* the console page exactly where the start block put it. `consoleUrl` carries
|
|
205
|
+
* the running session when the marker holds its id, and is undefined only for an
|
|
206
|
+
* unlinked folder, which prints no console line rather than a guessed one.
|
|
195
207
|
*/
|
|
196
|
-
export declare function attachedToRunning(url: string, expiresAt?: string): string[];
|
|
208
|
+
export declare function attachedToRunning(url: string, consoleUrl?: string, expiresAt?: string): string[];
|
|
197
209
|
/**
|
|
198
210
|
* c. Superseded — the terminal that WON. This sits ABOVE the `✓ Preview is live`
|
|
199
211
|
* block so the consequence is read before the URL is copied.
|
package/dist/preview/copy.js
CHANGED
|
@@ -16,53 +16,60 @@
|
|
|
16
16
|
// session id, the edge hostname, or the workingTreeKey. The grant is the
|
|
17
17
|
// sharpest — it transits an agent's tool-output transcript by default in this
|
|
18
18
|
// product, so f1's "a token is never printed" extends to it verbatim.
|
|
19
|
-
/** Flow L13 — the start block. */
|
|
20
19
|
import { escapeControl, terminalSafe } from "../lib/terminalText.js";
|
|
21
20
|
import { INSTALL_COMMAND } from "../shared/upgradeError.js";
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Flow L13 — the binding line, printed BEFORE the mint. The creator sees which
|
|
23
|
+
* project this folder is before anything is minted, the only moment a wrong
|
|
24
|
+
* binding is cheap to catch; the start block follows once the preview is live.
|
|
25
|
+
*/
|
|
26
|
+
export function bindingLine(projectName, orgName) {
|
|
27
|
+
return [
|
|
24
28
|
"",
|
|
25
29
|
// The org is dropped rather than rendered as a dangling separator when it
|
|
26
30
|
// is not known. `Museum AR Tour · ` reads as a truncated line — the one
|
|
27
31
|
// moment a wrong binding is cheap to catch is the one moment the line must
|
|
28
32
|
// not look broken.
|
|
29
|
-
|
|
30
|
-
`→ Serving ${i.targetUrl}`,
|
|
31
|
-
"",
|
|
32
|
-
"✓ Preview is live",
|
|
33
|
-
"",
|
|
34
|
-
// The URL is on its own line, indented, unwrapped and UNADORNED. No `URL:`
|
|
35
|
-
// label, no backticks, no surrounding punctuation — it is the single
|
|
36
|
-
// most-copied string in the product, and anything beside it ends up in
|
|
37
|
-
// someone's paste.
|
|
38
|
-
` ${i.url}`,
|
|
39
|
-
"",
|
|
33
|
+
orgName ? `→ ${projectName} · ${orgName}` : `→ ${projectName}`,
|
|
40
34
|
];
|
|
35
|
+
}
|
|
36
|
+
export function startBlock(i) {
|
|
37
|
+
// No loopback address: the settings banner already names the dev port, and a
|
|
38
|
+
// URL in this transcript is a URL an agent can paste. A loopback one reaches
|
|
39
|
+
// nobody but this machine (B-F3-6: the local loop works, no surface points at
|
|
40
|
+
// it).
|
|
41
|
+
const out = ["", "✓ Preview is live", ""];
|
|
42
|
+
// Each URL is on its own line, indented, unwrapped and UNADORNED. No `URL:`
|
|
43
|
+
// label, no backticks, no surrounding punctuation — anything beside it ends up
|
|
44
|
+
// in someone's paste.
|
|
45
|
+
//
|
|
46
|
+
// The console page comes FIRST: it carries the phone link, the QR, the live
|
|
47
|
+
// device view and Stop, so it is the one address an agent hands over, and the
|
|
48
|
+
// first one it reads.
|
|
49
|
+
if (i.consoleUrl) {
|
|
50
|
+
out.push(" Open the console:", "", ` ${i.consoleUrl}`, "");
|
|
51
|
+
}
|
|
52
|
+
out.push(" On a phone:", "", ` ${i.url}`, "");
|
|
41
53
|
if (i.qr && i.qr.length > 0) {
|
|
42
54
|
out.push(...i.qr.map((row) => ` ${row}`), "");
|
|
43
55
|
}
|
|
44
56
|
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`, "");
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// read before the one that matters. Dropped entirely when the folder is unlinked.
|
|
48
|
-
if (i.consoleUrl) {
|
|
49
|
-
out.push(" In the console:", "", ` ${i.consoleUrl}`, "");
|
|
57
|
+
if (i.feedbackOrigin) {
|
|
58
|
+
out.push(` Feedback is accepted from the console at ${terminalSafe(i.feedbackOrigin)}`, "");
|
|
50
59
|
}
|
|
51
60
|
out.push(" Waiting for a device…", "");
|
|
52
61
|
return out;
|
|
53
62
|
}
|
|
54
63
|
/**
|
|
55
|
-
* Flow L14 — not signed in.
|
|
56
|
-
* feel like a wall.
|
|
64
|
+
* Flow L14 — not signed in.
|
|
57
65
|
*
|
|
58
66
|
* Line 1 is f1's exact string, so a creator meeting this gate in a second
|
|
59
|
-
* command reads one product. The
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* consolation — no "unfortunately", no "you can still".
|
|
67
|
+
* command reads one product. The second line is the reason, not a rule. No local
|
|
68
|
+
* alternative is offered (B-F3-6 as amended: the local loop keeps working, but
|
|
69
|
+
* no product surface points at it) — an agent reading this screen relays what
|
|
70
|
+
* it says, and the only thing to relay is: sign in.
|
|
64
71
|
*/
|
|
65
|
-
export function notSignedIn(
|
|
72
|
+
export function notSignedIn() {
|
|
66
73
|
return [
|
|
67
74
|
"",
|
|
68
75
|
"✗ You're not signed in.",
|
|
@@ -70,10 +77,7 @@ export function notSignedIn(devCommand = "npm run dev", localUrl = "http://local
|
|
|
70
77
|
" A preview needs a hostname your phone can reach, and that needs an account.",
|
|
71
78
|
"",
|
|
72
79
|
" Sign in: vincentt login",
|
|
73
|
-
"",
|
|
74
|
-
` Your local dev loop does not need one. Run \`${devCommand}\` and open`,
|
|
75
|
-
` ${localUrl} on this machine. The camera works there.`,
|
|
76
|
-
" What signing in adds is the phone.",
|
|
80
|
+
" Then start it again: vincentt preview",
|
|
77
81
|
"",
|
|
78
82
|
];
|
|
79
83
|
}
|
|
@@ -153,12 +157,13 @@ export function followingFooter() {
|
|
|
153
157
|
* room over an `npm run dev` they forgot.
|
|
154
158
|
*
|
|
155
159
|
* The middle line splits the wire in two, because the creator's first instinct
|
|
156
|
-
* on a 502 is to suspect us.
|
|
160
|
+
* on a 502 is to suspect us. It names the PORT, matching the settings banner,
|
|
161
|
+
* never a loopback URL an agent could paste.
|
|
157
162
|
*/
|
|
158
|
-
export function originDown(
|
|
163
|
+
export function originDown(devPort, settings) {
|
|
159
164
|
if (settings === undefined) {
|
|
160
165
|
return [
|
|
161
|
-
`! Your dev server isn't responding on ${
|
|
166
|
+
`! Your dev server isn't responding on port ${devPort}.`,
|
|
162
167
|
"",
|
|
163
168
|
" The preview is still live. The phone is reaching us; we can't reach your app.",
|
|
164
169
|
" Start it and reload the phone: npm run dev",
|
|
@@ -176,7 +181,7 @@ export function originDown(targetUrl, settings) {
|
|
|
176
181
|
? " If it runs on another port, pass that port with --port."
|
|
177
182
|
: " If it runs on another port, pass --port or add vincentt.json.";
|
|
178
183
|
return [
|
|
179
|
-
`! Your dev server isn't responding on ${
|
|
184
|
+
`! Your dev server isn't responding on port ${devPort}${source}.`,
|
|
180
185
|
"",
|
|
181
186
|
" The preview is still live. The phone is reaching us; we can't reach your app.",
|
|
182
187
|
` Start it and reload the phone: ${settings.devCommand}`,
|
|
@@ -402,11 +407,18 @@ export function supersededLoser() {
|
|
|
402
407
|
* each of those re-runs superseded the previous session: the link really did
|
|
403
408
|
* keep changing, because the attach never printed one.
|
|
404
409
|
*
|
|
405
|
-
* The
|
|
406
|
-
*
|
|
410
|
+
* The two addresses render in the start block's order and shape (Flow L16a),
|
|
411
|
+
* console page first, so an agent that re-runs preview to recover a link finds
|
|
412
|
+
* the console page exactly where the start block put it. `consoleUrl` carries
|
|
413
|
+
* the running session when the marker holds its id, and is undefined only for an
|
|
414
|
+
* unlinked folder, which prints no console line rather than a guessed one.
|
|
407
415
|
*/
|
|
408
|
-
export function attachedToRunning(url, expiresAt) {
|
|
409
|
-
const lines = ["", "→ A preview is already running for this folder.", ""
|
|
416
|
+
export function attachedToRunning(url, consoleUrl, expiresAt) {
|
|
417
|
+
const lines = ["", "→ A preview is already running for this folder.", ""];
|
|
418
|
+
if (consoleUrl) {
|
|
419
|
+
lines.push(" Open the console:", "", ` ${consoleUrl}`, "");
|
|
420
|
+
}
|
|
421
|
+
lines.push(" On a phone:", "", ` ${url}`, "");
|
|
410
422
|
// Stated only when known: a marker written by an older CLI has no expiry, and
|
|
411
423
|
// inventing one would be the over-claim this copy exists to avoid.
|
|
412
424
|
if (expiresAt !== undefined) {
|
package/dist/preview/runner.d.ts
CHANGED
|
@@ -101,6 +101,11 @@ export interface StartPreviewOptions {
|
|
|
101
101
|
appReadyTimeoutMs?: number;
|
|
102
102
|
/** Progress sink. Never write app stdout to an MCP server's stdout. */
|
|
103
103
|
onLog?: (message: string) => void;
|
|
104
|
+
/**
|
|
105
|
+
* The console this CLI signs in through, from the creator's own configuration.
|
|
106
|
+
* Its origin, if valid, may send feedback to the relay's annotation route.
|
|
107
|
+
*/
|
|
108
|
+
consoleUrl?: string;
|
|
104
109
|
/**
|
|
105
110
|
* A viewer label appearing on a stream. Fires per OPEN, so the roster's
|
|
106
111
|
* per-label idempotence is what keeps one device from announcing itself once
|
|
@@ -171,6 +176,8 @@ export interface RunningPreview {
|
|
|
171
176
|
appPort: number;
|
|
172
177
|
/** Whether the app was responding when we returned. */
|
|
173
178
|
appReady: boolean;
|
|
179
|
+
/** The console origin the relay admits on its annotation route, if any. */
|
|
180
|
+
consoleOrigin?: string;
|
|
174
181
|
/** Tear down the tunnel, the app dev serve, the front proxy and the relay. */
|
|
175
182
|
stop: () => Promise<void>;
|
|
176
183
|
}
|
package/dist/preview/runner.js
CHANGED
|
@@ -17,6 +17,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
17
17
|
import { realpath } from "node:fs/promises";
|
|
18
18
|
import { createServer } from "node:http";
|
|
19
19
|
import { TunnelClient, resolveForwardTarget, } from "../tunnel/index.js";
|
|
20
|
+
import { consoleOriginOf } from "../relay/consoleOrigin.js";
|
|
20
21
|
import { startRelay } from "../relay/server.js";
|
|
21
22
|
import { mintRelayToken } from "../relay/guard.js";
|
|
22
23
|
import { isHiddenAppPath, proxyWeb, proxyWs, relayRoute } from "./proxy.js";
|
|
@@ -168,10 +169,12 @@ export async function startPreview(opts) {
|
|
|
168
169
|
}
|
|
169
170
|
// Fixed here, from the server-computed URL, and never from a request.
|
|
170
171
|
const previewOrigin = new URL(minted.url).origin;
|
|
172
|
+
const consoleOrigin = consoleOriginOf(opts.consoleUrl);
|
|
171
173
|
relay = startRelay({
|
|
172
174
|
port: relayPort,
|
|
173
175
|
path: harnessPath,
|
|
174
176
|
allowedOrigins: [previewOrigin],
|
|
177
|
+
consoleOrigin,
|
|
175
178
|
token: relayToken,
|
|
176
179
|
// Annotations land beside the binding, so `vincentt feedback` finds them
|
|
177
180
|
// from anywhere in the tree rather than only from the folder that started
|
|
@@ -293,6 +296,7 @@ export async function startPreview(opts) {
|
|
|
293
296
|
relayToken,
|
|
294
297
|
appPort,
|
|
295
298
|
appReady,
|
|
299
|
+
consoleOrigin,
|
|
296
300
|
stop,
|
|
297
301
|
};
|
|
298
302
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The console origin to admit for `consoleUrl`, or undefined to admit none. */
|
|
2
|
+
export declare function consoleOriginOf(consoleUrl: string | undefined): string | undefined;
|
|
3
|
+
/**
|
|
4
|
+
* Is this preflight one the relay answers? The Origin was already matched
|
|
5
|
+
* exactly. The method must be exactly POST, and the requested headers, parsed
|
|
6
|
+
* as a comma list, trimmed and lowercased, a subset of `content-type`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isConsolePreflight(method: string | string[] | undefined, requestHeaders: string | string[] | undefined): boolean;
|
|
9
|
+
/** The CORS headers on a preflight the relay answers. Nothing else, ever. */
|
|
10
|
+
export declare function preflightHeaders(consoleOrigin: string): Record<string, string>;
|
|
11
|
+
/** The CORS headers on the probe and on every POST response to the console. */
|
|
12
|
+
export declare function responseHeaders(consoleOrigin: string): Record<string, string>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The one browser origin the relay admits besides the preview's own: the console
|
|
2
|
+
// this CLI signs in through, and only on the annotation route.
|
|
3
|
+
//
|
|
4
|
+
// It comes from the creator's own configuration (flag, env, machine config),
|
|
5
|
+
// never from a request and never from the folder's binding. A value that is not
|
|
6
|
+
// exactly an https origin, or an http origin on this machine's loopback, grants
|
|
7
|
+
// nothing, and the relay then behaves as one with no console origin at all.
|
|
8
|
+
import { isLoopbackOrigin } from "./guard.js";
|
|
9
|
+
/** The console origin to admit for `consoleUrl`, or undefined to admit none. */
|
|
10
|
+
export function consoleOriginOf(consoleUrl) {
|
|
11
|
+
if (consoleUrl === undefined)
|
|
12
|
+
return undefined;
|
|
13
|
+
let url;
|
|
14
|
+
try {
|
|
15
|
+
url = new URL(consoleUrl.trim());
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
// `origin` is the literal "null" for every scheme without a host, so this one
|
|
21
|
+
// check refuses `javascript:`, `data:`, `file:` and a blob URL alike.
|
|
22
|
+
const origin = url.origin;
|
|
23
|
+
if (origin === "null" || origin === "*")
|
|
24
|
+
return undefined;
|
|
25
|
+
if (url.username !== "" || url.password !== "")
|
|
26
|
+
return undefined;
|
|
27
|
+
if (url.protocol === "https:")
|
|
28
|
+
return origin;
|
|
29
|
+
if (url.protocol === "http:" && isLoopbackOrigin(origin))
|
|
30
|
+
return origin;
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Is this preflight one the relay answers? The Origin was already matched
|
|
35
|
+
* exactly. The method must be exactly POST, and the requested headers, parsed
|
|
36
|
+
* as a comma list, trimmed and lowercased, a subset of `content-type`.
|
|
37
|
+
*/
|
|
38
|
+
export function isConsolePreflight(method, requestHeaders) {
|
|
39
|
+
if (method !== "POST")
|
|
40
|
+
return false;
|
|
41
|
+
if (requestHeaders === undefined)
|
|
42
|
+
return true;
|
|
43
|
+
if (typeof requestHeaders !== "string")
|
|
44
|
+
return false;
|
|
45
|
+
if (requestHeaders.trim() === "")
|
|
46
|
+
return true;
|
|
47
|
+
return requestHeaders
|
|
48
|
+
.split(",")
|
|
49
|
+
.map((h) => h.trim().toLowerCase())
|
|
50
|
+
.every((h) => h === "content-type");
|
|
51
|
+
}
|
|
52
|
+
/** The CORS headers on a preflight the relay answers. Nothing else, ever. */
|
|
53
|
+
export function preflightHeaders(consoleOrigin) {
|
|
54
|
+
return {
|
|
55
|
+
"access-control-allow-origin": consoleOrigin,
|
|
56
|
+
"access-control-allow-methods": "POST",
|
|
57
|
+
"access-control-allow-headers": "content-type",
|
|
58
|
+
"access-control-max-age": "600",
|
|
59
|
+
vary: "Origin",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** The CORS headers on the probe and on every POST response to the console. */
|
|
63
|
+
export function responseHeaders(consoleOrigin) {
|
|
64
|
+
return {
|
|
65
|
+
"access-control-allow-origin": consoleOrigin,
|
|
66
|
+
vary: "Origin",
|
|
67
|
+
};
|
|
68
|
+
}
|
package/dist/relay/guard.d.ts
CHANGED
|
@@ -16,8 +16,9 @@ export declare function isAllowedOrigin(raw: string | string[] | undefined, allo
|
|
|
16
16
|
export declare function isLoopbackOrigin(raw: string): boolean;
|
|
17
17
|
/**
|
|
18
18
|
* A cross-site form or `no-cors` fetch can only send `text/plain`, form or
|
|
19
|
-
* multipart bodies without a preflight. Requiring JSON forces a preflight
|
|
20
|
-
* relay
|
|
19
|
+
* multipart bodies without a preflight. Requiring JSON forces a preflight, and
|
|
20
|
+
* the relay answers one only for the configured console origin, only on the
|
|
21
|
+
* annotation route.
|
|
21
22
|
*/
|
|
22
23
|
export declare function isJsonContentType(raw: string | undefined): boolean;
|
|
23
24
|
export declare function hasRelayToken(raw: string | string[] | undefined, token: string): boolean;
|
package/dist/relay/guard.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// The relay's request gate. Every caller the relay serves is on this machine:
|
|
2
2
|
// the CLI (no Origin), the tunnel client and the front proxy (both rewrite Host
|
|
3
3
|
// to the loopback target), and the preview page itself (its own minted origin).
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// The one exception is the creator's console, admitted on the annotation route
|
|
5
|
+
// only (see consoleOrigin.ts). A web page the creator happens to have open is
|
|
6
|
+
// none of those, and these checks are what tell it apart.
|
|
6
7
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
7
8
|
/** The header carrying the per-session token on the local-only routes. */
|
|
8
9
|
export const RELAY_TOKEN_HEADER = "x-vincentt-relay-token";
|
|
@@ -55,8 +56,9 @@ export function isLoopbackOrigin(raw) {
|
|
|
55
56
|
}
|
|
56
57
|
/**
|
|
57
58
|
* A cross-site form or `no-cors` fetch can only send `text/plain`, form or
|
|
58
|
-
* multipart bodies without a preflight. Requiring JSON forces a preflight
|
|
59
|
-
* relay
|
|
59
|
+
* multipart bodies without a preflight. Requiring JSON forces a preflight, and
|
|
60
|
+
* the relay answers one only for the configured console origin, only on the
|
|
61
|
+
* annotation route.
|
|
60
62
|
*/
|
|
61
63
|
export function isJsonContentType(raw) {
|
|
62
64
|
if (raw === undefined)
|
package/dist/relay/server.d.ts
CHANGED
|
@@ -64,6 +64,15 @@ export interface RelayOptions {
|
|
|
64
64
|
* loopback pass, which is what a standalone `vincentt relay` serves.
|
|
65
65
|
*/
|
|
66
66
|
allowedOrigins?: readonly string[];
|
|
67
|
+
/**
|
|
68
|
+
* The console this CLI signs in through, already validated (`consoleOriginOf`).
|
|
69
|
+
* Admitted on the annotation route ONLY: its preflight and probe are answered
|
|
70
|
+
* there and its POST carries CORS headers. It is never added to
|
|
71
|
+
* `allowedOrigins`, which also gates the WebSocket, so the diagnostics socket,
|
|
72
|
+
* `/query` and `feedback/next` refuse it like any foreign origin. Omitted, the
|
|
73
|
+
* relay admits no console.
|
|
74
|
+
*/
|
|
75
|
+
consoleOrigin?: string;
|
|
67
76
|
/**
|
|
68
77
|
* The per-session secret `/query` and `feedback/next` require in
|
|
69
78
|
* `x-vincentt-relay-token`. The preview always sets it; without it those
|
|
@@ -136,3 +145,9 @@ export declare function isUnsafeRelayPath(rawUrl: string): boolean;
|
|
|
136
145
|
* number before it reaches a timer.
|
|
137
146
|
*/
|
|
138
147
|
export declare function feedbackTimeoutMs(raw: string | null): number;
|
|
148
|
+
/**
|
|
149
|
+
* The body as UTF-8, decoded ONCE from the joined bytes: decoding each chunk on
|
|
150
|
+
* its own turns a multi-byte character split across two chunks into U+FFFD.
|
|
151
|
+
* The size limit is in bytes.
|
|
152
|
+
*/
|
|
153
|
+
export declare function readBody(req: IncomingMessage, maxBytes?: number): Promise<string>;
|
package/dist/relay/server.js
CHANGED
|
@@ -10,6 +10,8 @@ import { EventStore } from "./store.js";
|
|
|
10
10
|
import { TesterRegistry, isTesterId } from "./testers.js";
|
|
11
11
|
import { AnnotationStore } from "./annotations.js";
|
|
12
12
|
import { RELAY_TOKEN_HEADER, hasRelayToken, isAllowedHost, isAllowedOrigin, isJsonContentType, } from "./guard.js";
|
|
13
|
+
import { isConsolePreflight, preflightHeaders, responseHeaders, } from "./consoleOrigin.js";
|
|
14
|
+
import { boundedSpec } from "./specBounds.js";
|
|
13
15
|
import { VIEWER_LABEL_RE, VIEWER_STAMP_HEADER } from "../tunnel/index.js";
|
|
14
16
|
/** Cap an annotation POST body so a bad/huge upload can't exhaust memory. */
|
|
15
17
|
const MAX_ANNOTATION_BYTES = 25 * 1024 * 1024;
|
|
@@ -56,6 +58,7 @@ export function startRelay(opts = {}) {
|
|
|
56
58
|
host,
|
|
57
59
|
port: boundPort(),
|
|
58
60
|
allowedOrigins,
|
|
61
|
+
consoleOrigin: opts.consoleOrigin,
|
|
59
62
|
token: opts.token,
|
|
60
63
|
store,
|
|
61
64
|
annotations,
|
|
@@ -144,7 +147,9 @@ export function startRelay(opts = {}) {
|
|
|
144
147
|
});
|
|
145
148
|
ws.on("close", () => log("client disconnected"));
|
|
146
149
|
});
|
|
147
|
-
|
|
150
|
+
// Port only: this line reaches `vincentt preview`'s transcript, and a loopback
|
|
151
|
+
// address there is one an agent can paste and hand over.
|
|
152
|
+
http.listen(port, host, () => log(`relay listening on port ${port} (ws ${wsPath}, http /query)`));
|
|
148
153
|
return {
|
|
149
154
|
store,
|
|
150
155
|
annotations,
|
|
@@ -208,9 +213,22 @@ function handleHttp(req, res, ctx) {
|
|
|
208
213
|
res.writeHead(403).end();
|
|
209
214
|
return;
|
|
210
215
|
}
|
|
216
|
+
const annotationRoute = p === `${ctx.wsPath}/annotation`;
|
|
217
|
+
// The console origin is matched HERE, inside the annotation route, and never
|
|
218
|
+
// joins the origin list: that list also gates the WebSocket, and a console
|
|
219
|
+
// page reaching the diagnostics socket is the hole this placement closes.
|
|
220
|
+
// Exact string equality, so `null`, a duplicated header, a trailing slash and
|
|
221
|
+
// a case variant all miss.
|
|
222
|
+
const fromConsole = annotationRoute &&
|
|
223
|
+
ctx.consoleOrigin !== undefined &&
|
|
224
|
+
req.headers.origin === ctx.consoleOrigin;
|
|
225
|
+
if (annotationRoute && req.method === "OPTIONS") {
|
|
226
|
+
handlePreflight(req, res, ctx, fromConsole);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
211
229
|
// The local-only routes have no browser caller at all, so any Origin is foreign.
|
|
212
230
|
const origins = localOnly ? [] : ctx.allowedOrigins;
|
|
213
|
-
if (!isAllowedOrigin(req.headers.origin, origins)) {
|
|
231
|
+
if (!fromConsole && !isAllowedOrigin(req.headers.origin, origins)) {
|
|
214
232
|
ctx.log("refused a request from a foreign Origin");
|
|
215
233
|
res.writeHead(403).end();
|
|
216
234
|
return;
|
|
@@ -221,6 +239,26 @@ function handleHttp(req, res, ctx) {
|
|
|
221
239
|
return;
|
|
222
240
|
}
|
|
223
241
|
}
|
|
242
|
+
// On EVERY status from here, so the console can read a refusal as well as a
|
|
243
|
+
// 200 and never retries an annotation the relay already stored.
|
|
244
|
+
if (fromConsole) {
|
|
245
|
+
for (const [k, v] of Object.entries(responseHeaders(ctx.consoleOrigin))) {
|
|
246
|
+
res.setHeader(k, v);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (annotationRoute && req.method === "GET") {
|
|
250
|
+
// The console's probe: presence only, and it writes nothing.
|
|
251
|
+
if (!fromConsole) {
|
|
252
|
+
res.writeHead(405).end();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
res.writeHead(ctx.annotations ? 204 : 503).end();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (annotationRoute && req.method !== "POST") {
|
|
259
|
+
res.writeHead(405).end();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
224
262
|
if (req.method === "POST" && !isJsonContentType(req.headers["content-type"])) {
|
|
225
263
|
res.writeHead(415).end();
|
|
226
264
|
return;
|
|
@@ -229,7 +267,9 @@ function handleHttp(req, res, ctx) {
|
|
|
229
267
|
handleQuery(req, res, url, ctx);
|
|
230
268
|
return;
|
|
231
269
|
}
|
|
232
|
-
if (
|
|
270
|
+
if (annotationRoute) {
|
|
271
|
+
if (fromConsole)
|
|
272
|
+
ctx.log("annotation from the console origin");
|
|
233
273
|
handleAnnotationPost(req, res, ctx);
|
|
234
274
|
return;
|
|
235
275
|
}
|
|
@@ -239,6 +279,27 @@ function handleHttp(req, res, ctx) {
|
|
|
239
279
|
}
|
|
240
280
|
res.writeHead(404).end();
|
|
241
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* The console's CORS preflight on the annotation route. Answered only for the
|
|
284
|
+
* exact console origin asking for POST with at most `content-type`; anything
|
|
285
|
+
* else is 403 with no CORS header at all. Never `*`, never credentials, never a
|
|
286
|
+
* private-network grant.
|
|
287
|
+
*/
|
|
288
|
+
function handlePreflight(req, res, ctx, fromConsole) {
|
|
289
|
+
if (!fromConsole) {
|
|
290
|
+
ctx.log("refused a preflight from a foreign Origin");
|
|
291
|
+
res.writeHead(403).end();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const ok = isConsolePreflight(req.headers["access-control-request-method"], req.headers["access-control-request-headers"]);
|
|
295
|
+
if (!ok) {
|
|
296
|
+
ctx.log("refused a console preflight for a method or header it does not allow");
|
|
297
|
+
res.writeHead(403).end();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
ctx.log("answered a console preflight");
|
|
301
|
+
res.writeHead(204, preflightHeaders(ctx.consoleOrigin)).end();
|
|
302
|
+
}
|
|
242
303
|
// The CLI (same laptop) GETs/POSTs /query with a RelayQuery, carrying the
|
|
243
304
|
// session token from the 0600 relay marker.
|
|
244
305
|
function handleQuery(req, res, url, ctx) {
|
|
@@ -279,6 +340,16 @@ function handleAnnotationPost(req, res, ctx) {
|
|
|
279
340
|
res.writeHead(400).end("annotation requires message + screenshot");
|
|
280
341
|
return;
|
|
281
342
|
}
|
|
343
|
+
// Refused, never clamped: the agent acts on these values, and a stored
|
|
344
|
+
// coordinate must be exactly the one that was sent.
|
|
345
|
+
// What is stored is the spec REBUILT from the checked fields, never the
|
|
346
|
+
// body's own object: an unknown key would otherwise ride to the agent.
|
|
347
|
+
const bounded = boundedSpec(input);
|
|
348
|
+
if (!bounded.ok) {
|
|
349
|
+
ctx.log("refused marks out of bounds");
|
|
350
|
+
res.writeHead(400).end("annotation marks out of bounds");
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
282
353
|
// The annotation's session key is RESOLVED, never assigned — `resolve`,
|
|
283
354
|
// not `attribute`. This route is unauthenticated and reachable by anyone
|
|
284
355
|
// holding the capability URL, so a door that minted here would let 64 bare
|
|
@@ -288,7 +359,12 @@ function handleAnnotationPost(req, res, ctx) {
|
|
|
288
359
|
// The key is still hostile input either way: the record reaches an agent's
|
|
289
360
|
// context on the consumer's stdout, so the raw string must never survive
|
|
290
361
|
// onto it.
|
|
291
|
-
const annotation = await ctx.annotations.add({
|
|
362
|
+
const annotation = await ctx.annotations.add({
|
|
363
|
+
message: input.message,
|
|
364
|
+
screenshot: input.screenshot,
|
|
365
|
+
spec: bounded.spec,
|
|
366
|
+
sessionId: ctx.testers.resolve(input.sessionId),
|
|
367
|
+
}, { now: Date.now(), id: ctx.makeId() });
|
|
292
368
|
// The live stream is told an annotation ARRIVED, with the resolved tester
|
|
293
369
|
// and nothing else. It is a fact, not a directive: the consumer is the
|
|
294
370
|
// long-poll, and a terminal line naming what to run would be the platform
|
|
@@ -299,7 +375,11 @@ function handleAnnotationPost(req, res, ctx) {
|
|
|
299
375
|
.end(JSON.stringify({ id: annotation.id, seq: annotation.seq }));
|
|
300
376
|
})
|
|
301
377
|
.catch((err) => {
|
|
302
|
-
|
|
378
|
+
// A fixed line, never the error's text: a JSON.parse message quotes the
|
|
379
|
+
// body, which is hostile input, and this log reaches the terminal.
|
|
380
|
+
ctx.log(err instanceof SyntaxError
|
|
381
|
+
? "annotation error: the body is not JSON"
|
|
382
|
+
: "annotation error: not stored (too large, or the write failed)");
|
|
303
383
|
if (!res.headersSent)
|
|
304
384
|
res.writeHead(400).end();
|
|
305
385
|
});
|
|
@@ -397,9 +477,14 @@ async function readQuery(req, url) {
|
|
|
397
477
|
q.presence = true;
|
|
398
478
|
return normalizeTester(q);
|
|
399
479
|
}
|
|
400
|
-
|
|
480
|
+
/**
|
|
481
|
+
* The body as UTF-8, decoded ONCE from the joined bytes: decoding each chunk on
|
|
482
|
+
* its own turns a multi-byte character split across two chunks into U+FFFD.
|
|
483
|
+
* The size limit is in bytes.
|
|
484
|
+
*/
|
|
485
|
+
export function readBody(req, maxBytes = Infinity) {
|
|
401
486
|
return new Promise((resolve, reject) => {
|
|
402
|
-
|
|
487
|
+
const chunks = [];
|
|
403
488
|
let bytes = 0;
|
|
404
489
|
req.on("data", (c) => {
|
|
405
490
|
bytes += c.length;
|
|
@@ -408,9 +493,9 @@ function readBody(req, maxBytes = Infinity) {
|
|
|
408
493
|
req.destroy();
|
|
409
494
|
return;
|
|
410
495
|
}
|
|
411
|
-
|
|
496
|
+
chunks.push(c);
|
|
412
497
|
});
|
|
413
|
-
req.on("end", () => resolve(
|
|
498
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
414
499
|
req.on("error", reject);
|
|
415
500
|
});
|
|
416
501
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { AnnotationInput, AnnotationSpec } from "@vincentt-xr/harness/events";
|
|
2
|
+
export declare const COORD_MIN = -1;
|
|
3
|
+
export declare const COORD_MAX = 2;
|
|
4
|
+
export declare const MAX_SHAPES = 50;
|
|
5
|
+
export declare const MAX_STROKES = 50;
|
|
6
|
+
export declare const MAX_PINS = 50;
|
|
7
|
+
export declare const MAX_LABELS = 50;
|
|
8
|
+
export declare const MAX_PATH_POINTS = 500;
|
|
9
|
+
export declare const MAX_NOTE_CHARS = 2000;
|
|
10
|
+
export declare const MAX_MESSAGE_CHARS = 110000;
|
|
11
|
+
/** The mark colours every sender draws with: the keys of the harness's MARK_COLORS. */
|
|
12
|
+
export declare const MARK_COLOR_NAMES: ReadonlySet<string>;
|
|
13
|
+
/**
|
|
14
|
+
* The width and height a PNG data URL's IHDR declares, or undefined when it is
|
|
15
|
+
* not a PNG data URL. Reads only the first 24 decoded bytes; never decodes the
|
|
16
|
+
* image.
|
|
17
|
+
*/
|
|
18
|
+
export declare function pngSize(dataUrl: string): {
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
} | undefined;
|
|
22
|
+
/** Refused, or in bounds with the `spec` to store (undefined when none was sent). */
|
|
23
|
+
export type BoundedSpec = {
|
|
24
|
+
ok: false;
|
|
25
|
+
} | {
|
|
26
|
+
ok: true;
|
|
27
|
+
spec?: AnnotationSpec;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Check an annotation's message and marks, and rebuild `spec` from the fields
|
|
31
|
+
* that passed. Assumes the caller already checked `message` and `screenshot` are
|
|
32
|
+
* strings.
|
|
33
|
+
*
|
|
34
|
+
* `spec` absent, `{}` and `{shapes: []}` are all in bounds. A field the relay
|
|
35
|
+
* does not know is not an error; it is simply not stored.
|
|
36
|
+
*/
|
|
37
|
+
export declare function boundedSpec(input: AnnotationInput): BoundedSpec;
|
|
38
|
+
/** Whether an annotation's message and marks are within bounds. */
|
|
39
|
+
export declare function isWithinBounds(input: AnnotationInput): boolean;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// The annotation's marks are hostile input the agent then acts on, from any
|
|
2
|
+
// sender: the phone, the console, or anyone holding the preview address. The
|
|
3
|
+
// relay refuses an annotation outside these bounds rather than clamping it, and
|
|
4
|
+
// stores a `spec` REBUILT from the fields it checked: an unknown key is dropped,
|
|
5
|
+
// so what reaches the agent is exactly what was validated, value for value.
|
|
6
|
+
//
|
|
7
|
+
// Coordinates are picture-relative: [0,1] spans the picture and a value outside
|
|
8
|
+
// it is a point past the picture's edge. No sender's drawing margin reaches past
|
|
9
|
+
// [-1, 2].
|
|
10
|
+
export const COORD_MIN = -1;
|
|
11
|
+
export const COORD_MAX = 2;
|
|
12
|
+
export const MAX_SHAPES = 50;
|
|
13
|
+
export const MAX_STROKES = 50;
|
|
14
|
+
export const MAX_PINS = 50;
|
|
15
|
+
export const MAX_LABELS = 50;
|
|
16
|
+
export const MAX_PATH_POINTS = 500;
|
|
17
|
+
export const MAX_NOTE_CHARS = 2_000;
|
|
18
|
+
export const MAX_MESSAGE_CHARS = 110_000;
|
|
19
|
+
/** The mark colours every sender draws with: the keys of the harness's MARK_COLORS. */
|
|
20
|
+
export const MARK_COLOR_NAMES = new Set(["red", "yellow", "blue"]);
|
|
21
|
+
/**
|
|
22
|
+
* A legacy stroke's colour, which an app could set itself: a mark colour name, a
|
|
23
|
+
* hex colour, or one short lowercase word. Never free text.
|
|
24
|
+
*/
|
|
25
|
+
const STROKE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|[a-z]{1,16})$/;
|
|
26
|
+
const SHAPE_TYPES = new Set(["rect", "circle", "arrow", "pen"]);
|
|
27
|
+
const PNG_PREFIX = "data:image/png;base64,";
|
|
28
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
29
|
+
/** Thrown inside the rebuild; never escapes `boundedSpec`. */
|
|
30
|
+
class Refused extends Error {
|
|
31
|
+
}
|
|
32
|
+
const refuse = () => {
|
|
33
|
+
throw new Refused();
|
|
34
|
+
};
|
|
35
|
+
const isRec = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
36
|
+
const coord = (v) => typeof v === "number" && Number.isFinite(v) && v >= COORD_MIN && v <= COORD_MAX
|
|
37
|
+
? v
|
|
38
|
+
: refuse();
|
|
39
|
+
const size = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : refuse();
|
|
40
|
+
const point = (v) => isRec(v) ? { x: coord(v.x), y: coord(v.y) } : refuse();
|
|
41
|
+
const text = (v) => typeof v === "string" && v.length <= MAX_NOTE_CHARS ? v : refuse();
|
|
42
|
+
const list = (v, max) => Array.isArray(v) && v.length <= max ? v : refuse();
|
|
43
|
+
const path = (v) => list(v, MAX_PATH_POINTS).map(point);
|
|
44
|
+
function shape(v) {
|
|
45
|
+
if (!isRec(v))
|
|
46
|
+
return refuse();
|
|
47
|
+
if (typeof v.type !== "string" || !SHAPE_TYPES.has(v.type))
|
|
48
|
+
return refuse();
|
|
49
|
+
const n = typeof v.n === "number" && Number.isInteger(v.n) && v.n >= 1 && v.n <= MAX_SHAPES
|
|
50
|
+
? v.n
|
|
51
|
+
: refuse();
|
|
52
|
+
const color = typeof v.color === "string" && MARK_COLOR_NAMES.has(v.color) ? v.color : refuse();
|
|
53
|
+
const base = { n, color, ...(v.note === undefined ? {} : { note: text(v.note) }) };
|
|
54
|
+
if (v.type === "arrow")
|
|
55
|
+
return { ...base, type: "arrow", from: point(v.from), to: point(v.to) };
|
|
56
|
+
if (v.type === "pen")
|
|
57
|
+
return { ...base, type: "pen", points: path(v.points) };
|
|
58
|
+
const [x, y, w, h] = [coord(v.x), coord(v.y), size(v.w), size(v.h)];
|
|
59
|
+
// The far corner is a coordinate too. Its sum carries float error, so it alone
|
|
60
|
+
// gets a hair of tolerance; every coordinate a sender wrote is checked exactly.
|
|
61
|
+
if (x + w > COORD_MAX + 1e-9 || y + h > COORD_MAX + 1e-9)
|
|
62
|
+
return refuse();
|
|
63
|
+
return { ...base, type: v.type, x, y, w, h };
|
|
64
|
+
}
|
|
65
|
+
function stroke(v) {
|
|
66
|
+
if (!isRec(v))
|
|
67
|
+
return refuse();
|
|
68
|
+
const out = { points: path(v.points) };
|
|
69
|
+
if (v.color !== undefined) {
|
|
70
|
+
if (typeof v.color !== "string" || !STROKE_COLOR_RE.test(v.color))
|
|
71
|
+
return refuse();
|
|
72
|
+
out.color = v.color;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
function pin(v) {
|
|
77
|
+
if (!isRec(v))
|
|
78
|
+
return refuse();
|
|
79
|
+
const out = { x: coord(v.x), y: coord(v.y) };
|
|
80
|
+
if (v.label !== undefined)
|
|
81
|
+
out.label = text(v.label);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The width and height a PNG data URL's IHDR declares, or undefined when it is
|
|
86
|
+
* not a PNG data URL. Reads only the first 24 decoded bytes; never decodes the
|
|
87
|
+
* image.
|
|
88
|
+
*/
|
|
89
|
+
export function pngSize(dataUrl) {
|
|
90
|
+
if (!dataUrl.startsWith(PNG_PREFIX))
|
|
91
|
+
return undefined;
|
|
92
|
+
// 32 base64 characters decode to exactly the 24 header bytes.
|
|
93
|
+
const head = Buffer.from(dataUrl.slice(PNG_PREFIX.length, PNG_PREFIX.length + 32), "base64");
|
|
94
|
+
if (head.length < 24)
|
|
95
|
+
return undefined;
|
|
96
|
+
if (!PNG_SIGNATURE.every((b, i) => head[i] === b))
|
|
97
|
+
return undefined;
|
|
98
|
+
if (head.toString("latin1", 12, 16) !== "IHDR")
|
|
99
|
+
return undefined;
|
|
100
|
+
return { width: head.readUInt32BE(16), height: head.readUInt32BE(20) };
|
|
101
|
+
}
|
|
102
|
+
function frameInImage(v, screenshot) {
|
|
103
|
+
if (!isRec(v))
|
|
104
|
+
return refuse();
|
|
105
|
+
const { x, y, w, h } = v;
|
|
106
|
+
if (![x, y, w, h].every((n) => typeof n === "number" && Number.isInteger(n)))
|
|
107
|
+
return refuse();
|
|
108
|
+
const [fx, fy, fw, fh] = [x, y, w, h];
|
|
109
|
+
if (fx < 0 || fy < 0 || fw < 1 || fh < 1)
|
|
110
|
+
return refuse();
|
|
111
|
+
const image = pngSize(screenshot) ?? refuse();
|
|
112
|
+
if (fx + fw > image.width || fy + fh > image.height)
|
|
113
|
+
return refuse();
|
|
114
|
+
return { x: fx, y: fy, w: fw, h: fh };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Check an annotation's message and marks, and rebuild `spec` from the fields
|
|
118
|
+
* that passed. Assumes the caller already checked `message` and `screenshot` are
|
|
119
|
+
* strings.
|
|
120
|
+
*
|
|
121
|
+
* `spec` absent, `{}` and `{shapes: []}` are all in bounds. A field the relay
|
|
122
|
+
* does not know is not an error; it is simply not stored.
|
|
123
|
+
*/
|
|
124
|
+
export function boundedSpec(input) {
|
|
125
|
+
if (input.message.length > MAX_MESSAGE_CHARS)
|
|
126
|
+
return { ok: false };
|
|
127
|
+
const raw = input.spec;
|
|
128
|
+
if (raw === undefined)
|
|
129
|
+
return { ok: true };
|
|
130
|
+
try {
|
|
131
|
+
if (!isRec(raw))
|
|
132
|
+
return refuse();
|
|
133
|
+
const spec = {};
|
|
134
|
+
if (raw.shapes !== undefined)
|
|
135
|
+
spec.shapes = list(raw.shapes, MAX_SHAPES).map(shape);
|
|
136
|
+
if (raw.strokes !== undefined)
|
|
137
|
+
spec.strokes = list(raw.strokes, MAX_STROKES).map(stroke);
|
|
138
|
+
if (raw.pins !== undefined)
|
|
139
|
+
spec.pins = list(raw.pins, MAX_PINS).map(pin);
|
|
140
|
+
if (raw.labels !== undefined)
|
|
141
|
+
spec.labels = list(raw.labels, MAX_LABELS).map(text);
|
|
142
|
+
if (raw.note !== undefined)
|
|
143
|
+
spec.note = text(raw.note);
|
|
144
|
+
if (raw.frameInImage !== undefined)
|
|
145
|
+
spec.frameInImage = frameInImage(raw.frameInImage, input.screenshot);
|
|
146
|
+
return { ok: true, spec };
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
if (err instanceof Refused)
|
|
150
|
+
return { ok: false };
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Whether an annotation's message and marks are within bounds. */
|
|
155
|
+
export function isWithinBounds(input) {
|
|
156
|
+
return boundedSpec(input).ok;
|
|
157
|
+
}
|
|
@@ -50,8 +50,6 @@ export type RefusalArm = {
|
|
|
50
50
|
stillServing?: number;
|
|
51
51
|
} | {
|
|
52
52
|
kind: "preview_start";
|
|
53
|
-
devCommand: string;
|
|
54
|
-
localUrl: string;
|
|
55
53
|
};
|
|
56
54
|
/** Flow L22: the version refusal on any verb, in the arm that knows where it landed. */
|
|
57
55
|
export declare function upgradeRefusalLines(facts: UpgradeFacts, arm?: RefusalArm, own?: string): string[];
|
|
@@ -171,11 +171,10 @@ export function upgradeRefusalLines(facts, arm = { kind: "generic" }, own = clie
|
|
|
171
171
|
"",
|
|
172
172
|
remedy(variant),
|
|
173
173
|
];
|
|
174
|
+
// No local-loop paragraph on preview_start (B-F3-6 as amended): the remedy is
|
|
175
|
+
// the upgrade, and the screen ends on the command that leads back to a preview.
|
|
174
176
|
if (variant !== "not_received")
|
|
175
177
|
out.push(thenLine(arm));
|
|
176
|
-
if (arm.kind === "preview_start") {
|
|
177
|
-
out.push("", ` Your local dev loop does not need an upgrade. Run \`${arm.devCommand}\` and open`, ` ${arm.localUrl} on this machine. The camera works there.`);
|
|
178
|
-
}
|
|
179
178
|
out.push("");
|
|
180
179
|
return out;
|
|
181
180
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincentt-xr/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay)",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"qrcode": "^1.5.4",
|
|
29
29
|
"ws": "^8.18.0",
|
|
30
30
|
"zod": "^3.23.8",
|
|
31
|
-
"@vincentt-xr/harness": "3.
|
|
31
|
+
"@vincentt-xr/harness": "3.2.0"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/qrcode": "^1.5.5",
|