@vincentt-xr/cli 0.22.0 → 0.23.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 +11 -0
- package/dist/cli/args.js +2 -0
- package/dist/cli/index.js +27 -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/package.json +2 -2
package/MIGRATION.md
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
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.23.0
|
|
7
|
+
|
|
8
|
+
required: false
|
|
9
|
+
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 `Vincentt Editor.app` in `/Applications`, then `~/Applications`, 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
|
+
- 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
|
+
- `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
|
+
- `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
|
+
- 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 `~/.vincentt/editor-handoffs.json`, 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
|
+
|
|
6
17
|
## 0.22.0
|
|
7
18
|
|
|
8
19
|
required: false
|
package/dist/cli/args.js
CHANGED
package/dist/cli/index.js
CHANGED
|
@@ -1446,6 +1446,25 @@ async function domain() {
|
|
|
1446
1446
|
yes: has("--yes"),
|
|
1447
1447
|
});
|
|
1448
1448
|
}
|
|
1449
|
+
// ---- Vincentt Editor (f21) --------------------------------------------------
|
|
1450
|
+
/** Why `editor` exited 1, for the journal (one word from a closed set, B-F7-1). */
|
|
1451
|
+
let editorReason;
|
|
1452
|
+
/**
|
|
1453
|
+
* `vincentt editor` hands the project folder to the installed Vincentt Editor app and exits.
|
|
1454
|
+
* Needs no account and makes no network call.
|
|
1455
|
+
*/
|
|
1456
|
+
async function editor() {
|
|
1457
|
+
const { editorVerb } = await import("../editor/launcher.js");
|
|
1458
|
+
// The launcher's parser accepts `--dir=<path>`, so the folder it names is the one handed over.
|
|
1459
|
+
const dir = flag("--dir") ?? valueOfEqualsFlag(argv, "--dir");
|
|
1460
|
+
const run = await editorVerb(argv.slice(1), dir ? path.resolve(dir) : process.cwd());
|
|
1461
|
+
for (const l of run.stdout)
|
|
1462
|
+
console.log(l);
|
|
1463
|
+
for (const l of run.stderr)
|
|
1464
|
+
console.error(l);
|
|
1465
|
+
editorReason = run.reason;
|
|
1466
|
+
process.exitCode = run.exitCode;
|
|
1467
|
+
}
|
|
1449
1468
|
// ---- servers ----------------------------------------------------------------
|
|
1450
1469
|
async function relay() {
|
|
1451
1470
|
const { startRelay } = await import("../relay/server.js");
|
|
@@ -1549,6 +1568,8 @@ async function main() {
|
|
|
1549
1568
|
return outdated();
|
|
1550
1569
|
case "domain":
|
|
1551
1570
|
return domain();
|
|
1571
|
+
case "editor":
|
|
1572
|
+
return editor();
|
|
1552
1573
|
}
|
|
1553
1574
|
}
|
|
1554
1575
|
// Every invocation is journaled here, at the one site that sees all of them —
|
|
@@ -1556,6 +1577,8 @@ async function main() {
|
|
|
1556
1577
|
// command. Recording is best-effort and local-only (see dx/journal.ts); it never
|
|
1557
1578
|
// changes what the CLI does or what it exits with.
|
|
1558
1579
|
const startedAt = Date.now();
|
|
1580
|
+
/** Verbs that make no network request, footnotes included: their footnotes never refresh. */
|
|
1581
|
+
const NO_NETWORK_VERBS = new Set(["editor"]);
|
|
1559
1582
|
function journal(exitCode, error) {
|
|
1560
1583
|
recordInvocation({
|
|
1561
1584
|
argv,
|
|
@@ -1565,6 +1588,7 @@ function journal(exitCode, error) {
|
|
|
1565
1588
|
endedAt: Date.now(),
|
|
1566
1589
|
exitCode,
|
|
1567
1590
|
error,
|
|
1591
|
+
reason: editorReason,
|
|
1568
1592
|
});
|
|
1569
1593
|
// The hint goes out AFTER the run it is about, so it reads as a footnote to
|
|
1570
1594
|
// what just happened rather than a warning about what is about to. Never on
|
|
@@ -1597,7 +1621,7 @@ function journal(exitCode, error) {
|
|
|
1597
1621
|
for (const l of updateNotice(cliVersion(), cached?.latest))
|
|
1598
1622
|
console.error(l);
|
|
1599
1623
|
}
|
|
1600
|
-
if (!isFresh(cached, Date.now()))
|
|
1624
|
+
if (!isFresh(cached, Date.now()) && !NO_NETWORK_VERBS.has(verbOf(argv)))
|
|
1601
1625
|
refreshUpdateCache();
|
|
1602
1626
|
}
|
|
1603
1627
|
catch {
|
|
@@ -1642,7 +1666,8 @@ async function announceBehind() {
|
|
|
1642
1666
|
console.error(l);
|
|
1643
1667
|
}
|
|
1644
1668
|
}
|
|
1645
|
-
if (needsRefresh(loadReleaseCache(), Date.now())
|
|
1669
|
+
if (needsRefresh(loadReleaseCache(), Date.now()) &&
|
|
1670
|
+
!NO_NETWORK_VERBS.has(verbOf(argv))) {
|
|
1646
1671
|
refreshReleaseCache({
|
|
1647
1672
|
apiUrl: machineApiUrl(await loadMachineConfig()),
|
|
1648
1673
|
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>;
|