@telorun/kernel 0.10.0 → 0.11.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/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export { ControllerLoader } from "./controller-loader.js";
2
2
  export { ControllerRegistry } from "./controller-registry.js";
3
3
  export { EvaluationContext } from "./evaluation-context.js";
4
4
  export { LocalFileSource } from "./manifest-sources/local-file-source.js";
5
+ export { LocalManifestCacheSource, cachePathForCanonical, resolveEntryDir, writeManifestCache, } from "./manifest-sources/local-manifest-cache-source.js";
5
6
  export { MemorySource } from "./manifest-sources/memory-source.js";
6
7
  export { EventStream } from "./event-stream.js";
7
8
  export { ExecutionContext } from "./execution-context.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAC5D,OAAO,EAAE,eAAe,EAAE,MAAM,yCAAyC,CAAC;AAC1E,OAAO,EAAE,YAAY,EAAE,MAAM,qCAAqC,CAAC;AACnE,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAAE,MAAM,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,gBAAgB,IAAI,QAAQ,EAAE,MAAM,eAAe,CAAC;AAC7D,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,YAAY,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAC5D,OAAO,EAAE,eAAe,EAAE,MAAM,yCAAyC,CAAC;AAC1E,OAAO,EACL,wBAAwB,EACxB,qBAAqB,EACrB,eAAe,EACf,kBAAkB,GACnB,MAAM,mDAAmD,CAAC;AAC3D,OAAO,EAAE,YAAY,EAAE,MAAM,qCAAqC,CAAC;AACnE,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAAE,MAAM,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,gBAAgB,IAAI,QAAQ,EAAE,MAAM,eAAe,CAAC;AAC7D,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,YAAY,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC"}
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ export { ControllerLoader } from "./controller-loader.js";
2
2
  export { ControllerRegistry } from "./controller-registry.js";
3
3
  export { EvaluationContext } from "./evaluation-context.js";
4
4
  export { LocalFileSource } from "./manifest-sources/local-file-source.js";
5
+ export { LocalManifestCacheSource, cachePathForCanonical, resolveEntryDir, writeManifestCache, } from "./manifest-sources/local-manifest-cache-source.js";
5
6
  export { MemorySource } from "./manifest-sources/memory-source.js";
6
7
  export { EventStream } from "./event-stream.js";
