@vincentt-xr/cli 0.22.0 → 0.24.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 +23 -0
- package/dist/cli/args.js +2 -0
- package/dist/cli/index.js +32 -2
- package/dist/cli/usage-text.js +10 -1
- package/dist/cli/verb-surface.d.ts +2 -1
- package/dist/cli/verb-surface.js +5 -0
- package/dist/dx/journal.d.ts +3 -0
- package/dist/dx/journal.js +6 -1
- package/dist/dx/skeleton.js +3 -0
- package/dist/editor/app.d.ts +61 -0
- package/dist/editor/app.js +180 -0
- package/dist/editor/copy.d.ts +22 -0
- package/dist/editor/copy.js +95 -0
- package/dist/editor/handoffs.d.ts +12 -0
- package/dist/editor/handoffs.js +93 -0
- package/dist/editor/launcher.d.ts +86 -0
- package/dist/editor/launcher.js +255 -0
- package/dist/editor/localFiles.d.ts +31 -0
- package/dist/editor/localFiles.js +100 -0
- package/dist/editor/reads.d.ts +47 -0
- package/dist/editor/reads.js +269 -0
- package/dist/editor/text.d.ts +7 -0
- package/dist/editor/text.js +58 -0
- package/dist/editor/tools.d.ts +5 -0
- package/dist/editor/tools.js +20 -0
- package/dist/preview/copy.d.ts +7 -0
- package/dist/preview/copy.js +3 -0
- 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 +91 -8
- package/dist/relay/specBounds.d.ts +39 -0
- package/dist/relay/specBounds.js +157 -0
- package/package.json +2 -2
package/MIGRATION.md
CHANGED
|
@@ -3,6 +3,29 @@
|
|
|
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.24.0
|
|
7
|
+
|
|
8
|
+
required: false
|
|
9
|
+
No action required. `vincentt preview` accepts feedback from the console the CLI signs in through, and the relay now refuses marks outside fixed bounds.
|
|
10
|
+
- 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.
|
|
11
|
+
- The diagnostics socket, the diagnostics query and the feedback drain refuse the console origin with 403, like any other browser origin.
|
|
12
|
+
- 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.
|
|
13
|
+
- 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.
|
|
14
|
+
- 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.
|
|
15
|
+
- An annotation sent from the console carries no tester.
|
|
16
|
+
- 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>`.
|
|
17
|
+
|
|
18
|
+
## 0.23.0
|
|
19
|
+
|
|
20
|
+
required: false
|
|
21
|
+
No action required. New verb `vincentt editor`, which hands the project to the Vincentt Editor desktop app.
|
|
22
|
+
- `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.
|
|
23
|
+
- 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.
|
|
24
|
+
- `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.
|
|
25
|
+
- `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.
|
|
26
|
+
- 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.
|
|
27
|
+
- 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.
|
|
28
|
+
|
|
6
29
|
## 0.22.0
|
|
7
30
|
|
|
8
31
|
required: false
|
package/dist/cli/args.js
CHANGED
package/dist/cli/index.js
CHANGED
|
@@ -660,6 +660,7 @@ async function preview() {
|
|
|
660
660
|
appPort: devPort,
|
|
661
661
|
appPortFresh: devPortFresh,
|
|
662
662
|
devCommand,
|
|
663
|
+
consoleUrl: (await resolveAuthUrls()).consoleUrl,
|
|
663
664
|
onHiddenPath: (p) => ui.hiddenPath(p),
|
|
664
665
|
appStdio: has("--verbose") ? "inherit" : "ignore",
|
|
665
666
|
onViewer: (label, uaClass) => ui.viewerSeen(label, uaClass),
|
|
@@ -727,6 +728,10 @@ async function preview() {
|
|
|
727
728
|
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
|
})) {
|
|
@@ -1446,6 +1451,25 @@ async function domain() {
|
|
|
1446
1451
|
yes: has("--yes"),
|
|
1447
1452
|
});
|
|
1448
1453
|
}
|
|
1454
|
+
// ---- Vincentt Editor (f21) --------------------------------------------------
|
|
1455
|
+
/** Why `editor` exited 1, for the journal (one word from a closed set, B-F7-1). */
|
|
1456
|
+
let editorReason;
|
|
1457
|
+
/**
|
|
1458
|
+
* `vincentt editor` hands the project folder to the installed Vincentt Editor app and exits.
|
|
1459
|
+
* Needs no account and makes no network call.
|
|
1460
|
+
*/
|
|
1461
|
+
async function editor() {
|
|
1462
|
+
const { editorVerb } = await import("../editor/launcher.js");
|
|
1463
|
+
// The launcher's parser accepts `--dir=<path>`, so the folder it names is the one handed over.
|
|
1464
|
+
const dir = flag("--dir") ?? valueOfEqualsFlag(argv, "--dir");
|
|
1465
|
+
const run = await editorVerb(argv.slice(1), dir ? path.resolve(dir) : process.cwd());
|
|
1466
|
+
for (const l of run.stdout)
|
|
1467
|
+
console.log(l);
|
|
1468
|
+
for (const l of run.stderr)
|
|
1469
|
+
console.error(l);
|
|
1470
|
+
editorReason = run.reason;
|
|
1471
|
+
process.exitCode = run.exitCode;
|
|
1472
|
+
}
|
|
1449
1473
|
// ---- servers ----------------------------------------------------------------
|
|
1450
1474
|
async function relay() {
|
|
1451
1475
|
const { startRelay } = await import("../relay/server.js");
|
|
@@ -1549,6 +1573,8 @@ async function main() {
|
|
|
1549
1573
|
return outdated();
|
|
1550
1574
|
case "domain":
|
|
1551
1575
|
return domain();
|
|
1576
|
+
case "editor":
|
|
1577
|
+
return editor();
|
|
1552
1578
|
}
|
|
1553
1579
|
}
|
|
1554
1580
|
// Every invocation is journaled here, at the one site that sees all of them —
|
|
@@ -1556,6 +1582,8 @@ async function main() {
|
|
|
1556
1582
|
// command. Recording is best-effort and local-only (see dx/journal.ts); it never
|
|
1557
1583
|
// changes what the CLI does or what it exits with.
|
|
1558
1584
|
const startedAt = Date.now();
|
|
1585
|
+
/** Verbs that make no network request, footnotes included: their footnotes never refresh. */
|
|
1586
|
+
const NO_NETWORK_VERBS = new Set(["editor"]);
|
|
1559
1587
|
function journal(exitCode, error) {
|
|
1560
1588
|
recordInvocation({
|
|
1561
1589
|
argv,
|
|
@@ -1565,6 +1593,7 @@ function journal(exitCode, error) {
|
|
|
1565
1593
|
endedAt: Date.now(),
|
|
1566
1594
|
exitCode,
|
|
1567
1595
|
error,
|
|
1596
|
+
reason: editorReason,
|
|
1568
1597
|
});
|
|
1569
1598
|
// The hint goes out AFTER the run it is about, so it reads as a footnote to
|
|
1570
1599
|
// what just happened rather than a warning about what is about to. Never on
|
|
@@ -1597,7 +1626,7 @@ function journal(exitCode, error) {
|
|
|
1597
1626
|
for (const l of updateNotice(cliVersion(), cached?.latest))
|
|
1598
1627
|
console.error(l);
|
|
1599
1628
|
}
|
|
1600
|
-
if (!isFresh(cached, Date.now()))
|
|
1629
|
+
if (!isFresh(cached, Date.now()) && !NO_NETWORK_VERBS.has(verbOf(argv)))
|
|
1601
1630
|
refreshUpdateCache();
|
|
1602
1631
|
}
|
|
1603
1632
|
catch {
|
|
@@ -1642,7 +1671,8 @@ async function announceBehind() {
|
|
|
1642
1671
|
console.error(l);
|
|
1643
1672
|
}
|
|
1644
1673
|
}
|
|
1645
|
-
if (needsRefresh(loadReleaseCache(), Date.now())
|
|
1674
|
+
if (needsRefresh(loadReleaseCache(), Date.now()) &&
|
|
1675
|
+
!NO_NETWORK_VERBS.has(verbOf(argv))) {
|
|
1646
1676
|
refreshReleaseCache({
|
|
1647
1677
|
apiUrl: machineApiUrl(await loadMachineConfig()),
|
|
1648
1678
|
now: () => Date.now(),
|
package/dist/cli/usage-text.js
CHANGED
|
@@ -60,7 +60,16 @@ export const USAGE_TEXT = `Vincentt CLI: the local-first AR dev loop\n\n` +
|
|
|
60
60
|
` [--no-qr] [--stop]\n` +
|
|
61
61
|
` Runs dev.command and forwards dev.port from vincentt.json,\n` +
|
|
62
62
|
` else npm run dev on a free port (passed as PORT).\n` +
|
|
63
|
-
` Re-running prints the live link; --restart mints a new one.\n
|
|
63
|
+
` Re-running prints the live link; --restart mints a new one.\n` +
|
|
64
|
+
` editor Hand this project to the Vincentt Editor app (macOS 14 or\n` +
|
|
65
|
+
` later) and exit. Opens only the app in /Applications or\n` +
|
|
66
|
+
` ~/Applications, signed by Vincentt. No account needed.\n` +
|
|
67
|
+
` [--json] one vincentt.editor-open/1 object; handedOff says\n` +
|
|
68
|
+
` the folder reached the app, not that it opened.\n` +
|
|
69
|
+
` editor --selection The object selected in the editor, with its source\n` +
|
|
70
|
+
` location and values [--json]\n` +
|
|
71
|
+
` editor --status --json The editor's state for this folder, including\n` +
|
|
72
|
+
` app.lastOpen. Both reads exit 1 when no editor runs here.\n\n` +
|
|
64
73
|
// f16. HELP TEACHES THE CANONICAL VERBS ONLY: `add` and `remove` are accepted
|
|
65
74
|
// as silent aliases and are deliberately ABSENT here, because help, docs and
|
|
66
75
|
// every error string are where the vocabulary is actually taught. Listing the
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const PINNED_VERBS: readonly ["version", "login", "logout", "whoami", "init", "create", "link", "unlink", "publish", "preview", "logs", "network", "trace", "feedback", "relay", "assets", "dx", "projects", "report", "outdated", "domain"];
|
|
1
|
+
export declare const PINNED_VERBS: readonly ["version", "login", "logout", "whoami", "init", "create", "link", "unlink", "publish", "preview", "logs", "network", "trace", "feedback", "relay", "assets", "dx", "projects", "report", "outdated", "domain", "editor"];
|
|
2
2
|
export type PinnedVerb = (typeof PINNED_VERBS)[number];
|
|
3
3
|
export declare const VERB_HANDLERS: {
|
|
4
4
|
readonly version: true;
|
|
@@ -22,6 +22,7 @@ export declare const VERB_HANDLERS: {
|
|
|
22
22
|
readonly report: true;
|
|
23
23
|
readonly outdated: true;
|
|
24
24
|
readonly domain: true;
|
|
25
|
+
readonly editor: true;
|
|
25
26
|
};
|
|
26
27
|
export type DispatchVerb = keyof typeof VERB_HANDLERS;
|
|
27
28
|
export declare function resolveVerb(cmd: string | undefined): DispatchVerb | undefined;
|
package/dist/cli/verb-surface.js
CHANGED
|
@@ -26,6 +26,10 @@ export const PINNED_VERBS = [
|
|
|
26
26
|
// collection verb. `domains` is routed to an answer, not accepted as a verb —
|
|
27
27
|
// see domainVerbRedirect. An addition, so B-F3-8 permits it.
|
|
28
28
|
"domain",
|
|
29
|
+
// Appended (f21): the Vincentt Editor launcher. `editor`, not `edit`: an agent
|
|
30
|
+
// reading "vincentt edit" would take it as a command that changes files. An
|
|
31
|
+
// addition, so B-F3-8 permits it.
|
|
32
|
+
"editor",
|
|
29
33
|
];
|
|
30
34
|
// The dispatch's verb set, as data. `main()` switches over this, so the two can
|
|
31
35
|
// never disagree: a verb added to the CLI is a key added here, and QA-AHG-01
|
|
@@ -52,6 +56,7 @@ export const VERB_HANDLERS = {
|
|
|
52
56
|
report: true,
|
|
53
57
|
outdated: true,
|
|
54
58
|
domain: true,
|
|
59
|
+
editor: true,
|
|
55
60
|
};
|
|
56
61
|
// Answers "is this a verb this CLI accepts" without running it. The
|
|
57
62
|
// console-redirect strings are NOT accepted here: they reach their explanation
|
package/dist/dx/journal.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface JournalEntry {
|
|
|
12
12
|
err: string | null;
|
|
13
13
|
/** Hash of the working tree, so runs group by project without recording where it is. */
|
|
14
14
|
wt: string;
|
|
15
|
+
/** `editor` only: why it exited 1, one word from a closed set. Never a path or a value. */
|
|
16
|
+
reason?: string;
|
|
15
17
|
}
|
|
16
18
|
/**
|
|
17
19
|
* Beside the machine config, so the journal shares the home the PAT already
|
|
@@ -38,6 +40,7 @@ export interface RecordInput {
|
|
|
38
40
|
endedAt: number;
|
|
39
41
|
exitCode: number;
|
|
40
42
|
error?: unknown;
|
|
43
|
+
reason?: string;
|
|
41
44
|
}
|
|
42
45
|
export declare function buildEntry(input: RecordInput): JournalEntry;
|
|
43
46
|
/**
|
package/dist/dx/journal.js
CHANGED
|
@@ -68,8 +68,10 @@ function trim(file) {
|
|
|
68
68
|
return;
|
|
69
69
|
fs.writeFileSync(file, lines.slice(-MAX_ENTRIES).join("\n") + "\n", "utf8");
|
|
70
70
|
}
|
|
71
|
+
/** Only a lowercase word survives as a reason, so no caller can route a value through it. */
|
|
72
|
+
const REASON = /^[a-z_]{1,40}$/;
|
|
71
73
|
export function buildEntry(input) {
|
|
72
|
-
|
|
74
|
+
const entry = {
|
|
73
75
|
ts: new Date(input.startedAt).toISOString(),
|
|
74
76
|
v: input.cliVersion,
|
|
75
77
|
verb: verbOf(input.argv),
|
|
@@ -79,6 +81,9 @@ export function buildEntry(input) {
|
|
|
79
81
|
err: classify(input.error),
|
|
80
82
|
wt: workingTreeId(input.cwd),
|
|
81
83
|
};
|
|
84
|
+
if (input.reason !== undefined && REASON.test(input.reason))
|
|
85
|
+
entry.reason = input.reason;
|
|
86
|
+
return entry;
|
|
82
87
|
}
|
|
83
88
|
/**
|
|
84
89
|
* Append one entry. Never throws: a journal that can break a publish is a
|
package/dist/dx/skeleton.js
CHANGED
|
@@ -39,6 +39,7 @@ const KNOWN_VERBS = new Set([
|
|
|
39
39
|
"create",
|
|
40
40
|
"domain",
|
|
41
41
|
"dx",
|
|
42
|
+
"editor",
|
|
42
43
|
"feedback",
|
|
43
44
|
"init",
|
|
44
45
|
"link",
|
|
@@ -82,10 +83,12 @@ const KNOWN_FLAGS = new Set([
|
|
|
82
83
|
"--port",
|
|
83
84
|
"--preview",
|
|
84
85
|
"--restart",
|
|
86
|
+
"--selection",
|
|
85
87
|
"--send",
|
|
86
88
|
"--session",
|
|
87
89
|
"--since",
|
|
88
90
|
"--slug",
|
|
91
|
+
"--status",
|
|
89
92
|
"--stop",
|
|
90
93
|
"--template",
|
|
91
94
|
"--tester",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export declare const EDITOR_BUNDLE_NAME = "Vincentt Editor.app";
|
|
2
|
+
export declare const EDITOR_BUNDLE_ID = "studio.vincentt.editor";
|
|
3
|
+
export declare const MIN_MACOS_MAJOR = 14;
|
|
4
|
+
/** The Apple Team ID of the Developer ID that signs Vincentt Editor. */
|
|
5
|
+
export declare const VINCENTT_TEAM_ID = "RB8P2M86YU";
|
|
6
|
+
/**
|
|
7
|
+
* The designated requirement: Apple-anchored, a Developer ID leaf (1.2.840.113635.100.6.1.13)
|
|
8
|
+
* under the Developer ID intermediate (1.2.840.113635.100.6.2.6), the leaf's OU the Team ID,
|
|
9
|
+
* and the bundle identifier.
|
|
10
|
+
*/
|
|
11
|
+
export declare function designatedRequirement(teamId: string, identifier: string): string;
|
|
12
|
+
export declare const EDITOR_REQUIREMENT: string;
|
|
13
|
+
/** `/Applications`, then the user's own `~/Applications` (from the user database, not HOME). */
|
|
14
|
+
export declare function defaultSearchRoots(): string[];
|
|
15
|
+
export type Verdict = {
|
|
16
|
+
ok: true;
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
detail: string;
|
|
20
|
+
};
|
|
21
|
+
export interface RunningCopy {
|
|
22
|
+
pid: number;
|
|
23
|
+
bundlePath: string;
|
|
24
|
+
}
|
|
25
|
+
/** The OS questions the launcher and the read flags ask. Tests replace any of them. */
|
|
26
|
+
export interface MacSystem {
|
|
27
|
+
/** The macOS product version (`14.5`), or undefined when unreadable. */
|
|
28
|
+
osVersion(): Promise<string | undefined>;
|
|
29
|
+
/** `codesign --verify --deep --strict -R=<requirement> <app>`. */
|
|
30
|
+
verify(app: string, requirement: string): Promise<Verdict>;
|
|
31
|
+
/** Info.plist values of a bundle; read only after the bundle passed `verify`. */
|
|
32
|
+
bundleInfo(app: string): Promise<{
|
|
33
|
+
executable?: string;
|
|
34
|
+
version?: string;
|
|
35
|
+
}>;
|
|
36
|
+
/** Running apps registered with LaunchServices under this bundle identifier. */
|
|
37
|
+
runningCopies(bundleId: string): Promise<RunningCopy[]>;
|
|
38
|
+
/** `codesign --verify -R=<requirement> <pid>`: the running code of `pid`, as the kernel holds it. */
|
|
39
|
+
verifyPid(pid: number, requirement: string): Promise<Verdict>;
|
|
40
|
+
/** When `pid` started (epoch ms, whole seconds), or undefined when it cannot be read. */
|
|
41
|
+
startTime(pid: number): Promise<number | undefined>;
|
|
42
|
+
/** `/usr/bin/open -a <app> <folder>`; rejects when `open` fails. */
|
|
43
|
+
open(app: string, folder: string): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
/** codesign's last stderr line, without the leading `<path>: ` it names the bundle with. */
|
|
46
|
+
export declare function codesignDetail(stderr: string, app: string): string;
|
|
47
|
+
/** `lsappinfo find` output → its ASNs. */
|
|
48
|
+
export declare function parseAsns(out: string): string[];
|
|
49
|
+
/** `lsappinfo info -only pid -only bundlepath <asn>` output → the copy it names. */
|
|
50
|
+
export declare function parseAppInfo(out: string): RunningCopy | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* `ps -o lstart=` run with LC_ALL=C and TZ=UTC0 (`Thu Oct 23 00:15:02 2026`, UTC) → epoch ms.
|
|
53
|
+
* Parsed as UTC, so the result does not depend on this process's time zone.
|
|
54
|
+
*/
|
|
55
|
+
export declare function parseLstart(out: string): number | undefined;
|
|
56
|
+
/** `14.5` → 14, `13.6.1` → 13; undefined when it is not a version. */
|
|
57
|
+
export declare function macosMajor(version: string | undefined): number | undefined;
|
|
58
|
+
export declare function macSystem(): MacSystem;
|
|
59
|
+
export declare function pidAlive(pid: number): boolean;
|
|
60
|
+
/** Both paths with links resolved, compared; a path that cannot be resolved compares as given. */
|
|
61
|
+
export declare function samePath(a: string, b: string): Promise<boolean>;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// The installed Vincentt Editor app as the launcher sees it: where it may live, the designated
|
|
2
|
+
// requirement it must meet, and the macOS probes that answer questions about it. Every probe
|
|
3
|
+
// runs a system tool by absolute path; none reads the environment or the project.
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { realpath } from "node:fs/promises";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { toolPath } from "./tools.js";
|
|
10
|
+
const execFileP = promisify(execFile);
|
|
11
|
+
export const EDITOR_BUNDLE_NAME = "Vincentt Editor.app";
|
|
12
|
+
export const EDITOR_BUNDLE_ID = "studio.vincentt.editor";
|
|
13
|
+
export const MIN_MACOS_MAJOR = 14;
|
|
14
|
+
/** The Apple Team ID of the Developer ID that signs Vincentt Editor. */
|
|
15
|
+
export const VINCENTT_TEAM_ID = "RB8P2M86YU";
|
|
16
|
+
/**
|
|
17
|
+
* The designated requirement: Apple-anchored, a Developer ID leaf (1.2.840.113635.100.6.1.13)
|
|
18
|
+
* under the Developer ID intermediate (1.2.840.113635.100.6.2.6), the leaf's OU the Team ID,
|
|
19
|
+
* and the bundle identifier.
|
|
20
|
+
*/
|
|
21
|
+
export function designatedRequirement(teamId, identifier) {
|
|
22
|
+
return ("anchor apple generic" +
|
|
23
|
+
" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists" +
|
|
24
|
+
" and certificate 1[field.1.2.840.113635.100.6.2.6] exists" +
|
|
25
|
+
` and certificate leaf[subject.OU] = "${teamId}"` +
|
|
26
|
+
` and identifier "${identifier}"`);
|
|
27
|
+
}
|
|
28
|
+
export const EDITOR_REQUIREMENT = designatedRequirement(VINCENTT_TEAM_ID, EDITOR_BUNDLE_ID);
|
|
29
|
+
/** `/Applications`, then the user's own `~/Applications` (from the user database, not HOME). */
|
|
30
|
+
export function defaultSearchRoots() {
|
|
31
|
+
return ["/Applications", path.join(os.userInfo().homedir, "Applications")];
|
|
32
|
+
}
|
|
33
|
+
const RUN = {
|
|
34
|
+
timeout: 30_000,
|
|
35
|
+
maxBuffer: 4 << 20,
|
|
36
|
+
env: { LC_ALL: "C", PATH: "/usr/bin:/bin" },
|
|
37
|
+
};
|
|
38
|
+
async function run(tool, args, env = RUN.env) {
|
|
39
|
+
const cmd = toolPath(tool);
|
|
40
|
+
if (cmd === undefined)
|
|
41
|
+
throw new Error(`${tool} is not where macOS keeps it`);
|
|
42
|
+
return (await execFileP(cmd, args, { ...RUN, env })).stdout;
|
|
43
|
+
}
|
|
44
|
+
/** codesign's last stderr line, without the leading `<path>: ` it names the bundle with. */
|
|
45
|
+
export function codesignDetail(stderr, app) {
|
|
46
|
+
const lines = stderr
|
|
47
|
+
.split("\n")
|
|
48
|
+
.map((l) => l.trim())
|
|
49
|
+
.filter((l) => l !== "");
|
|
50
|
+
const last = lines[lines.length - 1] ?? "codesign refused the app";
|
|
51
|
+
return last.startsWith(`${app}: `) ? last.slice(app.length + 2) : last;
|
|
52
|
+
}
|
|
53
|
+
/** `lsappinfo find` output → its ASNs. */
|
|
54
|
+
export function parseAsns(out) {
|
|
55
|
+
return [...out.matchAll(/ASN:0x[0-9a-f]+-0x[0-9a-f]+/gi)].map((m) => m[0]);
|
|
56
|
+
}
|
|
57
|
+
/** `lsappinfo info -only pid -only bundlepath <asn>` output → the copy it names. */
|
|
58
|
+
export function parseAppInfo(out) {
|
|
59
|
+
const pid = /"pid"\s*=\s*(\d+)/.exec(out)?.[1];
|
|
60
|
+
const bundle = /"LSBundlePath"\s*=\s*"(.*)"\s*$/m.exec(out)?.[1];
|
|
61
|
+
if (pid === undefined || bundle === undefined || bundle === "")
|
|
62
|
+
return undefined;
|
|
63
|
+
return { pid: Number(pid), bundlePath: bundle };
|
|
64
|
+
}
|
|
65
|
+
const MONTHS = [
|
|
66
|
+
"Jan",
|
|
67
|
+
"Feb",
|
|
68
|
+
"Mar",
|
|
69
|
+
"Apr",
|
|
70
|
+
"May",
|
|
71
|
+
"Jun",
|
|
72
|
+
"Jul",
|
|
73
|
+
"Aug",
|
|
74
|
+
"Sep",
|
|
75
|
+
"Oct",
|
|
76
|
+
"Nov",
|
|
77
|
+
"Dec",
|
|
78
|
+
];
|
|
79
|
+
/**
|
|
80
|
+
* `ps -o lstart=` run with LC_ALL=C and TZ=UTC0 (`Thu Oct 23 00:15:02 2026`, UTC) → epoch ms.
|
|
81
|
+
* Parsed as UTC, so the result does not depend on this process's time zone.
|
|
82
|
+
*/
|
|
83
|
+
export function parseLstart(out) {
|
|
84
|
+
const m = /^\w{3}\s+(\w{3})\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})$/.exec(out.trim());
|
|
85
|
+
if (m === null)
|
|
86
|
+
return undefined;
|
|
87
|
+
const month = MONTHS.indexOf(m[1]);
|
|
88
|
+
if (month < 0)
|
|
89
|
+
return undefined;
|
|
90
|
+
const t = Date.UTC(Number(m[6]), month, Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5]));
|
|
91
|
+
return Number.isFinite(t) ? t : undefined;
|
|
92
|
+
}
|
|
93
|
+
/** `14.5` → 14, `13.6.1` → 13; undefined when it is not a version. */
|
|
94
|
+
export function macosMajor(version) {
|
|
95
|
+
const m = /^(\d+)(?:\.\d+)*$/.exec(version?.trim() ?? "");
|
|
96
|
+
return m ? Number(m[1]) : undefined;
|
|
97
|
+
}
|
|
98
|
+
/** `codesign <args>`: ok on exit 0, else its last stderr line (with `subject: ` removed). */
|
|
99
|
+
async function codesignVerify(args, subject) {
|
|
100
|
+
const cmd = toolPath("codesign");
|
|
101
|
+
if (cmd === undefined)
|
|
102
|
+
return { ok: false, detail: "codesign is not where macOS keeps it" };
|
|
103
|
+
try {
|
|
104
|
+
await execFileP(cmd, args, RUN);
|
|
105
|
+
return { ok: true };
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
const stderr = err.stderr;
|
|
109
|
+
return { ok: false, detail: codesignDetail(stderr ?? String(err), subject) };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export function macSystem() {
|
|
113
|
+
return {
|
|
114
|
+
osVersion: async () => {
|
|
115
|
+
const v = await run("sw_vers", ["-productVersion"]).catch(() => "");
|
|
116
|
+
return v.trim() || undefined;
|
|
117
|
+
},
|
|
118
|
+
verify: (app, requirement) => codesignVerify(["--verify", "--deep", "--strict", `-R=${requirement}`, app], app),
|
|
119
|
+
verifyPid: (pid, requirement) => Number.isInteger(pid) && pid > 0
|
|
120
|
+
? codesignVerify(["--verify", `-R=${requirement}`, String(pid)], String(pid))
|
|
121
|
+
: Promise.resolve({ ok: false, detail: "not a process id" }),
|
|
122
|
+
bundleInfo: async (app) => {
|
|
123
|
+
const plist = path.join(app, "Contents", "Info.plist");
|
|
124
|
+
const read = async (key) => (await run("plutil", ["-extract", key, "raw", "-o", "-", plist]).catch(() => "")).trim() || undefined;
|
|
125
|
+
return {
|
|
126
|
+
executable: await read("CFBundleExecutable"),
|
|
127
|
+
version: await read("CFBundleShortVersionString"),
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
runningCopies: async (bundleId) => {
|
|
131
|
+
// Throws when the running copies cannot be listed: an unknown answer is not "none".
|
|
132
|
+
const found = await run("lsappinfo", ["find", `bundleID=${bundleId}`]);
|
|
133
|
+
const asns = parseAsns(found);
|
|
134
|
+
if (asns.length === 0 && found.trim() !== "")
|
|
135
|
+
throw new Error("lsappinfo find answered without an ASN");
|
|
136
|
+
const copies = [];
|
|
137
|
+
for (const asn of asns) {
|
|
138
|
+
const info = await run("lsappinfo", [
|
|
139
|
+
"info",
|
|
140
|
+
"-only",
|
|
141
|
+
"pid",
|
|
142
|
+
"-only",
|
|
143
|
+
"bundlepath",
|
|
144
|
+
asn,
|
|
145
|
+
]);
|
|
146
|
+
const copy = parseAppInfo(info);
|
|
147
|
+
if (copy === undefined)
|
|
148
|
+
throw new Error("lsappinfo info named no pid and bundle");
|
|
149
|
+
// LaunchServices can list an app for a moment after it exits.
|
|
150
|
+
if (pidAlive(copy.pid))
|
|
151
|
+
copies.push(copy);
|
|
152
|
+
}
|
|
153
|
+
return copies;
|
|
154
|
+
},
|
|
155
|
+
startTime: async (pid) => parseLstart(await run("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
156
|
+
...RUN.env,
|
|
157
|
+
TZ: "UTC0",
|
|
158
|
+
}).catch(() => "")),
|
|
159
|
+
open: async (app, folder) => {
|
|
160
|
+
await run("open", ["-a", app, folder]);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
export function pidAlive(pid) {
|
|
165
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
166
|
+
return false;
|
|
167
|
+
try {
|
|
168
|
+
process.kill(pid, 0);
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
return err.code === "EPERM";
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Both paths with links resolved, compared; a path that cannot be resolved compares as given. */
|
|
176
|
+
export async function samePath(a, b) {
|
|
177
|
+
const ra = await realpath(a).catch(() => path.resolve(a));
|
|
178
|
+
const rb = await realpath(b).catch(() => path.resolve(b));
|
|
179
|
+
return ra === rb;
|
|
180
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const DOWNLOAD_URL = "https://vincentt.studio/editor";
|
|
2
|
+
export declare const NOTHING_OPENED = "Nothing was opened.";
|
|
3
|
+
export declare const NOT_RUNNING = "\u2717 No editor is running for this folder.";
|
|
4
|
+
/** `~/Downloads/Vincentt Editor.app` for a path under the home folder. */
|
|
5
|
+
export declare function tildePath(p: string, home?: string): string;
|
|
6
|
+
export declare function sent(name: string): string;
|
|
7
|
+
export declare function notInstalled(): string[];
|
|
8
|
+
/** `app` is the refused bundle; the copy names the folder it was found in. */
|
|
9
|
+
export declare function badSignature(app: string): string[];
|
|
10
|
+
/** The copy on disk passes, but the one running from the same place does not. */
|
|
11
|
+
export declare function runningCopyNotSigned(): string[];
|
|
12
|
+
export declare function notMacos(): string[];
|
|
13
|
+
export declare function macosTooOld(version: string): string[];
|
|
14
|
+
export declare function otherCopyRunning(bundlePath: string): string[];
|
|
15
|
+
/** A flag the launcher had before it handed off to the app (`--port`, `--dev-url`). */
|
|
16
|
+
export declare function removedFlag(flag: string): string[];
|
|
17
|
+
export declare function unknownFlag(flag: string): string[];
|
|
18
|
+
export declare function strayArgument(): string[];
|
|
19
|
+
export declare function bothReads(): string[];
|
|
20
|
+
export declare function openFailed(): string[];
|
|
21
|
+
/** A local file written in a schema major this CLI does not know. */
|
|
22
|
+
export declare function tooNew(schema: string): string[];
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// The `vincentt editor` screens (features/f21-visual-editor/mock/cli.md, C1 to C3). After a
|
|
2
|
+
// non-zero exit the last line states what did not happen.
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { agentSafe } from "./text.js";
|
|
6
|
+
export const DOWNLOAD_URL = "https://vincentt.studio/editor";
|
|
7
|
+
export const NOTHING_OPENED = "Nothing was opened.";
|
|
8
|
+
export const NOT_RUNNING = "✗ No editor is running for this folder.";
|
|
9
|
+
/** `~/Downloads/Vincentt Editor.app` for a path under the home folder. */
|
|
10
|
+
export function tildePath(p, home = os.homedir()) {
|
|
11
|
+
const safe = agentSafe(p, 400);
|
|
12
|
+
if (home && (safe === home || safe.startsWith(home + "/"))) {
|
|
13
|
+
return "~" + safe.slice(home.length);
|
|
14
|
+
}
|
|
15
|
+
return safe;
|
|
16
|
+
}
|
|
17
|
+
export function sent(name) {
|
|
18
|
+
return `✓ Sent ${agentSafe(name, 80)} to Vincentt Editor.`;
|
|
19
|
+
}
|
|
20
|
+
export function notInstalled() {
|
|
21
|
+
return [
|
|
22
|
+
`✗ Vincentt Editor is not installed. Get it at ${DOWNLOAD_URL}`,
|
|
23
|
+
NOTHING_OPENED,
|
|
24
|
+
];
|
|
25
|
+
}
|
|
26
|
+
/** `app` is the refused bundle; the copy names the folder it was found in. */
|
|
27
|
+
export function badSignature(app) {
|
|
28
|
+
return [
|
|
29
|
+
`✗ The Vincentt Editor in ${tildePath(path.dirname(app))} is not signed by Vincentt, so the CLI will not open it.`,
|
|
30
|
+
` Delete it and download it again from ${DOWNLOAD_URL}`,
|
|
31
|
+
NOTHING_OPENED,
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
/** The copy on disk passes, but the one running from the same place does not. */
|
|
35
|
+
export function runningCopyNotSigned() {
|
|
36
|
+
return [
|
|
37
|
+
"✗ The Vincentt Editor that is running is not signed by Vincentt, so the CLI will not open it.",
|
|
38
|
+
" Quit the running Vincentt Editor, then run vincentt editor again.",
|
|
39
|
+
NOTHING_OPENED,
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
export function notMacos() {
|
|
43
|
+
return ["✗ Vincentt Editor runs on macOS only for now.", NOTHING_OPENED];
|
|
44
|
+
}
|
|
45
|
+
export function macosTooOld(version) {
|
|
46
|
+
return [
|
|
47
|
+
`✗ Vincentt Editor needs macOS 14 or later. This Mac has macOS ${agentSafe(version, 20)}.`,
|
|
48
|
+
NOTHING_OPENED,
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
export function otherCopyRunning(bundlePath) {
|
|
52
|
+
return [
|
|
53
|
+
`✗ Another Vincentt Editor is running from ${tildePath(bundlePath)}.`,
|
|
54
|
+
" Quit the other Vincentt Editor first, then run vincentt editor again.",
|
|
55
|
+
NOTHING_OPENED,
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
/** A flag the launcher had before it handed off to the app (`--port`, `--dev-url`). */
|
|
59
|
+
export function removedFlag(flag) {
|
|
60
|
+
return [
|
|
61
|
+
`✗ ${flag} was removed. Vincentt Editor starts the preview itself.`,
|
|
62
|
+
" Run: vincentt editor",
|
|
63
|
+
NOTHING_OPENED,
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
export function unknownFlag(flag) {
|
|
67
|
+
return [
|
|
68
|
+
`✗ vincentt editor has no option ${agentSafe(flag, 40)}.`,
|
|
69
|
+
" Run: vincentt editor (or --selection --json, or --status --json)",
|
|
70
|
+
NOTHING_OPENED,
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
export function strayArgument() {
|
|
74
|
+
return [
|
|
75
|
+
"✗ vincentt editor takes no folder name. Run it inside the project, or pass --dir <folder>.",
|
|
76
|
+
NOTHING_OPENED,
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
export function bothReads() {
|
|
80
|
+
return ["✗ --selection and --status cannot be used together. Run one, then the other."];
|
|
81
|
+
}
|
|
82
|
+
export function openFailed() {
|
|
83
|
+
return [
|
|
84
|
+
"✗ macOS could not hand the folder to Vincentt Editor.",
|
|
85
|
+
" Open Vincentt Editor from Applications, then run vincentt editor again.",
|
|
86
|
+
NOTHING_OPENED,
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
/** A local file written in a schema major this CLI does not know. */
|
|
90
|
+
export function tooNew(schema) {
|
|
91
|
+
return [
|
|
92
|
+
`✗ Vincentt Editor wrote ${agentSafe(schema, 80)}, which is newer than this CLI reads.`,
|
|
93
|
+
" Update the CLI: npm install -g @vincentt-xr/cli@latest",
|
|
94
|
+
];
|
|
95
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Hand-off time (epoch ms) by project key. */
|
|
2
|
+
export type Handoffs = Record<string, number>;
|
|
3
|
+
/** Resolved per call: the home folder is the seam tests move. */
|
|
4
|
+
export declare function handoffsPath(): string;
|
|
5
|
+
/** The key a canonical project path is recorded under. */
|
|
6
|
+
export declare function projectKey(project: string): string;
|
|
7
|
+
/** Every recorded hand-off; empty when the file is absent, a link or unreadable. */
|
|
8
|
+
export declare function readHandoffs(file?: string): Promise<Handoffs>;
|
|
9
|
+
/** The last hand-off of `project` (a canonical path), or undefined when there was none. */
|
|
10
|
+
export declare function lastHandoff(project: string, file?: string): Promise<number | undefined>;
|
|
11
|
+
/** Records a hand-off of `project`, keeping the newest entries. Best-effort: never throws. */
|
|
12
|
+
export declare function recordHandoff(project: string, at: number, file?: string): Promise<void>;
|