@gobing-ai/ts-runtime 0.3.0 → 0.3.2

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/README.md +234 -176
  2. package/dist/config.d.ts +13 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +14 -0
  5. package/dist/context.d.ts +28 -1
  6. package/dist/context.d.ts.map +1 -1
  7. package/dist/context.js +45 -2
  8. package/dist/file-system-cf.d.ts +25 -0
  9. package/dist/file-system-cf.d.ts.map +1 -0
  10. package/dist/file-system-cf.js +59 -0
  11. package/dist/file-system-node.d.ts +29 -0
  12. package/dist/file-system-node.d.ts.map +1 -0
  13. package/dist/file-system-node.js +94 -0
  14. package/dist/file-system.d.ts +47 -0
  15. package/dist/file-system.d.ts.map +1 -0
  16. package/dist/file-system.js +0 -0
  17. package/dist/fs.d.ts +31 -1
  18. package/dist/fs.d.ts.map +1 -1
  19. package/dist/fs.js +32 -19
  20. package/dist/index.d.ts +21 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +10 -2
  23. package/dist/path.d.ts +12 -0
  24. package/dist/path.d.ts.map +1 -1
  25. package/dist/path.js +65 -4
  26. package/dist/platform.d.ts +12 -0
  27. package/dist/platform.d.ts.map +1 -0
  28. package/dist/platform.js +41 -0
  29. package/dist/plugin/capability-registry.d.ts +35 -0
  30. package/dist/plugin/capability-registry.d.ts.map +1 -0
  31. package/dist/plugin/capability-registry.js +43 -0
  32. package/dist/plugin/extension-loader.d.ts +66 -0
  33. package/dist/plugin/extension-loader.d.ts.map +1 -0
  34. package/dist/plugin/extension-loader.js +47 -0
  35. package/dist/plugin/extension-path.d.ts +15 -0
  36. package/dist/plugin/extension-path.d.ts.map +1 -0
  37. package/dist/plugin/extension-path.js +20 -0
  38. package/dist/plugin/index.d.ts +4 -0
  39. package/dist/plugin/index.d.ts.map +1 -0
  40. package/dist/plugin/index.js +3 -0
  41. package/dist/plugin.d.ts +2 -0
  42. package/dist/plugin.d.ts.map +1 -0
  43. package/dist/plugin.js +1 -0
  44. package/dist/process-executor.d.ts +77 -19
  45. package/dist/process-executor.d.ts.map +1 -1
  46. package/dist/process-executor.js +209 -37
  47. package/dist/runtime-cf.d.ts +6 -0
  48. package/dist/runtime-cf.d.ts.map +1 -0
  49. package/dist/runtime-cf.js +33 -0
  50. package/dist/runtime-factory.d.ts +24 -0
  51. package/dist/runtime-factory.d.ts.map +1 -0
  52. package/dist/runtime-factory.js +0 -0
  53. package/dist/runtime-node-bun.d.ts +8 -0
  54. package/dist/runtime-node-bun.d.ts.map +1 -0
  55. package/dist/runtime-node-bun.js +67 -0
  56. package/dist/schema-validation.d.ts +16 -0
  57. package/dist/schema-validation.d.ts.map +1 -1
  58. package/dist/schema-validation.js +9 -4
  59. package/dist/types.d.ts +4 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/package.json +6 -2
  62. package/src/config.ts +16 -4
  63. package/src/context.ts +58 -4
  64. package/src/file-system-cf.ts +74 -0
  65. package/src/file-system-node.ts +122 -0
  66. package/src/file-system.ts +55 -0
  67. package/src/fs.ts +35 -18
  68. package/src/index.ts +57 -2
  69. package/src/path.ts +68 -4
  70. package/src/platform.ts +47 -0
  71. package/src/plugin/capability-registry.ts +58 -0
  72. package/src/plugin/extension-loader.ts +105 -0
  73. package/src/plugin/extension-path.ts +20 -0
  74. package/src/plugin/index.ts +3 -0
  75. package/src/plugin.ts +1 -0
  76. package/src/process-executor.ts +296 -58
  77. package/src/runtime-cf.ts +44 -0
  78. package/src/runtime-factory.ts +28 -0
  79. package/src/runtime-node-bun.ts +83 -0
  80. package/src/schema-validation.ts +20 -4
  81. package/src/types.ts +4 -0