7
8
  export { ExecutionContext } from "./execution-context.js";
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAC5D,OAAO,EAAE,eAAe,EAAE,MAAM,yCAAyC,CAAC;AAC1E,OAAO,EAAE,YAAY,EAAE,MAAM,qCAAqC,CAAC;AACnE,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAAE,MAAM,EAAsB,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,gBAAgB,IAAI,QAAQ,EAAE,MAAM,eAAe,CAAC;AAC7D,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAC5D,OAAO,EAAE,eAAe,EAAE,MAAM,yCAAyC,CAAC;AAC1E,OAAO,EACL,wBAAwB,EACxB,qBAAqB,EACrB,eAAe,EACf,kBAAkB,GACnB,MAAM,mDAAmD,CAAC;AAC3D,OAAO,EAAE,YAAY,EAAE,MAAM,qCAAqC,CAAC;AACnE,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAAE,MAAM,EAAsB,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,gBAAgB,IAAI,QAAQ,EAAE,MAAM,eAAe,CAAC;AAC7D,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC"}
@@ -0,0 +1,50 @@
1
+ import type { LoadedGraph, ManifestSource } from "@telorun/analyzer";
2
+ /**
3
+ * Reads previously-cached manifest YAMLs from `<entry-dir>/.telo/manifests/`.
4
+ * Sits ahead of `RegistrySource` / `HttpSource` in the source chain — a hit
5
+ * makes boot hermetic, a miss falls through to the network source unchanged.
6
+ *
7
+ * Populated by `writeManifestCache` at install time.
8
+ */
9
+ export declare class LocalManifestCacheSource implements ManifestSource {
10
+ private readonly cacheRoot;
11
+ private readonly registryUrl;
12
+ constructor(entryDir: string, registryUrl?: string);
13
+ supports(url: string): boolean;
14
+ read(url: string): Promise<{
15
+ text: string;
16
+ source: string;
17
+ }>;
18
+ resolveRelative(base: string, relative: string): string;
19
+ private tryMap;
20
+ }
21
+ /**
22
+ * Map a graph's canonical `source` URL to the on-disk cache file path it
23
+ * should be written to (writer side). Returns `null` for sources that do
24
+ * not need caching — file:// (already on disk), memory:// (transient), or
25
+ * any path that would escape the cache root.
26
+ *
27
+ * Uses the same mapping function as `LocalManifestCacheSource`, so the
28
+ * writer and reader always agree on where every URL lives.
29
+ */
30
+ export declare function cachePathForCanonical(canonicalSource: string, entryDir: string, registryUrl: string): string | null;
31
+ /**
32
+ * Persist every manifest file reachable from `graph` (owners + partials) to
33
+ * `<entryDir>/.telo/manifests/`, except the entry manifest itself and any
34
+ * file:// or memory:// sources (already on disk or transient).
35
+ *
36
+ * Idempotent: rewrites any existing file with the freshly fetched bytes so
37
+ * a partial re-install converges. Never deletes entries — stale versions
38
+ * stay until `.telo/manifests/` is removed by hand, matching the
39
+ * `.telo/npm/` convention.
40
+ *
41
+ * Returns the list of paths written, for diagnostics.
42
+ */
43
+ export declare function writeManifestCache(graph: LoadedGraph, entryDir: string, registryUrl?: string): Promise<string[]>;
44
+ /** Resolve the entry-anchor directory for the manifest cache.
45
+ *
46
+ * For a file path or `file://` URL: returns the containing directory.
47
+ * For a directory path: returns the directory itself.
48
+ * For an HTTP(S) URL: returns `null` (no local anchor; cache writes skipped). */
49
+ export declare function resolveEntryDir(entryPath: string): string | null;
50
+ //# sourceMappingURL=local-manifest-cache-source.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-manifest-cache-source.d.ts","sourceRoot":"","sources":["../../src/manifest-sources/local-manifest-cache-source.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AA4HrE;;;;;;GAMG;AACH,qBAAa,wBAAyB,YAAW,cAAc;IAC7D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;gBAEzB,QAAQ,EAAE,MAAM,EAAE,WAAW,GAAE,MAA6B;IAKxE,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAIxB,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAWlE,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM;IASvD,OAAO,CAAC,MAAM;CAYf;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,eAAe,EAAE,MAAM,EACvB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,GAClB,MAAM,GAAG,IAAI,CAGf;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,kBAAkB,CACtC,KAAK,EAAE,WAAW,EAClB,QAAQ,EAAE,MAAM,EAChB,WAAW,GAAE,MAA6B,GACzC,OAAO,CAAC,MAAM,EAAE,CAAC,CAoBnB;AAED;;;;iFAIiF;AACjF,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAgBhE"}
@@ -0,0 +1,227 @@
1
+ import { createHash } from "crypto";
2
+ import { statSync } from "fs";
3
+ import * as fs from "fs/promises";
4
+ import * as path from "path";
5
+ import { fileURLToPath, pathToFileURL } from "url";
6
+ const CACHE_SUBDIR = ".telo/manifests";
7
+ const HTTP_NAMESPACE = "__http";
8
+ const DEFAULT_MANIFEST_FILENAME = "telo.yaml";
9
+ const DEFAULT_REGISTRY_URL = "https://registry.telo.run";
10
+ const QUERY_HASH_LENGTH = 12;
11
+ /** Verify that `candidate` resolves to a path under `root`. Returns the
12
+ * candidate path on success, `null` when any segment escapes the root.
13
+ * Guards against `..` segments inside module refs or HTTP pathnames. */
14
+ function joinUnder(root, ...segments) {
15
+ if (segments.some((s) => s === ""))
16
+ return null;
17
+ const candidate = path.join(root, ...segments);
18
+ const resolved = path.resolve(candidate);
19
+ const resolvedRoot = path.resolve(root);
20
+ if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + path.sep)) {
21
+ return null;
22
+ }
23
+ return candidate;
24
+ }
25
+ /** Mirror `HttpSource.read`'s `fetchUrl` derivation: when the URL does not
26
+ * already point at a YAML file, append `/telo.yaml`. Used by both the
27
+ * reader and writer so a raw import URL and the canonical source it
28
+ * resolves to map to the same cache path. */
29
+ function normalizePathname(url, parsed) {
30
+ let pathname = parsed.pathname;
31
+ if (!url.includes(".yaml")) {
32
+ pathname = pathname.endsWith("/")
33
+ ? `${pathname}${DEFAULT_MANIFEST_FILENAME}`
34
+ : `${pathname}/${DEFAULT_MANIFEST_FILENAME}`;
35
+ }
36
+ return pathname;
37
+ }
38
+ /** Compute a short hash of `search + hash` so two URLs that differ only in
39
+ * query / fragment do not collide at the same cache path. Inserted before
40
+ * the final extension so the filename stays readable. */
41
+ function disambiguatePath(pathname, search, hash) {
42
+ if (!search && !hash)
43
+ return pathname;
44
+ const digest = createHash("sha256")
45
+ .update(search + hash)
46
+ .digest("hex")
47
+ .slice(0, QUERY_HASH_LENGTH);
48
+ const ext = path.extname(pathname);
49
+ const base = pathname.slice(0, pathname.length - ext.length);
50
+ return `${base}.${digest}${ext}`;
51
+ }
52
+ /** Single source of truth for URL → cache path. Used identically by the
53
+ * reader (cache lookup) and writer (install-time persistence). For any
54
+ * given import URL — registry ref, direct registry URL, or arbitrary
55
+ * HTTP — both sides land on the same file.
56
+ *
57
+ * Returns `null` for unsupported URLs (file://, memory://, relative paths)
58
+ * or for path-traversal attempts that would escape `cacheRoot`. */
59
+ function cachePathForUrl(url, cacheRoot, registryUrl) {
60
+ const trimmedRegistry = registryUrl.replace(/\/+$/, "");
61
+ // 1. Registry ref form: namespace/name@version
62
+ if (!url.startsWith("http://") &&
63
+ !url.startsWith("https://") &&
64
+ !url.startsWith("/") &&
65
+ !url.startsWith(".") &&
66
+ !url.startsWith("file://") &&
67
+ !url.startsWith("memory://") &&
68
+ url.includes("@") &&
69
+ url.includes("/")) {
70
+ const atIdx = url.lastIndexOf("@");
71
+ if (atIdx <= 0 || atIdx === url.length - 1)
72
+ return null;
73
+ const modulePath = url.slice(0, atIdx);
74
+ if (!modulePath.includes("/"))
75
+ return null;
76
+ const version = url.slice(atIdx + 1).replace(/^v/, "");
77
+ if (!version)
78
+ return null;
79
+ return joinUnder(cacheRoot, modulePath, version, DEFAULT_MANIFEST_FILENAME);
80
+ }
81
+ // 2. HTTP(S) URL — could be a direct registry URL or arbitrary external.
82
+ if (url.startsWith("http://") || url.startsWith("https://")) {
83
+ let parsed;
84
+ try {
85
+ parsed = new URL(url);
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ const pathname = normalizePathname(url, parsed);
91
+ // 2a. URL is on the configured registry, with no query/fragment:
92
+ // fold into the registry layout so the writer that received a
93
+ // registry-ref canonical and the reader that sees a direct URL
94
+ // both resolve to the same file.
95
+ const normalizedUrl = `${parsed.protocol}//${parsed.host}${pathname}`;
96
+ if (!parsed.search &&
97
+ !parsed.hash &&
98
+ (normalizedUrl === trimmedRegistry || normalizedUrl.startsWith(`${trimmedRegistry}/`))) {
99
+ const rel = normalizedUrl.slice(trimmedRegistry.length + 1);
100
+ if (!rel)
101
+ return null;
102
+ return joinUnder(cacheRoot, ...rel.split("/"));
103
+ }
104
+ // 2b. Arbitrary HTTP(S) URL → __http subtree, with a short query-hash
105
+ // suffix when query / fragment are present to prevent collisions.
106
+ const cleanPath = pathname.startsWith("/") ? pathname.slice(1) : pathname;
107
+ const disambiguated = disambiguatePath(cleanPath, parsed.search, parsed.hash);
108
+ return joinUnder(cacheRoot, HTTP_NAMESPACE, parsed.host, ...disambiguated.split("/"));
109
+ }
110
+ return null;
111
+ }
112
+ /**
113
+ * Reads previously-cached manifest YAMLs from `<entry-dir>/.telo/manifests/`.
114
+ * Sits ahead of `RegistrySource` / `HttpSource` in the source chain — a hit
115
+ * makes boot hermetic, a miss falls through to the network source unchanged.
116
+ *
117
+ * Populated by `writeManifestCache` at install time.
118
+ */
119
+ export class LocalManifestCacheSource {
120
+ constructor(entryDir, registryUrl = DEFAULT_REGISTRY_URL) {
121
+ this.cacheRoot = path.join(entryDir, CACHE_SUBDIR);
122
+ this.registryUrl = registryUrl;
123
+ }
124
+ supports(url) {
125
+ return this.tryMap(url) !== null;
126
+ }
127
+ async read(url) {
128
+ const mapped = this.tryMap(url);
129
+ if (!mapped) {
130
+ throw new Error(`LocalManifestCacheSource does not support '${url}' (cache miss or unsupported scheme)`);
131
+ }
132
+ const text = await fs.readFile(mapped, "utf-8");
133
+ return { text, source: pathToFileURL(mapped).href };
134
+ }
135
+ resolveRelative(base, relative) {
136
+ // Once `read()` serves a file the canonical `source` is a file:// URL, so
137
+ // any further include: / sibling resolution flows through LocalFileSource.
138
+ // This method exists only for completeness; if the loader ever invokes it
139
+ // with a cache-mapped base, fall back to file-URL semantics.
140
+ const baseDir = base.endsWith("/") ? base : base.slice(0, base.lastIndexOf("/") + 1);
141
+ return new URL(relative, baseDir).href;
142
+ }
143
+ tryMap(url) {
144
+ const candidate = cachePathForUrl(url, this.cacheRoot, this.registryUrl);
145
+ if (!candidate)
146
+ return null;
147
+ // Require a regular file. A directory, dangling symlink, or stat failure
148
+ // (ENOENT, EACCES, EISDIR-on-component) all fall through as a cache miss
149
+ // so the next source in the chain still gets a chance to serve the URL.
150
+ try {
151
+ return statSync(candidate).isFile() ? candidate : null;
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ }
157
+ }
158
+ /**
159
+ * Map a graph's canonical `source` URL to the on-disk cache file path it
160
+ * should be written to (writer side). Returns `null` for sources that do
161
+ * not need caching — file:// (already on disk), memory:// (transient), or
162
+ * any path that would escape the cache root.
163
+ *
164
+ * Uses the same mapping function as `LocalManifestCacheSource`, so the
165
+ * writer and reader always agree on where every URL lives.
166
+ */
167
+ export function cachePathForCanonical(canonicalSource, entryDir, registryUrl) {
168
+ const cacheRoot = path.join(entryDir, CACHE_SUBDIR);
169
+ return cachePathForUrl(canonicalSource, cacheRoot, registryUrl);
170
+ }
171
+ /**
172
+ * Persist every manifest file reachable from `graph` (owners + partials) to
173
+ * `<entryDir>/.telo/manifests/`, except the entry manifest itself and any
174
+ * file:// or memory:// sources (already on disk or transient).
175
+ *
176
+ * Idempotent: rewrites any existing file with the freshly fetched bytes so
177
+ * a partial re-install converges. Never deletes entries — stale versions
178
+ * stay until `.telo/manifests/` is removed by hand, matching the
179
+ * `.telo/npm/` convention.
180
+ *
181
+ * Returns the list of paths written, for diagnostics.
182
+ */
183
+ export async function writeManifestCache(graph, entryDir, registryUrl = DEFAULT_REGISTRY_URL) {
184
+ const written = [];
185
+ const seen = new Set();
186
+ for (const [, module] of graph.modules) {
187
+ for (const file of [module.owner, ...module.partials]) {
188
+ if (file.source === graph.rootSource)
189
+ continue;
190
+ if (seen.has(file.source))
191
+ continue;
192
+ seen.add(file.source);
193
+ const target = cachePathForCanonical(file.source, entryDir, registryUrl);
194
+ if (!target)
195
+ continue;
196
+ await fs.mkdir(path.dirname(target), { recursive: true });
197
+ await fs.writeFile(target, file.text, "utf-8");
198
+ written.push(target);
199
+ }
200
+ }
201
+ return written;
202
+ }
203
+ /** Resolve the entry-anchor directory for the manifest cache.
204
+ *
205
+ * For a file path or `file://` URL: returns the containing directory.
206
+ * For a directory path: returns the directory itself.
207
+ * For an HTTP(S) URL: returns `null` (no local anchor; cache writes skipped). */
208
+ export function resolveEntryDir(entryPath) {
209
+ if (entryPath.startsWith("http://") || entryPath.startsWith("https://")) {
210
+ return null;
211
+ }
212
+ let absolute;
213
+ if (entryPath.startsWith("file://")) {
214
+ absolute = fileURLToPath(entryPath);
215
+ }
216
+ else {
217
+ absolute = path.resolve(entryPath);
218
+ }
219
+ try {
220
+ const stat = statSync(absolute);
221
+ return stat.isDirectory() ? absolute : path.dirname(absolute);
222
+ }
223
+ catch {
224
+ return path.dirname(absolute);
225
+ }
226
+ }
227
+ //# sourceMappingURL=local-manifest-cache-source.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-manifest-cache-source.js","sourceRoot":"","sources":["../../src/manifest-sources/local-manifest-cache-source.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/kernel",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Telo Runtime - A lightweight, polyglot execution host.",
5
5
  "keywords": [
6
6
  "digly",
@@ -47,7 +47,7 @@
47
47
  "dependencies": {
48
48
  "@marcbachmann/cel-js": "^7.5.3",
49
49
  "@sinclair/typebox": "^0.34.48",
50
- "@telorun/analyzer": "0.9.0",
50
+ "@telorun/analyzer": "0.10.0",
51
51
  "@telorun/sdk": "0.10.0",
52
52
  "ajv": "^8.17.1",
53
53
  "ajv-formats": "^3.0.1",
package/src/index.ts CHANGED
@@ -2,6 +2,12 @@ export { ControllerLoader } from "./controller-loader.js";
2
2
  export { ControllerRegistry } from "./controller-registry.js";
3
3
  export { EvaluationContext } from "./evaluation-context.js";
4
4
  export { LocalFileSource } from "./manifest-sources/local-file-source.js";
5
+ export {
6
+ LocalManifestCacheSource,
7
+ cachePathForCanonical,
8
+ resolveEntryDir,
9
+ writeManifestCache,
10
+ } from "./manifest-sources/local-manifest-cache-source.js";
5
11
  export { MemorySource } from "./manifest-sources/memory-source.js";
6
12
  export { EventStream } from "./event-stream.js";
7
13
  export { ExecutionContext } from "./execution-context.js";
@@ -0,0 +1,256 @@
1
+ import type { LoadedGraph, ManifestSource } from "@telorun/analyzer";
2
+ import { createHash } from "crypto";
3
+ import { statSync } from "fs";
4
+ import * as fs from "fs/promises";
5
+ import * as path from "path";
6
+ import { fileURLToPath, pathToFileURL } from "url";
7
+
8
+ const CACHE_SUBDIR = ".telo/manifests";
9
+ const HTTP_NAMESPACE = "__http";
10
+ const DEFAULT_MANIFEST_FILENAME = "telo.yaml";
11
+ const DEFAULT_REGISTRY_URL = "https://registry.telo.run";
12
+ const QUERY_HASH_LENGTH = 12;
13
+
14
+ /** Verify that `candidate` resolves to a path under `root`. Returns the
15
+ * candidate path on success, `null` when any segment escapes the root.
16
+ * Guards against `..` segments inside module refs or HTTP pathnames. */
17
+ function joinUnder(root: string, ...segments: string[]): string | null {
18
+ if (segments.some((s) => s === "")) return null;
19
+ const candidate = path.join(root, ...segments);
20
+ const resolved = path.resolve(candidate);
21
+ const resolvedRoot = path.resolve(root);
22
+ if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + path.sep)) {
23
+ return null;
24
+ }
25
+ return candidate;
26
+ }
27
+
28
+ /** Mirror `HttpSource.read`'s `fetchUrl` derivation: when the URL does not
29
+ * already point at a YAML file, append `/telo.yaml`. Used by both the
30
+ * reader and writer so a raw import URL and the canonical source it
31
+ * resolves to map to the same cache path. */
32
+ function normalizePathname(url: string, parsed: URL): string {
33
+ let pathname = parsed.pathname;
34
+ if (!url.includes(".yaml")) {
35
+ pathname = pathname.endsWith("/")
36
+ ? `${pathname}${DEFAULT_MANIFEST_FILENAME}`
37
+ : `${pathname}/${DEFAULT_MANIFEST_FILENAME}`;
38
+ }
39
+ return pathname;
40
+ }
41
+
42
+ /** Compute a short hash of `search + hash` so two URLs that differ only in
43
+ * query / fragment do not collide at the same cache path. Inserted before
44
+ * the final extension so the filename stays readable. */
45
+ function disambiguatePath(pathname: string, search: string, hash: string): string {
46
+ if (!search && !hash) return pathname;
47
+ const digest = createHash("sha256")
48
+ .update(search + hash)
49
+ .digest("hex")
50
+ .slice(0, QUERY_HASH_LENGTH);
51
+ const ext = path.extname(pathname);
52
+ const base = pathname.slice(0, pathname.length - ext.length);
53
+ return `${base}.${digest}${ext}`;
54
+ }
55
+
56
+ /** Single source of truth for URL → cache path. Used identically by the
57
+ * reader (cache lookup) and writer (install-time persistence). For any
58
+ * given import URL — registry ref, direct registry URL, or arbitrary
59
+ * HTTP — both sides land on the same file.
60
+ *
61
+ * Returns `null` for unsupported URLs (file://, memory://, relative paths)
62
+ * or for path-traversal attempts that would escape `cacheRoot`. */
63
+ function cachePathForUrl(
64
+ url: string,
65
+ cacheRoot: string,
66
+ registryUrl: string,
67
+ ): string | null {
68
+ const trimmedRegistry = registryUrl.replace(/\/+$/, "");
69
+
70
+ // 1. Registry ref form: namespace/name@version
71
+ if (
72
+ !url.startsWith("http://") &&
73
+ !url.startsWith("https://") &&
74
+ !url.startsWith("/") &&
75
+ !url.startsWith(".") &&
76
+ !url.startsWith("file://") &&
77
+ !url.startsWith("memory://") &&
78
+ url.includes("@") &&
79
+ url.includes("/")
80
+ ) {
81
+ const atIdx = url.lastIndexOf("@");
82
+ if (atIdx <= 0 || atIdx === url.length - 1) return null;
83
+ const modulePath = url.slice(0, atIdx);
84
+ if (!modulePath.includes("/")) return null;
85
+ const version = url.slice(atIdx + 1).replace(/^v/, "");
86
+ if (!version) return null;
87
+ return joinUnder(cacheRoot, modulePath, version, DEFAULT_MANIFEST_FILENAME);
88
+ }
89
+
90
+ // 2. HTTP(S) URL — could be a direct registry URL or arbitrary external.
91
+ if (url.startsWith("http://") || url.startsWith("https://")) {
92
+ let parsed: URL;
93
+ try {
94
+ parsed = new URL(url);
95
+ } catch {
96
+ return null;
97
+ }
98
+ const pathname = normalizePathname(url, parsed);
99
+
100
+ // 2a. URL is on the configured registry, with no query/fragment:
101
+ // fold into the registry layout so the writer that received a
102
+ // registry-ref canonical and the reader that sees a direct URL
103
+ // both resolve to the same file.
104
+ const normalizedUrl = `${parsed.protocol}//${parsed.host}${pathname}`;
105
+ if (
106
+ !parsed.search &&
107
+ !parsed.hash &&
108
+ (normalizedUrl === trimmedRegistry || normalizedUrl.startsWith(`${trimmedRegistry}/`))
109
+ ) {
110
+ const rel = normalizedUrl.slice(trimmedRegistry.length + 1);
111
+ if (!rel) return null;
112
+ return joinUnder(cacheRoot, ...rel.split("/"));
113
+ }
114
+
115
+ // 2b. Arbitrary HTTP(S) URL → __http subtree, with a short query-hash
116
+ // suffix when query / fragment are present to prevent collisions.
117
+ const cleanPath = pathname.startsWith("/") ? pathname.slice(1) : pathname;
118
+ const disambiguated = disambiguatePath(cleanPath, parsed.search, parsed.hash);
119
+ return joinUnder(cacheRoot, HTTP_NAMESPACE, parsed.host, ...disambiguated.split("/"));
120
+ }
121
+
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * Reads previously-cached manifest YAMLs from `<entry-dir>/.telo/manifests/`.
127
+ * Sits ahead of `RegistrySource` / `HttpSource` in the source chain — a hit
128
+ * makes boot hermetic, a miss falls through to the network source unchanged.
129
+ *
130
+ * Populated by `writeManifestCache` at install time.
131
+ */
132
+ export class LocalManifestCacheSource implements ManifestSource {
133
+ private readonly cacheRoot: string;
134
+ private readonly registryUrl: string;
135
+
136
+ constructor(entryDir: string, registryUrl: string = DEFAULT_REGISTRY_URL) {
137
+ this.cacheRoot = path.join(entryDir, CACHE_SUBDIR);
138
+ this.registryUrl = registryUrl;
139
+ }
140
+
141
+ supports(url: string): boolean {
142
+ return this.tryMap(url) !== null;
143
+ }
144
+
145
+ async read(url: string): Promise<{ text: string; source: string }> {
146
+ const mapped = this.tryMap(url);
147
+ if (!mapped) {
148
+ throw new Error(
149
+ `LocalManifestCacheSource does not support '${url}' (cache miss or unsupported scheme)`,
150
+ );
151
+ }
152
+ const text = await fs.readFile(mapped, "utf-8");
153
+ return { text, source: pathToFileURL(mapped).href };
154
+ }
155
+
156
+ resolveRelative(base: string, relative: string): string {
157
+ // Once `read()` serves a file the canonical `source` is a file:// URL, so
158
+ // any further include: / sibling resolution flows through LocalFileSource.
159
+ // This method exists only for completeness; if the loader ever invokes it
160
+ // with a cache-mapped base, fall back to file-URL semantics.
161
+ const baseDir = base.endsWith("/") ? base : base.slice(0, base.lastIndexOf("/") + 1);
162
+ return new URL(relative, baseDir).href;
163
+ }
164
+
165
+ private tryMap(url: string): string | null {
166
+ const candidate = cachePathForUrl(url, this.cacheRoot, this.registryUrl);
167
+ if (!candidate) return null;
168
+ // Require a regular file. A directory, dangling symlink, or stat failure
169
+ // (ENOENT, EACCES, EISDIR-on-component) all fall through as a cache miss
170
+ // so the next source in the chain still gets a chance to serve the URL.
171
+ try {
172
+ return statSync(candidate).isFile() ? candidate : null;
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Map a graph's canonical `source` URL to the on-disk cache file path it
181
+ * should be written to (writer side). Returns `null` for sources that do
182
+ * not need caching — file:// (already on disk), memory:// (transient), or
183
+ * any path that would escape the cache root.
184
+ *
185
+ * Uses the same mapping function as `LocalManifestCacheSource`, so the
186
+ * writer and reader always agree on where every URL lives.
187
+ */
188
+ export function cachePathForCanonical(
189
+ canonicalSource: string,
190
+ entryDir: string,
191
+ registryUrl: string,
192
+ ): string | null {
193
+ const cacheRoot = path.join(entryDir, CACHE_SUBDIR);
194
+ return cachePathForUrl(canonicalSource, cacheRoot, registryUrl);
195
+ }
196
+
197
+ /**
198
+ * Persist every manifest file reachable from `graph` (owners + partials) to
199
+ * `<entryDir>/.telo/manifests/`, except the entry manifest itself and any
200
+ * file:// or memory:// sources (already on disk or transient).
201
+ *
202
+ * Idempotent: rewrites any existing file with the freshly fetched bytes so
203
+ * a partial re-install converges. Never deletes entries — stale versions
204
+ * stay until `.telo/manifests/` is removed by hand, matching the
205
+ * `.telo/npm/` convention.
206
+ *
207
+ * Returns the list of paths written, for diagnostics.
208
+ */
209
+ export async function writeManifestCache(
210
+ graph: LoadedGraph,
211
+ entryDir: string,
212
+ registryUrl: string = DEFAULT_REGISTRY_URL,
213
+ ): Promise<string[]> {
214
+ const written: string[] = [];
215
+ const seen = new Set<string>();
216
+
217
+ for (const [, module] of graph.modules) {
218
+ for (const file of [module.owner, ...module.partials]) {
219
+ if (file.source === graph.rootSource) continue;
220
+ if (seen.has(file.source)) continue;
221
+ seen.add(file.source);
222
+
223
+ const target = cachePathForCanonical(file.source, entryDir, registryUrl);
224
+ if (!target) continue;
225
+
226
+ await fs.mkdir(path.dirname(target), { recursive: true });
227
+ await fs.writeFile(target, file.text, "utf-8");
228
+ written.push(target);
229
+ }
230
+ }
231
+
232
+ return written;
233
+ }
234
+
235
+ /** Resolve the entry-anchor directory for the manifest cache.
236
+ *
237
+ * For a file path or `file://` URL: returns the containing directory.
238
+ * For a directory path: returns the directory itself.
239
+ * For an HTTP(S) URL: returns `null` (no local anchor; cache writes skipped). */
240
+ export function resolveEntryDir(entryPath: string): string | null {
241
+ if (entryPath.startsWith("http://") || entryPath.startsWith("https://")) {
242
+ return null;
243
+ }
244
+ let absolute: string;
245
+ if (entryPath.startsWith("file://")) {
246
+ absolute = fileURLToPath(entryPath);
247
+ } else {
248
+ absolute = path.resolve(entryPath);
249
+ }
250
+ try {
251
+ const stat = statSync(absolute);
252
+ return stat.isDirectory() ? absolute : path.dirname(absolute);
253
+ } catch {
254
+ return path.dirname(absolute);
255
+ }
256
+ }