@aexol/spectral 0.9.231 → 0.9.240
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 +47 -0
- package/dist/agent/index.d.ts +32 -5
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +209 -36
- package/dist/backend/generated-images.d.ts +1 -1
- package/dist/backend/generated-images.d.ts.map +1 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +18 -4
- package/dist/commands/desktop.d.ts +167 -0
- package/dist/commands/desktop.d.ts.map +1 -0
- package/dist/commands/desktop.js +540 -0
- package/dist/commands/serve.d.ts +9 -1
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +90 -2
- package/dist/commands/update.d.ts +85 -5
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +288 -58
- package/dist/extensions/desktop-control/macos.d.ts.map +1 -1
- package/dist/extensions/desktop-control/macos.js +2 -2
- package/dist/extensions/logic-trace.d.ts +1 -1
- package/dist/extensions/logic-trace.d.ts.map +1 -1
- package/dist/extensions/logic-trace.js +199 -70
- package/dist/extensions/video-generation/index.d.ts +3 -0
- package/dist/extensions/video-generation/index.d.ts.map +1 -0
- package/dist/extensions/video-generation/index.js +299 -0
- package/dist/generated/zeus/const.d.ts.map +1 -1
- package/dist/generated/zeus/const.js +30 -0
- package/dist/generated/zeus/index.d.ts +140 -0
- package/dist/generated/zeus/index.d.ts.map +1 -1
- package/dist/generated/zeus/index.js +5 -0
- package/dist/mcp/server-manager.d.ts.map +1 -1
- package/dist/mcp/server-manager.js +6 -0
- package/dist/memory/tools/receive-agent-observations.d.ts.map +1 -1
- package/dist/memory/tools/receive-agent-observations.js +12 -2
- package/dist/relay/dispatcher.d.ts +13 -1
- package/dist/relay/dispatcher.d.ts.map +1 -1
- package/dist/relay/dispatcher.js +41 -2
- package/dist/sdk/agent-core/agent-loop.js +13 -3
- package/dist/sdk/agent-core/agent.d.ts +11 -4
- package/dist/sdk/agent-core/agent.d.ts.map +1 -1
- package/dist/sdk/agent-core/agent.js +16 -4
- package/dist/sdk/agent-core/tool-call-registry.d.ts +75 -0
- package/dist/sdk/agent-core/tool-call-registry.d.ts.map +1 -0
- package/dist/sdk/agent-core/tool-call-registry.js +131 -0
- package/dist/sdk/agent-core/tool-execution.d.ts.map +1 -1
- package/dist/sdk/agent-core/tool-execution.js +103 -4
- package/dist/sdk/agent-core/types.d.ts +15 -4
- package/dist/sdk/agent-core/types.d.ts.map +1 -1
- package/dist/sdk/ai/index.d.ts +3 -0
- package/dist/sdk/ai/index.d.ts.map +1 -1
- package/dist/sdk/ai/index.js +3 -0
- package/dist/sdk/ai/providers/faux.d.ts.map +1 -1
- package/dist/sdk/ai/providers/faux.js +3 -0
- package/dist/sdk/ai/providers/leaked-tool-call-parser.d.ts +39 -6
- package/dist/sdk/ai/providers/leaked-tool-call-parser.d.ts.map +1 -1
- package/dist/sdk/ai/providers/leaked-tool-call-parser.js +159 -18
- package/dist/sdk/ai/providers/openai-completions.d.ts.map +1 -1
- package/dist/sdk/ai/providers/openai-completions.js +22 -8
- package/dist/sdk/ai/providers/openrouter-videos.d.ts +3 -0
- package/dist/sdk/ai/providers/openrouter-videos.d.ts.map +1 -0
- package/dist/sdk/ai/providers/openrouter-videos.js +412 -0
- package/dist/sdk/ai/providers/register-builtins.d.ts.map +1 -1
- package/dist/sdk/ai/providers/register-builtins.js +6 -0
- package/dist/sdk/ai/providers/simple-options.d.ts +1 -6
- package/dist/sdk/ai/providers/simple-options.d.ts.map +1 -1
- package/dist/sdk/ai/providers/simple-options.js +0 -22
- package/dist/sdk/ai/providers/transform-messages.d.ts.map +1 -1
- package/dist/sdk/ai/providers/transform-messages.js +52 -1
- package/dist/sdk/ai/types.d.ts +91 -10
- package/dist/sdk/ai/types.d.ts.map +1 -1
- package/dist/sdk/ai/video-models.d.ts +10 -0
- package/dist/sdk/ai/video-models.d.ts.map +1 -0
- package/dist/sdk/ai/video-models.generated.d.ts +440 -0
- package/dist/sdk/ai/video-models.generated.d.ts.map +1 -0
- package/dist/sdk/ai/video-models.generated.js +441 -0
- package/dist/sdk/ai/video-models.js +22 -0
- package/dist/sdk/ai/videos-api-registry.d.ts +14 -0
- package/dist/sdk/ai/videos-api-registry.d.ts.map +1 -0
- package/dist/sdk/ai/videos-api-registry.js +21 -0
- package/dist/sdk/ai/videos.d.ts +4 -0
- package/dist/sdk/ai/videos.d.ts.map +1 -0
- package/dist/sdk/ai/videos.js +13 -0
- package/dist/sdk/coding-agent/core/agent-session.d.ts +8 -0
- package/dist/sdk/coding-agent/core/agent-session.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/agent-session.js +11 -0
- package/dist/sdk/coding-agent/core/extensions/loader.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/extensions/loader.js +14 -0
- package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts +26 -0
- package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/extensions/native-extensions.js +23 -0
- package/dist/sdk/coding-agent/core/extensions/session-events.d.ts +2 -2
- package/dist/sdk/coding-agent/core/extensions/session-events.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/extensions/types.d.ts +12 -2
- package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/messages.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/messages.js +42 -2
- package/dist/sdk/coding-agent/core/resource-loader.d.ts +11 -0
- package/dist/sdk/coding-agent/core/resource-loader.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/resource-loader.js +23 -6
- package/dist/sdk/coding-agent/core/sdk.d.ts +24 -2
- package/dist/sdk/coding-agent/core/sdk.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/sdk.js +15 -3
- package/dist/sdk/coding-agent/core/settings-manager.d.ts +0 -8
- package/dist/sdk/coding-agent/core/settings-manager.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/settings-manager.js +3 -3
- package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/system-prompt.js +5 -0
- package/dist/sdk/coding-agent/index.d.ts +2 -2
- package/dist/sdk/coding-agent/index.d.ts.map +1 -1
- package/dist/sdk/coding-agent/index.js +1 -1
- package/dist/sdk-public.d.ts +96 -0
- package/dist/sdk-public.d.ts.map +1 -0
- package/dist/sdk-public.js +109 -0
- package/dist/server/agent-bridge/rehydrate.d.ts +2 -2
- package/dist/server/agent-bridge/rehydrate.d.ts.map +1 -1
- package/dist/server/agent-bridge/rehydrate.js +14 -0
- package/dist/server/agent-bridge/tps-metrics.d.ts +34 -0
- package/dist/server/agent-bridge/tps-metrics.d.ts.map +1 -1
- package/dist/server/agent-bridge/tps-metrics.js +22 -0
- package/dist/server/agent-bridge.d.ts +17 -2
- package/dist/server/agent-bridge.d.ts.map +1 -1
- package/dist/server/agent-bridge.js +108 -5
- package/dist/server/handlers/paths-list.d.ts +1 -1
- package/dist/server/handlers/paths-list.js +1 -1
- package/dist/server/inter-agent-broker.d.ts +12 -0
- package/dist/server/inter-agent-broker.d.ts.map +1 -1
- package/dist/server/inter-agent-broker.js +1 -0
- package/dist/server/preset-chain.d.ts +151 -0
- package/dist/server/preset-chain.d.ts.map +1 -0
- package/dist/server/preset-chain.js +250 -0
- package/dist/server/session-stream.d.ts +74 -1
- package/dist/server/session-stream.d.ts.map +1 -1
- package/dist/server/session-stream.js +238 -24
- package/dist/server/storage/rows.d.ts +2 -0
- package/dist/server/storage/rows.d.ts.map +1 -1
- package/dist/server/storage/schema.d.ts +2 -2
- package/dist/server/storage/schema.d.ts.map +1 -1
- package/dist/server/storage/schema.js +2 -1
- package/dist/server/storage.d.ts +7 -0
- package/dist/server/storage.d.ts.map +1 -1
- package/dist/server/storage.js +142 -9
- package/dist/server/wire.d.ts +84 -0
- package/dist/server/wire.d.ts.map +1 -1
- package/dist/utils/augment-path.d.ts +51 -0
- package/dist/utils/augment-path.d.ts.map +1 -0
- package/dist/utils/augment-path.js +234 -0
- package/dist/utils/open-browser.d.ts +55 -0
- package/dist/utils/open-browser.d.ts.map +1 -0
- package/dist/utils/open-browser.js +163 -0
- package/dist/utils/serve-state.d.ts +264 -0
- package/dist/utils/serve-state.d.ts.map +1 -0
- package/dist/utils/serve-state.js +563 -0
- package/dist/utils/standalone.d.ts +14 -0
- package/dist/utils/standalone.d.ts.map +1 -0
- package/dist/utils/standalone.js +23 -0
- package/package.json +7 -3
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PATH augmentation for processes spectral spawns.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists:
|
|
5
|
+
* A GUI-launched host (Dock, Spotlight, launchd, a `.app` bundle) inherits a
|
|
6
|
+
* minimal `PATH` — usually just `/usr/bin:/bin:/usr/sbin:/sbin`. MCP servers
|
|
7
|
+
* declared as `npx -y <pkg>` (and every other `npm`/`node`/`git` child
|
|
8
|
+
* process) then die with `Executable not found in $PATH: "npx"`. The Electron
|
|
9
|
+
* desktop app solved this by augmenting the PATH of the `spectral serve`
|
|
10
|
+
* child it spawned. That app is gone; the same logic now lives here so the
|
|
11
|
+
* `spectral desktop` wrapper, `spectral serve` itself, and the MCP stdio
|
|
12
|
+
* transports all get a usable PATH regardless of how spectral was launched.
|
|
13
|
+
*
|
|
14
|
+
* Sources merged, in priority order (first occurrence wins — shell lookup is
|
|
15
|
+
* left-to-right):
|
|
16
|
+
* 1. the current process PATH,
|
|
17
|
+
* 2. macOS `/etc/paths` + `/etc/paths.d/*` (what `path_helper` uses),
|
|
18
|
+
* 3. `getconf PATH` (the platform default),
|
|
19
|
+
* 4. well-known Node/npm/Homebrew/Git install directories,
|
|
20
|
+
* 5. the newest nvm-managed Node `bin` directory.
|
|
21
|
+
*
|
|
22
|
+
* Ported from `electron/path-resolver.ts` when the Electron app was removed, so
|
|
23
|
+
* behaviour is unchanged for existing users.
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { join, normalize } from "node:path";
|
|
28
|
+
import { spawnSync } from "node:child_process";
|
|
29
|
+
/** Platform path-list separator (`;` on Windows, `:` elsewhere). */
|
|
30
|
+
function pathDelimiter() {
|
|
31
|
+
return process.platform === "win32" ? ";" : ":";
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Returns the case-correct name of the PATH environment variable on this
|
|
35
|
+
* platform. Windows allows "Path", Unix is usually "PATH".
|
|
36
|
+
*/
|
|
37
|
+
export function getPathEnvKey(env = process.env) {
|
|
38
|
+
return Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH";
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Split the content of a path-list source into individual directories.
|
|
42
|
+
*
|
|
43
|
+
* Two formats show up in practice and both must be handled: one directory per
|
|
44
|
+
* line (`/etc/paths`, `/etc/paths.d/*`) and a single delimiter-joined line
|
|
45
|
+
* (`getconf PATH` prints `/usr/bin:/bin:/usr/sbin:/sbin`). The original
|
|
46
|
+
* `electron/path-resolver.ts` split on newlines only, so the `getconf` output
|
|
47
|
+
* landed in the Set as one malformed `/usr/bin:/bin:/usr/sbin:/sbin` entry —
|
|
48
|
+
* harmless-but-wrong, and enough to make `augmentPath()` non-idempotent.
|
|
49
|
+
*/
|
|
50
|
+
function parsePathsContent(content) {
|
|
51
|
+
return content
|
|
52
|
+
.split(/\r?\n/)
|
|
53
|
+
.map((line) => line.trim())
|
|
54
|
+
.filter((line) => line.length > 0 && !line.startsWith("#"))
|
|
55
|
+
.flatMap((line) => line.split(pathDelimiter()))
|
|
56
|
+
.map((entry) => entry.trim())
|
|
57
|
+
.filter((entry) => entry.length > 0);
|
|
58
|
+
}
|
|
59
|
+
function readMacEtcPaths() {
|
|
60
|
+
if (process.platform !== "darwin")
|
|
61
|
+
return [];
|
|
62
|
+
const paths = [];
|
|
63
|
+
const etcPaths = "/etc/paths";
|
|
64
|
+
if (existsSync(etcPaths)) {
|
|
65
|
+
try {
|
|
66
|
+
paths.push(...parsePathsContent(readFileSync(etcPaths, "utf-8")));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// ignore
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const pathsD = "/etc/paths.d";
|
|
73
|
+
if (existsSync(pathsD)) {
|
|
74
|
+
try {
|
|
75
|
+
for (const entry of readdirSync(pathsD, { withFileTypes: true })) {
|
|
76
|
+
if (!entry.isFile())
|
|
77
|
+
continue;
|
|
78
|
+
paths.push(...parsePathsContent(readFileSync(join(pathsD, entry.name), "utf-8")));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// ignore
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return paths;
|
|
86
|
+
}
|
|
87
|
+
function readGetconfPath() {
|
|
88
|
+
if (process.platform === "win32")
|
|
89
|
+
return [];
|
|
90
|
+
try {
|
|
91
|
+
const result = spawnSync("getconf", ["PATH"], { encoding: "utf-8", shell: false });
|
|
92
|
+
if (result.status === 0 && result.stdout) {
|
|
93
|
+
return parsePathsContent(result.stdout);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// ignore
|
|
98
|
+
}
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
function knownExtraPaths() {
|
|
102
|
+
const commonUnix = [
|
|
103
|
+
"/usr/local/bin",
|
|
104
|
+
"/usr/local/sbin",
|
|
105
|
+
"/usr/bin",
|
|
106
|
+
"/usr/sbin",
|
|
107
|
+
"/bin",
|
|
108
|
+
"/sbin",
|
|
109
|
+
"/opt/bin",
|
|
110
|
+
"/opt/local/bin",
|
|
111
|
+
];
|
|
112
|
+
const home = homedir();
|
|
113
|
+
if (process.platform === "darwin") {
|
|
114
|
+
return [
|
|
115
|
+
...commonUnix,
|
|
116
|
+
"/opt/homebrew/bin",
|
|
117
|
+
"/opt/homebrew/sbin",
|
|
118
|
+
"/usr/local/git/bin",
|
|
119
|
+
join(home, ".local", "bin"),
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
if (process.platform === "win32") {
|
|
123
|
+
const programFiles = process.env.PROGRAMFILES ?? "C:\\Program Files";
|
|
124
|
+
const programFilesX86 = process.env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)";
|
|
125
|
+
return [
|
|
126
|
+
join(programFiles, "nodejs"),
|
|
127
|
+
join(programFilesX86, "nodejs"),
|
|
128
|
+
join(process.env.LOCALAPPDATA ?? join(home, "AppData", "Local"), "npm"),
|
|
129
|
+
join(home, "AppData", "Roaming", "npm"),
|
|
130
|
+
join(programFiles, "Git", "cmd"),
|
|
131
|
+
join(programFilesX86, "Git", "cmd"),
|
|
132
|
+
join(programFiles, "Git", "bin"),
|
|
133
|
+
join(programFilesX86, "Git", "bin"),
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
return [...commonUnix, "/usr/local/git/bin", join(home, ".local", "bin")];
|
|
137
|
+
}
|
|
138
|
+
function findLatestNvmNodeBin() {
|
|
139
|
+
const nvmDir = process.env.NVM_DIR ?? join(homedir(), ".nvm");
|
|
140
|
+
const versionsDir = join(nvmDir, "versions", "node");
|
|
141
|
+
if (!existsSync(versionsDir))
|
|
142
|
+
return null;
|
|
143
|
+
try {
|
|
144
|
+
const versions = readdirSync(versionsDir, { withFileTypes: true })
|
|
145
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith("v"))
|
|
146
|
+
.map((entry) => entry.name);
|
|
147
|
+
if (versions.length === 0)
|
|
148
|
+
return null;
|
|
149
|
+
versions.sort(compareSemverDesc);
|
|
150
|
+
return join(versionsDir, versions[0], "bin");
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function compareSemverDesc(a, b) {
|
|
157
|
+
const parse = (value) => value
|
|
158
|
+
.replace(/^v/, "")
|
|
159
|
+
.split(".")
|
|
160
|
+
.map((part) => parseInt(part, 10) || 0);
|
|
161
|
+
const av = parse(a);
|
|
162
|
+
const bv = parse(b);
|
|
163
|
+
const len = Math.max(av.length, bv.length);
|
|
164
|
+
for (let i = 0; i < len; i++) {
|
|
165
|
+
const an = av[i] ?? 0;
|
|
166
|
+
const bn = bv[i] ?? 0;
|
|
167
|
+
if (an !== bn)
|
|
168
|
+
return bn - an;
|
|
169
|
+
}
|
|
170
|
+
return b.localeCompare(a);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Cached "static" augmentation. `/etc/paths`, `getconf PATH`, the well-known
|
|
174
|
+
* install dirs and the nvm lookup do not change during the lifetime of a
|
|
175
|
+
* process, and `augmentPath()` is called on every MCP server connect — so the
|
|
176
|
+
* `spawnSync("getconf")` happens at most once per process.
|
|
177
|
+
*/
|
|
178
|
+
let staticExtrasCache = null;
|
|
179
|
+
function staticExtraPaths() {
|
|
180
|
+
if (staticExtrasCache)
|
|
181
|
+
return staticExtrasCache;
|
|
182
|
+
const extras = [];
|
|
183
|
+
extras.push(...readMacEtcPaths());
|
|
184
|
+
extras.push(...readGetconfPath());
|
|
185
|
+
extras.push(...knownExtraPaths());
|
|
186
|
+
const nvmBin = findLatestNvmNodeBin();
|
|
187
|
+
if (nvmBin)
|
|
188
|
+
extras.push(nvmBin);
|
|
189
|
+
staticExtrasCache = extras;
|
|
190
|
+
return extras;
|
|
191
|
+
}
|
|
192
|
+
/** Test-only hook: forget the cached /etc/paths + getconf + nvm lookup. */
|
|
193
|
+
export function _resetAugmentPathCacheForTests() {
|
|
194
|
+
staticExtrasCache = null;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Build an augmented PATH string.
|
|
198
|
+
*
|
|
199
|
+
* @param base PATH to start from. Defaults to the current process PATH, so
|
|
200
|
+
* callers holding a custom env (e.g. the MCP stdio transport env) can
|
|
201
|
+
* augment that instead.
|
|
202
|
+
*/
|
|
203
|
+
export function augmentPath(base) {
|
|
204
|
+
const current = base ?? process.env[getPathEnvKey()] ?? "";
|
|
205
|
+
const segments = new Set();
|
|
206
|
+
const add = (raw) => {
|
|
207
|
+
if (!raw)
|
|
208
|
+
return;
|
|
209
|
+
const trimmed = normalize(raw).replace(/[\\/]+$/, "");
|
|
210
|
+
if (trimmed)
|
|
211
|
+
segments.add(trimmed);
|
|
212
|
+
};
|
|
213
|
+
current.split(pathDelimiter()).forEach(add);
|
|
214
|
+
staticExtraPaths().forEach(add);
|
|
215
|
+
return [...segments].join(pathDelimiter());
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Return a single-entry env patch — `{ PATH: <augmented> }` under the
|
|
219
|
+
* case-correct key — ready to spread into a spawn `env`.
|
|
220
|
+
*/
|
|
221
|
+
export function augmentedPathEnvPatch(base, env = process.env) {
|
|
222
|
+
const key = getPathEnvKey(env);
|
|
223
|
+
return { [key]: augmentPath(base ?? env[key]) };
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Augment `env`'s PATH in place and return it. Used at daemon start so every
|
|
227
|
+
* descendant (bash tool, dev-process registry, MCP transports, git) inherits a
|
|
228
|
+
* PATH that can actually find `node`/`npx`/`git`.
|
|
229
|
+
*/
|
|
230
|
+
export function applyAugmentedPath(env = process.env) {
|
|
231
|
+
const key = getPathEnvKey(env);
|
|
232
|
+
env[key] = augmentPath(env[key]);
|
|
233
|
+
return env;
|
|
234
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open a URL in the user's default browser, best-effort.
|
|
3
|
+
*
|
|
4
|
+
* Used by `spectral desktop` (the browser *is* the desktop UI now that the
|
|
5
|
+
* Electron shell is gone) and kept separate from `auth-helper.ts`'s inline
|
|
6
|
+
* variant so both the login flow and the desktop launcher can share one
|
|
7
|
+
* implementation.
|
|
8
|
+
*
|
|
9
|
+
* Never throws: headless boxes, containers and CI have no browser and no
|
|
10
|
+
* `xdg-open`. Callers print the URL themselves when `opened` is false, so a
|
|
11
|
+
* missing browser degrades to "copy this link" instead of a failed command.
|
|
12
|
+
*
|
|
13
|
+
* Two hard rules, because this hands a URL to a process launcher:
|
|
14
|
+
* - only `http(s)` URLs are accepted — anything else (`file:`, `javascript:`,
|
|
15
|
+
* a bare string with shell metacharacters) is refused before a process is
|
|
16
|
+
* spawned, and
|
|
17
|
+
* - the fallback launches the platform opener by argv, never through a shell,
|
|
18
|
+
* so no quoting or re-interpretation of the URL can occur.
|
|
19
|
+
*/
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
export interface OpenBrowserResult {
|
|
22
|
+
opened: boolean;
|
|
23
|
+
/** Platform command that was attempted — surfaced in failure messages. */
|
|
24
|
+
command: string;
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface OpenBrowserDeps {
|
|
28
|
+
/** Injectable spawn (tests). */
|
|
29
|
+
spawn?: typeof spawn;
|
|
30
|
+
/** Injectable `open` module loader (tests). */
|
|
31
|
+
loadOpen?: () => Promise<unknown>;
|
|
32
|
+
/** How long to wait for a launched opener to confirm (defaults to 2 s). */
|
|
33
|
+
confirmTimeoutMs?: number;
|
|
34
|
+
}
|
|
35
|
+
/** How long an opener child gets to report a clean exit. */
|
|
36
|
+
export declare const OPEN_CONFIRM_TIMEOUT_MS = 2000;
|
|
37
|
+
export declare function isSafeBrowserUrl(url: string): boolean;
|
|
38
|
+
/** Build the shell command for the current platform (mirrors auth-helper). */
|
|
39
|
+
export declare function browserOpenCommand(url: string, platform?: NodeJS.Platform): string;
|
|
40
|
+
/**
|
|
41
|
+
* The same launch as argv for `spawn(..., { shell: false })`. `start` is a
|
|
42
|
+
* `cmd.exe` builtin, so Windows goes through `cmd /c start` with the URL as a
|
|
43
|
+
* separate argument — still no shell string to inject into.
|
|
44
|
+
*/
|
|
45
|
+
export declare function browserOpenArgv(url: string, platform?: NodeJS.Platform): {
|
|
46
|
+
command: string;
|
|
47
|
+
args: string[];
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Best-effort browser open. Tries the `open` npm package first (it handles
|
|
51
|
+
* flatpak/WSL and sandbox edge cases) but only accepts it once its child exits
|
|
52
|
+
* 0, then the plain platform opener by argv.
|
|
53
|
+
*/
|
|
54
|
+
export declare function openBrowser(url: string, platform?: NodeJS.Platform, deps?: OpenBrowserDeps): Promise<OpenBrowserResult>;
|
|
55
|
+
//# sourceMappingURL=open-browser.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"open-browser.d.ts","sourceRoot":"","sources":["../../src/utils/open-browser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,KAAK,EAAqB,MAAM,oBAAoB,CAAC;AAE9D,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,OAAO,CAAC;IAChB,0EAA0E;IAC1E,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,eAAe;IAC9B,gCAAgC;IAChC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IACrB,+CAA+C;IAC/C,QAAQ,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAClC,2EAA2E;IAC3E,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAKD,4DAA4D;AAC5D,eAAO,MAAM,uBAAuB,OAAQ,CAAC;AAE7C,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAErD;AAED,8EAA8E;AAC9E,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,kBAAmB,GAAG,MAAM,CAInF;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,MAAM,EACX,QAAQ,kBAAmB,GAC1B;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,CAIrC;AAwCD;;;;GAIG;AACH,wBAAsB,WAAW,CAC/B,GAAG,EAAE,MAAM,EACX,QAAQ,kBAAmB,EAC3B,IAAI,GAAE,eAAoB,GACzB,OAAO,CAAC,iBAAiB,CAAC,CA8E5B"}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open a URL in the user's default browser, best-effort.
|
|
3
|
+
*
|
|
4
|
+
* Used by `spectral desktop` (the browser *is* the desktop UI now that the
|
|
5
|
+
* Electron shell is gone) and kept separate from `auth-helper.ts`'s inline
|
|
6
|
+
* variant so both the login flow and the desktop launcher can share one
|
|
7
|
+
* implementation.
|
|
8
|
+
*
|
|
9
|
+
* Never throws: headless boxes, containers and CI have no browser and no
|
|
10
|
+
* `xdg-open`. Callers print the URL themselves when `opened` is false, so a
|
|
11
|
+
* missing browser degrades to "copy this link" instead of a failed command.
|
|
12
|
+
*
|
|
13
|
+
* Two hard rules, because this hands a URL to a process launcher:
|
|
14
|
+
* - only `http(s)` URLs are accepted — anything else (`file:`, `javascript:`,
|
|
15
|
+
* a bare string with shell metacharacters) is refused before a process is
|
|
16
|
+
* spawned, and
|
|
17
|
+
* - the fallback launches the platform opener by argv, never through a shell,
|
|
18
|
+
* so no quoting or re-interpretation of the URL can occur.
|
|
19
|
+
*/
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
/** Only plain http(s) URLs may reach an opener process. */
|
|
22
|
+
const SAFE_URL = /^https?:\/\/[^\s]+$/i;
|
|
23
|
+
/** How long an opener child gets to report a clean exit. */
|
|
24
|
+
export const OPEN_CONFIRM_TIMEOUT_MS = 2_000;
|
|
25
|
+
export function isSafeBrowserUrl(url) {
|
|
26
|
+
return SAFE_URL.test(url.trim());
|
|
27
|
+
}
|
|
28
|
+
/** Build the shell command for the current platform (mirrors auth-helper). */
|
|
29
|
+
export function browserOpenCommand(url, platform = process.platform) {
|
|
30
|
+
if (platform === "darwin")
|
|
31
|
+
return `open "${url}"`;
|
|
32
|
+
if (platform === "win32")
|
|
33
|
+
return `start "" "${url}"`;
|
|
34
|
+
return `xdg-open "${url}"`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The same launch as argv for `spawn(..., { shell: false })`. `start` is a
|
|
38
|
+
* `cmd.exe` builtin, so Windows goes through `cmd /c start` with the URL as a
|
|
39
|
+
* separate argument — still no shell string to inject into.
|
|
40
|
+
*/
|
|
41
|
+
export function browserOpenArgv(url, platform = process.platform) {
|
|
42
|
+
if (platform === "darwin")
|
|
43
|
+
return { command: "open", args: [url] };
|
|
44
|
+
if (platform === "win32")
|
|
45
|
+
return { command: "cmd.exe", args: ["/c", "start", "", url] };
|
|
46
|
+
return { command: "xdg-open", args: [url] };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Wait for a launched opener to exit cleanly.
|
|
50
|
+
*
|
|
51
|
+
* `open` resolves as soon as its child is spawned — the child is what actually
|
|
52
|
+
* fails on a headless box — so "it returned a process" is not proof anything
|
|
53
|
+
* opened. Resolves with the exit code, or null when it failed, exited non-zero,
|
|
54
|
+
* or never reported within `timeoutMs` (the child is killed on timeout so a
|
|
55
|
+
* hung opener cannot wedge the caller).
|
|
56
|
+
*/
|
|
57
|
+
function waitForOpenerExit(child, timeoutMs) {
|
|
58
|
+
const cp = child;
|
|
59
|
+
if (!cp || typeof cp.once !== "function")
|
|
60
|
+
return Promise.resolve(null);
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
let settled = false;
|
|
63
|
+
const finish = (code) => {
|
|
64
|
+
if (settled)
|
|
65
|
+
return;
|
|
66
|
+
settled = true;
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
resolve(code);
|
|
69
|
+
};
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
try {
|
|
72
|
+
cp.kill?.();
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// already gone
|
|
76
|
+
}
|
|
77
|
+
finish(null);
|
|
78
|
+
}, timeoutMs);
|
|
79
|
+
timer.unref?.();
|
|
80
|
+
cp.once("close", (code) => finish(code));
|
|
81
|
+
cp.once("error", () => finish(null));
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Best-effort browser open. Tries the `open` npm package first (it handles
|
|
86
|
+
* flatpak/WSL and sandbox edge cases) but only accepts it once its child exits
|
|
87
|
+
* 0, then the plain platform opener by argv.
|
|
88
|
+
*/
|
|
89
|
+
export async function openBrowser(url, platform = process.platform, deps = {}) {
|
|
90
|
+
const command = browserOpenCommand(url, platform);
|
|
91
|
+
if (!isSafeBrowserUrl(url)) {
|
|
92
|
+
return { opened: false, command, error: `refused non-http(s) url: ${url}` };
|
|
93
|
+
}
|
|
94
|
+
const confirmTimeoutMs = deps.confirmTimeoutMs ?? OPEN_CONFIRM_TIMEOUT_MS;
|
|
95
|
+
try {
|
|
96
|
+
const openModule = deps.loadOpen ? await deps.loadOpen() : await import("open");
|
|
97
|
+
const openFn = (openModule.default ?? openModule);
|
|
98
|
+
if (typeof openFn === "function") {
|
|
99
|
+
const child = await openFn(url);
|
|
100
|
+
if ((await waitForOpenerExit(child, confirmTimeoutMs)) === 0) {
|
|
101
|
+
return { opened: true, command };
|
|
102
|
+
}
|
|
103
|
+
// Unconfirmed or failed: fall through to the plain opener.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// fall through to the platform opener
|
|
108
|
+
}
|
|
109
|
+
const spawnFn = deps.spawn ?? spawn;
|
|
110
|
+
const argv = browserOpenArgv(url, platform);
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
let settled = false;
|
|
113
|
+
// Declared up front: `finish` may run before the timer exists (a spawn that
|
|
114
|
+
// throws, or one that fails synchronously), and reading a `const` that has
|
|
115
|
+
// not been initialised yet is a TDZ crash inside a promise.
|
|
116
|
+
let timer;
|
|
117
|
+
// Set once WE gave up, so the kill's own `close` event (signal-killed, code
|
|
118
|
+
// null) cannot masquerade as the opener's verdict.
|
|
119
|
+
let gaveUp = false;
|
|
120
|
+
const finish = (result) => {
|
|
121
|
+
if (settled)
|
|
122
|
+
return;
|
|
123
|
+
settled = true;
|
|
124
|
+
if (timer)
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
resolve(result);
|
|
127
|
+
};
|
|
128
|
+
let child;
|
|
129
|
+
try {
|
|
130
|
+
// detached + stdio "ignore": the opener outlives this short-lived CLI
|
|
131
|
+
// process and cannot keep our stdout or the terminal alive.
|
|
132
|
+
child = spawnFn(argv.command, argv.args, { detached: true, stdio: "ignore" });
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
finish({ opened: false, command, error: err.message });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
timer = setTimeout(() => {
|
|
139
|
+
gaveUp = true;
|
|
140
|
+
try {
|
|
141
|
+
child.kill();
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// already gone
|
|
145
|
+
}
|
|
146
|
+
finish({
|
|
147
|
+
opened: false,
|
|
148
|
+
command,
|
|
149
|
+
error: `${argv.command} did not exit within ${confirmTimeoutMs}ms`,
|
|
150
|
+
});
|
|
151
|
+
}, confirmTimeoutMs);
|
|
152
|
+
timer.unref?.();
|
|
153
|
+
child.on("error", (err) => finish({ opened: false, command, error: err.message }));
|
|
154
|
+
child.on("close", (code) => {
|
|
155
|
+
if (gaveUp)
|
|
156
|
+
return; // the timeout above already reported the real reason
|
|
157
|
+
finish(code === 0
|
|
158
|
+
? { opened: true, command }
|
|
159
|
+
: { opened: false, command, error: `${argv.command} exited with code ${code}` });
|
|
160
|
+
});
|
|
161
|
+
child.unref();
|
|
162
|
+
});
|
|
163
|
+
}
|