@telorun/kernel 0.75.0 → 0.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bundle/module-artifact.d.ts +21 -4
- package/dist/bundle/module-artifact.d.ts.map +1 -1
- package/dist/bundle/module-artifact.js +42 -17
- package/dist/bundle/module-artifact.js.map +1 -1
- package/dist/bundle/module-manifest.d.ts +5 -1
- package/dist/bundle/module-manifest.d.ts.map +1 -1
- package/dist/bundle/module-manifest.js +5 -1
- package/dist/bundle/module-manifest.js.map +1 -1
- package/dist/controller-loader.d.ts +3 -2
- package/dist/controller-loader.d.ts.map +1 -1
- package/dist/controller-loader.js +10 -9
- package/dist/controller-loader.js.map +1 -1
- package/dist/controller-loaders/bundle-loader.d.ts +51 -4
- package/dist/controller-loaders/bundle-loader.d.ts.map +1 -1
- package/dist/controller-loaders/bundle-loader.js +260 -11
- package/dist/controller-loaders/bundle-loader.js.map +1 -1
- package/dist/controller-loaders/napi-loader.d.ts.map +1 -1
- package/dist/controller-loaders/napi-loader.js +8 -1
- package/dist/controller-loaders/napi-loader.js.map +1 -1
- package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
- package/dist/controller-loaders/npm-loader.js +65 -3
- package/dist/controller-loaders/npm-loader.js.map +1 -1
- package/dist/controller-loaders/sibling-libraries.d.ts +93 -0
- package/dist/controller-loaders/sibling-libraries.d.ts.map +1 -0
- package/dist/controller-loaders/sibling-libraries.js +111 -0
- package/dist/controller-loaders/sibling-libraries.js.map +1 -0
- package/dist/controller-loaders/source-bundle-builder.d.ts +25 -2
- package/dist/controller-loaders/source-bundle-builder.d.ts.map +1 -1
- package/dist/controller-loaders/source-bundle-builder.js +232 -26
- package/dist/controller-loaders/source-bundle-builder.js.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js +5 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
- package/dist/host-versions.d.ts +23 -0
- package/dist/host-versions.d.ts.map +1 -0
- package/dist/host-versions.js +25 -0
- package/dist/host-versions.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/kernel.d.ts +11 -1
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +43 -5
- package/dist/kernel.js.map +1 -1
- package/dist/manifest-schemas.d.ts +1 -1
- package/dist/manifest-schemas.d.ts.map +1 -1
- package/dist/manifest-schemas.js +5 -3
- package/dist/manifest-schemas.js.map +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.d.ts +13 -2
- package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.js +19 -3
- package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
- package/dist/resource-context.d.ts +5 -0
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +6 -0
- package/dist/resource-context.js.map +1 -1
- package/dist/runtime-seam.d.ts.map +1 -1
- package/dist/runtime-seam.js +2 -0
- package/dist/runtime-seam.js.map +1 -1
- package/package.json +3 -3
- package/src/bundle/module-artifact.ts +50 -20
- package/src/bundle/module-manifest.ts +14 -1
- package/src/controller-loader.ts +25 -7
- package/src/controller-loaders/bundle-loader.ts +326 -10
- package/src/controller-loaders/napi-loader.ts +8 -1
- package/src/controller-loaders/npm-loader.ts +73 -3
- package/src/controller-loaders/sibling-libraries.ts +171 -0
- package/src/controller-loaders/source-bundle-builder.ts +281 -24
- package/src/controllers/resource-definition/resource-definition-controller.ts +7 -1
- package/src/host-versions.ts +26 -0
- package/src/index.ts +2 -0
- package/src/kernel.ts +56 -12
- package/src/manifest-schemas.ts +8 -2
- package/src/manifest-sources/local-manifest-cache-source.ts +20 -3
- package/src/resource-context.ts +8 -0
- package/src/runtime-seam.ts +2 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describeSelector, selectorFromQualifiers, selectorMatches } from "@telorun/analyzer";
|
|
2
|
-
import { ControllerInstance, RuntimeError } from "@telorun/sdk";
|
|
2
|
+
import { ControllerInstance, RuntimeError, type Logger } from "@telorun/sdk";
|
|
3
3
|
import { existsSync, readFileSync } from "fs";
|
|
4
4
|
import * as fs from "fs/promises";
|
|
5
5
|
import { createRequire } from "module";
|
|
@@ -10,7 +10,16 @@ import { hostPlatformTarget, type ModuleArtifact } from "../bundle/module-artifa
|
|
|
10
10
|
import type { ControllerResolveSource } from "../controller-loader.js";
|
|
11
11
|
import { ControllerEnvMissingError } from "./napi-loader.js";
|
|
12
12
|
import { REALM_COLLAPSE_NAMES } from "./realm.js";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
buildControllerFromSource,
|
|
15
|
+
canBuildFromSource,
|
|
16
|
+
type SiblingLibrary,
|
|
17
|
+
} from "./source-bundle-builder.js";
|
|
18
|
+
import {
|
|
19
|
+
NO_SIBLING_LIBRARIES,
|
|
20
|
+
type ResolvedSiblingLibrary,
|
|
21
|
+
type SiblingLibraryMap,
|
|
22
|
+
} from "./sibling-libraries.js";
|
|
14
23
|
|
|
15
24
|
/** A base URI whose files are already on disk: a `file://` URL or a bare
|
|
16
25
|
* absolute path. Everything else (`oci://`, `http(s)://`, `memory://`) names a
|
|
@@ -45,6 +54,11 @@ async function pathExists(filePath: string): Promise<boolean> {
|
|
|
45
54
|
const realmLinkedDirs = new Set<string>();
|
|
46
55
|
async function ensureRealmSymlinks(bundleDir: string): Promise<void> {
|
|
47
56
|
if (realmLinkedDirs.has(bundleDir)) return;
|
|
57
|
+
await linkRealmNames(bundleDir);
|
|
58
|
+
realmLinkedDirs.add(bundleDir);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function linkRealmNames(bundleDir: string): Promise<void> {
|
|
48
62
|
const req = createRequire(import.meta.url);
|
|
49
63
|
for (const name of REALM_COLLAPSE_NAMES) {
|
|
50
64
|
let pkgRoot: string | null = null;
|
|
@@ -84,9 +98,168 @@ async function ensureRealmSymlinks(bundleDir: string): Promise<void> {
|
|
|
84
98
|
// import surfaces the resolution failure.
|
|
85
99
|
}
|
|
86
100
|
}
|
|
87
|
-
realmLinkedDirs.add(bundleDir);
|
|
88
101
|
}
|
|
89
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Make each sibling module's declared specifier resolve, from this bundle, to
|
|
105
|
+
* that module's own library entry point.
|
|
106
|
+
*
|
|
107
|
+
* The realm collapse above points a closed, kernel-owned name at the kernel's own
|
|
108
|
+
* copy. This is the same move one step out: the name is declared by the library
|
|
109
|
+
* (`library: [pkg:telo/local/js?…&specifier=@telorun/sql]`), and the copy comes
|
|
110
|
+
* from that module's artifact rather than from the kernel. What it buys is the
|
|
111
|
+
* same thing — resolution *and* identity — which here means one module scope for
|
|
112
|
+
* `@telorun/sql` across its own six controllers and every dependent, instead of
|
|
113
|
+
* one copy per bundle.
|
|
114
|
+
*
|
|
115
|
+
* A **synthesized package** rather than a symlink to the module's directory: a
|
|
116
|
+
* published artifact ships files, not a `package.json`, so there is nothing to
|
|
117
|
+
* link to that standard resolution would accept. The generated shim re-exports
|
|
118
|
+
* the materialized entry by absolute URL, and every consumer's shim re-exports
|
|
119
|
+
* the *same* file — Node keys its module registry by resolved URL, so the scope
|
|
120
|
+
* stays single however many shims point at it.
|
|
121
|
+
*
|
|
122
|
+
* Written into `node_modules/` beside the bundle, which is per module for a
|
|
123
|
+
* published artifact and per content-addressed build for a working copy, so two
|
|
124
|
+
* dependents that legitimately resolve different versions of one library never
|
|
125
|
+
* write over each other.
|
|
126
|
+
*
|
|
127
|
+
* **A slot something else owns is never written.** The bundle directory is not
|
|
128
|
+
* always the loader's: the prebuilt-`path=` branch imports out of a working copy,
|
|
129
|
+
* where `node_modules/@telorun/sql` is a package manager's symlink INTO the
|
|
130
|
+
* library's own source tree — writing through it would replace that package's
|
|
131
|
+
* real `package.json`. So a slot is written only when it is absent or carries the
|
|
132
|
+
* marker this loader stamps, which is the posture `linkRealmNames` already takes
|
|
133
|
+
* ("a real file/dir in the slot is left untouched"). A foreign package in the slot
|
|
134
|
+
* already resolves the specifier to real code; what it costs is the single-scope
|
|
135
|
+
* property, so it is reported rather than passed over in silence.
|
|
136
|
+
*/
|
|
137
|
+
const SHIM_MARKER = "x-telo-generated";
|
|
138
|
+
|
|
139
|
+
async function ensureLibraryShims(
|
|
140
|
+
bundleDir: string,
|
|
141
|
+
entries: ReadonlyArray<{ specifier: string; entryFile: string }>,
|
|
142
|
+
cacheRoot: string | undefined,
|
|
143
|
+
log?: Logger,
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
for (const { specifier, entryFile } of entries) {
|
|
146
|
+
const dir = path.join(bundleDir, "node_modules", ...specifier.split("/"));
|
|
147
|
+
if (!(await isWritableShimSlot(dir, cacheRoot))) {
|
|
148
|
+
log?.debug("left an existing package in a sibling-library slot", {
|
|
149
|
+
"telo.library.specifier": specifier,
|
|
150
|
+
"telo.library.slot": dir,
|
|
151
|
+
"telo.library.entry": entryFile,
|
|
152
|
+
});
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const target = pathToFileURL(entryFile).href;
|
|
156
|
+
await writeIfChanged(
|
|
157
|
+
path.join(dir, "package.json"),
|
|
158
|
+
`${JSON.stringify(
|
|
159
|
+
{
|
|
160
|
+
name: specifier,
|
|
161
|
+
version: "0.0.0",
|
|
162
|
+
type: "module",
|
|
163
|
+
exports: { ".": "./index.mjs" },
|
|
164
|
+
[SHIM_MARKER]: "sibling-library-shim",
|
|
165
|
+
},
|
|
166
|
+
null,
|
|
167
|
+
2,
|
|
168
|
+
)}\n`,
|
|
169
|
+
);
|
|
170
|
+
// `export *` and nothing else: these entry points export named bindings, and
|
|
171
|
+
// a re-exported `default` that does not exist is a hard syntax-level error at
|
|
172
|
+
// import rather than an absent binding.
|
|
173
|
+
await writeIfChanged(path.join(dir, "index.mjs"), `export * from ${JSON.stringify(target)};\n`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Whether this loader may write the shim slot at `dir`.
|
|
179
|
+
*
|
|
180
|
+
* **Location first.** Every legitimate write site is inside the loader's own
|
|
181
|
+
* cache root — a bundle built from source lives under `<cache>/controller-src/`,
|
|
182
|
+
* and a published module's layers extract under `<cache>/manifests/` — so a slot
|
|
183
|
+
* there is ours whatever it currently holds. That is what keeps a shim written by
|
|
184
|
+
* an earlier kernel version (before the marker existed, or with different
|
|
185
|
+
* contents) updatable rather than mistaken for someone else's package.
|
|
186
|
+
*
|
|
187
|
+
* **Marker second**, for a slot outside the cache: the prebuilt-`path=` branch
|
|
188
|
+
* imports out of a working copy, where `node_modules/@telorun/sql` is a package
|
|
189
|
+
* manager's symlink straight into the sibling's own source tree. Reading the
|
|
190
|
+
* `package.json` **through** whatever is there settles it — a symlink resolves to
|
|
191
|
+
* the target's, which carries no marker — so one read covers both a link and a
|
|
192
|
+
* real installed package without caring which it was.
|
|
193
|
+
*/
|
|
194
|
+
async function isWritableShimSlot(dir: string, cacheRoot: string | undefined): Promise<boolean> {
|
|
195
|
+
if (cacheRoot) {
|
|
196
|
+
const root = path.resolve(cacheRoot) + path.sep;
|
|
197
|
+
if (path.resolve(dir).startsWith(root)) return true;
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const parsed = JSON.parse(await fs.readFile(path.join(dir, "package.json"), "utf8")) as Record<
|
|
201
|
+
string,
|
|
202
|
+
unknown
|
|
203
|
+
>;
|
|
204
|
+
return parsed[SHIM_MARKER] !== undefined;
|
|
205
|
+
} catch {
|
|
206
|
+
// Nothing readable there. A symlink with no package.json behind it is still
|
|
207
|
+
// someone else's, so refuse that too rather than writing through it.
|
|
208
|
+
try {
|
|
209
|
+
return !(await fs.lstat(dir)).isSymbolicLink();
|
|
210
|
+
} catch {
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Everything a bundle's directory needs before the bundle is imported: the
|
|
217
|
+
* kernel-owned realm names, and one shim per sibling library. */
|
|
218
|
+
async function prepareBundleDir(
|
|
219
|
+
bundleDir: string,
|
|
220
|
+
shims: ReadonlyArray<{ specifier: string; entryFile: string }>,
|
|
221
|
+
cacheRoot: string | undefined,
|
|
222
|
+
log?: Logger,
|
|
223
|
+
): Promise<void> {
|
|
224
|
+
await ensureRealmSymlinks(bundleDir);
|
|
225
|
+
await ensureLibraryShims(bundleDir, shims, cacheRoot, log);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The externals a build of `format` code takes from a sibling-library map: the
|
|
229
|
+
* specifier esbuild must not inline, and the source tree the post-build check
|
|
230
|
+
* proves was not reached by another route. A published sibling ships no sources,
|
|
231
|
+
* so it is externalized with no tree to check — there is nothing there to
|
|
232
|
+
* inline. */
|
|
233
|
+
function buildExternals(libraries: SiblingLibraryMap, format: string): SiblingLibrary[] {
|
|
234
|
+
const out: SiblingLibrary[] = [];
|
|
235
|
+
for (const library of libraries.values()) {
|
|
236
|
+
if (library.selector.format !== format) continue;
|
|
237
|
+
const sourceDir =
|
|
238
|
+
library.moduleDir && library.localPath
|
|
239
|
+
? path.dirname(path.resolve(library.moduleDir, library.localPath))
|
|
240
|
+
: undefined;
|
|
241
|
+
out.push({ specifier: library.specifier, ...(sourceDir ? { sourceDir } : {}) });
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Write a generated file only when its content would change, through a private
|
|
247
|
+
* temp file and an atomic rename — several kernels may populate one cache
|
|
248
|
+
* directory at once, and a reader must see a whole file or none. */
|
|
249
|
+
async function writeIfChanged(file: string, content: string): Promise<void> {
|
|
250
|
+
try {
|
|
251
|
+
if ((await fs.readFile(file, "utf8")) === content) return;
|
|
252
|
+
} catch {
|
|
253
|
+
// Absent or unreadable — write it.
|
|
254
|
+
}
|
|
255
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
256
|
+
const tmp = `${file}.${process.pid}.${shimCounter++}.tmp`;
|
|
257
|
+
await fs.writeFile(tmp, content);
|
|
258
|
+
await fs.rename(tmp, file);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let shimCounter = 0;
|
|
262
|
+
|
|
90
263
|
/**
|
|
91
264
|
* Walk up from a resolved entry file to the directory whose package.json `name`
|
|
92
265
|
* matches — the package root to symlink (so the symlinked package.json `exports`
|
|
@@ -159,17 +332,147 @@ export class BundleControllerLoader {
|
|
|
159
332
|
/** Where a dev build from `local_path` is cached (`<cache-root>/controller-src`).
|
|
160
333
|
* Absent for callers that resolved no cache root, which simply disables the
|
|
161
334
|
* source path — a prebuilt `path=` still loads. */
|
|
162
|
-
constructor(
|
|
335
|
+
constructor(
|
|
336
|
+
private readonly cacheRoot?: string,
|
|
337
|
+
/** Reports what resolution had to leave alone — a sibling-library slot an
|
|
338
|
+
* installer already owns, which resolves but not to this module's own copy. */
|
|
339
|
+
private readonly log?: Logger,
|
|
340
|
+
) {}
|
|
163
341
|
|
|
164
342
|
async load(
|
|
165
343
|
purl: string,
|
|
166
344
|
baseUri: string,
|
|
167
345
|
artifact?: ModuleArtifact,
|
|
346
|
+
libraries: SiblingLibraryMap = NO_SIBLING_LIBRARIES,
|
|
168
347
|
): Promise<{ instance: ControllerInstance; source: ControllerResolveSource }> {
|
|
169
|
-
const { source, importInstance } = await this.resolve(purl, baseUri, artifact);
|
|
348
|
+
const { source, importInstance } = await this.resolve(purl, baseUri, artifact, libraries);
|
|
170
349
|
return { instance: await importInstance(), source };
|
|
171
350
|
}
|
|
172
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Resolve every sibling library this bundle imports to a file on disk, and
|
|
354
|
+
* prepare the module scope each one will run in.
|
|
355
|
+
*
|
|
356
|
+
* Filtered to the candidate's own format: a `js` bundle imports the `js` entry
|
|
357
|
+
* point, and a Rust crate of the same module — a different specifier entirely —
|
|
358
|
+
* is not its business. The host platform gate is the same one the candidate
|
|
359
|
+
* itself passed, since a library layer is selected exactly as a controller
|
|
360
|
+
* layer is.
|
|
361
|
+
*/
|
|
362
|
+
private async libraryEntries(
|
|
363
|
+
libraries: SiblingLibraryMap,
|
|
364
|
+
format: string,
|
|
365
|
+
purl: string,
|
|
366
|
+
seen: Set<string>,
|
|
367
|
+
): Promise<Array<{ specifier: string; entryFile: string }>> {
|
|
368
|
+
const host = hostPlatformTarget();
|
|
369
|
+
const out: Array<{ specifier: string; entryFile: string }> = [];
|
|
370
|
+
for (const library of libraries.values()) {
|
|
371
|
+
if (library.selector.format !== format) continue;
|
|
372
|
+
if (!selectorMatches(library.selector, host)) continue;
|
|
373
|
+
out.push({
|
|
374
|
+
specifier: library.specifier,
|
|
375
|
+
entryFile: await this.prepareLibrary(library, format, purl, seen),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The file a sibling's specifier resolves to, with that file's own imports made
|
|
383
|
+
* resolvable in turn.
|
|
384
|
+
*
|
|
385
|
+
* A library is delivered exactly as a controller is, so it takes the same two
|
|
386
|
+
* routes: a published module's entry point comes out of its `library` layer,
|
|
387
|
+
* and a working copy's is built from `local_path` so an edit is picked up with
|
|
388
|
+
* no build step. The recursion is real — a library that imports another library
|
|
389
|
+
* needs its own shims beside it — and `seen` bounds it at one visit per module.
|
|
390
|
+
*/
|
|
391
|
+
private async prepareLibrary(
|
|
392
|
+
library: ResolvedSiblingLibrary,
|
|
393
|
+
format: string,
|
|
394
|
+
purl: string,
|
|
395
|
+
seen: Set<string>,
|
|
396
|
+
): Promise<string> {
|
|
397
|
+
const entryFile = await this.libraryEntryFile(library, format, purl);
|
|
398
|
+
const dir = path.dirname(entryFile);
|
|
399
|
+
if (!seen.has(library.moduleSource)) {
|
|
400
|
+
seen.add(library.moduleSource);
|
|
401
|
+
const nested = await this.libraryEntries(library.libraries, format, purl, seen);
|
|
402
|
+
await ensureLibraryShims(dir, nested, this.cacheRoot, this.log);
|
|
403
|
+
}
|
|
404
|
+
// A library entry imports `@telorun/sdk` like any controller does.
|
|
405
|
+
await ensureRealmSymlinks(dir);
|
|
406
|
+
return entryFile;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The file a library's specifier resolves to.
|
|
411
|
+
*
|
|
412
|
+
* Every failure here is `ControllerEnvMissingError`, and that is a choice worth
|
|
413
|
+
* defending: it is not "this host lacks an environment" in the ordinary sense.
|
|
414
|
+
* But a library is resolved **per format**, so a failure is scoped to ONE
|
|
415
|
+
* candidate — the `js` library being absent says nothing about whether a
|
|
416
|
+
* `napi` candidate of the same kind can run, and the candidate list is exactly
|
|
417
|
+
* the mechanism for trying it. Failing hard would abort a list a sibling
|
|
418
|
+
* candidate could still satisfy. It also matches how the controller path
|
|
419
|
+
* already treats the same shapes: a missing bundle file and a selector the
|
|
420
|
+
* artifact ships no layer for are both env-missing there. What must never be
|
|
421
|
+
* masked this way is a *build* failure or a malformed bundle, and neither is
|
|
422
|
+
* reachable from here — those keep their hard codes. Each message names the
|
|
423
|
+
* sibling module and the action, and the aggregated
|
|
424
|
+
* `ERR_CONTROLLER_NOT_FOUND` carries every one of them.
|
|
425
|
+
*/
|
|
426
|
+
private async libraryEntryFile(
|
|
427
|
+
library: ResolvedSiblingLibrary,
|
|
428
|
+
format: string,
|
|
429
|
+
purl: string,
|
|
430
|
+
): Promise<string> {
|
|
431
|
+
if (library.artifact) {
|
|
432
|
+
const resolved = await library.artifact.materializeLibrary(library.selector);
|
|
433
|
+
if (!resolved) {
|
|
434
|
+
throw new ControllerEnvMissingError(
|
|
435
|
+
`pkg:telo controller "${purl}" imports "${library.specifier}", but module ` +
|
|
436
|
+
`${library.moduleSource} ships no ${format} library layer for it ` +
|
|
437
|
+
`(has: ${library.artifact.describeLayers()}). Republish that module.`,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
return path.resolve(resolved.layer.dir, library.path);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (!library.moduleDir) {
|
|
444
|
+
throw new ControllerEnvMissingError(
|
|
445
|
+
`pkg:telo controller "${purl}" imports "${library.specifier}", but module ` +
|
|
446
|
+
`${library.moduleSource} has no local directory to resolve its library entry point in.`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Working copy: the source is authoritative, exactly as it is for a
|
|
451
|
+
// controller — a stale checked-in bundle would otherwise shadow the edit.
|
|
452
|
+
const source = library.localPath
|
|
453
|
+
? path.resolve(library.moduleDir, library.localPath)
|
|
454
|
+
: undefined;
|
|
455
|
+
if (
|
|
456
|
+
source !== undefined &&
|
|
457
|
+
this.cacheRoot !== undefined &&
|
|
458
|
+
(await pathExists(source)) &&
|
|
459
|
+
(await canBuildFromSource())
|
|
460
|
+
) {
|
|
461
|
+
return buildControllerFromSource(
|
|
462
|
+
source,
|
|
463
|
+
this.cacheRoot,
|
|
464
|
+
buildExternals(library.libraries, format),
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const prebuilt = path.resolve(library.moduleDir, library.path);
|
|
469
|
+
if (await pathExists(prebuilt)) return prebuilt;
|
|
470
|
+
throw new ControllerEnvMissingError(
|
|
471
|
+
`pkg:telo controller "${purl}" imports "${library.specifier}", whose entry point is not at ` +
|
|
472
|
+
`"${prebuilt}"${source ? ` and whose source "${source}" cannot be built here` : ""}.`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
173
476
|
/**
|
|
174
477
|
* Resolve without importing: parse + validate the PURL, reject a candidate this
|
|
175
478
|
* host cannot run, materialize the layer that carries it, confirm the file
|
|
@@ -181,6 +484,7 @@ export class BundleControllerLoader {
|
|
|
181
484
|
purl: string,
|
|
182
485
|
baseUri: string,
|
|
183
486
|
artifact?: ModuleArtifact,
|
|
487
|
+
libraries: SiblingLibraryMap = NO_SIBLING_LIBRARIES,
|
|
184
488
|
): Promise<{ source: ControllerResolveSource; importInstance: () => Promise<ControllerInstance> }> {
|
|
185
489
|
let parsed: PackageURL;
|
|
186
490
|
try {
|
|
@@ -252,6 +556,14 @@ export class BundleControllerLoader {
|
|
|
252
556
|
cacheRoot !== undefined &&
|
|
253
557
|
(await pathExists(sourceFile)) &&
|
|
254
558
|
(await canBuildFromSource());
|
|
559
|
+
// Every sibling library this bundle imports, resolved before the bundle is:
|
|
560
|
+
// its `import { KeyedClaim } from "@telorun/kv-store"` has to have a file
|
|
561
|
+
// behind it, and which file that is depends on the import graph rather than
|
|
562
|
+
// on anything inside the bundle. Done at resolve time with the other
|
|
563
|
+
// fail-fast checks, so an unresolvable library reports itself as a candidate
|
|
564
|
+
// this host cannot run rather than as an opaque module-not-found at import.
|
|
565
|
+
const shims = await this.libraryEntries(libraries, format, purl, new Set());
|
|
566
|
+
|
|
255
567
|
if (buildFromSource) {
|
|
256
568
|
// The same file the prebuilt branch would import, kept as the fallback for
|
|
257
569
|
// an environment that stops being able to build between resolve and first
|
|
@@ -266,14 +578,18 @@ export class BundleControllerLoader {
|
|
|
266
578
|
importInstance: async () => {
|
|
267
579
|
let built: string;
|
|
268
580
|
try {
|
|
269
|
-
built = await buildControllerFromSource(
|
|
581
|
+
built = await buildControllerFromSource(
|
|
582
|
+
sourceFile!,
|
|
583
|
+
cacheRoot!,
|
|
584
|
+
buildExternals(libraries, format),
|
|
585
|
+
);
|
|
270
586
|
} catch (err) {
|
|
271
587
|
if (!(err instanceof ControllerEnvMissingError)) throw err;
|
|
272
588
|
if (!(await pathExists(prebuilt))) throw err;
|
|
273
|
-
await
|
|
589
|
+
await prepareBundleDir(path.dirname(prebuilt), shims, this.cacheRoot, this.log);
|
|
274
590
|
return importControllerModule(prebuilt, purl, fragment);
|
|
275
591
|
}
|
|
276
|
-
await
|
|
592
|
+
await prepareBundleDir(path.dirname(built), shims, this.cacheRoot, this.log);
|
|
277
593
|
return importControllerModule(built, purl, fragment);
|
|
278
594
|
},
|
|
279
595
|
};
|
|
@@ -321,9 +637,9 @@ export class BundleControllerLoader {
|
|
|
321
637
|
);
|
|
322
638
|
}
|
|
323
639
|
|
|
324
|
-
// Make bare `@telorun/sdk` (etc.)
|
|
640
|
+
// Make bare `@telorun/sdk` (etc.) and every sibling library resolve before
|
|
325
641
|
// importing the bundle, so authors write normal imports.
|
|
326
|
-
await
|
|
642
|
+
await prepareBundleDir(path.dirname(absFile), shims, this.cacheRoot, this.log);
|
|
327
643
|
|
|
328
644
|
return {
|
|
329
645
|
source,
|
|
@@ -4,6 +4,7 @@ import * as fs from "fs/promises";
|
|
|
4
4
|
import { createRequire } from "module";
|
|
5
5
|
import { PackageURL } from "packageurl-js";
|
|
6
6
|
import * as path from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
7
8
|
import { promisify } from "util";
|
|
8
9
|
|
|
9
10
|
import { hostEnv } from "../host-env.js";
|
|
@@ -151,7 +152,13 @@ export class NapiControllerLoader {
|
|
|
151
152
|
);
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
|
|
155
|
+
// `fileURLToPath`, never a slice of the prefix. A file URL's path is not a
|
|
156
|
+
// filesystem path: on Windows `file:///D:/a/telo/telo.yaml` sliced at seven
|
|
157
|
+
// characters leaves `/D:/a/telo/telo.yaml`, whose leading slash makes
|
|
158
|
+
// `path.resolve` graft the cwd's drive on and produce `D:\D:\a\telo\…`. It
|
|
159
|
+
// also leaves percent-escapes undecoded on every platform, so a manifest
|
|
160
|
+
// under a directory with a space resolved to a path that does not exist.
|
|
161
|
+
const baseUriPath = baseUri.startsWith("file://") ? fileURLToPath(baseUri) : baseUri;
|
|
155
162
|
const manifestDir = path.dirname(baseUriPath);
|
|
156
163
|
const cratePath = path.resolve(manifestDir, localPath);
|
|
157
164
|
|
|
@@ -556,13 +556,83 @@ async function resolveKernelPackageRoot(name: string): Promise<string | null> {
|
|
|
556
556
|
}
|
|
557
557
|
}
|
|
558
558
|
|
|
559
|
+
/**
|
|
560
|
+
* Windows ships no executable named `npm`. npm, pnpm and every corepack shim
|
|
561
|
+
* are `.cmd` files: libuv's PATH search probes only `.com`/`.exe` so it never
|
|
562
|
+
* finds one, and Node has refused to spawn `.cmd`/`.bat` without a shell since
|
|
563
|
+
* CVE-2024-27980. The install therefore has to go through `cmd.exe`.
|
|
564
|
+
*
|
|
565
|
+
* Going through cmd.exe makes quoting OURS. Node builds the line as
|
|
566
|
+
* `cmd.exe /d /s /c "<file> <args joined by single spaces>"` and quotes
|
|
567
|
+
* nothing, so a `file:` install spec holding a space would be re-split into two
|
|
568
|
+
* arguments, and cmd's metacharacters (`& ^ | < > ( )`) would be interpreted
|
|
569
|
+
* before npm ever saw them — `^` is the one that matters here, since it is both
|
|
570
|
+
* cmd's escape character and legal in the semver ranges a spec carries. Under
|
|
571
|
+
* `/s` cmd strips the outer pair and takes the remainder verbatim, so quoting
|
|
572
|
+
* each token individually is what makes the line arrive intact.
|
|
573
|
+
*
|
|
574
|
+
* A literal `"` is rejected rather than escaped: it toggles cmd's quote state,
|
|
575
|
+
* the escape that restores it differs between cmd and the batch shim's own
|
|
576
|
+
* parser, and no install spec has any business carrying one. Residual cmd
|
|
577
|
+
* limitation: `%VAR%` still expands inside quotes and cannot be escaped on a
|
|
578
|
+
* command line (only in a batch file), so an install root under a directory
|
|
579
|
+
* whose name spells a defined environment variable is not reachable here. A
|
|
580
|
+
* lone `%` is left alone by cmd and is safe.
|
|
581
|
+
*/
|
|
582
|
+
function quoteForCmd(token: string): string {
|
|
583
|
+
if (token.includes('"')) {
|
|
584
|
+
throw new Error(
|
|
585
|
+
`[telo] cannot pass '${token}' to '${PACKAGE_MANAGER}' on Windows: a literal '"' has no ` +
|
|
586
|
+
`portable escape through cmd.exe. Move the install root to a path without one.`,
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
return `"${token}"`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* The COMMAND is quoted only when it cannot be left bare, where every argument
|
|
594
|
+
* is quoted unconditionally. The asymmetry is not tidiness — quoting a bare
|
|
595
|
+
* command name breaks the batch shim it resolves to.
|
|
596
|
+
*
|
|
597
|
+
* `npm.cmd` locates the CLI it exists to launch relative to itself:
|
|
598
|
+
*
|
|
599
|
+
* SET "NPM_PREFIX_JS=%~dp0\node_modules\npm\bin\npm-prefix.js"
|
|
600
|
+
* SET "NPM_CLI_JS=%~dp0\node_modules\npm\bin\npm-cli.js"
|
|
601
|
+
*
|
|
602
|
+
* `%0` is the token as it appeared on the command line, and cmd substitutes the
|
|
603
|
+
* resolved script path only for a BARE one. Quoted, `%0` stays `"npm"`, so
|
|
604
|
+
* `%~dp0` — drive and path of a token carrying neither — expands against the
|
|
605
|
+
* current directory instead. The shim then looked for npm inside Telo's install
|
|
606
|
+
* root (`<root>/.telo/npm`), found no `node_modules/npm/bin/`, and both `SET`
|
|
607
|
+
* lines produced a MODULE_NOT_FOUND for a path that was never going to exist.
|
|
608
|
+
*
|
|
609
|
+
* A command that genuinely needs quoting is a path rather than a bare name
|
|
610
|
+
* (`TELO_PKG_MANAGER=C:\Program Files\nodejs\npm.cmd`), and there `%0` already
|
|
611
|
+
* carries a directory, so `%~dp0` is right whether or not it was quoted. Both
|
|
612
|
+
* cases are therefore correct, which is what makes this conditional rather than
|
|
613
|
+
* a preference.
|
|
614
|
+
*/
|
|
615
|
+
const CMD_NEEDS_QUOTING = /[\s&^|<>()]/;
|
|
616
|
+
|
|
559
617
|
async function runPackageManager(cwd: string, args: string[]): Promise<void> {
|
|
618
|
+
const viaCmd = process.platform === "win32";
|
|
560
619
|
try {
|
|
561
|
-
await execFileAsync(
|
|
620
|
+
await execFileAsync(
|
|
621
|
+
viaCmd && CMD_NEEDS_QUOTING.test(PACKAGE_MANAGER)
|
|
622
|
+
? quoteForCmd(PACKAGE_MANAGER)
|
|
623
|
+
: PACKAGE_MANAGER,
|
|
624
|
+
viaCmd ? args.map(quoteForCmd) : args,
|
|
625
|
+
{ cwd, maxBuffer: 32 * 1024 * 1024, env: hostEnv(), shell: viaCmd },
|
|
626
|
+
);
|
|
562
627
|
} catch (err: any) {
|
|
628
|
+
// Through a shell the binary always resolves — cmd.exe itself exists — so a
|
|
629
|
+
// missing package manager arrives as cmd's own 9009 plus "is not recognized
|
|
630
|
+
// as an internal or external command" on stderr, never as ENOENT. Matching
|
|
631
|
+
// only the direct-spawn shape reported that as a generic install failure and
|
|
632
|
+
// buried the one line saying what to install.
|
|
633
|
+
const said = `${err?.message ?? ""}\n${err?.stderr ?? ""}`;
|
|
563
634
|
const isMissing =
|
|
564
|
-
err?.code === "ENOENT" ||
|
|
565
|
-
/not found|command not recognized/i.test(err?.message ?? "");
|
|
635
|
+
err?.code === "ENOENT" || err?.code === 9009 || /not found|not recognized/i.test(said);
|
|
566
636
|
if (isMissing) {
|
|
567
637
|
throw new Error(
|
|
568
638
|
`[telo] '${PACKAGE_MANAGER}' not found on PATH. Telo's controller installer requires a ` +
|