@ravefox/space 0.0.0-stage → 0.2.1
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/LICENSE +205 -0
- package/NOTICE +55 -0
- package/README.md +63 -2
- package/assets/webhook-sdk/python/bot.py +63 -0
- package/assets/webhook-sdk/python/commonly.py +232 -0
- package/node_modules/@commonlyai/seats/README.md +25 -0
- package/node_modules/@commonlyai/seats/package.json +22 -0
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +264 -0
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +24 -0
- package/node_modules/@commonlyai/seats/src/contract.js +48 -0
- package/node_modules/@commonlyai/seats/src/failures.js +44 -0
- package/node_modules/@commonlyai/seats/src/harness-plan.js +329 -0
- package/node_modules/@commonlyai/seats/src/index.js +22 -0
- package/node_modules/@commonlyai/seats/src/process-group.js +269 -0
- package/node_modules/@commonlyai/seats/src/record/format.js +737 -0
- package/node_modules/@commonlyai/seats/src/record/otel.js +238 -0
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +29 -0
- package/node_modules/@commonlyai/seats/src/seat-model.js +17 -0
- package/node_modules/@commonlyai/seats/src/supervisor.js +308 -0
- package/node_modules/@commonlyai/seats/src/trace/events.js +46 -0
- package/node_modules/@commonlyai/seats/src/trace/store.js +276 -0
- package/node_modules/@commonlyai/seats/src/turn-processes.js +226 -0
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +418 -0
- package/node_modules/@commonlyai/seats/src/valet/env.js +201 -0
- package/node_modules/@commonlyai/seats/src/valet/grant.js +123 -0
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +213 -0
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +529 -0
- package/package.json +63 -4
- package/skills/commonly/SKILL.md +233 -0
- package/src/commands/agent.js +6334 -0
- package/src/commands/daemon.js +933 -0
- package/src/commands/dev.js +153 -0
- package/src/commands/doctor.js +141 -0
- package/src/commands/inbox.js +330 -0
- package/src/commands/login.js +194 -0
- package/src/commands/mcp.js +56 -0
- package/src/commands/pod.js +120 -0
- package/src/commands/task.js +279 -0
- package/src/commands/verify.js +48 -0
- package/src/index.js +99 -0
- package/src/lib/adapter-login.js +76 -0
- package/src/lib/adapters/broker-bridge-entry.js +76 -0
- package/src/lib/adapters/claude.js +2207 -0
- package/src/lib/adapters/codex-broker-bridge.mjs +298 -0
- package/src/lib/adapters/codex.js +1898 -0
- package/src/lib/adapters/dsh.js +513 -0
- package/src/lib/adapters/goose.js +510 -0
- package/src/lib/adapters/hermes.js +18 -0
- package/src/lib/adapters/index.js +68 -0
- package/src/lib/adapters/opencode.js +1681 -0
- package/src/lib/adapters/pi-commonly-mcp.mjs +71 -0
- package/src/lib/adapters/pi-mcp-client.mjs +617 -0
- package/src/lib/adapters/pi-shell-env.mjs +41 -0
- package/src/lib/adapters/pi.js +1025 -0
- package/src/lib/adapters/qwen.js +523 -0
- package/src/lib/adapters/seat-model.js +8 -0
- package/src/lib/adapters/stub.js +1 -0
- package/src/lib/adapters/tested-version.js +106 -0
- package/src/lib/api.js +205 -0
- package/src/lib/claim-outcome.js +128 -0
- package/src/lib/claimed-work-gate.js +73 -0
- package/src/lib/cli-hint.js +33 -0
- package/src/lib/commit-attribution.js +391 -0
- package/src/lib/config.js +155 -0
- package/src/lib/content-refusal.js +94 -0
- package/src/lib/credential-file.js +208 -0
- package/src/lib/daemon-agent-states.js +95 -0
- package/src/lib/daemon-hint.js +18 -0
- package/src/lib/daemon-logs.js +43 -0
- package/src/lib/daemon-service-windows.js +263 -0
- package/src/lib/daemon-service.js +486 -0
- package/src/lib/daemon-state.js +84 -0
- package/src/lib/daemon-store.js +119 -0
- package/src/lib/daemon-supervisor.js +629 -0
- package/src/lib/daemon-watch.js +115 -0
- package/src/lib/declared-mcp-guard.js +284 -0
- package/src/lib/default-environment.js +207 -0
- package/src/lib/device-login.js +126 -0
- package/src/lib/digest-frame.js +151 -0
- package/src/lib/doctor.js +291 -0
- package/src/lib/egress-proxy.js +160 -0
- package/src/lib/enforcement.js +946 -0
- package/src/lib/environment.js +681 -0
- package/src/lib/first-output-watchdog.js +191 -0
- package/src/lib/git-readonly-server.js +138 -0
- package/src/lib/git-readonly.js +463 -0
- package/src/lib/git-remote-commonly.mjs +100 -0
- package/src/lib/grant-broker-guard.js +345 -0
- package/src/lib/hooks-config.js +238 -0
- package/src/lib/host-gh-broker.js +152 -0
- package/src/lib/host-gh-shim.mjs +98 -0
- package/src/lib/inbox.js +483 -0
- package/src/lib/instance-proxy.js +76 -0
- package/src/lib/machine-host.js +29 -0
- package/src/lib/mcp-credential-delivery.js +121 -0
- package/src/lib/mcp-first-run.js +50 -0
- package/src/lib/mcp-home.js +644 -0
- package/src/lib/mcp-ready.js +68 -0
- package/src/lib/mcp-server-version.js +123 -0
- package/src/lib/mcp-token.js +36 -0
- package/src/lib/mcp-warm-child.mjs +412 -0
- package/src/lib/memory-bridge.js +191 -0
- package/src/lib/memory-import.js +156 -0
- package/src/lib/missing-harness.js +31 -0
- package/src/lib/model-unavailable.js +154 -0
- package/src/lib/native-runtime-inventory.js +40 -0
- package/src/lib/outsider-grant-mcp.js +82 -0
- package/src/lib/own-key-launch.js +42 -0
- package/src/lib/pod-catch-up.js +55 -0
- package/src/lib/pod-focus.js +224 -0
- package/src/lib/pod-tail.js +68 -0
- package/src/lib/poll-liveness.js +103 -0
- package/src/lib/poll-retry.js +81 -0
- package/src/lib/poller.js +200 -0
- package/src/lib/provider-proxy.js +42 -0
- package/src/lib/public-seat-mcp-flag.js +36 -0
- package/src/lib/public-tool-error.js +60 -0
- package/src/lib/public-withheld-tools.js +48 -0
- package/src/lib/reply-receipt.js +55 -0
- package/src/lib/resolve-on-path.js +22 -0
- package/src/lib/runtime-inventory.js +56 -0
- package/src/lib/runtime-observation.js +37 -0
- package/src/lib/sandbox/bwrap.js +275 -0
- package/src/lib/sandbox/codex-perimeter.js +16 -0
- package/src/lib/sandbox/host-check.js +268 -0
- package/src/lib/sandbox/mode.js +49 -0
- package/src/lib/sandbox/opencode-loopback.mjs +68 -0
- package/src/lib/sandbox/seatbelt.js +684 -0
- package/src/lib/sandbox/turn-dir.js +56 -0
- package/src/lib/sandbox/windows-unsandboxed.js +183 -0
- package/src/lib/sandbox/workspace-git-config.js +243 -0
- package/src/lib/sandbox/workspace-pin.js +158 -0
- package/src/lib/seat-runtime-controls.js +61 -0
- package/src/lib/seat-stop.js +97 -0
- package/src/lib/server-skills.js +32 -0
- package/src/lib/session-recycle.js +219 -0
- package/src/lib/session-store.js +215 -0
- package/src/lib/skill-prompt.js +74 -0
- package/src/lib/skills-import.js +113 -0
- package/src/lib/spawn-credential.js +402 -0
- package/src/lib/spawn-retry.js +311 -0
- package/src/lib/stable-install.js +176 -0
- package/src/lib/supervised-turn.js +352 -0
- package/src/lib/task.js +235 -0
- package/src/lib/turn-failure-notice.js +177 -0
- package/src/lib/unconfined-seat-guard.js +94 -0
- package/src/lib/update-check.js +192 -0
- package/src/lib/upstream-refusal.js +1 -0
- package/src/lib/webhook-server.js +109 -0
- package/src/lib/windows-command.js +393 -0
- package/src/lib/workspace-git.js +87 -0
- package/src/lib/workspace-trust.js +162 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a seat's `@commonlyai/mcp` is executed FROM.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists (TASK-174). Every seat's default declaration is
|
|
5
|
+
* `npx -y @commonlyai/mcp@latest` (default-environment.js), and npx names its
|
|
6
|
+
* cache dir by the hash of the SPEC STRING — npm 11.12.1,
|
|
7
|
+
* libnpmexec/lib/index.js:238-250, sha512(spec)[0:16]. So
|
|
8
|
+
* sha512('@commonlyai/mcp@latest')[0:16] = 6d82e98be466b586 is ONE dir shared by
|
|
9
|
+
* every seat, rewritten in place when the first spawn after a publish sees a new
|
|
10
|
+
* version, and every npx run does registry + lock work before the server is even
|
|
11
|
+
* started. Two failures were measured on 2026-09-27: seats lost the server for
|
|
12
|
+
* minutes after the 0.3.13 publish at 10:47:57Z (the in-place rebuild), and Vera
|
|
13
|
+
* got a 30 s CONNECT_TIMEOUT with no publish at all (the shared dir's mtime
|
|
14
|
+
* moves on every npx run, so a slow registry round-trip alone exceeds the
|
|
15
|
+
* client's connect budget).
|
|
16
|
+
*
|
|
17
|
+
* The fix takes npx OFF the spawn path. A spawn executes
|
|
18
|
+
* `node <home>/<version>/node_modules/@commonlyai/mcp/<bin>` for the version a
|
|
19
|
+
* pointer file names; versions are installed by EXACT spec into their own
|
|
20
|
+
* version dir by a background single-flight warm (mcp-warm-child.mjs), and the
|
|
21
|
+
* pointer moves only after the new bin answers MCP `initialize`.
|
|
22
|
+
*
|
|
23
|
+
* The DECLARATION is not touched. `declared-mcp-guard.isShippedCommonlyMcpEntry`
|
|
24
|
+
* still audits `npx -y @commonlyai/mcp@latest` verbatim (TASK-150) — that guard
|
|
25
|
+
* judges what an operator declared, and this module only changes what a spawn
|
|
26
|
+
* executes. A declaration that PINS a version is never rewritten: that is the
|
|
27
|
+
* operator saying which build to run, and the npx cache already gives a pinned
|
|
28
|
+
* spec its own dir.
|
|
29
|
+
*
|
|
30
|
+
* The cold path is pinned too (TASK-174's second half). A spawn with no usable
|
|
31
|
+
* home still runs the declaration, but not with the shared `@latest` spec: the
|
|
32
|
+
* spec this spawn executes is the version the registry already answered with,
|
|
33
|
+
* read from the warm's own cache, so `@latest`'s one shared npx dir is not on
|
|
34
|
+
* the spawn path even once. The fallback is the declaration as written, and it
|
|
35
|
+
* is used exactly when nothing has ever resolved a version on this host — a
|
|
36
|
+
* fresh machine's first spawn, where `@latest` is also the only spec that could
|
|
37
|
+
* work — or when the cache is unreadable. Nothing here throws.
|
|
38
|
+
*
|
|
39
|
+
* Every filesystem answer here is injected, because the two states that matter
|
|
40
|
+
* (a home that has never been warmed, and a home whose pointed-at version dir was
|
|
41
|
+
* deleted under it) cannot both be produced on one host.
|
|
42
|
+
*/
|
|
43
|
+
import { spawn } from 'node:child_process';
|
|
44
|
+
import {
|
|
45
|
+
existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync,
|
|
46
|
+
} from 'node:fs';
|
|
47
|
+
import { homedir } from 'node:os';
|
|
48
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
49
|
+
import { fileURLToPath } from 'node:url';
|
|
50
|
+
import { isShippedCommonlyMcpCommand } from './declared-mcp-guard.js';
|
|
51
|
+
import { MCP_PACKAGE, exactVersion, parseVersion } from './mcp-server-version.js';
|
|
52
|
+
|
|
53
|
+
/** The env override, so tests (and an operator) can point the home elsewhere. */
|
|
54
|
+
export const MCP_HOME_ENV = 'COMMONLY_MCP_HOME';
|
|
55
|
+
/**
|
|
56
|
+
* TASK-273: a public seat whose MCP server has no host-prepared build does not
|
|
57
|
+
* run the turn. Spawning without its tools left the model nothing to do but
|
|
58
|
+
* narrate the failure, and the run loop posted that narration to a stranger
|
|
59
|
+
* (Commonly HQ, 2026-10-15: "The commonly MCP server failed to connect… check
|
|
60
|
+
* the MCP server config"). Refusing before the model call posts nothing: the
|
|
61
|
+
* turn fails, the event stays unacked and is retried once the warm finishes,
|
|
62
|
+
* and the seat log names the reason. Every adapter raises this one error.
|
|
63
|
+
*/
|
|
64
|
+
export const PUBLIC_MCP_UNAVAILABLE_CODE = 'public_mcp_unavailable';
|
|
65
|
+
export const publicMcpUnavailableError = (names) => {
|
|
66
|
+
const error = new Error(
|
|
67
|
+
`public seat refused this turn: MCP server ${names.join(', ')} has no host-prepared build yet, `
|
|
68
|
+
+ 'so the model was not run and nothing was posted',
|
|
69
|
+
);
|
|
70
|
+
error.code = PUBLIC_MCP_UNAVAILABLE_CODE;
|
|
71
|
+
return error;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const WARM_LOCK_NAME = '.warm.lock';
|
|
75
|
+
export const REGISTRY_CACHE_NAME = '.registry.json';
|
|
76
|
+
|
|
77
|
+
/** How long a cached registry answer is trusted before the warm re-checks. */
|
|
78
|
+
export const REGISTRY_TTL_MS = 15 * 60 * 1000;
|
|
79
|
+
|
|
80
|
+
/** A lock older than this is a warm that died; reclaim rather than wedge. */
|
|
81
|
+
export const STALE_LOCK_MS = 10 * 60 * 1000;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Version dirs kept after a successful advance.
|
|
85
|
+
*
|
|
86
|
+
* This is a DISK BOUND, not the safety rule. Retention by count cannot express
|
|
87
|
+
* "not while someone is running it": keeping the newest three protects the
|
|
88
|
+
* newest three, and a seat still executing the fourth-newest dir is exactly the
|
|
89
|
+
* case that matters. A DIR IS REMOVABLE only when it is not `current`, carries
|
|
90
|
+
* no LIVE liveness claim, and is past `PRUNE_GRACE_MS` (Vera, 74878/74887).
|
|
91
|
+
*/
|
|
92
|
+
export const KEEP_VERSION_DIRS = 3;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A version dir younger than this is never pruned, even with no live claimant.
|
|
96
|
+
*
|
|
97
|
+
* Time, not count, is what bounds the exposure: a dir inside the window may
|
|
98
|
+
* belong to a spawn that is starting up and has not written its claim yet, or
|
|
99
|
+
* whose claim was written by a pid the kernel has since recycled.
|
|
100
|
+
*/
|
|
101
|
+
export const PRUNE_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
102
|
+
|
|
103
|
+
/** Where a spawn records that a process may still be executing from a version. */
|
|
104
|
+
export const IN_USE_DIR_NAME = '.inuse';
|
|
105
|
+
|
|
106
|
+
/** The warm child's own exit codes, read by the spawn path's logs. */
|
|
107
|
+
export const WARM_RESULTS = Object.freeze({
|
|
108
|
+
LOCKED: 'locked',
|
|
109
|
+
CURRENT: 'current',
|
|
110
|
+
ADVANCED: 'advanced',
|
|
111
|
+
REGISTRY_UNAVAILABLE: 'registry-unavailable',
|
|
112
|
+
INSTALL_FAILED: 'install-failed',
|
|
113
|
+
PROBE_FAILED: 'probe-failed',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export const mcpHomeDir = (env = process.env) => {
|
|
117
|
+
const configured = env && env[MCP_HOME_ENV];
|
|
118
|
+
// A DEFECT THIS LITERALLY PREVENTED, 2026-09-27: `process.env.X = undefined`
|
|
119
|
+
// does not unset X, it sets the STRING "undefined" (Node coerces env values),
|
|
120
|
+
// so a caller that saved and restored an unset override handed the warm the
|
|
121
|
+
// home `./undefined/` — which it created, populated and pointed at, inside the
|
|
122
|
+
// package directory. This value is a PATH; a value that is not a plausible
|
|
123
|
+
// path is not a home, and the real home is the safe answer.
|
|
124
|
+
if (typeof configured === 'string' && configured && configured !== 'undefined' && configured !== 'null') {
|
|
125
|
+
return configured;
|
|
126
|
+
}
|
|
127
|
+
return join(homedir(), '.commonly', 'mcp');
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** True for a home a warm may write into: a real, absolute-looking path. */
|
|
131
|
+
export const isUsableWarmHome = (home) => (
|
|
132
|
+
typeof home === 'string'
|
|
133
|
+
&& home.length > 1
|
|
134
|
+
&& home !== 'undefined'
|
|
135
|
+
&& home !== 'null'
|
|
136
|
+
&& (home.startsWith('/') || /^[A-Za-z]:[\\/]/.test(home))
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
/** The home a caller asked for, or the configured one — never a junk string. */
|
|
140
|
+
export const resolveHomeDir = (home) => (isUsableWarmHome(home) ? home : mcpHomeDir());
|
|
141
|
+
|
|
142
|
+
export const currentPointerPath = (home) => join(home, 'current');
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Where a version dir name that is not a triple lands: inside the home, and
|
|
146
|
+
* never a version (a version starts with a digit), so every read of it misses
|
|
147
|
+
* and the spawn falls back to its declaration. The alternative — building the
|
|
148
|
+
* path from whatever string arrived — is how `0.3.13/../../../../tmp/x` resolved
|
|
149
|
+
* outside the home, and #1970's `startsWith(pkgDir)` confinement is computed
|
|
150
|
+
* FROM that dir, so it would have confined to the wrong place (Vera, 74938).
|
|
151
|
+
*/
|
|
152
|
+
const UNUSABLE_VERSION_DIR = '.unusable-version';
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A version dir, built from the parsed triple — and only when the string given
|
|
156
|
+
* IS that triple.
|
|
157
|
+
*
|
|
158
|
+
* Refusing beats rebuilding here: this result is what the warm RENAMES into and
|
|
159
|
+
* what the pruner REMOVES, so silently rewriting `0.3.14-rc.1` to `0.3.14` would
|
|
160
|
+
* aim both at a different build. Normalising belongs at the producer of a
|
|
161
|
+
* version value (`resolveRegistryLatest`, `readCurrentVersion`,
|
|
162
|
+
* `readRegistryCache`, `pinShippedSpec`), where the choice is deliberate and
|
|
163
|
+
* visible; a producer that names something else gets a name no version can have.
|
|
164
|
+
*/
|
|
165
|
+
export const versionDirFor = (home, version) => {
|
|
166
|
+
const triple = exactVersion(version);
|
|
167
|
+
const name = triple && triple === String(version).trim() ? triple : UNUSABLE_VERSION_DIR;
|
|
168
|
+
return join(home, name);
|
|
169
|
+
};
|
|
170
|
+
export const packageDirFor = (home, version) => join(versionDirFor(home, version), 'node_modules', MCP_PACKAGE);
|
|
171
|
+
export const lockPathFor = (home) => join(home, WARM_LOCK_NAME);
|
|
172
|
+
|
|
173
|
+
/** `<home>/<version>/.inuse/` — one empty file per claiming pid. */
|
|
174
|
+
export const inUseDirFor = (home, version) => join(versionDirFor(home, version), IN_USE_DIR_NAME);
|
|
175
|
+
export const inUseMarkerPath = (home, version, pid) => join(inUseDirFor(home, version), String(pid));
|
|
176
|
+
|
|
177
|
+
/** A pid is a positive integer, or it is not a pid. */
|
|
178
|
+
const parsePid = (value) => {
|
|
179
|
+
const pid = Number(value);
|
|
180
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Is this pid still running? `kill(pid, 0)` asks the kernel and sends nothing.
|
|
185
|
+
*
|
|
186
|
+
* EPERM means the pid exists and belongs to another user — alive. ESRCH means
|
|
187
|
+
* gone — dead. Anything else, including a value that was never a pid, is DEAD,
|
|
188
|
+
* because the two errors are not symmetric: guessing "alive" pins a version dir
|
|
189
|
+
* forever (unbounded disk with extra steps), while guessing "dead" costs one
|
|
190
|
+
* reinstall on a later warm.
|
|
191
|
+
*/
|
|
192
|
+
export const isPidAlive = (pid, { kill = process.kill } = {}) => {
|
|
193
|
+
const parsed = parsePid(pid);
|
|
194
|
+
if (parsed === null) return false;
|
|
195
|
+
try {
|
|
196
|
+
kill(parsed, 0);
|
|
197
|
+
return true;
|
|
198
|
+
} catch (error) {
|
|
199
|
+
return Boolean(error) && error.code === 'EPERM';
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The claimants under a version dir that are still running.
|
|
205
|
+
*
|
|
206
|
+
* The marker's PRESENCE is not the signal — a seat that is killed or crashes
|
|
207
|
+
* leaves its file behind, and a pruner that read presence would never remove
|
|
208
|
+
* that version again. The check is whether the claimant still exists; a dead or
|
|
209
|
+
* unparseable marker counts as absent.
|
|
210
|
+
*/
|
|
211
|
+
export const liveInUsePids = (home, version, {
|
|
212
|
+
readDir = (dir) => {
|
|
213
|
+
try {
|
|
214
|
+
return readdirSync(dir);
|
|
215
|
+
} catch {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
isAlive = isPidAlive,
|
|
220
|
+
} = {}) => readDir(inUseDirFor(home, version))
|
|
221
|
+
.map(parsePid)
|
|
222
|
+
.filter((pid) => pid !== null && isAlive(pid));
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Claim a version dir for a process that may be executing from it.
|
|
226
|
+
*
|
|
227
|
+
* Called by `prepareMcpSpawn`, keyed by THIS process: the MCP server runs as a
|
|
228
|
+
* descendant of the process that materialised the command, so while that process
|
|
229
|
+
* lives its version dir must not be pruned. A claim that cannot be written is
|
|
230
|
+
* reported, never swallowed: `prepareMcpSpawn` refuses to exec from a dir it
|
|
231
|
+
* could not claim (Vera 74903), because an unclaimed dir is one the pruner is
|
|
232
|
+
* free to `rm -rf` while this seat is running out of it.
|
|
233
|
+
*/
|
|
234
|
+
export const claimInUse = ({
|
|
235
|
+
home,
|
|
236
|
+
version,
|
|
237
|
+
pid = process.pid,
|
|
238
|
+
mkdir = mkdirSync,
|
|
239
|
+
writeFile = writeFileSync,
|
|
240
|
+
} = {}) => {
|
|
241
|
+
try {
|
|
242
|
+
if (!isUsableWarmHome(home) || !parseVersion(version) || parsePid(pid) === null) {
|
|
243
|
+
return { claimed: false, reason: 'not_claimable' };
|
|
244
|
+
}
|
|
245
|
+
mkdir(inUseDirFor(home, version), { recursive: true });
|
|
246
|
+
writeFile(inUseMarkerPath(home, version, pid), '');
|
|
247
|
+
return { claimed: true, reason: null };
|
|
248
|
+
} catch (error) {
|
|
249
|
+
// The errno is the difference between a home nobody can write to and a
|
|
250
|
+
// `.inuse` path that is a file, and only one of those is worth a log line
|
|
251
|
+
// that says which.
|
|
252
|
+
return { claimed: false, reason: (error && error.code) || 'claim_failed' };
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Say it out loud. A seat whose claim failed is still a seat, but it must not be
|
|
258
|
+
* a silent one: warn() is injectable so a test can hold the sentence.
|
|
259
|
+
*/
|
|
260
|
+
const defaultWarn = (line) => {
|
|
261
|
+
try {
|
|
262
|
+
process.stderr.write(`${line}\n`);
|
|
263
|
+
} catch { /* a seat that cannot warn still runs */ }
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const defaultRead = (path) => {
|
|
267
|
+
try {
|
|
268
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const defaultExists = (path) => {
|
|
275
|
+
try {
|
|
276
|
+
return existsSync(path);
|
|
277
|
+
} catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The version the pointer file names, or null.
|
|
284
|
+
*
|
|
285
|
+
* A pointer that does not parse is treated as no pointer at all: a spawn must
|
|
286
|
+
* never build a path out of a string it did not validate, and the fallback is
|
|
287
|
+
* the declared command, not a guess.
|
|
288
|
+
*/
|
|
289
|
+
export const readCurrentVersion = (home, { readFile = defaultRead } = {}) => {
|
|
290
|
+
const raw = readFile(currentPointerPath(home));
|
|
291
|
+
if (typeof raw !== 'string') return null;
|
|
292
|
+
const version = exactVersion(raw.trim());
|
|
293
|
+
return version || null;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The bin a package.json beside a script names, resolved — never guessed.
|
|
298
|
+
*
|
|
299
|
+
* `bin` is `{ 'commonly-mcp': 'src/index.js' }` at 0.3.13 and a bare string in
|
|
300
|
+
* older releases, so both shapes are read. A dir whose bin is missing on disk is
|
|
301
|
+
* not a build this seat can run: returning null there is what sends a spawn back
|
|
302
|
+
* to the declared command instead of exec'ing a path that does not exist.
|
|
303
|
+
*/
|
|
304
|
+
const readBinInPackageDir = (pkgDir, {
|
|
305
|
+
readFile = defaultRead, exists = defaultExists,
|
|
306
|
+
} = {}) => {
|
|
307
|
+
const raw = readFile(join(pkgDir, 'package.json'));
|
|
308
|
+
if (typeof raw !== 'string') return null;
|
|
309
|
+
let pkg;
|
|
310
|
+
try {
|
|
311
|
+
pkg = JSON.parse(raw);
|
|
312
|
+
} catch {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
if (!pkg || typeof pkg !== 'object') return null;
|
|
316
|
+
// A package.json naming another package settles it: this dir is not ours.
|
|
317
|
+
if (pkg.name !== undefined && pkg.name !== MCP_PACKAGE) return null;
|
|
318
|
+
const bin = pkg.bin;
|
|
319
|
+
let relative = null;
|
|
320
|
+
if (typeof bin === 'string' && bin) {
|
|
321
|
+
relative = bin;
|
|
322
|
+
} else if (bin && typeof bin === 'object') {
|
|
323
|
+
const keys = Object.keys(bin).filter((k) => typeof bin[k] === 'string' && bin[k]);
|
|
324
|
+
if (keys.length === 1) [relative] = keys.map((k) => bin[k]);
|
|
325
|
+
else if (typeof bin['commonly-mcp'] === 'string' && bin['commonly-mcp']) relative = bin['commonly-mcp'];
|
|
326
|
+
}
|
|
327
|
+
if (!relative) return null;
|
|
328
|
+
const binPath = join(pkgDir, relative);
|
|
329
|
+
// `join` resolves `..`, so this is the whole confinement. The package is our
|
|
330
|
+
// own published build and today `bin` cannot escape — but a check that costs
|
|
331
|
+
// one line makes the exec path self-evident instead of trusted (Vera, 74880).
|
|
332
|
+
if (!binPath.startsWith(`${pkgDir}${sep}`)) return null;
|
|
333
|
+
return exists(binPath) ? binPath : null;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/** The bin of a version dir inside the seat's home. */
|
|
337
|
+
export const readBinPath = (home, version, options = {}) => {
|
|
338
|
+
// Truthiness of `parseVersion` is truthiness of `exactVersion`; the check that
|
|
339
|
+
// matters here is `versionDirFor` refusing a name that is not a triple, so a
|
|
340
|
+
// reader naming `0.3.14-rc.1` gets nothing rather than 0.3.14's build.
|
|
341
|
+
if (!parseVersion(version)) return null;
|
|
342
|
+
return readBinInPackageDir(packageDirFor(home, version), options);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
/** Read grant for only the verified warm build selected for this spawn. */
|
|
346
|
+
export const selectedMcpVersionDir = (command, home = mcpHomeDir()) => {
|
|
347
|
+
if (!Array.isArray(command) || command.length !== 2 || command[0] !== 'node') return null;
|
|
348
|
+
const version = relative(home, command[1]).split(sep)[0];
|
|
349
|
+
if (exactVersion(version) !== version || readBinPath(home, version) !== command[1]) return null;
|
|
350
|
+
return versionDirFor(home, version);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Whether the warm build this spawn runs writes the ready file (F421).
|
|
355
|
+
*
|
|
356
|
+
* A public OpenCode seat's stream reports nothing about its MCP servers, so the
|
|
357
|
+
* adapter names a file the Commonly server creates once its host reaches it
|
|
358
|
+
* (commonly-mcp/src/ready-file.js) and withholds a turn that ends without it.
|
|
359
|
+
* Only a release that says so in its package.json (`commonly.readyFile`) is
|
|
360
|
+
* asked: a build from before the file existed would leave every turn withheld.
|
|
361
|
+
* Anything this cannot read or verify answers false, which keeps the turn as it
|
|
362
|
+
* was before F421.
|
|
363
|
+
*/
|
|
364
|
+
export const selectedMcpWritesReadyFile = (command, home = mcpHomeDir(), { readFile = defaultRead } = {}) => {
|
|
365
|
+
if (!selectedMcpVersionDir(command, home)) return false;
|
|
366
|
+
const version = relative(home, command[1]).split(sep)[0];
|
|
367
|
+
const raw = readFile(join(packageDirFor(home, version), 'package.json'));
|
|
368
|
+
if (typeof raw !== 'string') return false;
|
|
369
|
+
try {
|
|
370
|
+
const pkg = JSON.parse(raw);
|
|
371
|
+
return pkg?.name === MCP_PACKAGE && pkg?.commonly?.readyFile === true;
|
|
372
|
+
} catch {
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The bin of an npm PREFIX dir, which is the shape the warm installs into:
|
|
379
|
+
* `npm install --prefix <dir>` leaves `<dir>/node_modules/...`, with no version
|
|
380
|
+
* component in the path. Reading the two shapes with one function would mean
|
|
381
|
+
* looking for a version dir inside the temp prefix and concluding the install
|
|
382
|
+
* failed when it did not.
|
|
383
|
+
*/
|
|
384
|
+
export const readBinInPrefix = (prefix, options = {}) => (
|
|
385
|
+
readBinInPackageDir(join(prefix, 'node_modules', MCP_PACKAGE), options)
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* The newest version dir in the home that actually has a runnable bin.
|
|
390
|
+
*
|
|
391
|
+
* The pointer is only written after a verified warm, so it is normally the whole
|
|
392
|
+
* answer. This is the recovery path for the case the pointer cannot cover: the
|
|
393
|
+
* pointed-at version dir deleted under a live seat (hand-pruned, a wiped home,
|
|
394
|
+
* npm falling back to a reinstall). Falling back to the next-newest local build
|
|
395
|
+
* keeps the spawn off npx even then, which is the whole point of the change.
|
|
396
|
+
*/
|
|
397
|
+
export const newestInstalledVersion = (home, {
|
|
398
|
+
readDir = (dir) => {
|
|
399
|
+
try {
|
|
400
|
+
return readdirSync(dir);
|
|
401
|
+
} catch {
|
|
402
|
+
return [];
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
readBin = (h, v) => readBinPath(h, v),
|
|
406
|
+
} = {}) => {
|
|
407
|
+
const versions = readDir(home)
|
|
408
|
+
// An exact triple, not a prefix match: a dir literally named `0.3.13-rc.1`
|
|
409
|
+
// is not `0.3.13`, and normalising the name would point this read at a dir
|
|
410
|
+
// the caller never mentioned.
|
|
411
|
+
.filter((name) => exactVersion(name) === name)
|
|
412
|
+
.filter((name) => readBin(home, name))
|
|
413
|
+
.sort((a, b) => {
|
|
414
|
+
const [am, an, ap] = parseVersion(a);
|
|
415
|
+
const [bm, bn, bp] = parseVersion(b);
|
|
416
|
+
return (am - bm) || (an - bn) || (ap - bp);
|
|
417
|
+
});
|
|
418
|
+
return versions.length ? versions[versions.length - 1] : null;
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
/** True for the shipped, UNPINNED spec — the only command shape this rewrites. */
|
|
422
|
+
const isUnpinnedShippedSpec = (spec) => (
|
|
423
|
+
spec === MCP_PACKAGE || spec === `${MCP_PACKAGE}@latest`
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* The shipped command with its spec pinned to `version`, or the command
|
|
428
|
+
* unchanged when there is no version to pin to.
|
|
429
|
+
*
|
|
430
|
+
* Only slot 2 is touched, and only after `isShippedCommonlyMcpCommand` has
|
|
431
|
+
* proven the slot is the package spec — so `npx`, its `-y` and every other
|
|
432
|
+
* entry survive byte-for-byte, and a command that is not the shipped 3-arg form
|
|
433
|
+
* cannot be rewritten by construction. npx keys its cache dir by the spec
|
|
434
|
+
* string, which is the whole point: `@latest` is one dir every seat shares and a
|
|
435
|
+
* publish rewrites in place, while `@0.3.13` is a dir only this version ever
|
|
436
|
+
* uses.
|
|
437
|
+
*/
|
|
438
|
+
export const pinShippedSpec = (command, version) => {
|
|
439
|
+
// The triple, not the string: this spec is an argv entry we execute, so a
|
|
440
|
+
// version carrying a suffix must not reach it.
|
|
441
|
+
const safe = exactVersion(version);
|
|
442
|
+
if (!Array.isArray(command) || !safe) return command;
|
|
443
|
+
let changed = false;
|
|
444
|
+
const pinned = command.map((part, index) => {
|
|
445
|
+
// Slot 2 only. `isShippedCommonlyMcpCommand` already proves the package sits
|
|
446
|
+
// in slot 2 of a 3-arg command, so this bound is not reachable today — it is
|
|
447
|
+
// here so the function is safe to read on its own, and it is armed in the
|
|
448
|
+
// tests rather than argued from the shape check (Vera, 74940).
|
|
449
|
+
if (index !== 2 || !isUnpinnedShippedSpec(String(part))) return part;
|
|
450
|
+
changed = true;
|
|
451
|
+
return `${MCP_PACKAGE}@${safe}`;
|
|
452
|
+
});
|
|
453
|
+
// The same reference when nothing changed, so a caller can use identity as the
|
|
454
|
+
// "did this spawn's spec move" check — the convention the rest of this module
|
|
455
|
+
// already keeps (`ensureCommonlyMcpServer`, `withholdGrantBroker`).
|
|
456
|
+
return changed ? pinned : command;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* What a spawn should execute, and where that decision came from.
|
|
461
|
+
*
|
|
462
|
+
* `source: 'home'` — the command is `node <bin>` from the version dir, no npx
|
|
463
|
+
* and no network. `source: 'declared'` — the declaration runs as written, which
|
|
464
|
+
* is either an operator's pinned spec or an EMPTY HOME (fresh host, or a home
|
|
465
|
+
* that was deleted). The empty-home path is the old failure mode and it is
|
|
466
|
+
* accepted, written down, and once per empty home rather than once per publish.
|
|
467
|
+
* `reason` names which of those it was, for the spawn path's logs.
|
|
468
|
+
*/
|
|
469
|
+
export const planMcpSpawn = (command, {
|
|
470
|
+
home,
|
|
471
|
+
readFile = defaultRead,
|
|
472
|
+
exists = defaultExists,
|
|
473
|
+
readBin = (h, v) => readBinPath(h, v, { readFile, exists }),
|
|
474
|
+
newest = (h) => newestInstalledVersion(h, { readBin }),
|
|
475
|
+
} = {}) => {
|
|
476
|
+
const target = resolveHomeDir(home);
|
|
477
|
+
if (!isShippedCommonlyMcpCommand(command) || !isUnpinnedShippedSpec(String(command[2]))) {
|
|
478
|
+
return { command, source: 'declared', version: null, reason: 'not-the-shipped-unpinned-spec' };
|
|
479
|
+
}
|
|
480
|
+
const pointer = readCurrentVersion(target, { readFile });
|
|
481
|
+
if (pointer) {
|
|
482
|
+
const bin = readBin(target, pointer);
|
|
483
|
+
if (bin) return { command: ['node', bin], source: 'home', version: pointer, reason: 'pointer' };
|
|
484
|
+
}
|
|
485
|
+
const fallback = newest(target);
|
|
486
|
+
if (fallback) {
|
|
487
|
+
const bin = readBin(target, fallback);
|
|
488
|
+
if (bin) {
|
|
489
|
+
return {
|
|
490
|
+
command: ['node', bin],
|
|
491
|
+
source: 'home',
|
|
492
|
+
version: fallback,
|
|
493
|
+
reason: pointer ? 'pointer-bin-missing' : 'no-pointer',
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// No usable home, so the declaration is what runs — and the one thing worth
|
|
498
|
+
// changing about it is the spec string. The cached registry answer is used
|
|
499
|
+
// rather than a fresh lookup because this function is synchronous and on the
|
|
500
|
+
// spawn's critical path, and the warm refreshes that same cache on the same
|
|
501
|
+
// TTL (`REGISTRY_TTL_MS`). A stale-but-real version still gives this run its
|
|
502
|
+
// own npx dir, which `@latest` does not, and a stale cache means the registry
|
|
503
|
+
// has been answering nothing for a while — the case where `@latest` is least
|
|
504
|
+
// usable. `pinned` is reported so the caller can log which spec it ran.
|
|
505
|
+
const cached = readRegistryCache(target, { readFile });
|
|
506
|
+
const pinned = cached ? pinShippedSpec(command, cached.version) : command;
|
|
507
|
+
return {
|
|
508
|
+
command: pinned,
|
|
509
|
+
source: 'declared',
|
|
510
|
+
version: null,
|
|
511
|
+
reason: 'home-empty',
|
|
512
|
+
pinned: cached ? cached.version : null,
|
|
513
|
+
};
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
/** The warm's cached registry answer, with its age. */
|
|
517
|
+
export const readRegistryCache = (home, { readFile = defaultRead } = {}) => {
|
|
518
|
+
const raw = readFile(join(home, REGISTRY_CACHE_NAME));
|
|
519
|
+
if (typeof raw !== 'string') return null;
|
|
520
|
+
try {
|
|
521
|
+
const parsed = JSON.parse(raw);
|
|
522
|
+
const version = exactVersion(parsed && parsed.version);
|
|
523
|
+
const checkedAt = parsed && Number.isFinite(parsed.checkedAt) ? parsed.checkedAt : null;
|
|
524
|
+
if (!version || checkedAt === null) return null;
|
|
525
|
+
return { version, checkedAt };
|
|
526
|
+
} catch {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
/** True when the cached "latest" is newer than what this spawn is about to run. */
|
|
532
|
+
export const warmLooksWanted = (home, version, { now = Date.now(), readFile = defaultRead } = {}) => {
|
|
533
|
+
const cached = readRegistryCache(home, { readFile });
|
|
534
|
+
if (!cached) return true;
|
|
535
|
+
if (now - cached.checkedAt > REGISTRY_TTL_MS) return true;
|
|
536
|
+
if (!version) return true;
|
|
537
|
+
const target = parseVersion(cached.version);
|
|
538
|
+
const running = parseVersion(version);
|
|
539
|
+
if (!target || !running) return true;
|
|
540
|
+
return (target[0] > running[0])
|
|
541
|
+
|| (target[0] === running[0] && target[1] > running[1])
|
|
542
|
+
|| (target[0] === running[0] && target[1] === running[1] && target[2] > running[2]);
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
export const warmChildPath = () => join(dirname(fileURLToPath(import.meta.url)), 'mcp-warm-child.mjs');
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Start the warm and walk away. Detached + unref'd, so a seat that spawns and
|
|
549
|
+
* exits does not wait on it, and the child is written to never throw at the
|
|
550
|
+
* parent: a warm that fails costs the NEXT spawn nothing but the old version.
|
|
551
|
+
*/
|
|
552
|
+
export const kickWarm = ({
|
|
553
|
+
home,
|
|
554
|
+
apiUrl = null,
|
|
555
|
+
spawnImpl = spawn,
|
|
556
|
+
childPath = warmChildPath(),
|
|
557
|
+
} = {}) => {
|
|
558
|
+
try {
|
|
559
|
+
// argv entries are coerced with String(), so an undefined home reaches the
|
|
560
|
+
// child as the literal 'undefined' and it warms a directory by that name.
|
|
561
|
+
const targetHome = resolveHomeDir(home);
|
|
562
|
+
const child = spawnImpl(process.execPath, [childPath, targetHome], {
|
|
563
|
+
detached: true,
|
|
564
|
+
stdio: 'ignore',
|
|
565
|
+
env: {
|
|
566
|
+
...process.env,
|
|
567
|
+
COMMONLY_MCP_HOME: targetHome,
|
|
568
|
+
...(apiUrl ? { COMMONLY_WARM_API_URL: String(apiUrl) } : {}),
|
|
569
|
+
},
|
|
570
|
+
});
|
|
571
|
+
if (child && typeof child.unref === 'function') child.unref();
|
|
572
|
+
return true;
|
|
573
|
+
} catch {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
const lockPresent = (home, { exists = defaultExists } = {}) => exists(lockPathFor(home));
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* The one entry the adapters call: what to execute, plus a warm if one is due.
|
|
582
|
+
*
|
|
583
|
+
* Sync and network-free by construction — the registry check, the install and
|
|
584
|
+
* the probe all live in the child. A lock already held means another spawn is
|
|
585
|
+
* warming, so this spawn just runs the current version. Public seats receive
|
|
586
|
+
* null if that version is unavailable and the fallback is a package runner;
|
|
587
|
+
* adapters omit that MCP declaration and tell the seat.
|
|
588
|
+
*/
|
|
589
|
+
export const prepareMcpSpawn = (command, {
|
|
590
|
+
home,
|
|
591
|
+
apiUrl = null,
|
|
592
|
+
publicSeat = false,
|
|
593
|
+
spawnImpl,
|
|
594
|
+
childPath,
|
|
595
|
+
now = Date.now(),
|
|
596
|
+
readFile = defaultRead,
|
|
597
|
+
exists = defaultExists,
|
|
598
|
+
warn = defaultWarn,
|
|
599
|
+
} = {}) => {
|
|
600
|
+
const resolvedHome = resolveHomeDir(home);
|
|
601
|
+
try {
|
|
602
|
+
const plan = planMcpSpawn(command, { home: resolvedHome, readFile, exists });
|
|
603
|
+
// Claim the version this spawn is about to execute from, BEFORE returning the
|
|
604
|
+
// command, so a prune that runs while the seat is starting cannot take it.
|
|
605
|
+
let resolved = plan.command;
|
|
606
|
+
if (plan.source === 'home') {
|
|
607
|
+
const claim = claimInUse({ home: resolvedHome, version: plan.version });
|
|
608
|
+
// A claim that could not be written means the pruner may take this very
|
|
609
|
+
// directory out from under the running seat — the hazard the claim exists
|
|
610
|
+
// for. The declared command is what the guard approved and what no pruner
|
|
611
|
+
// owns, so the spawn falls back to it rather than executing unprotected,
|
|
612
|
+
// and says why. The warm still runs: with the seat off the dir, a prune or
|
|
613
|
+
// a repair is safe.
|
|
614
|
+
if (!claim.claimed) {
|
|
615
|
+
warn(`commonly: cannot claim ${versionDirFor(resolvedHome, plan.version)} for pid ${process.pid} (${claim.reason}) — running the declared command instead, because a prune could delete the directory this seat would run from`);
|
|
616
|
+
resolved = command;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const due = !lockPresent(resolvedHome, { exists })
|
|
620
|
+
&& warmLooksWanted(resolvedHome, plan.version, { now, readFile });
|
|
621
|
+
if (due) kickWarm({ home: resolvedHome, apiUrl, spawnImpl, childPath });
|
|
622
|
+
if (publicSeat && resolved[0] === 'npx') {
|
|
623
|
+
warn('commonly: an MCP server is unavailable for this public turn; no host-prepared executable was ready');
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
return resolved;
|
|
627
|
+
} catch {
|
|
628
|
+
// Trusted seats retain the declared fallback. A public seat must not
|
|
629
|
+
// execute a package runner from the workspace even on this failure path.
|
|
630
|
+
if (publicSeat && command?.[0] === 'npx') return null;
|
|
631
|
+
return command;
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
/** Kept for callers that want the plan and the command in one call. */
|
|
636
|
+
export const resolveMcpSpawnCommand = prepareMcpSpawn;
|
|
637
|
+
|
|
638
|
+
export const statOrNull = (path) => {
|
|
639
|
+
try {
|
|
640
|
+
return statSync(path);
|
|
641
|
+
} catch {
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
};
|