@vecteur/cli 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,16 @@ calls the OPE, engine or database directly.
6
6
 
7
7
  ```bash
8
8
  npm install -g @vecteur/cli
9
+ vecteur
10
+ ```
11
+
12
+ `vecteur` opens the session, signs in through your browser when needed, and finds the Project
13
+ linked to this folder. A new folder creates its Project only when you send your first ask.
14
+ No `init` command or file in the working directory is required.
15
+
16
+ For scripts and explicit one-shot commands:
17
+
18
+ ```bash
9
19
  vecteur login
10
20
  vecteur whoami
11
21
  vecteur project list
@@ -57,3 +67,118 @@ SHA-256, and refuse to overwrite a local path. Use `--project ID` when no Projec
57
67
  `vecteur mcp` serves MCP over stdio using the same credentials and public client. It exposes
58
68
  `vecteur_project_get`, `vecteur_run_submit`, `vecteur_run_get` and `vecteur_run_events`.
59
69
  An MCP host supplies tool arguments; stdout carries only MCP protocol.
70
+
71
+ `vecteur mcp setup claude` (or `codex`) runs that vendor's own `mcp add` once so your Claude Code
72
+ or Codex can call Vecteur. `vecteur handoff claude` (or `codex`, or `/handoff` in a session) opens
73
+ your own Claude Code/Codex in this terminal with Vecteur attached for that session only, and
74
+ returns when it exits. The vendor keeps its own login and billing; Vecteur never reads its
75
+ credentials and the handoff changes no vendor configuration. This is a handoff, not the integrated
76
+ mode in which the vendor session appears inside Vecteur's TUI and web.
77
+
78
+ `vecteur run export <run-id> [--output <dir>]` (or `/export [dir]`) writes a completed Run into a
79
+ new folder: `answer.md` (exact answer bytes), `run.json` (the canonical record) and every artifact,
80
+ each verified against its SHA-256 CAS id. Nothing is overwritten.
81
+
82
+ ## Interactive session
83
+
84
+ Enter sends; Ctrl+J or Alt+Enter inserts a line. Bracketed paste is one edit and never sends.
85
+ The rich view uses normal terminal scrollback. `vecteur --plain` gives a linear view;
86
+ `NO_COLOR=1 vecteur` or `--no-color` removes styling. `TERM=dumb` selects the linear view.
87
+ Bare invocation in a pipe or CI keeps the previous status output; `vecteur status` always prints it.
88
+
89
+ - `/project` chooses a Project for this session; `/link` saves the folder association.
90
+ `/unlink` removes that association for this subtree and preserves the draft. `/name` renames
91
+ the current Project or names the one created on first send. `/history` loads older Runs on demand.
92
+ - `/model` selects the next Run's model or route from the matched server catalogue. `/mode`
93
+ explains leadership, execution location and payer. Stage 1 runs in Vecteur cloud.
94
+ - `/attach <path>` previews a selected file and asks before uploading on send. Up to eight
95
+ files, 8 MiB each and 32 MiB total; all available extracts are selected. `/clear-files` removes
96
+ pending attachments. Nothing scans your directory or uploads unrelated files.
97
+ - `/answer` handles clarification, including multiple choices and custom text. `/steer <text>`
98
+ and `/answer-now` submit instructions; acceptance is distinct from execution.
99
+ - `/cancel` or double Esc requests cloud Run cancellation. Ctrl+D on an empty composer or
100
+ `/quit` detaches; the cloud Run continues. Ctrl+C clears a nonempty composer.
101
+ - `/files` lists outputs; `/download <number> [path]` verifies bytes and refuses overwrite.
102
+ `/copy [path]` preserves the exact answer: terminal clipboard when supported, otherwise a file.
103
+ - `/resume` reconciles an interrupted submission using its original identity, or follows the
104
+ same Run from its validated event cursor. A moved head requires an explicit new send.
105
+ Unknown admission remains pending if authority or transport prevents reconciliation.
106
+ - `/details [number]`, `/web`, `/doctor` and `/help` expose details, browser access, diagnostics
107
+ and commands. Updates are manual; an incompatible origin supplies its required install command.
108
+
109
+ Folder links are scoped by origin, authenticated account and canonical directory identity.
110
+ An explicit parent link applies to descendants; a nearer link or unlink overrides it. Linking
111
+ home, a filesystem root or a detected mount root requires typing its full path. A replaced directory or inaccessible Project asks for recovery.
112
+ A link is metadata and never a filesystem grant. Viewer access permits reads and downloads.
113
+
114
+ `folders.json` beside the credential store holds bindings, drafts and unresolved request identities.
115
+ POSIX directories/files use 0700/0600; writes are atomic and protected by a short writer lock.
116
+ A crashed writer leaves an explicit lock recovery message. Drafts are bounded to 256 KiB and
117
+ expire after 30 days on next registry access/write. Up to 20 revisions from concurrent editors remain
118
+ recoverable through `/drafts`; `/clear` removes the selected draft. Unchanged terminals never
119
+ overwrite another editor, and admission consumes only its exact draft revision. Unresolved
120
+ request evidence stays until resolved. Account draft recovery requires the same credential fingerprint
121
+ previously bound by a successful account read. Never-signed-in drafts occupy a separate unsigned
122
+ namespace with no Project authority; only explicit login transfers them into an account. Neither
123
+ recovery path grants server access. Attachments are held in
124
+ memory until send and must be selected again after restart. A newer store version is refused without
125
+ rewriting it. Previous CLI versions ignore the separate folder registry.
126
+
127
+ ## Maintainer boundaries and later stages
128
+
129
+ One package, one session owner, two views; stage 1 has no daemon or second application.
130
+
131
+ | Owner | Responsibility |
132
+ |---|---|
133
+ | `bin/vecteur.js` | TTY dispatch and preserved script/MCP commands; lazy terminal loading |
134
+ | `session.js`, `commands.js` | Cloud actions, prompts, recovery and view facts |
135
+ | `client.js` | Public API, auth transport, validated records, sources, controls and cursor replay |
136
+ | `credentials.js`, `folders.js`, `store.js` | Existing login, account/folder metadata and atomic persistence |
137
+ | `input.js`, `terminal.js` | Grapheme-aware composer, paste, signals and the single stdout writer |
138
+ | `tui/view.js`, `blocks.js` | Rich layout and Run presentation; plain view uses the same session facts |
139
+ | `markdown.js`, `contract.js` | Generated parser/contracts; no runtime web or SDK import |
140
+ | `download.js`, `diagnostics.js` | Verified downloader, Run export and shared read-only diagnostics |
141
+ | `vendors.js` | Claude Code/Codex MCP setup and native-terminal handoff argv; no vendor credentials |
142
+
143
+ The renderer uses exact `wrap-ansi`/`string-width` pins and a common display-width implementation.
144
+ Ink failed the frozen startup budget. Completed output is appended; resize abandons old live
145
+ geometry so it never guesses how many reflowed scrollback lines to erase. Neither view authorizes
146
+ operations or issues requests itself.
147
+
148
+ Later computer-use work adds separate device credentials, grants, IPC/relay and cancellation
149
+ subjects. Native Claude/Codex sessions need their own identity and lifecycle; they are not cloud
150
+ Runs. A folder link must never become a local execution grant. External tool results enter existing
151
+ custody with tool/version/hash/trust metadata. No companion/native/driver stubs, vendor credential
152
+ discovery or local execution capability ships in this package.
153
+
154
+ The intended platform matrix includes Linux, macOS and Windows Terminal, plus screen-reader
155
+ qualification. Current evidence and unresolved cases belong in the stage-1 WBS/backlog; Linux
156
+ tests alone do not qualify other platforms. Standalone binaries are a later packaging option using
157
+ the same session owner. This candidate is unpublished until release acceptance. Its package-owned
158
+ `vecteur.channel` is `preview`, visible even offline; release preparation must promote it to
159
+ `stable` before freezing the published bytes. Updates across channels always ask explicitly.
160
+
161
+ ## Drafting, recovery and updates
162
+
163
+ `vecteur --offline` opens a saved local draft without a network request or browser. Use `/login`
164
+ when ready; signing in restores the draft for editing and never sends it automatically. `/help`
165
+ searches every command and `/shortcuts` lists editing, choice and Run-control keys. `/settings`
166
+ saves plain/screen-reader display, reduced motion and update preferences separately from tokens.
167
+
168
+ `/update check` (or `vecteur update --check`) identifies the actual launcher, Node, PATH mismatch
169
+ and origin's required version. Default installation asks first. Opted-in automatic upgrades defer
170
+ while a Run, login or unresolved submission is active. Only a user-owned npm symlink launcher with
171
+ verified release integrity and npm provenance qualifies. Existing module trees stay intact; the
172
+ launcher switches to a separately installed npm generation. Recovery instructions name the retained
173
+ previous target. Windows wrappers, custom Node loaders, direct source scripts and packages without
174
+ provenance receive a refusal and exact manual guidance. No elevation, shell setup edits or automatic
175
+ old-generation deletion occurs. Scripts, JSON, MCP, help and version never install an update.
176
+
177
+ For an **unpublished preview**, use its exact installed path with `--offline`, or pass `--origin`
178
+ for the matching ready acceptance backend. Installing a global release does not update an absolute
179
+ preview path. Production's exact contract pin may reject a preview before browser sign-in.
180
+
181
+ ```bash
182
+ /path/to/preview/bin/vecteur --offline
183
+ /path/to/preview/bin/vecteur --origin http://127.0.0.1:PORT --no-browser
184
+ ```
package/bin/vecteur.js CHANGED
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import { spawn } from "node:child_process";
3
2
  import { readFileSync } from "node:fs";
4
3
 
5
4
  import {
@@ -9,9 +8,10 @@ import {
9
8
  import {
10
9
  clearCredentials, credentialsPath, readCredentials, setCurrentOrigin, writeCredentials, selectProject,
11
10
  } from "../src/credentials.js";
12
- import { downloadArtifact, downloadProjection } from "../src/download.js";
11
+ import { downloadArtifact, downloadProjection, exportRun } from "../src/download.js";
13
12
  import { humanProjects, resolveNamed, resolveRunProject, resolveWorkspace } from "../src/projects.js";
14
13
  import { canonicalJson, currentTransferTables, human, humanCost, progress } from "../src/format.js";
14
+ import { openBrowser } from "../src/browser.js";
15
15
 
16
16
  function manifest() {
17
17
  return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
@@ -43,8 +43,11 @@ function refuseOldNode() {
43
43
 
44
44
  function helpText(version) {
45
45
  return `vecteur ${version}
