@telorun/kernel 0.74.0 → 0.76.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.
Files changed (81) hide show
  1. package/dist/bundle/module-artifact.d.ts +21 -4
  2. package/dist/bundle/module-artifact.d.ts.map +1 -1
  3. package/dist/bundle/module-artifact.js +42 -17
  4. package/dist/bundle/module-artifact.js.map +1 -1
  5. package/dist/bundle/module-manifest.d.ts +5 -1
  6. package/dist/bundle/module-manifest.d.ts.map +1 -1
  7. package/dist/bundle/module-manifest.js +5 -1
  8. package/dist/bundle/module-manifest.js.map +1 -1
  9. package/dist/controller-loader.d.ts +3 -2
  10. package/dist/controller-loader.d.ts.map +1 -1
  11. package/dist/controller-loader.js +10 -9
  12. package/dist/controller-loader.js.map +1 -1
  13. package/dist/controller-loaders/bundle-loader.d.ts +51 -4
  14. package/dist/controller-loaders/bundle-loader.d.ts.map +1 -1
  15. package/dist/controller-loaders/bundle-loader.js +260 -11
  16. package/dist/controller-loaders/bundle-loader.js.map +1 -1
  17. package/dist/controller-loaders/napi-loader.d.ts.map +1 -1
  18. package/dist/controller-loaders/napi-loader.js +8 -1
  19. package/dist/controller-loaders/napi-loader.js.map +1 -1
  20. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  21. package/dist/controller-loaders/npm-loader.js +65 -3
  22. package/dist/controller-loaders/npm-loader.js.map +1 -1
  23. package/dist/controller-loaders/sibling-libraries.d.ts +93 -0
  24. package/dist/controller-loaders/sibling-libraries.d.ts.map +1 -0
  25. package/dist/controller-loaders/sibling-libraries.js +111 -0
  26. package/dist/controller-loaders/sibling-libraries.js.map +1 -0
  27. package/dist/controller-loaders/source-bundle-builder.d.ts +43 -1
  28. package/dist/controller-loaders/source-bundle-builder.d.ts.map +1 -1
  29. package/dist/controller-loaders/source-bundle-builder.js +249 -24
  30. package/dist/controller-loaders/source-bundle-builder.js.map +1 -1
  31. package/dist/controllers/module/import-controller.d.ts.map +1 -1
  32. package/dist/controllers/module/import-controller.js +5 -1
  33. package/dist/controllers/module/import-controller.js.map +1 -1
  34. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  35. package/dist/controllers/resource-definition/resource-definition-controller.js +5 -1
  36. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  37. package/dist/index.d.ts +1 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/kernel.d.ts +11 -1
  42. package/dist/kernel.d.ts.map +1 -1
  43. package/dist/kernel.js +44 -5
  44. package/dist/kernel.js.map +1 -1
  45. package/dist/manifest-schemas.d.ts +1 -1
  46. package/dist/manifest-schemas.d.ts.map +1 -1
  47. package/dist/manifest-schemas.js +6 -5
  48. package/dist/manifest-schemas.js.map +1 -1
  49. package/dist/manifest-sources/local-manifest-cache-source.d.ts +13 -2
  50. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
  51. package/dist/manifest-sources/local-manifest-cache-source.js +19 -3
  52. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  53. package/dist/module-context.d.ts +7 -0
  54. package/dist/module-context.d.ts.map +1 -1
  55. package/dist/module-context.js +27 -7
  56. package/dist/module-context.js.map +1 -1
  57. package/dist/resource-context.d.ts +5 -0
  58. package/dist/resource-context.d.ts.map +1 -1
  59. package/dist/resource-context.js +6 -0
  60. package/dist/resource-context.js.map +1 -1
  61. package/dist/schema-validator.d.ts.map +1 -1
  62. package/dist/schema-validator.js +2 -2
  63. package/dist/schema-validator.js.map +1 -1
  64. package/package.json +4 -4
  65. package/src/bundle/module-artifact.ts +50 -20
  66. package/src/bundle/module-manifest.ts +14 -1
  67. package/src/controller-loader.ts +25 -7
  68. package/src/controller-loaders/bundle-loader.ts +326 -10
  69. package/src/controller-loaders/napi-loader.ts +8 -1
  70. package/src/controller-loaders/npm-loader.ts +73 -3
  71. package/src/controller-loaders/sibling-libraries.ts +171 -0
  72. package/src/controller-loaders/source-bundle-builder.ts +303 -23
  73. package/src/controllers/module/import-controller.ts +8 -1
  74. package/src/controllers/resource-definition/resource-definition-controller.ts +7 -1
  75. package/src/index.ts +6 -1
  76. package/src/kernel.ts +59 -11
  77. package/src/manifest-schemas.ts +9 -5
  78. package/src/manifest-sources/local-manifest-cache-source.ts +20 -3
  79. package/src/module-context.ts +35 -7
  80. package/src/resource-context.ts +8 -0
  81. package/src/schema-validator.ts +1 -2
@@ -1,6 +1,10 @@
1
1
  import { ControllerInstance, RuntimeError, type Logger } from "@telorun/sdk";
2
2
  import { BundleControllerLoader } from "./controller-loaders/bundle-loader.js";
3
3
  import type { ModuleArtifact } from "./bundle/module-artifact.js";
4
+ import {
5
+ NO_SIBLING_LIBRARIES,
6
+ type SiblingLibraryMap,
7
+ } from "./controller-loaders/sibling-libraries.js";
4
8
  import { ControllerEnvMissingError, NapiControllerLoader } from "./controller-loaders/napi-loader.js";
5
9
  import { NpmControllerLoader } from "./controller-loaders/npm-loader.js";