package/src/fs.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { findProjectRoot } from './file-system-node';
2
3
  import { dirnamePath, getProcessCwd, joinPath, resolvePath } from './path';
3
4
 
5
+ /** Portable file stat interface — mirrors the subset of `node:fs.Stats` used by the runtime. */
4
6
  export interface FileStat {
5
7
  isFile(): boolean;
6
8
  isDirectory(): boolean;
@@ -8,11 +10,13 @@ export interface FileStat {
8
10
  mtimeMs: number;
9
11
  }
10
12
 
13
+ /** Write-only append stream for log output. */
11
14
  export interface LogStream {
12
15
  write(chunk: string): void;
13
16
  end(): void;
14
17
  }
15
18
 
19
+ /** @deprecated Use {@link import('./file-system').FileSystem} instead — the new interface has union return types and does not require a separate SyncFileSystem. */
16
20
  export interface FileSystem {
17
21
  readFile(path: string): Promise<string>;
18
22
  writeFile(path: string, content: string): Promise<void>;
@@ -28,6 +32,7 @@ export interface FileSystem {
28
32
  createLogStream(path: string): LogStream;
29
33
  }
30
34
 
35
+ /** @deprecated Use {@link import('./file-system').FileSystem} instead — its union return types make a separate sync interface unnecessary. */
31
36
  export interface SyncFileSystem {
32
37
  readFile(path: string): string;
33
38
  writeFile(path: string, content: string): void;
@@ -54,6 +59,8 @@ function nodeFs(): Promise<NodeFs> {
54
59
  return fsModule;
55
60
  }
56
61
 
62
+ /** {@link FileSystem} backed by `node:fs/promises`. Lazy-loads the module to avoid top-level import cost. */
63
+ /** @deprecated Use createNodeFileSystem() from './file-system-node' instead. */
57
64
  export class NodeFileSystem implements FileSystem {
58
65
  async readFile(path: string): Promise<string> {
59
66
  const { readFile } = await nodeFsPromises();
@@ -132,6 +139,8 @@ export class NodeFileSystem implements FileSystem {
132
139
  }
133
140
  }
134
141
 
142
+ /** {@link SyncFileSystem} backed by `node:fs` synchronous APIs. */
143
+ /** @deprecated Use createNodeFileSystem() from './file-system-node' instead — its FileSystem interface has union return types, eliminating the need for a separate sync implementation. */
135
144
  export class NodeSyncFileSystem implements SyncFileSystem {
136
145
  readFile(path: string): string {
137
146
  return readFileSync(path, 'utf-8');
@@ -214,6 +223,8 @@ class LazyNodeLogStream implements LogStream {
214
223
 
215
224
  const CLOUDFLARE_FS_ERROR = 'FileSystem is not available on Cloudflare Workers. Use D1, KV, or R2.';
216
225
 
226
+ /** {@link FileSystem} stub for Cloudflare Workers — all file operations throw. Use D1, KV, or R2 instead. */
227
+ /** @deprecated Use createCfFileSystem() from './file-system-cf' instead. */
217
228
  export class CloudflareFileSystem implements FileSystem {
218
229
  async readFile(path: string): Promise<string> {
219
230
  throw unsupportedCloudflareFs('readFile', path);
@@ -270,6 +281,8 @@ function unsupportedCloudflareFs(operation: string, path: string): Error {
270
281
 
271
282
  let activeFileSystem: FileSystem = new NodeFileSystem();
272
283
 
284
+ /** Swaps the active global file system, returning a restore function for the previous instance. */
285
+ /** @deprecated Use RuntimeFactory.createFileSystem() or ctx.require('fileSystem') instead. The global swap is replaced by factory-based DI. */
273
286
  export function setFileSystem(fileSystem: FileSystem): () => void {
274
287
  const previous = activeFileSystem;
275
288
  activeFileSystem = fileSystem;
@@ -278,18 +291,24 @@ export function setFileSystem(fileSystem: FileSystem): () => void {
278
291
  };
279
292
  }
280
293
 
294
+ /** Returns the currently active global {@link FileSystem} instance. */
295
+ /** @deprecated Use RuntimeFactory.createFileSystem() or ctx.require('fileSystem') instead. */
281
296
  export function getFs(): FileSystem {
282
297
  return activeFileSystem;
283
298
  }
284
299
 
300
+ /** Creates parent directories for a file path before writing. */
285
301
  export async function ensureDirForFile(path: string, fs = getFs()): Promise<void> {
286
302
  await fs.mkdir(dirnamePath(path));
287
303
  }
288
304
 
305
+ /** Synchronous variant of {@link ensureDirForFile}. */
306
+ /** @deprecated The new createNodeFileSystem() handles parent-directory creation internally. */
289
307
  export function ensureDirForFileSync(path: string, fs: SyncFileSystem): void {
290
308
  fs.mkdir(dirnamePath(path));
291
309
  }
292
310
 
311
+ /** Atomically writes a file by writing to a temp path then renaming, avoiding partial writes on crash. */
293
312
  export async function atomicWriteFile(path: string, content: string, fs = getFs()): Promise<void> {
294
313
  await ensureDirForFile(path, fs);
295
314
  const tempPath = `${path}.${getProcessPid()}.${uniqueToken()}.tmp`;
@@ -297,26 +316,31 @@ export async function atomicWriteFile(path: string, content: string, fs = getFs(
297
316
  await fs.rename(tempPath, path);
298
317
  }
299
318
 
319
+ /** Atomically writes a value as JSON with trailing newline. */
300
320
  export async function atomicWriteJson(path: string, value: unknown, fs = getFs()): Promise<void> {
301
321
  await atomicWriteFile(path, `${JSON.stringify(value, null, 2)}\n`, fs);
302
322
  }
303
323
 
324
+ /** Reads and parses a JSON file. */
304
325
  export async function readJsonFile<T = unknown>(path: string, fs = getFs()): Promise<T> {
305
326
  return JSON.parse(await fs.readFile(path)) as T;
306
327
  }
307
328
 
329
+ /** Writes a value as JSON with 2-space indentation and trailing newline. */
308
330
  export async function writeJsonFile(path: string, value: unknown, fs = getFs()): Promise<void> {
309
331
  await fs.writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
310
332
  }
311
333
 
312
- export async function walkDir(path: string, fs = getFs()): Promise<string[]> {
334
+ /** Recursively walks a directory, returning sorted paths to all files, optionally excluding entries by name. */
335
+ export async function walkDir(path: string, fs = getFs(), exclude?: Set<string>): Promise<string[]> {
313
336
  const entries = (await fs.readDir(path)).sort();
314
337
  const result: string[] = [];
315
338
  for (const entry of entries) {
339
+ if (exclude?.has(entry)) continue;
316
340
  const fullPath = joinPath(path, entry);
317
341
  const entryStat = await fs.stat(fullPath);
318
342
  if (entryStat?.isDirectory()) {
319
- result.push(...(await walkDir(fullPath, fs)));
343
+ result.push(...(await walkDir(fullPath, fs, exclude)));
320
344
  } else if (entryStat?.isFile()) {
321
345
  result.push(fullPath);
322
346
  }
@@ -324,33 +348,26 @@ export async function walkDir(path: string, fs = getFs()): Promise<string[]> {
324
348
  return result;
325
349
  }
326
350
 
351
+ /**
352
+ * Walks up from `startDir` looking for a `package.json` or `bun.lock` to locate the project root.
353
+ *
354
+ * @deprecated Use {@link import('./file-system-node').findProjectRoot} (or `createNodeFileSystem().getProjectRoot()`).
355
+ * This delegates to the single shared implementation.
356
+ */
327
357
  export function getProjectRoot(startDir = getProcessCwd()): string {
328
- let current = resolvePath(startDir);
329
- for (let i = 0; i < 12; i++) {
330
- if (hasBunFile(joinPath(current, 'bun.lock')) || hasBunFile(joinPath(current, 'package.json'))) {
331
- return current;
332
- }
333
- const parent = dirnamePath(current);
334
- if (parent === current) return startDir;
335
- current = parent;
336
- }
337
- return startDir;
358
+ return findProjectRoot(startDir);
338
359
  }
339
360
 
361
+ /** Resolves path segments relative to the project root. */
340
362
  export function resolveProjectPath(...segments: string[]): string {
341
363
  return resolvePath(getProjectRoot(), ...segments);
342
364
  }
343
365
 
366
+ /** Creates a {@link LogStream} at the given path using the active file system. */
344
367
  export function createLogStream(path: string, fs = getFs()): LogStream {
345
368
  return fs.createLogStream(path);
346
369
  }
347
370
 
348
- function hasBunFile(path: string): boolean {
349
- const bun = (globalThis as { Bun?: { file: (path: string) => { size: number } } }).Bun;
350
- if (bun === undefined) return false;
351
- return bun.file(path).size !== 0;
352
- }
353
-
354
371
  function getProcessPid(): number {
355
372
  return (globalThis as { process?: { pid?: number } }).process?.pid ?? 0;
356
373
  }
package/src/index.ts CHANGED
@@ -1,7 +1,62 @@
1
1
  export * from './config';
2
2
  export * from './context';
3
- export * from './fs';
3
+ export { createRuntimeContextFromFactory } from './context';
4
+ export type { FileStat, FileSystem } from './file-system';
5
+ export { createCfFileSystem } from './file-system-cf';
6
+ export { createNodeFileSystem, findProjectRoot } from './file-system-node';
7
+ export {
8
+ atomicWriteFile,
9
+ atomicWriteJson,
10
+ CloudflareFileSystem,
11
+ createLogStream,
12
+ ensureDirForFile,
13
+ ensureDirForFileSync,
14
+ type FileSystem as LegacyFileSystem,
15
+ getFs,
16
+ getProjectRoot,
17
+ NodeFileSystem,
18
+ NodeSyncFileSystem,
19
+ readJsonFile,
20
+ resolveProjectPath,
21
+ type SyncFileSystem,
22
+ setFileSystem,
23
+ walkDir,
24
+ writeJsonFile,
25
+ } from './fs';
4
26
  export * from './path';
5
- export * from './process-executor';
27
+ export { _resetRuntimeFactory, isCloudflareWorkerRuntime, loadRuntimeFactory } from './platform';
28
+ export type {
29
+ OutputPolicy,
30
+ PipeProcess,
31
+ PipeProcessOptions,
32
+ ProcessEventDetail,
33
+ ProcessEventSink,
34
+ ProcessEvents,
35
+ ProcessExecutorConfig,
36
+ ProcessExitReason,
37
+ ProcessOptions,
38
+ ProcessResult,
39
+ ProcessSignal,
40
+ TracerPort,
41
+ } from './process-executor';
42
+ export { ProcessExecutor } from './process-executor';
43
+ export { cloudflareWorkersFactory } from './runtime-cf';
44
+ export type { RuntimeFactory } from './runtime-factory';
45
+ export { _resetNodeFileSystem, nodeBunFactory } from './runtime-node-bun';
6
46
  export * from './schema-validation';
7
47
  export * from './types';
48
+
49
+ // ── Deprecated re-exports (backward compatibility) ──────────────────────
50
+
51
+ export { BunPipeProcessSpawner, BunSyncProcessExecutor, NodeProcessExecutor } from './process-executor';
52
+
53
+ /**
54
+ * @deprecated Use {@link ProcessExecutor} directly for async execution.
55
+ * Use `Bun.spawnSync` or `child_process.spawnSync` for sync.
56
+ */
57
+ export type SyncProcessExecutor = InstanceType<typeof import('./process-executor').BunSyncProcessExecutor>;
58
+
59
+ /**
60
+ * @deprecated Use {@link ProcessExecutor.runStreaming} instead.
61
+ */
62
+ export type PipeProcessSpawner = InstanceType<typeof import('./process-executor').BunPipeProcessSpawner>;
package/src/path.ts CHANGED
@@ -2,16 +2,42 @@
2
2
  // `cloudflare-workers` (no `node:*`) as on node-bun (ADR-008). POSIX-style separators throughout;
3
3
  // Windows drive paths (`C:/...`) are normalized and treated as absolute.
4
4
 
5
+ /** Replaces Windows backslashes with forward slashes for consistent POSIX-style path handling. */
5
6
  export function normalizeSeparators(path: string): string {
6
7
  return path.replaceAll('\\', '/');
7
8
  }
8
9
 
10
+ /** Platform-specific path segment separator (`'/'` on POSIX, `'\\'` on Windows). */
11
+ export const SEP: string =
12
+ (globalThis as { process?: { platform?: string } }).process?.platform === 'win32' ? '\\' : '/';
13
+
14
+ /** Returns `true` for POSIX absolute paths and Windows drive paths (`C:/…`). */
9
15
  export function isAbsolutePath(path: string): boolean {
10
16
  return path.startsWith('/') || /^[A-Za-z]:\//.test(normalizeSeparators(path));
11
17
  }
12
18
 
19
+ function splitRoot(path: string): { root: string; rest: string } {
20
+ const normalized = normalizeSeparators(path);
21
+ const drive = normalized.match(/^([A-Za-z]:)(?:\/|$)/);
22
+ if (drive) {
23
+ return { root: drive[1] ?? '', rest: normalized.slice((drive[1] ?? '').length).replace(/^\/+/, '') };
24
+ }
25
+ if (normalized.startsWith('/')) {
26
+ return { root: '/', rest: normalized.replace(/^\/+/, '') };
27
+ }
28
+ return { root: '', rest: normalized };
29
+ }
30
+
31
+ function pathParts(path: string): { root: string; parts: string[] } {
32
+ const { root, rest } = splitRoot(path);
33
+ return { root, parts: rest.split('/').filter(Boolean) };
34
+ }
35
+
36
+ /** Returns the parent directory of a path. Platform-independent — avoids `node:path`. */
13
37
  export function dirnamePath(path: string): string {
14
38
  const input = normalizeSeparators(path);
39
+ const { root } = splitRoot(input);
40
+ if (root !== '' && input.replace(/\/+$/, '') === root) return root === '/' ? '/' : `${root}/`;
15
41
  if (/^\/+$/.test(input)) return '/';
16
42
  const normalized = input.replace(/\/+$/, '');
17
43
  if (normalized === '' || normalized === '/') return normalized || '.';
@@ -21,6 +47,39 @@ export function dirnamePath(path: string): string {
21
47
  return normalized.slice(0, index);
22
48
  }
23
49
 
50
+ /** Return the last segment of a path. Optionally strip a trailing extension. */
51
+ export function basenamePath(p: string, ext?: string): string {
52
+ const normalized = normalizeSeparators(p).replace(/\/+$/, '');
53
+ const index = normalized.lastIndexOf('/');
54
+ let base = index < 0 ? normalized : normalized.slice(index + 1);
55
+ if (ext !== undefined && base !== ext && base.endsWith(ext)) {
56
+ base = base.slice(0, -ext.length);
57
+ }
58
+ return base;
59
+ }
60
+
61
+ /** Compute a platform-independent relative path from `from` to `to`. Both paths should be absolute. */
62
+ export function relativePath(from: string, to: string): string {
63
+ const fromParsed = pathParts(resolvePath(from));
64
+ const toParsed = pathParts(resolvePath(to));
65
+ if (fromParsed.root.toLowerCase() !== toParsed.root.toLowerCase()) {
66
+ return resolvePath(to);
67
+ }
68
+ const fromParts = fromParsed.parts;
69
+ const toParts = toParsed.parts;
70
+
71
+ // Strip common prefix.
72
+ let i = 0;
73
+ const minLen = Math.min(fromParts.length, toParts.length);
74
+ while (i < minLen && fromParts[i] === toParts[i]) i++;
75
+
76
+ const up = fromParts.slice(i).map(() => '..');
77
+ const down = toParts.slice(i);
78
+ const result = [...up, ...down].join('/');
79
+ return result || '.';
80
+ }
81
+
82
+ /** Joins path segments with `/`, normalizing separators and collapsing redundant slashes. */
24
83
  export function joinPath(...segments: string[]): string {
25
84
  const filtered = segments.filter((segment) => segment.length > 0).map(normalizeSeparators);
26
85
  if (filtered.length === 0) return '.';
@@ -29,6 +88,7 @@ export function joinPath(...segments: string[]): string {
29
88
  return absolute ? joined : joined.replace(/^\//, '');
30
89
  }
31
90
 
91
+ /** Resolves a sequence of path segments to an absolute path, collapsing `..` and `.`. */
32
92
  export function resolvePath(...segments: string[]): string {
33
93
  const candidates = segments.length === 0 ? [getProcessCwd()] : segments;
34
94
  let resolved = '';
@@ -36,19 +96,23 @@ export function resolvePath(...segments: string[]): string {
36
96
  if (segment.length === 0) continue;
37
97
  resolved = isAbsolutePath(segment) ? segment : joinPath(resolved || getProcessCwd(), segment);
38
98
  }
99
+ const { root, rest } = splitRoot(resolved);
39
100
  const parts: string[] = [];
40
- const absolute = isAbsolutePath(resolved);
41
- for (const part of resolved.split('/')) {
101
+ const absolute = root !== '';
102
+ for (const part of rest.split('/')) {
42
103
  if (part === '' || part === '.') continue;
43
104
  if (part === '..') {
44
- parts.pop();
105
+ if (parts.length > 0) parts.pop();
45
106
  continue;
46
107
  }
47
108
  parts.push(part);
48
109
  }
49
- return `${absolute ? '/' : ''}${parts.join('/')}` || (absolute ? '/' : '.');
110
+ if (!absolute) return parts.join('/') || '.';
111
+ if (root === '/') return `/${parts.join('/')}` || '/';
112
+ return parts.length > 0 ? `${root}/${parts.join('/')}` : `${root}/`;
50
113
  }
51
114
 
115
+ /** Returns `process.cwd()` if available, or `/` as fallback (Cloudflare Workers). */
52
116
  export function getProcessCwd(): string {
53
117
  return (globalThis as { process?: { cwd?: () => string } }).process?.cwd?.() ?? '/';
54
118
  }
@@ -0,0 +1,47 @@
1
+ import type { RuntimeFactory } from './runtime-factory';
2
+ import type { RuntimeName } from './types';
3
+
4
+ // Single authoritative source for runtime detection.
5
+ // All other code MUST consume RuntimeFactory via loadRuntimeFactory().
6
+ // Probe-once contract: each primitive is invoked at most once per process and
7
+ // the result is cached in `_factory` / `_runtimeName`.
8
+
9
+ /** True when running in a Cloudflare Worker (server-tier only). */
10
+ export function isCloudflareWorkerRuntime(): boolean {
11
+ return globalThis.navigator?.userAgent?.startsWith('Cloudflare-Workers') ?? false;
12
+ }
13
+
14
+ let _factory: RuntimeFactory | undefined;
15
+ let _runtimeName: RuntimeName | undefined;
16
+
17
+ /**
18
+ * Lazy-load and cache the appropriate {@link RuntimeFactory} based on environment detection.
19
+ */
20
+ export async function loadRuntimeFactory(): Promise<RuntimeFactory> {
21
+ if (_factory) return _factory;
22
+
23
+ let factory: RuntimeFactory;
24
+ if (getRuntimeName() === 'cloudflare-workers') {
25
+ const { cloudflareWorkersFactory } = await import('./runtime-cf');
26
+ factory = cloudflareWorkersFactory;
27
+ } else {
28
+ const { nodeBunFactory } = await import('./runtime-node-bun');
29
+ factory = nodeBunFactory;
30
+ }
31
+ _factory = factory;
32
+ return factory;
33
+ }
34
+
35
+ function getRuntimeName(): RuntimeName {
36
+ if (_runtimeName) return _runtimeName;
37
+ _runtimeName = isCloudflareWorkerRuntime() ? 'cloudflare-workers' : 'node-bun';
38
+ return _runtimeName;
39
+ }
40
+
41
+ /**
42
+ * Reset the cached runtime factory and name (for test isolation).
43
+ */
44
+ export function _resetRuntimeFactory(): void {
45
+ _factory = undefined;
46
+ _runtimeName = undefined;
47
+ }
@@ -0,0 +1,58 @@
1
+ /** Registry origin for a host capability. */
2
+ export type CapabilityOrigin = 'builtin' | 'extension';
3
+
4
+ /** Registry entry metadata. */
5
+ export interface CapabilityEntry<TCapability> {
6
+ /** Capability implementation. */
7
+ capability: TCapability;
8
+ /** Registration origin. */
9
+ origin: CapabilityOrigin;
10
+ }
11
+
12
+ /**
13
+ * Generic, domain-agnostic capability registry shared by engine hosts.
14
+ *
15
+ * Stores named capabilities of an opaque type `TCapability` with replace-by-name
16
+ * semantics and `origin` metadata. It knows nothing about what a capability *is* —
17
+ * each engine supplies its own type and owns its own error types and override
18
+ * semantics (the registry only reports what it stores).
19
+ */
20
+ export class CapabilityRegistry<TCapability> {
21
+ private readonly capabilities = new Map<string, CapabilityEntry<TCapability>>();
22
+
23
+ constructor(private readonly kind: string) {}
24
+
25
+ /** Register or replace a capability. */
26
+ register(name: string, capability: TCapability, origin: CapabilityOrigin = 'extension'): void {
27
+ this.capabilities.set(name, { capability, origin });
28
+ }
29
+
30
+ /** Return true when a capability exists. */
31
+ has(name: string): boolean {
32
+ return this.capabilities.has(name);
33
+ }
34
+
35
+ /** Get a registered capability or throw a clear error. */
36
+ get(name: string): TCapability {
37
+ const entry = this.capabilities.get(name);
38
+ if (entry === undefined) {
39
+ throw new Error(`Unknown ${this.kind}: ${name}`);
40
+ }
41
+ return entry.capability;
42
+ }
43
+
44
+ /** Get a registered entry (capability plus origin) without throwing. */
45
+ getEntry(name: string): CapabilityEntry<TCapability> | undefined {
46
+ return this.capabilities.get(name);
47
+ }
48
+
49
+ /** List registered capability names in insertion order. */
50
+ list(): string[] {
51
+ return [...this.capabilities.keys()];
52
+ }
53
+
54
+ /** List registered entries (name + capability + origin) in insertion order. */
55
+ entries(): Array<readonly [string, CapabilityEntry<TCapability>]> {
56
+ return [...this.capabilities.entries()];
57
+ }
58
+ }
@@ -0,0 +1,105 @@
1
+ import { isAbsolute, resolve } from 'node:path';
2
+ import { assertRelativeExtensionPath } from './extension-path';
3
+
4
+ /**
5
+ * A single extension module reference.
6
+ *
7
+ * `kind` is an engine-defined tag the registration callback uses to route the
8
+ * module to the right registry — the loader itself never interprets it. `path` is
9
+ * the relative path as authored; the loader **derives** the import target by
10
+ * resolving `path` against `baseDir` *after* validating it, so the trust guard
11
+ * always governs the actual module that gets imported. `sourceName` identifies the
12
+ * declaring config for diagnostics.
13
+ */
14
+ export interface ExtensionRef<TExtensionKind extends string = string> {
15
+ /** Engine-defined capability tag, used only by the registration callback. */
16
+ readonly kind: TExtensionKind;
17
+ /** Relative path as authored, enforced by {@link assertRelativeExtensionPath}. */
18
+ readonly path: string;
19
+ /** Absolute directory the authored `path` is resolved against. */
20
+ readonly baseDir: string;
21
+ /** Name of the config (preset, workflow, …) that declared this ref. */
22
+ readonly sourceName: string;
23
+ }
24
+
25
+ /** Options controlling extension-module loading. */
26
+ export interface LoadExtensionsOptions {
27
+ /**
28
+ * Whether to actually import extension modules. Defaults to `false`: loading
29
+ * arbitrary code is a trust decision the caller must make explicitly. When refs
30
+ * exist and this is not `true`, loading throws **before any import**.
31
+ */
32
+ readonly allowExtensions?: boolean;
33
+ /** Optional sink for non-fatal warnings (e.g. capability overrides). */
34
+ readonly logger?: { warn: (message: string) => void };
35
+ /**
36
+ * Required module loader. The generic core never performs a dynamic `import`
37
+ * itself — the embedder supplies the import policy (typically
38
+ * `(absPath) => import(absPath)`), and tests pass a stub. Keeping this explicit
39
+ * means the shared core has no ambient code-loading capability of its own.
40
+ */
41
+ readonly moduleLoader: (absPath: string) => Promise<Record<string, unknown>>;
42
+ }
43
+
44
+ /** A validated extension module export: an object carrying at least a string `name`. */
45
+ export interface LoadedExtension {
46
+ /** Stable name of the contributed capability bundle. */
47
+ readonly name: string;
48
+ /** Engine-specific contribution payload (actions, evaluators, …). */
49
+ readonly [key: string]: unknown;
50
+ }
51
+
52
+ /**
53
+ * Import each extension module behind an explicit trust gate, validate its export,
54
+ * then hand it to an engine-provided registration callback.
55
+ *
56
+ * The loader is domain-agnostic: it does not know which registry a module belongs to.
57
+ * It enforces the trust boundary (gate + relative-path guard), validates the
58
+ * default/named-`extension` export shape, and delegates routing to `register`.
59
+ *
60
+ * Security invariants:
61
+ * - No refs → no-op.
62
+ * - Refs present but `allowExtensions !== true` → throws **before** any `import` or
63
+ * `moduleLoader` call, so a declared extension is never silently dropped.
64
+ * - Every ref's authored path is re-validated by {@link assertRelativeExtensionPath}.
65
+ *
66
+ * @throws When extensions are present but `allowExtensions` is not `true`, when a path
67
+ * fails the trust guard, or when a module lacks a valid `name`.
68
+ */
69
+ export async function loadExtensionModules<TExtensionKind extends string>(
70
+ refs: readonly ExtensionRef<TExtensionKind>[],
71
+ options: LoadExtensionsOptions,
72
+ register: (ref: ExtensionRef<TExtensionKind>, extension: LoadedExtension) => void | Promise<void>,
73
+ ): Promise<void> {
74
+ if (refs.length === 0) return;
75
+
76
+ // Fail closed before importing anything: a declared extension under a disabled gate
77
+ // is a hard error, never a silent drop.
78
+ if (options.allowExtensions !== true) {
79
+ const first = refs[0] as ExtensionRef<TExtensionKind>;
80
+ throw new Error(
81
+ `"${first.sourceName}" declares ${first.kind} extension "${first.path}", but extensions are disabled — pass allowExtensions: true to load extension modules`,
82
+ );
83
+ }
84
+
85
+ for (const ref of refs) {
86
+ if (!isAbsolute(ref.baseDir)) {
87
+ throw new Error(`"${ref.sourceName}" extension baseDir "${ref.baseDir}" must be an absolute directory`);
88
+ }
89
+ // Validate the authored path, then derive the import target from it so the
90
+ // trust guard always governs the module actually imported — the loader never
91
+ // imports a caller-supplied absolute path it did not resolve itself.
92
+ assertRelativeExtensionPath(ref.path, { sourceName: ref.sourceName });
93
+ const absPath = resolve(ref.baseDir, ref.path);
94
+ const moduleExports = await options.moduleLoader(absPath);
95
+ const candidate = moduleExports.default ?? moduleExports.extension;
96
+ if (
97
+ candidate === null ||
98
+ typeof candidate !== 'object' ||
99
+ typeof (candidate as { name?: unknown }).name !== 'string'
100
+ ) {
101
+ throw new Error(`"${ref.sourceName}" extension "${ref.path}" must export an object with a string "name"`);
102
+ }
103
+ await register(ref, candidate as LoadedExtension);
104
+ }
105
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Assert that an extension module path is relative and does not escape its
3
+ * declaring directory.
4
+ *
5
+ * Extension declarations are data, and a path that is absolute or escapes via `..`
6
+ * is a trust-boundary violation even when extension loading is explicitly allowed.
7
+ * This is a standalone validator (not a schema refinement) so the loader can enforce
8
+ * it at load time, independent of any engine's config schema — defense in depth.
9
+ *
10
+ * @throws When the path is absolute or contains a `..` traversal segment.
11
+ */
12
+ export function assertRelativeExtensionPath(path: string, options: { sourceName?: string } = {}): void {
13
+ const where = options.sourceName !== undefined ? ` declared by "${options.sourceName}"` : '';
14
+ if (/^([/\\]|[A-Za-z]:[/\\])/.test(path)) {
15
+ throw new Error(`extension path "${path}"${where} must be relative (no absolute paths)`);
16
+ }
17
+ if (path.split(/[/\\]/).includes('..')) {
18
+ throw new Error(`extension path "${path}"${where} must not contain ".." traversal`);
19
+ }
20
+ }
@@ -0,0 +1,3 @@
1
+ export * from './capability-registry';
2
+ export * from './extension-loader';
3
+ export * from './extension-path';
package/src/plugin.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './plugin/index';