46
- login | logout | whoami | project list | project create <name> [--workspace <name-or-id>] | project use <name-or-id> | project get <id> | run submit [-p <id>] -a <ask> [--parent <state-hash>] | run get <id> | run events <id> | artifact get <id> [--output <path>] [-p <id>] | projection get <run-id> <projection-id> [--output <path>] [-p <id>] | mcp
47
- --help --version --json --origin <url> --no-browser --local -p/--project -a/--ask --output <path>
46
+ login | logout | whoami | project list | project create <name> [--workspace <name-or-id>] | project use <name-or-id> | project get <id> | run submit [-p <id>] -a <ask> [--parent <state-hash>] | run get <id> | run events <id> | run export <id> [--output <dir>] [-p <id>] | artifact get <id> [--output <path>] [-p <id>] | projection get <run-id> <projection-id> [--output <path>] [-p <id>] | mcp
47
+ mcp setup claude|codex adds Vecteur to your Claude Code/Codex · handoff claude|codex opens it in this terminal with Vecteur attached
48
+ status | doctor | (bare vecteur opens an interactive session)
49
+ --help --version --json --origin <url> --no-browser --offline --plain --no-color --local -p/--project -a/--ask --output <path>
50
+ update [--check] [--yes] [--origin <url>] updates the executing installation
48
51
  run submit continues the Project's current head; --parent <state-hash> admits it only if that is still the head
