@sigrun/sigrun 0.0.0-stage → 0.1.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/CHANGELOG.md +214 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +89 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- package/webui/dist/index.html +37 -0
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot-time extension package install (the pi-style packages dir).
|
|
3
|
+
*
|
|
4
|
+
* Sigrun deployments declare extensions in the settings file's unified
|
|
5
|
+
* top-level `extensions` list (an `ExtensionSpec`: a string source or
|
|
6
|
+
* `{source, entry?, ref?, enabled?}`) and restart — no image rebuild,
|
|
7
|
+
* no manual npm. The `SIGRUN_EXTENSIONS` env var (a comma-separated
|
|
8
|
+
* specifier list) REPLACES the file's list when set — env wins, validated
|
|
9
|
+
* first; it is the one environment-shaped override of the declaration.
|
|
10
|
+
*
|
|
11
|
+
* The install list is derived from the entries' sources:
|
|
12
|
+
* `normalizeExtSpec` flattens each entry, and `sourceKind` routes it —
|
|
13
|
+
* npm-shaped sources (registry names/ranges/scoped specifiers, and
|
|
14
|
+
* `file:` installable specifiers) install into the packages dir; git
|
|
15
|
+
* sources (`git:` or an https `….git` URL, optional `@ref`) clone/fetch
|
|
16
|
+
* into the git dir (`<config-dir>/git/`) with entry discovery; bare
|
|
17
|
+
* `./`/`../`/absolute paths LOAD IN PLACE (nothing installed). A `file:`
|
|
18
|
+
* entry is an INSTALLABLE npm specifier keyed by the TARGET's own
|
|
19
|
+
* manifest name and compared by canonical absolute path; a `file:`-less
|
|
20
|
+
* relative/absolute path never installs. `enabled: false` entries keep
|
|
21
|
+
* their directory state but produce no loadable resolution. The packages
|
|
22
|
+
* live in a dedicated section of the config directory
|
|
23
|
+
* (`<config-dir>/packages/`, a sibling of `settings.json` and
|
|
24
|
+
* `auth.json`), which is the PVC-backed path in every deployment shape,
|
|
25
|
+
* so installed packages persist across restarts.
|
|
26
|
+
*
|
|
27
|
+
* The directory is a REAL npm root, exactly pi's shape (`~/.pi/agent/npm`):
|
|
28
|
+
* a synthetic private root `package.json` (`{"name": "sigrun-packages",
|
|
29
|
+
* "private": true, "dependencies": {…}}`) plus `node_modules/`. The root
|
|
30
|
+
* manifest's `dependencies` map IS the resolved state (name → range);
|
|
31
|
+
* the settings file's npm-shaped `extensions` entries are the
|
|
32
|
+
* user-authored INTENT. Boot diffs the two (`planSync`):
|
|
33
|
+
*
|
|
34
|
+
* - a wanted specifier whose package is missing from (or whose range or
|
|
35
|
+
* canonical path differs in) the dependencies map → `npm install
|
|
36
|
+
* <changed specifiers> --prefix <packagesDir>`;
|
|
37
|
+
* - a dependencies entry named by NO wanted specifier → `npm uninstall
|
|
38
|
+
* <name> --prefix <packagesDir>` (a removed declaration prunes);
|
|
39
|
+
* - no diff → no subprocess at all (the fast path; a steady-state boot
|
|
40
|
+
* costs one file read).
|
|
41
|
+
*
|
|
42
|
+
* Fail-loud is the posture: an install or uninstall that fails, times out
|
|
43
|
+
* (120s), or leaves the declared package unresolved fails the BOOT, naming
|
|
44
|
+
* the specifier — an extension declared but uninstallable is a config
|
|
45
|
+
* error, exactly like a bad `SIGRUN_*` value.
|
|
46
|
+
*
|
|
47
|
+
* SECURITY: `npm install` executes the installed packages' lifecycle
|
|
48
|
+
* scripts (`postinstall` and friends). The packages dir is
|
|
49
|
+
* operator-configured — the specifiers the operator declares carry the
|
|
50
|
+
* same trust level as the extension code they name (which runs IN the
|
|
51
|
+
* harness with full tool authority). This is pi's documented posture too:
|
|
52
|
+
* the dir is trusted by configuration, not sandboxed.
|
|
53
|
+
*/
|
|
54
|
+
import { createHash } from "node:crypto";
|
|
55
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
56
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs";
|
|
57
|
+
import { createRequire } from "node:module";
|
|
58
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
59
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
60
|
+
/** The npm install/uninstall timeout (ms) — one subprocess invocation. */
|
|
61
|
+
export const NPM_TIMEOUT_MS = 120_000;
|
|
62
|
+
/**
|
|
63
|
+
* Runtime modules the HOST ships that extension packages may import bare.
|
|
64
|
+
* Extension packages declare these devDependencies-only (the
|
|
65
|
+
* provider-plugins dependency posture: pi-ai/pi-durable ship with sigrun,
|
|
66
|
+
* typebox ships as pi-ai's own dependency — structural typing at runtime,
|
|
67
|
+
* never identity), and npm never installs a dependency's devDependencies,
|
|
68
|
+
* so a package installed into the packages dir cannot resolve them on its
|
|
69
|
+
* own. {@link ensureHostModuleLinks} links sigrun's own copies into the
|
|
70
|
+
* packages dir's node_modules so the dynamic import of such a package
|
|
71
|
+
* resolves them — the SAME module instances the host runs.
|
|
72
|
+
*/
|
|
73
|
+
export const HOST_RUNTIME_MODULES = [
|
|
74
|
+
"@earendil-works/pi-ai",
|
|
75
|
+
"@earendil-works/pi-durable",
|
|
76
|
+
"@earendil-works/chord",
|
|
77
|
+
"typebox",
|
|
78
|
+
];
|
|
79
|
+
/**
|
|
80
|
+
* Read the `version` field of the package at `moduleDir` (following
|
|
81
|
+
* links — a linked entry reads its target's manifest). `undefined` when
|
|
82
|
+
* the dir carries no readable manifest: callers treat that as "nothing
|
|
83
|
+
* to compare", never as a mismatch.
|
|
84
|
+
*/
|
|
85
|
+
function installedVersionAt(moduleDir) {
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(readFileSync(join(moduleDir, "package.json"), "utf8"));
|
|
88
|
+
return typeof parsed.version === "string" ? parsed.version : undefined;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The version of the HOST's own copy of a runtime module (the tree
|
|
96
|
+
* sigrun itself runs from), or `undefined` when sigrun's tree does not
|
|
97
|
+
* carry it. The loader's host-module guard compares every extension's
|
|
98
|
+
* resolution against THIS value (spec §6.4 R6).
|
|
99
|
+
*/
|
|
100
|
+
export function hostModuleVersion(name) {
|
|
101
|
+
const dir = resolveHostModuleDir(name);
|
|
102
|
+
return dir === undefined ? undefined : installedVersionAt(dir);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Resolve a host runtime module's package dir from sigrun's own tree: the
|
|
106
|
+
* nearest `node_modules/<name>` walking up from THIS module's location
|
|
107
|
+
* (the repo root in every deploy shape — sigrun runs from source). The
|
|
108
|
+
* walk stops at the filesystem root; `undefined` when nothing resolves.
|
|
109
|
+
*/
|
|
110
|
+
function resolveHostModuleDir(name) {
|
|
111
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
112
|
+
for (let hops = 0; hops < 10; hops++) {
|
|
113
|
+
const candidate = join(dir, "node_modules", ...name.split("/"));
|
|
114
|
+
if (existsSync(join(candidate, "package.json")))
|
|
115
|
+
return candidate;
|
|
116
|
+
const parent = dirname(dir);
|
|
117
|
+
if (parent === dir)
|
|
118
|
+
return undefined;
|
|
119
|
+
dir = parent;
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Link every host runtime module into the packages dir's node_modules
|
|
125
|
+
* (absolute dir symlinks), so packages installed there whose manifests
|
|
126
|
+
* declare those modules devDependencies-only resolve their bare imports
|
|
127
|
+
* — without the link, `import()` of the package fails ERR_MODULE_NOT_FOUND
|
|
128
|
+
* (the packages dir is an unrelated node_modules tree; Node's upward walk
|
|
129
|
+
* from the package never finds sigrun's own).
|
|
130
|
+
*
|
|
131
|
+
* Present entries are LEFT ALONE (an npm-installed real copy — a package
|
|
132
|
+
* that declared the module a runtime dependency — wins over the link; the
|
|
133
|
+
* sync never fights npm's own tree). A stale BROKEN link is removed before
|
|
134
|
+
* relinking. A module unresolvable from sigrun's tree warns and skips: a
|
|
135
|
+
* package that does not import it never needs the link, and the loader's
|
|
136
|
+
* quarantine names the gap for one that does.
|
|
137
|
+
*/
|
|
138
|
+
export function ensureHostModuleLinks(packagesDir) {
|
|
139
|
+
const nodeModules = join(packagesDir, "node_modules");
|
|
140
|
+
if (!existsSync(nodeModules))
|
|
141
|
+
return;
|
|
142
|
+
for (const name of HOST_RUNTIME_MODULES) {
|
|
143
|
+
const linkPath = join(nodeModules, ...name.split("/"));
|
|
144
|
+
// existsSync follows links: an INTACT link (or a real npm-installed
|
|
145
|
+
// copy) reads true and is left alone.
|
|
146
|
+
if (existsSync(linkPath)) {
|
|
147
|
+
// A present entry whose version differs from the host's is
|
|
148
|
+
// SURFACED, not replaced (spec §6.4 R6): the sync never fights
|
|
149
|
+
// npm's tree — an npm-installed copy still wins over the link —
|
|
150
|
+
// but a skewing copy must not sit silently. The loader's guard
|
|
151
|
+
// quarantines the extensions that RESOLVE a skewing module; this
|
|
152
|
+
// warn covers the packages tree itself (same version reads equal
|
|
153
|
+
// for an intact host link, so links never trip it).
|
|
154
|
+
const present = installedVersionAt(linkPath);
|
|
155
|
+
const host = hostModuleVersion(name);
|
|
156
|
+
if (present !== undefined &&
|
|
157
|
+
host !== undefined &&
|
|
158
|
+
present !== host) {
|
|
159
|
+
console.warn(`packages: ${name}@${present} installed in ${packagesDir} differs from host ${host}`);
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
let stat;
|
|
164
|
+
try {
|
|
165
|
+
stat = lstatSync(linkPath);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
stat = undefined; // nothing at the path
|
|
169
|
+
}
|
|
170
|
+
if (stat !== undefined) {
|
|
171
|
+
// Present but NOT a link: something npm placed that does not
|
|
172
|
+
// resolve (a stray file) — leave it for npm, never fight the tree.
|
|
173
|
+
if (!stat.isSymbolicLink())
|
|
174
|
+
continue;
|
|
175
|
+
// A present-but-BROKEN symlink (existsSync followed it and failed):
|
|
176
|
+
// clear it so the fresh link below is creatable.
|
|
177
|
+
rmSync(linkPath, { force: true });
|
|
178
|
+
}
|
|
179
|
+
const target = resolveHostModuleDir(name);
|
|
180
|
+
if (target === undefined) {
|
|
181
|
+
console.warn(`packages: host module ${name} not found in the sigrun tree; not linking into ${packagesDir}`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
mkdirSync(dirname(linkPath), { recursive: true });
|
|
185
|
+
try {
|
|
186
|
+
symlinkSync(target, linkPath, "dir");
|
|
187
|
+
}
|
|
188
|
+
catch (cause) {
|
|
189
|
+
if (cause?.code === "EEXIST")
|
|
190
|
+
continue;
|
|
191
|
+
throw cause;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** The packages dir's synthetic root manifest name (pi's is `pi-extensions`). */
|
|
196
|
+
export const PACKAGES_ROOT_NAME = "sigrun-packages";
|
|
197
|
+
/** The packages dir under the config dir: `<configDir>/packages`. */
|
|
198
|
+
export function packagesDirOf(configDir) {
|
|
199
|
+
return join(configDir, "packages");
|
|
200
|
+
}
|
|
201
|
+
/** The synthetic root manifest path: `<packagesDir>/package.json`. */
|
|
202
|
+
export function packagesManifestPath(packagesDir) {
|
|
203
|
+
return join(packagesDir, "package.json");
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The failure type: the boot wiring (`src/index.ts`) lets this throw —
|
|
207
|
+
* boot fails loud naming the specifier (or the manifest path).
|
|
208
|
+
*/
|
|
209
|
+
export class PackageSyncError extends Error {
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* One `npm <args>` invocation against the packages dir (execFile — no
|
|
213
|
+
* shell, so no quoting surface). Failures and timeouts reject with a
|
|
214
|
+
* `PackageSyncError` carrying npm's stderr tail; `verbose` captures the
|
|
215
|
+
* full output for the error message.
|
|
216
|
+
*/
|
|
217
|
+
function runNpm(args, packagesDir, verbose) {
|
|
218
|
+
return new Promise((resolve, reject) => {
|
|
219
|
+
execFile("npm", [...args, "--prefix", packagesDir, "--no-audit", "--no-fund"], { timeout: NPM_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
220
|
+
if (error === null) {
|
|
221
|
+
if (verbose === true && stderr !== "") {
|
|
222
|
+
console.warn(`packages: npm ${args[0] ?? ""}: ${stderr.trim()}`);
|
|
223
|
+
}
|
|
224
|
+
resolve();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const timedOut = error.killed === true || error.signal === "SIGTERM";
|
|
228
|
+
const detail = (stderr || stdout).trim().split("\n").slice(-5).join("\n");
|
|
229
|
+
reject(new PackageSyncError(`packages: npm ${args.join(" ")} ${timedOut ? `timed out after ${NPM_TIMEOUT_MS}ms` : `failed (exit ${String(error.code)})`}` +
|
|
230
|
+
(detail === "" ? "" : `:\n${detail}`)));
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Read the packages dir's root manifest dependencies (name → range). A
|
|
236
|
+
* missing manifest is the empty map (first boot with `packages` set);
|
|
237
|
+
* a manifest that exists but does not parse fails loud — the dir is
|
|
238
|
+
* sigrun-owned, so a corrupt manifest is an operator-visible error, not
|
|
239
|
+
* something to silently reinstall over.
|
|
240
|
+
*/
|
|
241
|
+
function readInstalled(packagesDir) {
|
|
242
|
+
const path = packagesManifestPath(packagesDir);
|
|
243
|
+
if (!existsSync(path))
|
|
244
|
+
return {};
|
|
245
|
+
let parsed;
|
|
246
|
+
try {
|
|
247
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
248
|
+
}
|
|
249
|
+
catch (cause) {
|
|
250
|
+
throw new PackageSyncError(`packages: ${path} is not valid JSON (${cause instanceof Error ? cause.message : String(cause)})`);
|
|
251
|
+
}
|
|
252
|
+
const manifest = parsed;
|
|
253
|
+
if (typeof manifest !== "object" ||
|
|
254
|
+
manifest === null ||
|
|
255
|
+
(manifest.dependencies !== undefined &&
|
|
256
|
+
(typeof manifest.dependencies !== "object" ||
|
|
257
|
+
manifest.dependencies === null ||
|
|
258
|
+
Array.isArray(manifest.dependencies)))) {
|
|
259
|
+
throw new PackageSyncError(`packages: ${path} must be an object with an optional dependencies map`);
|
|
260
|
+
}
|
|
261
|
+
return { ...(manifest.dependencies ?? {}) };
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Parse an npm package specifier into `{ name, range }`.
|
|
265
|
+
*
|
|
266
|
+
* A specifier is `<name>@<range>` with the `@<range>` part optional
|
|
267
|
+
* (npm installs `latest` when it is absent); scoped names
|
|
268
|
+
* (`@scope/name`) carry their leading `@`, so the range split is the
|
|
269
|
+
* LAST `@` that is not the first character. For PATH-shaped specifiers
|
|
270
|
+
* (`file:`, `./`, `/`) the "name" is the path itself — npm keys those
|
|
271
|
+
* dependencies entries by the package's OWN manifest name, so callers
|
|
272
|
+
* needing the real identity use {@link identifySpecifier}.
|
|
273
|
+
*/
|
|
274
|
+
export function parsePackageSpecifier(specifier) {
|
|
275
|
+
const at = specifier.lastIndexOf("@");
|
|
276
|
+
if (at <= 0)
|
|
277
|
+
return { name: specifier, range: "" };
|
|
278
|
+
return { name: specifier.slice(0, at), range: specifier.slice(at + 1) };
|
|
279
|
+
}
|
|
280
|
+
/** Whether a specifier names a path (`file:`, `./x`, `../x`, `/x`, a tarball) rather than a registry package. */
|
|
281
|
+
function isPathSpecifier(name) {
|
|
282
|
+
return (name.startsWith("file:") ||
|
|
283
|
+
name.startsWith("./") ||
|
|
284
|
+
name.startsWith("../") ||
|
|
285
|
+
name.startsWith("/") ||
|
|
286
|
+
name.endsWith(".tgz"));
|
|
287
|
+
}
|
|
288
|
+
/** The filesystem path of a path-shaped specifier (`file:` URL and plain forms). */
|
|
289
|
+
function specToPath(spec) {
|
|
290
|
+
if (spec.startsWith("file://"))
|
|
291
|
+
return fileURLToPath(spec);
|
|
292
|
+
if (spec.startsWith("file:"))
|
|
293
|
+
return spec.slice("file:".length);
|
|
294
|
+
return spec;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Canonical absolute path of a path-shaped specifier. `base` is the
|
|
298
|
+
* directory a RELATIVE spec resolves against — the process cwd for a
|
|
299
|
+
* wanted specifier (npm resolves install args against its cwd, which is
|
|
300
|
+
* the sigrun process's), the packages dir for a recorded range (npm
|
|
301
|
+
* relativizes `file:` dependency entries against the manifest's dir).
|
|
302
|
+
*/
|
|
303
|
+
function canonicalizePath(spec, base) {
|
|
304
|
+
return resolve(base, specToPath(spec));
|
|
305
|
+
}
|
|
306
|
+
/** Read a package's manifest: the directory form, else the tarball form. */
|
|
307
|
+
function readManifest(absPath) {
|
|
308
|
+
try {
|
|
309
|
+
return JSON.parse(readFileSync(join(absPath, "package.json"), "utf8"));
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
// Not a readable directory manifest — try the tarball form.
|
|
313
|
+
}
|
|
314
|
+
if (absPath.endsWith(".tgz")) {
|
|
315
|
+
try {
|
|
316
|
+
const out = execFileSync("tar", ["-xzOf", absPath, "package/package.json"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
317
|
+
return JSON.parse(out);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
// Unreadable tarball — the name is unknown; the install itself
|
|
321
|
+
// will fail loud if the specifier is bad.
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* The `name` of a path-shaped specifier's target package, when readable.
|
|
328
|
+
* A directory's `package.json` reads directly; a `.tgz` tarball's
|
|
329
|
+
* `package/package.json` reads by listing the archive (npm keys a tarball
|
|
330
|
+
* install by the packed manifest's name too). Exported for the loader's
|
|
331
|
+
* provisional-identity derivation (the nearest manifest above a load
|
|
332
|
+
* entry names the package).
|
|
333
|
+
*/
|
|
334
|
+
export function readPackageName(absPath) {
|
|
335
|
+
const name = readManifest(absPath)?.name;
|
|
336
|
+
return typeof name === "string" && name !== "" ? name : undefined;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* The `version` of a path-shaped specifier's target package, when
|
|
340
|
+
* readable — `readPackageName`'s sibling over the SAME manifests
|
|
341
|
+
* (directory `package.json`, else the tarball's `package/package.json`).
|
|
342
|
+
* Exported for the loader's outcome recording: the extension inventory
|
|
343
|
+
* (`GET /v1/extensions`, spec §7.7) surfaces the manifest version.
|
|
344
|
+
*/
|
|
345
|
+
export function readPackageVersion(absPath) {
|
|
346
|
+
const version = readManifest(absPath)?.version;
|
|
347
|
+
return typeof version === "string" && version !== "" ? version : undefined;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* The identity a specifier installs under: registry specs key by their
|
|
351
|
+
* parsed name; path specs key by the target package's OWN manifest name
|
|
352
|
+
* (npm's rule) and compare by canonical absolute path (npm relativizes
|
|
353
|
+
* the recorded `file:` entry against the packages dir, so a raw string
|
|
354
|
+
* compare would reinstall on every boot).
|
|
355
|
+
*/
|
|
356
|
+
export function identifySpecifier(specifier) {
|
|
357
|
+
const { name, range } = parsePackageSpecifier(specifier);
|
|
358
|
+
if (!isPathSpecifier(name))
|
|
359
|
+
return { name, range, pathShaped: false };
|
|
360
|
+
const absPath = canonicalizePath(name, process.cwd());
|
|
361
|
+
return {
|
|
362
|
+
name: readPackageName(absPath) ?? name,
|
|
363
|
+
range: absPath,
|
|
364
|
+
pathShaped: true,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Normalize one settings `extensions` entry (string or object form) into
|
|
369
|
+
* the install/load stem. Exported for the unit tests.
|
|
370
|
+
*/
|
|
371
|
+
export function normalizeExtSpec(entry) {
|
|
372
|
+
if (typeof entry === "string") {
|
|
373
|
+
return { source: entry, enabled: true };
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
source: entry.source,
|
|
377
|
+
...(entry.entry === undefined ? {} : { entry: entry.entry }),
|
|
378
|
+
...(entry.ref === undefined ? {} : { ref: entry.ref }),
|
|
379
|
+
enabled: entry.enabled ?? true,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* The kind of source a normalized spec points at, deciding which boot
|
|
384
|
+
* tree gets it (packages dir, git dir, or load-in-place).
|
|
385
|
+
*/
|
|
386
|
+
export function sourceKind(source) {
|
|
387
|
+
if (source.startsWith("git:") || /^https?:\/\/.*\.git/.test(source)) {
|
|
388
|
+
return "git";
|
|
389
|
+
}
|
|
390
|
+
// `file:` is an INSTALLABLE npm specifier (npm records it in the
|
|
391
|
+
// packages manifest and loads the symlinked source — the dev-checkout
|
|
392
|
+
// shape). Bare ./ ../ / paths are LOAD-IN-PLACE (nothing installed).
|
|
393
|
+
if (((source.startsWith("./") || source.startsWith("../")) &&
|
|
394
|
+
!source.startsWith("file:")) ||
|
|
395
|
+
(source.startsWith("/") && !source.startsWith("file:/"))) {
|
|
396
|
+
return "path";
|
|
397
|
+
}
|
|
398
|
+
// A git https/shp URL without a .git suffix would collide with the
|
|
399
|
+
// npm "name" grammar: only definite .git URLs are git here; everything
|
|
400
|
+
// else is a registry/bare name.
|
|
401
|
+
return "npm";
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Diff the wanted specifiers against the installed dependencies map.
|
|
405
|
+
* Wanted-but-missing or range/path-changed → install; installed-but-
|
|
406
|
+
* unwanted → uninstall. Exported for the unit tests (the diff rules are
|
|
407
|
+
* the sync's core logic).
|
|
408
|
+
*/
|
|
409
|
+
export function planSync(wanted, installed, packagesDir) {
|
|
410
|
+
const install = [];
|
|
411
|
+
const wantedNames = new Set();
|
|
412
|
+
for (const specifier of wanted) {
|
|
413
|
+
const id = identifySpecifier(specifier);
|
|
414
|
+
wantedNames.add(id.name);
|
|
415
|
+
const installedRange = installed[id.name];
|
|
416
|
+
if (installedRange === undefined) {
|
|
417
|
+
install.push(specifier);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (id.pathShaped) {
|
|
421
|
+
// A recorded registry range where a path is wanted (the package
|
|
422
|
+
// moved to a local checkout) is a change too.
|
|
423
|
+
const recordedPath = installedRange.startsWith("file:")
|
|
424
|
+
? canonicalizePath(installedRange, packagesDir)
|
|
425
|
+
: undefined;
|
|
426
|
+
if (recordedPath !== id.range)
|
|
427
|
+
install.push(specifier);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
// A rangeless registry specifier ("name") accepts any installed
|
|
431
|
+
// entry (npm recorded the resolved version or "latest" —
|
|
432
|
+
// reinstalling every boot would defeat the fast path); a ranged
|
|
433
|
+
// specifier must match the recorded range exactly, else the
|
|
434
|
+
// declaration changed.
|
|
435
|
+
if (id.range !== "" && installedRange !== id.range) {
|
|
436
|
+
install.push(specifier);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const uninstall = Object.keys(installed).filter((name) => !wantedNames.has(name));
|
|
440
|
+
return { install, uninstall };
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Ensure the packages dir exists with its synthetic root manifest. The
|
|
444
|
+
* manifest is written ONLY when absent — once npm owns the dir it keeps
|
|
445
|
+
* the manifest's dependencies map current, and rewriting it here would
|
|
446
|
+
* fight npm's own writes.
|
|
447
|
+
*/
|
|
448
|
+
function ensurePackagesDir(packagesDir) {
|
|
449
|
+
mkdirSync(packagesDir, { recursive: true });
|
|
450
|
+
const path = packagesManifestPath(packagesDir);
|
|
451
|
+
if (!existsSync(path)) {
|
|
452
|
+
writeFileSync(path, `${JSON.stringify({ name: PACKAGES_ROOT_NAME, private: true }, null, "\t")}\n`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Reconcile the packages dir with the settings file's `extensions`
|
|
457
|
+
* intent (the npm-shaped entries; git/path entries never reach the npm
|
|
458
|
+
* root).
|
|
459
|
+
*
|
|
460
|
+
* @param extensions the settings file's unified `extensions` array
|
|
461
|
+
* (already validated: string or object entries).
|
|
462
|
+
* @param packagesDir the packages dir (`packagesDirOf(configDir)`).
|
|
463
|
+
* @throws PackageSyncError on any install/uninstall failure, timeout, or
|
|
464
|
+
* a declared specifier that does not resolve afterward.
|
|
465
|
+
*/
|
|
466
|
+
export async function syncPackages(extensions, packagesDir, options = {}) {
|
|
467
|
+
// Normalize + split by source kind: npm-shaped entries install into
|
|
468
|
+
// the packages dir; git entries clone/fetch into the git dir; path
|
|
469
|
+
// entries load in place; disabled entries keep their directory state
|
|
470
|
+
// but produce no loadable resolution.
|
|
471
|
+
const normalized = extensions.map(normalizeExtSpec);
|
|
472
|
+
const resolutions = [];
|
|
473
|
+
const gitDir = gitDirOf(options.configDir ?? resolve(dirname(packagesDir)));
|
|
474
|
+
const npmSpecs = [];
|
|
475
|
+
for (const spec of normalized) {
|
|
476
|
+
if (!spec.enabled)
|
|
477
|
+
continue;
|
|
478
|
+
const kind = sourceKind(spec.source);
|
|
479
|
+
if (kind === "npm") {
|
|
480
|
+
npmSpecs.push(spec.source);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (kind === "git") {
|
|
484
|
+
const { source, ref } = splitGitRef(spec.source, spec.ref);
|
|
485
|
+
const repoDir = await syncGit(source, ref, gitDir, options);
|
|
486
|
+
let entry;
|
|
487
|
+
try {
|
|
488
|
+
entry = findExtEntry(repoDir, spec.entry);
|
|
489
|
+
}
|
|
490
|
+
catch (cause) {
|
|
491
|
+
throw cause instanceof GitSyncError
|
|
492
|
+
? new GitSyncError(`git extension ${sanitizeUrl(spec.source)}: ${cause.message}`)
|
|
493
|
+
: cause;
|
|
494
|
+
}
|
|
495
|
+
// The §9.4 three cases: a discoverable entry resolves entry-kind
|
|
496
|
+
// (carrying any manifest skills); NO entry + declared skills is the
|
|
497
|
+
// valid skills-only package; NO entry and NO skills keeps today's
|
|
498
|
+
// loud throw.
|
|
499
|
+
const skills = manifestSkills(repoDir);
|
|
500
|
+
if (entry !== undefined) {
|
|
501
|
+
resolutions.push({
|
|
502
|
+
kind: "entry",
|
|
503
|
+
entry: pathToFileURL(entry).href,
|
|
504
|
+
enabled: true,
|
|
505
|
+
...(skills.length === 0 ? {} : { skills }),
|
|
506
|
+
});
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (skills.length > 0) {
|
|
510
|
+
resolutions.push({
|
|
511
|
+
kind: "skills-only",
|
|
512
|
+
name: readPackageName(repoDir) ?? basename(repoDir),
|
|
513
|
+
skills,
|
|
514
|
+
enabled: true,
|
|
515
|
+
});
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
throw new GitSyncError(`git extension ${sanitizeUrl(spec.source)}: no loadable entry (no sigrun manifest, no dist/index.js) — ship a built entry; a .ts source cannot load under plain node`);
|
|
519
|
+
}
|
|
520
|
+
// path kind: load in place (nothing installed). A path package's
|
|
521
|
+
// manifest skills still ride along (the path target is its root).
|
|
522
|
+
const pathRoot = sourceKind(spec.source) === "path" && statIsDirectory(spec.source)
|
|
523
|
+
? spec.source
|
|
524
|
+
: undefined;
|
|
525
|
+
const pathSkills = pathRoot === undefined ? [] : manifestSkills(pathRoot);
|
|
526
|
+
resolutions.push({
|
|
527
|
+
kind: "entry",
|
|
528
|
+
entry: spec.source,
|
|
529
|
+
enabled: true,
|
|
530
|
+
...(pathSkills.length === 0 ? {} : { skills: pathSkills }),
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
await syncPackagesFlat(npmSpecs, packagesDir, options);
|
|
534
|
+
// npm-path resolutions (bare names, file: forms): resolved through the
|
|
535
|
+
// packages dir then the app tree — the loader's resolution seam, kept.
|
|
536
|
+
for (const spec of normalized) {
|
|
537
|
+
if (!spec.enabled)
|
|
538
|
+
continue;
|
|
539
|
+
if (sourceKind(spec.source) !== "npm")
|
|
540
|
+
continue;
|
|
541
|
+
const explicitEntry = spec.entry;
|
|
542
|
+
const root = manifestRootOf(spec.source, packagesDir);
|
|
543
|
+
const skills = root === undefined ? [] : manifestSkills(root);
|
|
544
|
+
const pushSkillsOnly = () => {
|
|
545
|
+
resolutions.push({
|
|
546
|
+
kind: "skills-only",
|
|
547
|
+
name: root === undefined ? spec.source : (readPackageName(root) ?? basename(root)),
|
|
548
|
+
skills,
|
|
549
|
+
enabled: true,
|
|
550
|
+
});
|
|
551
|
+
};
|
|
552
|
+
const resolved = resolveExtensionEntry(spec.source, packagesDir);
|
|
553
|
+
if (resolved === undefined) {
|
|
554
|
+
// Nothing resolvable: skills-only when the package declares skills,
|
|
555
|
+
// else today's shape (the specifier verbatim — the import fails
|
|
556
|
+
// loud at load, the quarantine).
|
|
557
|
+
if (skills.length > 0) {
|
|
558
|
+
pushSkillsOnly();
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
resolutions.push({ kind: "entry", entry: spec.source, enabled: true });
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
// A resolved DIRECTORY is packageEntry's "nothing discoverable"
|
|
565
|
+
// fallback (no self-resolvable entry, no manifest entry, no
|
|
566
|
+
// convention file): with declared skills that is the skills-only
|
|
567
|
+
// package; without, keep today's loud dir-import failure.
|
|
568
|
+
const resolvedPath = resolved.startsWith("file://")
|
|
569
|
+
? fileURLToPath(resolved)
|
|
570
|
+
: resolved;
|
|
571
|
+
if (statIsDirectory(resolvedPath)) {
|
|
572
|
+
if (skills.length > 0) {
|
|
573
|
+
pushSkillsOnly();
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
resolutions.push({ kind: "entry", entry: resolved, enabled: true });
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
resolutions.push({
|
|
580
|
+
kind: "entry",
|
|
581
|
+
entry: explicitEntry === undefined
|
|
582
|
+
? resolved
|
|
583
|
+
: pathToFileURL(join(dirname(resolved), explicitEntry)).href,
|
|
584
|
+
enabled: true,
|
|
585
|
+
...(skills.length === 0 ? {} : { skills }),
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
return resolutions;
|
|
589
|
+
}
|
|
590
|
+
/** Split a git source into its fetch URL + declared ref. */
|
|
591
|
+
function splitGitRef(source, objectRef) {
|
|
592
|
+
if (objectRef !== undefined)
|
|
593
|
+
return { source, ref: objectRef };
|
|
594
|
+
// The @ref suffix: only treat the segment AFTER the last slash (a
|
|
595
|
+
// scoped/URL @ in the host part is not a ref).
|
|
596
|
+
const slash = source.lastIndexOf("/");
|
|
597
|
+
const at = source.lastIndexOf("@");
|
|
598
|
+
if (at > slash + 1)
|
|
599
|
+
return { source: source.slice(0, at), ref: source.slice(at + 1) };
|
|
600
|
+
return { source };
|
|
601
|
+
}
|
|
602
|
+
/** The pre-existing flat npm synchronizer (now an internal step). */
|
|
603
|
+
async function syncPackagesFlat(packages, packagesDir, options = {}) {
|
|
604
|
+
const log = options.log !== false;
|
|
605
|
+
ensurePackagesDir(packagesDir);
|
|
606
|
+
const installed = readInstalled(packagesDir);
|
|
607
|
+
const plan = planSync(packages, installed, packagesDir);
|
|
608
|
+
if (plan.install.length === 0 && plan.uninstall.length === 0) {
|
|
609
|
+
// Fast path: the dir already matches the declaration — no
|
|
610
|
+
// subprocess, no npm startup cost (the steady-state boot).
|
|
611
|
+
if (log)
|
|
612
|
+
console.log(`packages: ${packages.length} declared, 0 changes`);
|
|
613
|
+
// Host-module links are idempotent-cheap (an existsSync per module)
|
|
614
|
+
// and still ensured here: a packages dir populated by an older boot
|
|
615
|
+
// (or hand-copied) gains them on the next steady-state boot.
|
|
616
|
+
ensureHostModuleLinks(packagesDir);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (plan.uninstall.length > 0) {
|
|
620
|
+
if (log)
|
|
621
|
+
console.log(`packages: removing ${plan.uninstall.join(", ")}`);
|
|
622
|
+
await runNpm(["uninstall", ...plan.uninstall], packagesDir, options.verbose);
|
|
623
|
+
}
|
|
624
|
+
if (plan.install.length > 0) {
|
|
625
|
+
if (log)
|
|
626
|
+
console.log(`packages: installing ${plan.install.join(", ")}`);
|
|
627
|
+
// npm resolves a CWD-relative file: install arg against ITS OWN cwd
|
|
628
|
+
// (the spawned npm inherits sigrun's, but an operator's "./x" is
|
|
629
|
+
// authored against the deploy root) — absolutize path specifiers so
|
|
630
|
+
// the meaning never depends on the subprocess's working directory.
|
|
631
|
+
const installArgs = plan.install.map((specifier) => {
|
|
632
|
+
const id = identifySpecifier(specifier);
|
|
633
|
+
return id.pathShaped ? `file:${id.range}` : specifier;
|
|
634
|
+
});
|
|
635
|
+
await runNpm(["install", "--save-exact=false", ...installArgs], packagesDir, options.verbose);
|
|
636
|
+
// Post-install assertion: every DECLARED specifier must now resolve
|
|
637
|
+
// against the packages dir. npm exit 0 with nothing installed is the
|
|
638
|
+
// classic bad-specifier failure mode (typo'd name, unreachable
|
|
639
|
+
// registry serving an empty success) — catch it here, at boot, not
|
|
640
|
+
// at extension-load time.
|
|
641
|
+
const nodeModules = join(packagesDir, "node_modules");
|
|
642
|
+
for (const specifier of plan.install) {
|
|
643
|
+
const { name } = identifySpecifier(specifier);
|
|
644
|
+
if (!existsSync(join(nodeModules, ...name.split("/"), "package.json"))) {
|
|
645
|
+
throw new PackageSyncError(`packages: ${JSON.stringify(specifier)} installed without error but ` +
|
|
646
|
+
`${join(nodeModules, ...name.split("/"))} does not exist — the specifier ` +
|
|
647
|
+
`does not resolve to an installed package`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
// Uninstalled-but-still-present is npm's problem to have gotten right —
|
|
652
|
+
// npm uninstall is authoritative; no post-assertion (a leftover
|
|
653
|
+
// transitive dep directory is normal and not an error).
|
|
654
|
+
// Host-module links: after any npm pass (install or uninstall), so the
|
|
655
|
+
// dir the loader resolves from carries the host's runtime modules.
|
|
656
|
+
ensureHostModuleLinks(packagesDir);
|
|
657
|
+
if (log)
|
|
658
|
+
console.log("packages: sync complete");
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Resolve an extension LOAD specifier against the packages dir first
|
|
662
|
+
* (deployment-installed wins), falling back to bare import resolution
|
|
663
|
+
* (paths, `file://` URLs, dev checkouts, image-bundled packages).
|
|
664
|
+
*
|
|
665
|
+
* Returns the absolute entry path to dynamic-import. The packages dir is
|
|
666
|
+
* an unrelated tree from sigrun's own `node_modules`, so Node's upward
|
|
667
|
+
* walk never finds packages-dir modules from sigrun's imports — the
|
|
668
|
+
* packages dir must be consulted explicitly, via its own root manifest as
|
|
669
|
+
* the resolution anchor. A bare name that resolves in BOTH places loads
|
|
670
|
+
* from the packages dir (the deployment's declared version wins over
|
|
671
|
+
* whatever the image happened to bundle).
|
|
672
|
+
*
|
|
673
|
+
* Returns `undefined` when nothing resolves — the loader quarantines with
|
|
674
|
+
* its standard not-found message.
|
|
675
|
+
*/
|
|
676
|
+
export function resolveExtensionEntry(specifier, packagesDir) {
|
|
677
|
+
// Bare ./ ../ / relative paths are load-in-place (never packages-dir
|
|
678
|
+
// entries; the pre-packages behavior). NOTE: `file:` is NOT here —
|
|
679
|
+
// a `file:` LOAD specifier may name a package the packages dir
|
|
680
|
+
// installed under its own manifest name (via a file: INSTALL
|
|
681
|
+
// specifier — the npm symlinked checkout); resolve through the
|
|
682
|
+
// manifest first, then fall through to bare resolution.
|
|
683
|
+
if ((specifier.startsWith("./") || specifier.startsWith("../")) &&
|
|
684
|
+
!specifier.startsWith("file:")) {
|
|
685
|
+
return specifier;
|
|
686
|
+
}
|
|
687
|
+
if (specifier.startsWith("/") || specifier.startsWith("file://")) {
|
|
688
|
+
return specifier;
|
|
689
|
+
}
|
|
690
|
+
// A file:-installed package LOADS under its own manifest name, which
|
|
691
|
+
// resolves from the packages dir to the real (symlinked) source —
|
|
692
|
+
// exactly pi's "symlink a dev checkout" shape. For a file:-SHAPE load
|
|
693
|
+
// specifier, the manifest name is looked up BY PATH identity (npm
|
|
694
|
+
// records the dep under the target's own manifest name, not the
|
|
695
|
+
// specifier): find the dependency entry whose canonical path matches
|
|
696
|
+
// this specifier's.
|
|
697
|
+
const manifest = packagesManifestPath(packagesDir);
|
|
698
|
+
if (existsSync(manifest)) {
|
|
699
|
+
if (specifier.startsWith("file:") || specifier.startsWith("/")) {
|
|
700
|
+
const id = identifySpecifier(specifier);
|
|
701
|
+
const name = readPackageName(id.pathShaped ? id.range : specifier) ?? id.name;
|
|
702
|
+
const pkgDir = join(packagesDir, "node_modules", ...name.split("/"));
|
|
703
|
+
if (existsSync(pkgDir))
|
|
704
|
+
return packageEntry(pkgDir, name);
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
try {
|
|
708
|
+
return importableEntry(createRequire(manifest).resolve(specifier));
|
|
709
|
+
}
|
|
710
|
+
catch {
|
|
711
|
+
// Not installed in the packages dir — fall through to bare
|
|
712
|
+
// resolution.
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
try {
|
|
717
|
+
return importableEntry(createRequire(import.meta.url).resolve(specifier));
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
return undefined;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Import-shape the resolved entry: `import()` must receive a `file://`
|
|
725
|
+
* URL (Windows absolutes are not import specifiers; POSIX absolutes are
|
|
726
|
+
* tolerated, but the URL is the portable form).
|
|
727
|
+
*/
|
|
728
|
+
function importableEntry(absolutePath) {
|
|
729
|
+
return pathToFileURL(absolutePath).href;
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Resolve a packages-dir package's ENTRY FILE — the thing `import()` can
|
|
733
|
+
* actually load. A bare package DIRECTORY url is not importable in ESM
|
|
734
|
+
* (`ERR_UNSUPPORTED_DIR_IMPORT`), so the loader needs the file.
|
|
735
|
+
* Self-resolution through the package's own manifest (anchored at its
|
|
736
|
+
* root — `exports` map or `main`) is the npm-blessed computation;
|
|
737
|
+
* conventional fallbacks (`index.js`, `dist/index.js`, `index.ts`) cover
|
|
738
|
+
* manifest-less shapes, and a package with none of those resolves to its
|
|
739
|
+
* dir so the loader's import fails LOUD (quarantined with the path)
|
|
740
|
+
* instead of silently resolving something adjacent.
|
|
741
|
+
*/
|
|
742
|
+
function packageEntry(pkgDir, name) {
|
|
743
|
+
try {
|
|
744
|
+
return importableEntry(createRequire(join(pkgDir, "package.json")).resolve(name));
|
|
745
|
+
}
|
|
746
|
+
catch {
|
|
747
|
+
// No self-resolvable entry (no exports, no main) — probe the
|
|
748
|
+
// manifest and conventions in priority order.
|
|
749
|
+
}
|
|
750
|
+
// W5 Task 4: the manifest entry (sigrun.extension, else the first
|
|
751
|
+
// non-`.ts` pi entry — {@link manifestEntry} warns each `.ts` skip).
|
|
752
|
+
const manifest = manifestEntry(pkgDir);
|
|
753
|
+
if (manifest !== undefined)
|
|
754
|
+
return importableEntry(manifest);
|
|
755
|
+
for (const entry of ["index.js", "dist/index.js", "index.ts"]) {
|
|
756
|
+
if (existsSync(join(pkgDir, entry))) {
|
|
757
|
+
return importableEntry(join(pkgDir, entry));
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
// The package DIR is today's loud fallback: an unimportable entry the
|
|
761
|
+
// loader quarantines naming the path (the sync's caller decides
|
|
762
|
+
// skills-only when the package declared skill dirs).
|
|
763
|
+
return importableEntry(pkgDir);
|
|
764
|
+
}
|
|
765
|
+
// ---------------------------------------------------------------------------
|
|
766
|
+
// Git sources — the non-npm half of the unified extensions list
|
|
767
|
+
// ---------------------------------------------------------------------------
|
|
768
|
+
/** A git-source sync failure (clone, fetch, ref, or entry discovery). */
|
|
769
|
+
export class GitSyncError extends Error {
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Strip userinfo credentials from a URL surfaced in an error or log:
|
|
773
|
+
* `https://user:token@host/path` renders as `https://<sanitized>@host/path`.
|
|
774
|
+
* Never echoes secret material.
|
|
775
|
+
*/
|
|
776
|
+
export function sanitizeUrl(url) {
|
|
777
|
+
// Only strip userinfo that CONTAINS a colon (user:pass = credential).
|
|
778
|
+
// A plain non-colon user (`ssh://git@host`, the git-remote convention)
|
|
779
|
+
// is not secret material and stays visible.
|
|
780
|
+
return url.replace(/^(https?|ssh):\/\/[^/@]*:[^/@]+@/, (_match, scheme) => `${scheme}://<sanitized>@`);
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* The git checkout dir under the config dir: `<configDir>/git` (pi's
|
|
784
|
+
* package-manager layout — host/path segments, one dir per repo).
|
|
785
|
+
*/
|
|
786
|
+
export function gitDirOf(configDir) {
|
|
787
|
+
return join(configDir, "git");
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* The clone target for one git source: `<gitDir>/<host>/<path>…`.
|
|
791
|
+
* Rejects any resulting path that escapes `gitDir` (the traversal guard).
|
|
792
|
+
*/
|
|
793
|
+
export function gitTargetDir(gitDir, source) {
|
|
794
|
+
// Two dir shapes: absolute sources (file:/ https:// with real slashes)
|
|
795
|
+
// map to <gitDir>/<domain-ish prefix>/<hash of path> — flat, safe,
|
|
796
|
+
// and never encodes a filesystem path inside gitDir (a local
|
|
797
|
+
// file:///tmp/repo must not nest tmp dirs that could escape).
|
|
798
|
+
const stripped = source
|
|
799
|
+
.replace(/^(git:|https?:|ssh:)/, "")
|
|
800
|
+
.replace(/\.git$/, "")
|
|
801
|
+
.split("@")[0];
|
|
802
|
+
const segments = stripped.split("/").filter((s) => s !== "");
|
|
803
|
+
// Local absolute paths / URLs with a real scheme: flatten to a single
|
|
804
|
+
// segment keyed by a stable digest (traversal-safe by construction).
|
|
805
|
+
if (source.startsWith("file:") || source.startsWith("https:/") || source.startsWith("ssh:")) {
|
|
806
|
+
segments.splice(1);
|
|
807
|
+
}
|
|
808
|
+
// Hash every segment beyond the first two (host/leaf only in pi's
|
|
809
|
+
// layout) when the source is a full URL/abs path.
|
|
810
|
+
const leaf = segments.at(-1) ?? "";
|
|
811
|
+
const digest = createHash("sha256").update(stripped).digest("hex").slice(0, 12);
|
|
812
|
+
const safeSegments = [leaf || digest].map((s) => s.replace(/[^A-Za-z0-9._-]/g, "-"));
|
|
813
|
+
const target = join(gitDir, ...safeSegments);
|
|
814
|
+
if (!target.startsWith(gitDir)) {
|
|
815
|
+
throw new GitSyncError(`git source ${sanitizeUrl(source)} resolves outside the git dir`);
|
|
816
|
+
}
|
|
817
|
+
return target;
|
|
818
|
+
}
|
|
819
|
+
async function runGit(args, cwd) {
|
|
820
|
+
return new Promise((resolveResult, rejectResult) => {
|
|
821
|
+
execFile("git", args, { cwd, timeout: NPM_TIMEOUT_MS, encoding: "utf8" }, (error, stdout) => error === null
|
|
822
|
+
? resolveResult(stdout)
|
|
823
|
+
: rejectResult(new GitSyncError(`git ${args[0]} failed: ${sanitizeUrl(String(stderrTail(error)))}`)));
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
/** The last stderr line of a child failure (concise context, no spam). */
|
|
827
|
+
function stderrTail(error) {
|
|
828
|
+
const raw = typeof error.stderr === "string" ? error.stderr : error.message;
|
|
829
|
+
const lines = raw.trim().split("\n").filter((l) => l !== "");
|
|
830
|
+
return lines.at(-1) ?? error.message;
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Clone-or-reconcile one git source into `gitDir`. Absent → clone.
|
|
834
|
+
* Present + `ref` → fetch + checkout FETCH_HEAD (pi's ensureGitRef
|
|
835
|
+
* pattern). Present + no ref → plain fetch only (the checkout stays
|
|
836
|
+
* where the user left it — no silent rebases).
|
|
837
|
+
*/
|
|
838
|
+
export async function syncGit(source, ref, gitDir, options = {}) {
|
|
839
|
+
const log = options.log !== false;
|
|
840
|
+
mkdirSync(gitDir, { recursive: true });
|
|
841
|
+
const target = gitTargetDir(gitDir, source);
|
|
842
|
+
const { source: fetchUrl } = splitGitRef(source, ref);
|
|
843
|
+
// A `git:` source is a LOCAL-first shorthand: `git:file://…` clones a
|
|
844
|
+
// local path (hermetic tests); any other host part stays as written
|
|
845
|
+
// (git itself resolves https/ssh schemes). The scheme is what pi uses;
|
|
846
|
+
// the shim only strips the `git:` marker itself.
|
|
847
|
+
const url = fetchUrl.startsWith("git:") ? fetchUrl.slice(4) : fetchUrl;
|
|
848
|
+
if (!existsSync(target)) {
|
|
849
|
+
if (log)
|
|
850
|
+
console.log(`git: cloning ${sanitizeUrl(url)} → ${target}`);
|
|
851
|
+
await runGit(["clone", url, target], gitDir);
|
|
852
|
+
}
|
|
853
|
+
else if (ref !== undefined) {
|
|
854
|
+
if (log)
|
|
855
|
+
console.log(`git: fetching ${sanitizeUrl(url)} @ ${ref}`);
|
|
856
|
+
await runGit(["fetch", "origin", ref], target);
|
|
857
|
+
await runGit(["checkout", "FETCH_HEAD"], target);
|
|
858
|
+
}
|
|
859
|
+
else {
|
|
860
|
+
if (log)
|
|
861
|
+
console.log(`git: fetching ${sanitizeUrl(url)} (no ref change)`);
|
|
862
|
+
await runGit(["fetch", "origin"], target);
|
|
863
|
+
}
|
|
864
|
+
return target;
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* The pi-compat `.ts` skip warning (spec §9.4): a `pi.extensions` entry
|
|
868
|
+
* ending in `.ts` cannot load under plain node (the build spec removed
|
|
869
|
+
* the `.ts` entry fallback) — it is skipped, keeping the package's
|
|
870
|
+
* skills loadable. ONE line per skipped entry, per discovery.
|
|
871
|
+
*/
|
|
872
|
+
function warnTsPiExtension(entry) {
|
|
873
|
+
console.warn(`pi-compat: skipping TypeScript pi extension ${entry} (not loadable under node); loading the package's skills only`);
|
|
874
|
+
}
|
|
875
|
+
/** Read one package root's manifest (absent/unreadable → undefined). */
|
|
876
|
+
function readDiscoveryManifest(pkgRoot) {
|
|
877
|
+
const manifestPath = join(pkgRoot, "package.json");
|
|
878
|
+
if (!existsSync(manifestPath))
|
|
879
|
+
return undefined;
|
|
880
|
+
try {
|
|
881
|
+
return JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
882
|
+
}
|
|
883
|
+
catch {
|
|
884
|
+
return undefined;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
/** A string-array manifest field (skills lists), else undefined. */
|
|
888
|
+
function manifestStringArray(value) {
|
|
889
|
+
if (!Array.isArray(value))
|
|
890
|
+
return undefined;
|
|
891
|
+
return value.every((entry) => typeof entry === "string")
|
|
892
|
+
? value
|
|
893
|
+
: undefined;
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* A package root's manifest skill dirs (spec §9.4): `sigrun.skills`
|
|
897
|
+
* (string[]) else pi-compat `pi.skills` (string[] — same SKILL.md format,
|
|
898
|
+
* so no warning), each resolved against the package root, keeping only
|
|
899
|
+
* EXISTING directories. No manifest, no field, or nothing existing → [].
|
|
900
|
+
*/
|
|
901
|
+
export function manifestSkills(pkgRoot) {
|
|
902
|
+
const manifest = readDiscoveryManifest(pkgRoot);
|
|
903
|
+
if (manifest === undefined)
|
|
904
|
+
return [];
|
|
905
|
+
const declared = manifestStringArray(manifest.sigrun?.skills) ??
|
|
906
|
+
manifestStringArray(manifest.pi?.skills);
|
|
907
|
+
if (declared === undefined)
|
|
908
|
+
return [];
|
|
909
|
+
const dirs = [];
|
|
910
|
+
for (const dir of declared) {
|
|
911
|
+
const resolved = resolve(pkgRoot, dir);
|
|
912
|
+
if (statIsDirectory(resolved))
|
|
913
|
+
dirs.push(resolved);
|
|
914
|
+
}
|
|
915
|
+
return dirs;
|
|
916
|
+
}
|
|
917
|
+
/** Whether `path` is an existing directory (never throws). */
|
|
918
|
+
function statIsDirectory(path) {
|
|
919
|
+
try {
|
|
920
|
+
return statSync(path).isDirectory();
|
|
921
|
+
}
|
|
922
|
+
catch {
|
|
923
|
+
return false;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* A package root's manifest ENTRY (the discovery both git checkouts and
|
|
928
|
+
* packages-dir packages run, W5 Task 4): the `sigrun.extension` manifest
|
|
929
|
+
* value VERBATIM (an entry that does not resolve keeps today's loud
|
|
930
|
+
* load-time failure — never a silent skills-only downgrade), else the
|
|
931
|
+
* FIRST `pi.extensions` entry that does not end in `.ts` (each `.ts` skip
|
|
932
|
+
* warns once; a `.ts`-only list discovers no entry — the package loads
|
|
933
|
+
* its skills only). `undefined` when the manifest declares no usable
|
|
934
|
+
* entry.
|
|
935
|
+
*/
|
|
936
|
+
export function manifestEntry(pkgRoot) {
|
|
937
|
+
const manifest = readDiscoveryManifest(pkgRoot);
|
|
938
|
+
if (manifest === undefined)
|
|
939
|
+
return undefined;
|
|
940
|
+
const sigrunEntry = manifest.sigrun?.extension;
|
|
941
|
+
if (typeof sigrunEntry === "string" && sigrunEntry !== "") {
|
|
942
|
+
return join(pkgRoot, sigrunEntry);
|
|
943
|
+
}
|
|
944
|
+
const piEntries = manifestStringArray(manifest.pi?.extensions);
|
|
945
|
+
if (piEntries !== undefined) {
|
|
946
|
+
for (const entry of piEntries) {
|
|
947
|
+
if (/\.ts$/.test(entry)) {
|
|
948
|
+
warnTsPiExtension(join(pkgRoot, entry));
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
return join(pkgRoot, entry);
|
|
952
|
+
}
|
|
953
|
+
console.warn(`pi-compat: no loadable pi extension in ${pkgRoot} (all entries are TypeScript); loading the package's skills only`);
|
|
954
|
+
}
|
|
955
|
+
return undefined;
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* The package ROOT a resolution's manifest skills read from, for
|
|
959
|
+
* npm/file-shaped sources: the packages-dir install (`node_modules/<name>`
|
|
960
|
+
* under the packages dir — npm keys path-shaped installs by the target
|
|
961
|
+
* package's own manifest name), else the path target itself when it is a
|
|
962
|
+
* package dir. `undefined` when neither carries a manifest (a bare
|
|
963
|
+
* app-tree resolution — no root to read skills from; such a package
|
|
964
|
+
* contributes skills through its factory result instead).
|
|
965
|
+
*/
|
|
966
|
+
function manifestRootOf(specifier, packagesDir) {
|
|
967
|
+
const id = identifySpecifier(specifier);
|
|
968
|
+
const name = id.pathShaped ? (readPackageName(id.range) ?? id.name) : id.name;
|
|
969
|
+
const installed = join(packagesDir, "node_modules", ...name.split("/"));
|
|
970
|
+
if (existsSync(join(installed, "package.json")))
|
|
971
|
+
return installed;
|
|
972
|
+
if (id.pathShaped && existsSync(join(id.range, "package.json")))
|
|
973
|
+
return id.range;
|
|
974
|
+
return undefined;
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Entry discovery for a synced git checkout, in priority order: the
|
|
978
|
+
* object form's explicit `entry` (must exist — a declared entry that
|
|
979
|
+
* does not resolve is today's loud failure), the manifest entry
|
|
980
|
+
* (`sigrun.extension` verbatim, else the first non-`.ts` pi entry — see
|
|
981
|
+
* {@link manifestEntry}), or `dist/index.js`. Returns `undefined` when
|
|
982
|
+
* nothing is discoverable: the CALLER then decides skills-only (declared
|
|
983
|
+
* skills) vs today's loud throw. No `.ts` fallback (build-and-webui Task
|
|
984
|
+
* 3, BREAKING): the built server runs plain node, which cannot load a
|
|
985
|
+
* `.ts` entry — npm/git/path sources must ship a built entry.
|
|
986
|
+
*/
|
|
987
|
+
export function findExtEntry(repoDir, explicitEntry) {
|
|
988
|
+
if (explicitEntry !== undefined) {
|
|
989
|
+
const entry = join(repoDir, explicitEntry);
|
|
990
|
+
if (!existsSync(entry)) {
|
|
991
|
+
throw new GitSyncError(`git source entry ${explicitEntry} does not exist in the checkout`);
|
|
992
|
+
}
|
|
993
|
+
return entry;
|
|
994
|
+
}
|
|
995
|
+
const manifest = manifestEntry(repoDir);
|
|
996
|
+
if (manifest !== undefined)
|
|
997
|
+
return manifest;
|
|
998
|
+
const dist = join(repoDir, "dist", "index.js");
|
|
999
|
+
if (existsSync(dist))
|
|
1000
|
+
return dist;
|
|
1001
|
+
return undefined;
|
|
1002
|
+
}
|