6
10
  import { ControllerPolicy, DEFAULT_POLICY, POLICY_WILDCARD } from "./runtime-registry.js";
@@ -118,7 +122,7 @@ export class ControllerLoader {
118
122
  });
119
123
  if (options.log) this.npmLoader.setLogger(options.log);
120
124
  this.napiLoader = new NapiControllerLoader();
121
- this.bundleLoader = new BundleControllerLoader(options.cacheRoot);
125
+ this.bundleLoader = new BundleControllerLoader(options.cacheRoot, options.log);
122
126
  }
123
127
 
124
128
  async load(
@@ -126,6 +130,7 @@ export class ControllerLoader {
126
130
  baseUri: string,
127
131
  policy?: ControllerPolicy,
128
132
  artifact?: ModuleArtifact,
133
+ libraries: SiblingLibraryMap = NO_SIBLING_LIBRARIES,
129
134
  ): Promise<ControllerInstance> {
130
135
  if (!purlCandidates || purlCandidates.length === 0) {
131
136
  throw new RuntimeError("ERR_CONTROLLER_NOT_FOUND", "Missing controller PURL candidates");
@@ -144,7 +149,7 @@ export class ControllerLoader {
144
149
  await this.emit?.({ name: "ControllerLoading", payload: { purl } });
145
150
  const startedAt = Date.now();
146
151
  try {
147
- const { instance, source } = await this.dispatchOne(purl, baseUri, artifact);
152
+ const { instance, source } = await this.dispatchOne(purl, baseUri, artifact, libraries);
148
153
  await this.emit?.({
149
154
  name: "ControllerLoaded",
150
155
  payload: { purl, source, durationMs: Date.now() - startedAt },
@@ -199,6 +204,7 @@ export class ControllerLoader {
199
204
  baseUri: string,
200
205
  policy?: ControllerPolicy,
201
206
  artifact?: ModuleArtifact,
207
+ libraries: SiblingLibraryMap = NO_SIBLING_LIBRARIES,
202
208
  ): Promise<ResolvedController> {
203
209
  if (!purlCandidates || purlCandidates.length === 0) {
204
210
  throw new RuntimeError("ERR_CONTROLLER_NOT_FOUND", "Missing controller PURL candidates");
@@ -214,7 +220,12 @@ export class ControllerLoader {
214
220
  const errors: string[] = [];
215
221
  for (const purl of ordered) {
216
222
  try {
217
- const { source, importInstance } = await this.dispatchResolveOne(purl, baseUri, artifact);
223
+ const { source, importInstance } = await this.dispatchResolveOne(
224
+ purl,
225
+ baseUri,
226
+ artifact,
227
+ libraries,
228
+ );
218
229
  return { purl, source, importInstance };
219
230
  } catch (err) {
220
231
  if (err instanceof ControllerEnvMissingError) {
@@ -233,16 +244,23 @@ export class ControllerLoader {
233
244
  private async dispatchOne(
234
245
  purl: string,
235
246
  baseUri: string,
236
- artifact?: ModuleArtifact,
247
+ artifact: ModuleArtifact | undefined,
248
+ libraries: SiblingLibraryMap,
237
249
  ): Promise<{ instance: ControllerInstance; source: ControllerResolveSource }> {
238
- const { source, importInstance } = await this.dispatchResolveOne(purl, baseUri, artifact);
250
+ const { source, importInstance } = await this.dispatchResolveOne(
251
+ purl,
252
+ baseUri,
253
+ artifact,
254
+ libraries,
255
+ );
239
256
  return { instance: await importInstance(), source };
240
257
  }
241
258
 
242
259
  private async dispatchResolveOne(
243
260
  purl: string,
244
261
  baseUri: string,
245
- artifact?: ModuleArtifact,
262
+ artifact: ModuleArtifact | undefined,
263
+ libraries: SiblingLibraryMap,
246
264
  ): Promise<{ source: ControllerResolveSource; importInstance: () => Promise<ControllerInstance> }> {
247
265
  if (purl.startsWith("pkg:npm")) {
248
266
  return this.npmLoader.resolve(purl, baseUri);
@@ -251,7 +269,7 @@ export class ControllerLoader {
251
269
  return this.napiLoader.resolve(purl, baseUri);
252
270
  }
253
271
  if (purl.startsWith("pkg:telo")) {
254
- return this.bundleLoader.resolve(purl, baseUri, artifact);
272
+ return this.bundleLoader.resolve(purl, baseUri, artifact, libraries);
255
273
  }
256
274
  throw new ControllerEnvMissingError(`Unsupported PURL scheme: ${purl}`);
257
275
  }
@@ -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 { buildControllerFromSource, canBuildFromSource } from "./source-bundle-builder.js";
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(private readonly cacheRoot?: string) {}
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(sourceFile!, cacheRoot!);
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 ensureRealmSymlinks(path.dirname(prebuilt));
589
+ await prepareBundleDir(path.dirname(prebuilt), shims, this.cacheRoot, this.log);
274
590
  return importControllerModule(prebuilt, purl, fragment);
275
591
  }
276
- await ensureRealmSymlinks(path.dirname(built));
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.) resolve to the kernel's copy before
640
+ // Make bare `@telorun/sdk` (etc.) and every sibling library resolve before
325
641
  // importing the bundle, so authors write normal imports.
326
- await ensureRealmSymlinks(path.dirname(absFile));
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
- const baseUriPath = baseUri.startsWith("file://") ? baseUri.slice("file://".length) : baseUri;
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(PACKAGE_MANAGER, args, { cwd, maxBuffer: 32 * 1024 * 1024, env: hostEnv() });
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 ` +