49
52
  `;
50
53
  }
@@ -58,19 +61,6 @@ function writeStatus(env = process.env) {
58
61
  }
59
62
  }
60
63
 
61
- function openBrowser(url) {
62
- const command = process.platform === "darwin" ? "open"
63
- : process.platform === "win32" ? "cmd" : "xdg-open";
64
- const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
65
- try {
66
- const child = spawn(command, args, { detached: true, stdio: "ignore" });
67
- child.on("error", () => {});
68
- child.unref();
69
- } catch {
70
- // The address is already on screen; failing to spawn a browser is not a failed login.
71
- }
72
- }
73
-
74
64
  function parseCommand(argv) {
75
65
  let args = [...argv];
76
66
  const value = (candidate) => typeof candidate === "string" && candidate.length > 0 &&
@@ -97,6 +87,10 @@ function parseCommand(argv) {
97
87
  const json = takeFlag("--json");
98
88
  const origin = takeOption("--origin");
99
89
  const noBrowser = takeFlag("--no-browser");
90
+ const offline = takeFlag("--offline");
91
+ const checkUpdate = takeFlag("--check"), yesUpdate = takeFlag("--yes");
92
+ const plain = takeFlag("--plain");
93
+ const noColor = takeFlag("--no-color");
100
94
  const local = takeFlag("--local");
101
95
  const projectId = takeOption("--project", "-p");
102
96
  const ask = takeOption("--ask", "-a");
@@ -111,10 +105,24 @@ function parseCommand(argv) {
111
105
  }
112
106
  if (version) return { kind: "version" };
113
107
  if (help) return { kind: "help" };
108
+ if (args.length === 1 && args[0] === "update" && !json && !local && !offline && !plain && !noColor
109
+ && !noBrowser && projectId === undefined && ask === undefined && workspace === undefined && output === undefined && parent === undefined) {
110
+ return { kind: "update", origin, check: checkUpdate, yes: yesUpdate };
111
+ }
112
+ if (checkUpdate || yesUpdate) throw new CliError("usage_invalid", "--check and --yes apply only to update");
113
+ if (!args.length && !json && !local && projectId === undefined && ask === undefined
114
+ && workspace === undefined && output === undefined && parent === undefined) {
115
+ return { kind: "session", origin, noBrowser, plain, noColor, offline };
116
+ }
117
+ if (offline) throw new CliError("usage_invalid", "--offline applies only to an interactive session");
118
+ if (plain || noColor) throw new CliError("usage_invalid", "--plain and --no-color apply to an interactive session");
114
119
  const extra = json || origin !== undefined || noBrowser || local
115
120
  || projectId !== undefined || ask !== undefined || workspace !== undefined || output !== undefined;
116
- if (args.length === 0 && !extra) return { kind: "status" };
121
+ if (args.length === 1 && ["status", "doctor"].includes(args[0]) && !extra) return { kind: args[0] };
117
122
  if (args.length === 1 && args[0] === "mcp" && !extra) return { kind: "mcp" };
123
+ if (!extra && ((args.length === 3 && args[0] === "mcp" && args[1] === "setup") || (args.length === 2 && args[0] === "handoff"))) {
124
+ return { kind: "vendor", mode: args[0] === "mcp" ? "setup" : "handoff", vendor: args.at(-1) };
125
+ }
118
126
  if (args[0] === "project" && projectId === undefined && ask === undefined && output === undefined
119
127
  && !noBrowser && !local) {
120
128
  if (args.length === 3 && args[1] === "create" && value(args[2])) {
@@ -157,6 +165,9 @@ function parseCommand(argv) {
157
165
  && projectId === undefined && ask === undefined && output === undefined) {
158
166
  return { kind: "run_events", id: args[2], origin, json };
159
167
  }
168
+ if (args.length === 3 && args[0] === "run" && args[1] === "export" && value(args[2]) && ask === undefined) {
169
+ return { kind: "run_export", id: args[2], projectId, output, origin, json };
170
+ }
160
171
  if (args.length === 3 && args[0] === "artifact" && args[1] === "get" && value(args[2])
161
172
  && ask === undefined) {
162
173
  return { kind: "artifact_get", id: args[2], projectId, output, origin, json };
@@ -203,7 +214,7 @@ async function login(command, env = process.env) {
203
214
  try {
204
215
  await new VecteurClient({ origin, token: previous, timeout }).credentialSelfRevoke();
205
216
  } catch (error) {
206
- if (!(error instanceof CliError && error.code === "pat_revoked")) throw error;
217
+ if (!(error instanceof CliError && ["pat_revoked", "pat_expired", "pat_invalid"].includes(error.code))) throw error;
207
218
  }
208
219
  }
209
220
  const path = writeCredentials({ origin, token: created.token }, env);
@@ -277,6 +288,39 @@ async function main() {
277
288
  writeStatus();
278
289
  return;
279
290
  }
291
+ if (command.kind === "doctor") {
292
+ const { diagnose } = await import("../src/diagnostics.js");
293
+ const result = await diagnose();
294
+ process.stdout.write(result.text + "\n");
295
+ if (!result.ok) process.exitCode = 1;
296
+ return;
297
+ }
298
+ if (command.kind === "session") {
299
+ if (!process.stdin.isTTY || !process.stdout.isTTY || process.env.CI !== undefined) {
300
+ if (command.plain || command.noColor || command.origin || command.noBrowser || command.offline) {
301
+ throw new CliError("terminal_required", "Interactive options need a terminal; use existing subcommands for scripts");
302
+ }
303
+ writeStatus(); return;
304
+ }
305
+ const { startTerminal } = await import("../src/terminal.js");
306
+ await startTerminal(command);
307
+ return;
308
+ }
309
+ if (command.kind === "update") {
310
+ const { updateCommand } = await import("../src/update.js");
311
+ await updateCommand(command);
312
+ return;
313
+ }
314
+ if (command.kind === "vendor") {
315
+ const { VENDORS, executingServer, runVendor, shellLine, vendorArgv } = await import("../src/vendors.js");
316
+ const argv = vendorArgv(command.vendor, command.mode, command.mode === "handoff" ? executingServer() : undefined);
317
+ process.stderr.write(command.mode === "setup"
318
+ ? `Adding Vecteur to ${VENDORS[command.vendor]} with its own command:\n ${shellLine(argv)}\n`
319
+ : `Opening your ${VENDORS[command.vendor]} terminal with Vecteur attached for this session only.\n`
320
+ + `${VENDORS[command.vendor]} keeps its own login and billing; exit it to return here.\n`);
321
+ process.exitCode = await runVendor(argv);
322
+ return;
323
+ }
280
324
  if (command.kind === "mcp") {
281
325
  const { serveVecteurMcp } = await import("../src/mcp.js");
282
326
  serveVecteurMcp();
@@ -328,6 +372,14 @@ async function main() {
328
372
  : `Downloaded ${value.bytes} bytes to ${value.path} (${value.content_type})\n`);
329
373
  return;
330
374
  }
375
+ else if (command.kind === "run_export") {
376
+ const projectId = await resolveRunProject(client, command.projectId);
377
+ value = await exportRun(client, await client.runGet(command.id), projectId,
378
+ command.output ?? `vecteur-run-${command.id.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 12)}`);
379
+ process.stdout.write(command.json ? canonicalJson(value) : `Exported Run ${value.run_id} (${value.terminal}) to ${value.directory}\n`
380
+ + value.files.map(file => ` ${file.path} · ${file.bytes} bytes${file.cas_ref ? " · verified" : ""}\n`).join(""));
381
+ return;
382
+ }
331
383
  else if (command.kind === "projection_get") {
332
384
  const projectId = await resolveRunProject(client, command.projectId);
333
385
  const run = await client.runGet(command.runId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vecteur/cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vecteur": "bin/vecteur.js"
@@ -15,7 +15,21 @@
15
15
  "src/errors.js",
16
16
  "src/format.js",
17
17
  "src/mcp.js",
18
- "src/projects.js"
18
+ "src/projects.js",
19
+ "src/store.js",
20
+ "src/folders.js",
21
+ "src/browser.js",
22
+ "src/blocks.js",
23
+ "src/session.js",
24
+ "src/input.js",
25
+ "src/commands.js",
26
+ "src/terminal.js",
27
+ "src/markdown.js",
28
+ "src/tui/view.js",
29
+ "src/diagnostics.js",
30
+ "src/preferences.js",
31
+ "src/update.js",
32
+ "src/vendors.js"
19
33
  ],
20
34
  "scripts": {
21
35
  "test": "node --test tests/*.test.js",
@@ -28,14 +42,20 @@
28
42
  },
29
43
  "dependencies": {
30
44
  "@modelcontextprotocol/server": "2.0.0",
45
+ "string-width": "7.2.0",
46
+ "wrap-ansi": "9.0.2",
31
47
  "zod": "4.4.3"
32
48
  },
33
49
  "devDependencies": {
34
50
  "@modelcontextprotocol/client": "2.0.0"
35
51
  },
36
- "description": "The Vecteur command line — one login, the same product as the web app, from any directory.",
52
+ "description": "The Vecteur command line \u2014 one login, the same product as the web app, from any directory.",
37
53
  "license": "UNLICENSED",
38
54
  "publishConfig": {
39
55
  "access": "public"
56
+ },
57
+ "vecteur": {
58
+ "updateProtocol": 1,
59
+ "channel": "stable"
40
60
  }
41
61
  }
package/src/blocks.js ADDED
@@ -0,0 +1,40 @@
1
+ import { humanRun, progress } from "./format.js";
2
+
3
+ function elapsed(run, now) {
4
+ if (!run) return null;
5
+ const accepted = run.accepted_at;
6
+ // Both owner duration and live age start at admission, never at local follow/restart.
7
+ // Live age uses the client wall clock; invalid/future anchors are unavailable.
8
+ const seconds = run.terminal ? run.duration_seconds
9
+ : Number.isSafeInteger(accepted) && accepted >= 0 && accepted <= now / 1000
10
+ ? Math.floor(now / 1000 - accepted) : null;
11
+ if (!Number.isSafeInteger(seconds) || seconds < 0) return "Elapsed unavailable";
12
+ const pad = value => String(value).padStart(2, "0");
13
+ return "Elapsed " + (seconds >= 3600 ? `${Math.floor(seconds / 3600)}:` : "")
14
+ + `${pad(Math.floor(seconds / 60) % 60)}:${pad(seconds % 60)}`;
15
+ }
16
+
17
+ /** RunWire owns the facts. Optional session cache projects only newly appended events. */
18
+ export function runView(run, cache, now = Date.now()) {
19
+ const events = run?.events ?? [];
20
+ const same = cache && cache.run === run && cache.count <= events.length && cache.view;
21
+ if (same && cache.count === events.length && cache.terminal === run?.terminal) return { ...cache.view, elapsed: elapsed(run, now) };
22
+ const view = same ? cache.view : {
23
+ subject: run ? { kind: "vecteur_run", id: run.run_id } : null,
24
+ answer: "", question: null, artifacts: [], steps: [], progress: "Waiting for Vecteur",
25
+ terminal: "", transcript: "",
26
+ };
27
+ for (let index = same ? cache.count : 0; index < events.length; index++) {
28
+ const event = events[index];
29
+ if (event.kind === "synthesis") view.answer = event.payload.answer ?? "";
30
+ if (event.kind === "clarification") view.question = event.payload.state === "requested" ? event.payload : null;
31
+ if (event.kind === "artifact") view.artifacts.push(event.payload);
32
+ if (["step_started", "step_finished", "task"].includes(event.kind)) view.steps.push(event);
33
+ const line = progress(event);
34
+ if (line) view.progress = line.trim();
35
+ }
36
+ view.terminal = run?.terminal ?? "";
37
+ view.transcript = run?.terminal ? humanRun(run) : "";
38
+ if (cache) Object.assign(cache, { run, count: events.length, terminal: run?.terminal, view });
39
+ return { ...view, elapsed: elapsed(run, now) };
40
+ }
package/src/browser.js ADDED
@@ -0,0 +1,18 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ /** Only an explicit user action calls this; the visible URL always remains usable. */
4
+ export async function openBrowser(url) {
5
+ try {
6
+ const parsed = new URL(url);
7
+ if (!["https:", "http:"].includes(parsed.protocol)) return false;
8
+ const command = process.platform === "darwin" ? "open"
9
+ : process.platform === "win32" ? "rundll32.exe" : "xdg-open";
10
+ const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", parsed.href] : [parsed.href];
11
+ return await new Promise(resolve => {
12
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
13
+ const timer = setTimeout(() => { child.unref(); resolve(true); }, 1_500);
14
+ child.once("error", () => { clearTimeout(timer); resolve(false); });
15
+ child.once("exit", code => { clearTimeout(timer); resolve(code === 0); });
16
+ });
17
+ } catch { return false; }
18
+ }