@pmelab/gtd 62.1.0 → 64.0.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 (68) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +8 -3
  3. package/dist/chunks/{flows-DENt0uWV.mjs → flows-B7ZRCe-B.mjs} +1 -1
  4. package/dist/chunks/{rolldown-runtime-8H4AJuhK.mjs → rolldown-runtime-D7D4PA-g.mjs} +0 -1
  5. package/dist/chunks/{steering-DF_BMadH.mjs → steering-DkA51qDa.mjs} +1 -1
  6. package/dist/chunks/{testkit-CPzWnl3P.mjs → testkit-zGjNeQNL.mjs} +1144 -345
  7. package/dist/flows.mjs +1 -1
  8. package/dist/gtd.bundle.mjs +4023 -1326
  9. package/dist/gtd.core.mjs +120 -119
  10. package/dist/steering.mjs +1 -1
  11. package/dist/testkit/gherkin.mjs +2 -2
  12. package/dist/testkit.mjs +2 -2
  13. package/dist/workflow-kit.mjs +2 -2
  14. package/package.json +1 -1
  15. package/src/cli/Cli.test.ts +5 -8
  16. package/src/cli/Cli.ts +10 -28
  17. package/src/cli/Output.ts +2 -1
  18. package/src/cli/Program.test.ts +55 -53
  19. package/src/cli/Program.ts +36 -45
  20. package/src/cli/ToolCommands.ts +3 -4
  21. package/src/edge/Edge.test.ts +38 -12
  22. package/src/edge/Edge.ts +98 -13
  23. package/src/edge/index.ts +1 -0
  24. package/src/emit/SteeringMode.test.ts +4 -2
  25. package/src/emit/SteeringMode.ts +10 -9
  26. package/src/gherkin/messages.test.ts +1 -1
  27. package/src/gherkin/module-config.feature +1 -1
  28. package/src/gherkin/testkit.feature +2 -2
  29. package/src/install/Version.ts +14 -13
  30. package/src/platform/Host.ts +75 -22
  31. package/src/platform/NodePinFile.ts +44 -0
  32. package/src/platform/NodeRuntime.ts +19 -0
  33. package/src/platform/NodeSync.ts +64 -0
  34. package/src/platform/PinFile.ts +0 -50
  35. package/src/platform/Sha.ts +121 -0
  36. package/src/platform/SyncIo.ts +35 -0
  37. package/src/platform/Workspace.test.ts +8 -3
  38. package/src/platform/Workspace.ts +82 -123
  39. package/src/platform/WorktreeState.test.ts +8 -4
  40. package/src/platform/WorktreeState.ts +21 -19
  41. package/src/platform/index.ts +18 -5
  42. package/src/replay/Format.test.ts +32 -10
  43. package/src/replay/Format.ts +5 -2
  44. package/src/replay/Replay.test.ts +110 -3
  45. package/src/replay/Replay.ts +120 -28
  46. package/src/replay/Trailers.test.ts +10 -10
  47. package/src/replay/Trailers.ts +32 -1
  48. package/src/replay/index.ts +2 -0
  49. package/src/step/planStart.test.ts +2 -2
  50. package/src/step/planStep.test.ts +4 -4
  51. package/src/testkit/TestRepo.test.ts +1 -1
  52. package/src/testkit/TestRepo.ts +4 -18
  53. package/src/twins/Host.test.ts +151 -0
  54. package/src/twins/Layers.test.ts +2 -2
  55. package/src/twins/Layers.ts +44 -18
  56. package/src/twins/Path.test.ts +52 -0
  57. package/src/twins/SyncIo.test.ts +153 -0
  58. package/src/twins/SyncIo.ts +159 -0
  59. package/src/twins/cliIo.ts +8 -1
  60. package/src/twins/index.ts +5 -0
  61. package/src/workflow/discovery.test.ts +6 -5
  62. package/src/workflow/discovery.ts +43 -32
  63. package/src/workflow/fingerprint.test.ts +23 -3
  64. package/src/workflow/fingerprint.ts +9 -11
  65. package/src/workflow/load.test.ts +16 -6
  66. package/src/workflow/load.ts +107 -69
  67. package/src/workflow/pin.test.ts +3 -2
  68. package/src/workflow/pin.ts +17 -11
@@ -1,12 +1,6 @@
1
- import { existsSync, readFileSync, realpathSync } from "node:fs"
2
- import { createRequire, isBuiltin } from "node:module"
3
- import { dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path"
4
- import { fileURLToPath, pathToFileURL } from "node:url"
5
- import { stripVTControlCharacters } from "node:util"
6
- import { runInThisContext } from "node:vm"
7
1
  import type { Plugin as RolldownPlugin } from "rolldown"
8
- import { Context, Effect, Layer } from "effect"
9
- import { GtdError, Narrator } from "../platform/index.js"
2
+ import { Context, Effect, Layer, Path } from "effect"
3
+ import { GtdError, Narrator, NodeRuntime, nodeSyncIo } from "../platform/index.js"
10
4
  import { accessShapeFault, replay, type TreeView } from "../replay/index.js"
11
5
  // The published facade: what a config's `@pmelab/gtd/flows` import is served.
12
6
  import * as flows from "../flows/index.js"
@@ -129,22 +123,23 @@ const configModule: Effect.Effect<WorkflowModule, Error, Host | ConfigDiscovery>
129
123
  export const load: Effect.Effect<
130
124
  ConfigOperations,
131
125
  Error,
132
- Narrator | Workspace | Host | ConfigDiscovery
126
+ Narrator | Workspace | Host | Path.Path | ConfigDiscovery
133
127
  > = Effect.gen(function* () {
134
128
  const host = yield* Host
129
+ const paths = yield* Path.Path
135
130
  yield* refuseLegacyRc
136
131
  const pinned = pinnedWorkflow()
137
132
  const module: WorkflowModule =
138
133
  pinned === undefined
139
134
  ? yield* configModule
140
- : { filepath: resolvePath(host.root, pinned.origin), source: "" }
135
+ : { filepath: paths.resolve(host.root, pinned.origin), source: "" }
141
136
  yield* (yield* Narrator).narrate(
142
137
  `config: ${pinned === undefined ? "module" : "pinned module"} ${module.filepath}`,
143
138
  )
144
139
  const shipped =
145
140
  pinned === undefined
146
141
  ? yield* Effect.tryPromise({
147
- try: shippedModules,
142
+ try: () => shippedModules(paths),
148
143
  catch: (e) => new GtdError(`gtd: ${e instanceof Error ? e.message : String(e)}`),
149
144
  })
150
145
  : undefined
@@ -153,8 +148,8 @@ export const load: Effect.Effect<
153
148
  loadWorkflows(
154
149
  module.filepath,
155
150
  shipped === undefined
156
- ? runPinned(pinned!, module.filepath, host.root)
157
- : await evaluate(module, shipped),
151
+ ? runPinned(paths, pinned!, module.filepath, host.root)
152
+ : await evaluate(paths, module, shipped),
158
153
  ),
159
154
  catch: (e) =>
160
155
  new GtdError(
@@ -205,14 +200,22 @@ export const load: Effect.Effect<
205
200
  warnings: diagnostics.filter((d) => d.severity === "warning"),
206
201
  fingerprint:
207
202
  pinned?.fingerprint ??
208
- configFingerprint(loaded.file.dependencyFiles, host.root, module.read ?? readFromDisk),
203
+ configFingerprint(
204
+ nodeSyncIo,
205
+ paths,
206
+ loaded.file.dependencyFiles,
207
+ host.root,
208
+ module.read ?? readFromDisk,
209
+ ),
209
210
  drift:
210
211
  pinned === undefined
211
212
  ? []
212
213
  : changedConfigFiles(
213
214
  pinned.fingerprint,
214
215
  configFingerprint(
215
- Object.keys(pinned.fingerprint).map((file) => resolvePath(host.root, file)),
216
+ nodeSyncIo,
217
+ paths,
218
+ Object.keys(pinned.fingerprint).map((file) => paths.resolve(host.root, file)),
216
219
  host.root,
217
220
  readFromDisk,
218
221
  ),
@@ -220,9 +223,11 @@ export const load: Effect.Effect<
220
223
  }
221
224
  })
222
225
 
226
+ // Loading reads the real disk in every tier, as rolldown does: gtd's own
227
+ // package and the config's node_modules exist nowhere else.
223
228
  const readFromDisk = (path: string): string | undefined => {
224
229
  try {
225
- return readFileSync(path, "utf8")
230
+ return nodeSyncIo.readText(path)
226
231
  } catch {
227
232
  return undefined
228
233
  }
@@ -275,15 +280,17 @@ interface PackageJson {
275
280
 
276
281
  // Only gtd's own package.json: whatever other one sits nearest (a vendored
277
282
  // bundle inside a project) must never get its exports executed.
278
- export const ownPackage = (): { readonly root: string; readonly pkg: PackageJson } => {
279
- const start = dirname(fileURLToPath(import.meta.url))
280
- for (let dir = start; ; dir = dirname(dir)) {
281
- const file = join(dir, "package.json")
282
- if (existsSync(file)) {
283
- const pkg = JSON.parse(readFileSync(file, "utf8")) as PackageJson
283
+ export const ownPackage = (
284
+ paths: Path.Path,
285
+ ): { readonly root: string; readonly pkg: PackageJson } => {
286
+ const start = paths.dirname(Effect.runSync(paths.fromFileUrl(new URL(import.meta.url))))
287
+ for (let dir = start; ; dir = paths.dirname(dir)) {
288
+ const source = readFromDisk(paths.join(dir, "package.json"))
289
+ if (source !== undefined) {
290
+ const pkg = JSON.parse(source) as PackageJson
284
291
  if (pkg.name === PACKAGE) return { root: dir, pkg }
285
292
  }
286
- if (dirname(dir) === dir) throw new Error(`no ${PACKAGE} package.json above ${start}`)
293
+ if (paths.dirname(dir) === dir) throw new Error(`no ${PACKAGE} package.json above ${start}`)
287
294
  }
288
295
  }
289
296
 
@@ -302,10 +309,10 @@ interface ShippedModules {
302
309
  // config — or a workflow as it runs — requires it, so a command loads only the
303
310
  // subpaths its config uses; elsewhere, and from source, all of them up front.
304
311
  let shippedMemo: Promise<ShippedModules> | undefined
305
- const shippedModules = (): Promise<ShippedModules> =>
312
+ const shippedModules = (paths: Path.Path): Promise<ShippedModules> =>
306
313
  (shippedMemo ??= (async () => {
307
314
  const fromSource = import.meta.url.endsWith(".ts")
308
- const files = subpathFiles(fromSource ? "source" : "default")
315
+ const files = subpathFiles(paths, fromSource ? "source" : "default")
309
316
  const modules: Record<string, unknown> = { [FLOWS]: flows }
310
317
  const missing: Record<string, string> = {}
311
318
  if (process.features.require_module && !fromSource) {
@@ -313,7 +320,7 @@ const shippedModules = (): Promise<ShippedModules> =>
313
320
  }
314
321
  for (const [id, path] of files) {
315
322
  try {
316
- modules[id] = await import(pathToFileURL(path).href)
323
+ modules[id] = await import(fileUrl(paths, path))
317
324
  } catch {
318
325
  missing[id] = path
319
326
  }
@@ -322,14 +329,14 @@ const shippedModules = (): Promise<ShippedModules> =>
322
329
  })())
323
330
 
324
331
  /** The file each subpath a config may import serves: all but `flows`, the engine's own, and the testkit's. */
325
- const subpathFiles = (condition: string): ReadonlyMap<string, string> => {
326
- const { root, pkg } = ownPackage()
332
+ const subpathFiles = (paths: Path.Path, condition: string): ReadonlyMap<string, string> => {
333
+ const { root, pkg } = ownPackage(paths)
327
334
  const files = new Map<string, string>()
328
335
  for (const [subpath, target] of Object.entries(pkg.exports)) {
329
336
  const file = typeof target === "string" ? target : target[condition]
330
337
  // A config runs no tests, and the gherkin steps import the consumer's test runner.
331
338
  if (subpath === "./flows" || subpath.startsWith("./testkit") || file === undefined) continue
332
- files.set(`${PACKAGE}/${subpath.replace(/^\.\//, "")}`, join(root, file))
339
+ files.set(`${PACKAGE}/${subpath.replace(/^\.\//, "")}`, paths.join(root, file))
333
340
  }
334
341
  return files
335
342
  }
@@ -338,7 +345,7 @@ const requiredOnUse = (
338
345
  modules: Record<string, unknown>,
339
346
  files: ReadonlyMap<string, string>,
340
347
  ): Record<string, unknown> => {
341
- const require = createRequire(import.meta.url)
348
+ const require = NodeRuntime.createRequire(import.meta.url)
342
349
  return new Proxy(modules, {
343
350
  has: (loaded, id) => id in loaded || (typeof id === "string" && files.has(id)),
344
351
  get: (loaded, id) =>
@@ -619,12 +626,12 @@ const shadowingDoors = (
619
626
 
620
627
  // How a TypeScript module is found behind a relative specifier: as written,
621
628
  // its `.js` spelling as `.ts`, with `.ts` appended, or as a directory index.
622
- const moduleCandidates = (path: string): readonly string[] => [
629
+ const moduleCandidates = (paths: Path.Path, path: string): readonly string[] => [
623
630
  path,
624
631
  path.replace(/\.js$/, ".ts"),
625
632
  path.replace(/\.mjs$/, ".mts"),
626
633
  `${path}.ts`,
627
- join(path, "index.ts"),
634
+ paths.join(path, "index.ts"),
628
635
  ]
629
636
 
630
637
  interface Evaluated {
@@ -641,9 +648,14 @@ type Chunk = (
641
648
  __dirname: string,
642
649
  ) => void
643
650
 
644
- const execute = (chunk: Chunk, filepath: string, require: (id: string) => unknown): unknown => {
651
+ const execute = (
652
+ paths: Path.Path,
653
+ chunk: Chunk,
654
+ filepath: string,
655
+ require: (id: string) => unknown,
656
+ ): unknown => {
645
657
  const cjs = { exports: {} as unknown }
646
- chunk(cjs.exports, require, cjs, filepath, dirname(filepath))
658
+ chunk(cjs.exports, require, cjs, filepath, paths.dirname(filepath))
647
659
  return cjs.exports
648
660
  }
649
661
 
@@ -656,9 +668,13 @@ const execute = (chunk: Chunk, filepath: string, require: (id: string) => unknow
656
668
  * it, as Node would. `@pmelab/gtd/*` is the running gtd's own copy, and
657
669
  * `files` leaves out every package file.
658
670
  */
659
- const evaluate = async (module: WorkflowModule, shipped: ShippedModules): Promise<Evaluated> => {
660
- const { code, files } = await bundleConfig(module, { kind: "evaluate", shipped })
661
- const fromConfig = createRequire(module.filepath)
671
+ const evaluate = async (
672
+ paths: Path.Path,
673
+ module: WorkflowModule,
674
+ shipped: ShippedModules,
675
+ ): Promise<Evaluated> => {
676
+ const { code, files } = await bundleConfig(paths, module, { kind: "evaluate", shipped })
677
+ const fromConfig = NodeRuntime.createRequire(module.filepath)
662
678
  const require = Object.assign(
663
679
  (id: string): unknown =>
664
680
  id in shipped.modules
@@ -666,11 +682,11 @@ const evaluate = async (module: WorkflowModule, shipped: ShippedModules): Promis
666
682
  : fromConfig(id in shipped.missing ? shipped.missing[id]! : id),
667
683
  { resolve: fromConfig.resolve },
668
684
  )
669
- const chunk = runInThisContext(
685
+ const chunk = NodeRuntime.compileScript(
670
686
  `(function (exports, require, module, __filename, __dirname) {${code}\n})`,
671
- { filename: module.filepath },
687
+ module.filepath,
672
688
  ) as Chunk
673
- return { exports: execute(chunk, module.filepath, require), files }
689
+ return { exports: execute(paths, chunk, module.filepath, require), files }
674
690
  }
675
691
 
676
692
  /**
@@ -688,12 +704,17 @@ interface PinnedWorkflow {
688
704
  const pinnedWorkflow = (): PinnedWorkflow | undefined =>
689
705
  (globalThis as { [PIN_KEY]?: PinnedWorkflow })[PIN_KEY]
690
706
 
691
- const runPinned = (pinned: PinnedWorkflow, filepath: string, root: string): Evaluated => ({
707
+ const runPinned = (
708
+ paths: Path.Path,
709
+ pinned: PinnedWorkflow,
710
+ filepath: string,
711
+ root: string,
712
+ ): Evaluated => ({
692
713
  // Everything but the engine's own flows and Node's builtins is in the bundle.
693
- exports: execute(pinned.run, filepath, (id) =>
694
- id === FLOWS ? flows : createRequire(import.meta.url)(id),
714
+ exports: execute(paths, pinned.run, filepath, (id) =>
715
+ id === FLOWS ? flows : NodeRuntime.createRequire(import.meta.url)(id),
695
716
  ),
696
- files: Object.keys(pinned.fingerprint).map((file) => resolvePath(root, file)),
717
+ files: Object.keys(pinned.fingerprint).map((file) => paths.resolve(root, file)),
697
718
  })
698
719
 
699
720
  /**
@@ -702,22 +723,25 @@ const runPinned = (pinned: PinnedWorkflow, filepath: string, root: string): Eval
702
723
  * one function, so the engine runs it without a config, a package or rolldown.
703
724
  */
704
725
  export const pinnedWorkflowSource = async (
726
+ paths: Path.Path,
705
727
  module: WorkflowModule,
706
728
  root: string,
707
729
  version: string,
708
730
  ): Promise<string> => {
709
- const gtd = realpathSync(ownPackage().root) + sep
710
- const { code, files } = await bundleConfig(module, {
731
+ const gtd = nodeSyncIo.realPath(ownPackage(paths).root) + paths.sep
732
+ const { code, files } = await bundleConfig(paths, module, {
711
733
  kind: "pin",
712
- shipped: subpathFiles(import.meta.url.endsWith(".ts") ? "source" : "default"),
734
+ shipped: subpathFiles(paths, import.meta.url.endsWith(".ts") ? "source" : "default"),
713
735
  })
714
736
  // gtd's own subpaths are bundled from its package, which only a config
715
737
  // living in gtd's own repository may count among its files.
716
738
  const own = (file: string) => file.startsWith(gtd) && !module.filepath.startsWith(gtd)
717
739
  const registered = {
718
740
  version,
719
- origin: relative(root, module.filepath),
741
+ origin: paths.relative(root, module.filepath),
720
742
  fingerprint: configFingerprint(
743
+ nodeSyncIo,
744
+ paths,
721
745
  files.filter((file) => !own(file)),
722
746
  root,
723
747
  module.read ?? readFromDisk,
@@ -743,13 +767,14 @@ const shippedImport = (
743
767
  }
744
768
 
745
769
  const bundleConfig = async (
770
+ paths: Path.Path,
746
771
  module: WorkflowModule,
747
772
  mode: BundleMode,
748
773
  ): Promise<{ readonly code: string; readonly files: readonly string[] }> => {
749
774
  const { read } = module
750
775
  const served = new Set<string>()
751
776
  const fromRead = (path: string): string | null => {
752
- const hit = moduleCandidates(path).find((candidate) => read?.(candidate) !== undefined)
777
+ const hit = moduleCandidates(paths, path).find((candidate) => read?.(candidate) !== undefined)
753
778
  if (hit === undefined) return null
754
779
  served.add(hit)
755
780
  return hit
@@ -760,13 +785,15 @@ const bundleConfig = async (
760
785
  if (importer === undefined) return source === module.filepath ? source : null
761
786
  const shipped = shippedImport(source, mode)
762
787
  if (shipped !== undefined) return shipped
763
- if (/^\.\.?(\/|$)/.test(source)) return fromRead(join(dirname(importer), source))
764
- if (!isPackage(source)) return null
788
+ if (/^\.\.?(\/|$)/.test(source)) return fromRead(paths.join(paths.dirname(importer), source))
789
+ if (!isPackage(paths, source)) return null
765
790
  const resolved = await this.resolve(source, importer, { skipSelf: true })
766
- return mode.kind === "pin" ? resolved : packageImport(source, resolved)
791
+ return mode.kind === "pin" ? resolved : packageImport(paths, source, resolved)
767
792
  },
768
793
  load(id) {
769
- if (mode.kind === "pin" && isNativeAddon(id)) throw new Error(nativeAddon(id))
794
+ if (mode.kind === "pin" && isNativeAddon(paths, id)) {
795
+ throw new Error(nativeAddon(paths, id))
796
+ }
770
797
  if (id === module.filepath) return module.source
771
798
  return served.has(id) ? read!(id) : null
772
799
  },
@@ -774,7 +801,7 @@ const bundleConfig = async (
774
801
  transform(code, id) {
775
802
  return code.replace(/\bimport\.meta\.(url|filename|dirname)\b/g, (_, key: string) =>
776
803
  JSON.stringify(
777
- key === "url" ? pathToFileURL(id).href : key === "filename" ? id : dirname(id),
804
+ key === "url" ? fileUrl(paths, id) : key === "filename" ? id : paths.dirname(id),
778
805
  ),
779
806
  )
780
807
  },
@@ -783,7 +810,7 @@ const bundleConfig = async (
783
810
  input: module.filepath,
784
811
  platform: "node",
785
812
  plugins: [plugin],
786
- external: (id) => isBuiltin(id),
813
+ external: (id) => NodeRuntime.isBuiltin(id),
787
814
  resolve: { extensionAlias: { ".js": [".js", ".ts"], ".mjs": [".mjs", ".mts"] } },
788
815
  // Every statement runs, as it would unbundled.
789
816
  treeshake: false,
@@ -814,17 +841,23 @@ const bundleConfig = async (
814
841
 
815
842
  // A pin is plain JavaScript: a compiled addon stays a file on one machine.
816
843
  // A package node-gyp builds loads its addon by a path no bundler sees.
817
- const isNativeAddon = (id: string): boolean => {
844
+ const isNativeAddon = (paths: Path.Path, id: string): boolean => {
818
845
  if (id.endsWith(".node")) return true
846
+ const { sep } = paths
819
847
  const at = id.lastIndexOf(`node_modules${sep}`)
820
848
  if (at < 0) return false
821
849
  const rest = id.slice(at + `node_modules${sep}`.length).split(sep)
822
850
  const root =
823
851
  id.slice(0, at) + ["node_modules", ...rest.slice(0, rest[0]!.startsWith("@") ? 2 : 1)].join(sep)
824
- return existsSync(join(root, "binding.gyp"))
852
+ try {
853
+ return nodeSyncIo.stat(paths.join(root, "binding.gyp")) !== undefined
854
+ } catch {
855
+ return false
856
+ }
825
857
  }
826
858
 
827
- const nativeAddon = (path: string): string => {
859
+ const nativeAddon = (paths: Path.Path, path: string): string => {
860
+ const { sep } = paths
828
861
  const at = path.lastIndexOf(`node_modules${sep}`)
829
862
  const rest = at < 0 ? [] : path.slice(at + `node_modules${sep}`.length).split(sep)
830
863
  const name = rest[0]?.startsWith("@") ? rest.slice(0, 2).join("/") : rest[0]
@@ -834,28 +867,32 @@ const nativeAddon = (path: string): string => {
834
867
  // Bundled, an ES module gets the shipped `@pmelab/gtd/*` like the config
835
868
  // does. CommonJS stays Node's: it may read `__dirname`, which a bundle loses.
836
869
  const packageImport = (
870
+ paths: Path.Path,
837
871
  source: string,
838
872
  resolved: { readonly id: string } | null,
839
873
  ): string | { readonly id: string; readonly external: true } =>
840
- resolved !== null && isEsModule(resolved.id)
874
+ resolved !== null && isEsModule(paths, resolved.id)
841
875
  ? resolved.id
842
876
  : { id: resolved?.id ?? source, external: true }
843
877
 
844
- const isEsModule = (path: string): boolean => {
878
+ const isEsModule = (paths: Path.Path, path: string): boolean => {
845
879
  if (/\.(mjs|[cm]?tsx?)$/.test(path)) return true
846
880
  if (!/\.jsx?$/.test(path)) return false
847
- for (let dir = dirname(path); dirname(dir) !== dir; dir = dirname(dir)) {
848
- const file = join(dir, "package.json")
849
- if (existsSync(file)) {
850
- return (JSON.parse(readFileSync(file, "utf8")) as { type?: string }).type === "module"
851
- }
881
+ for (let dir = paths.dirname(path); paths.dirname(dir) !== dir; dir = paths.dirname(dir)) {
882
+ const source = readFromDisk(paths.join(dir, "package.json"))
883
+ if (source !== undefined) return (JSON.parse(source) as { type?: string }).type === "module"
852
884
  }
853
885
  return false
854
886
  }
855
887
 
856
888
  const inPackage = (path: string): boolean => /[\\/]node_modules[\\/]/.test(path)
857
889
 
858
- const isPackage = (specifier: string): boolean => !isAbsolute(specifier) && !isBuiltin(specifier)
890
+ const isPackage = (paths: Path.Path, specifier: string): boolean =>
891
+ !paths.isAbsolute(specifier) && !NodeRuntime.isBuiltin(specifier)
892
+
893
+ // `toFileUrl` fails only where `pathToFileURL` throws.
894
+ const fileUrl = (paths: Path.Path, path: string): string =>
895
+ Effect.runSync(paths.toFileUrl(path)).href
859
896
 
860
897
  // rolldown reports each error with a colored code frame; a diagnostic takes
861
898
  // its headline and location.
@@ -863,7 +900,7 @@ const bundleError = (e: unknown): unknown => {
863
900
  const errors = (e as { errors?: readonly { message: string }[] } | null)?.errors
864
901
  if (errors === undefined) return e
865
902
  const lines = errors.map(({ message }) => {
866
- const plain = stripVTControlCharacters(message)
903
+ const plain = NodeRuntime.stripVTControlCharacters(message)
867
904
  const at = /\[ (.+:\d+:\d+) \]/.exec(plain)?.[1]
868
905
  const headline = plain.split("\n")[0]!
869
906
  return at === undefined ? headline : `${headline} (${at})`
@@ -874,7 +911,8 @@ const bundleError = (e: unknown): unknown => {
874
911
  type Rolldown = typeof import("rolldown")
875
912
  let rolldownModule: Rolldown | undefined
876
913
  // Required at run time, never bundled: rolldown ships a native binding.
877
- const requireRolldown = (): Rolldown => createRequire(import.meta.url)("rolldown") as Rolldown
914
+ const requireRolldown = (): Rolldown =>
915
+ NodeRuntime.createRequire(import.meta.url)("rolldown") as Rolldown
878
916
 
879
917
  const isWorkflow = (value: unknown): boolean =>
880
918
  markedOptions(value) !== undefined || isFactoryWorkflow(value)
@@ -1119,7 +1157,7 @@ interface ConfigServiceOperations {
1119
1157
  readonly load: Effect.Effect<
1120
1158
  ConfigOperations,
1121
1159
  Error,
1122
- Narrator | Workspace | Host | ConfigDiscovery
1160
+ Narrator | Workspace | Host | Path.Path | ConfigDiscovery
1123
1161
  >
1124
1162
  }
1125
1163
 
@@ -2,10 +2,11 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod
2
2
  import { isBuiltin } from "node:module"
3
3
  import { tmpdir } from "node:os"
4
4
  import { join } from "node:path"
5
- import { Effect, Exit } from "effect"
5
+ import { Effect, Exit, Layer } from "effect"
6
6
  import { parseAst } from "rolldown/parseAst"
7
7
  import { afterEach, describe, expect, it } from "vitest"
8
8
  import { PIN_KEY } from "../install/index.js"
9
+ import { NodeServices } from "../platform/index.js"
9
10
  import { InMemRepo, testLayers } from "../twins/index.js"
10
11
  import { ConfigDiscovery, ConfigService } from "./index.js"
11
12
  import { EngineCore, pin, readPin } from "./pin.js"
@@ -16,7 +17,7 @@ afterEach(() => {
16
17
 
17
18
  // A pin must run with nothing installed: the engine imports Node and nothing else.
18
19
  describe.each([
19
- ["live", EngineCore.Live],
20
+ ["live", EngineCore.Live.pipe(Layer.provide(NodeServices.layer))],
20
21
  ["in memory", testLayers(new InMemRepo())],
21
22
  ])("EngineCore (%s)", (_, layer) => {
22
23
  const source = () =>
@@ -1,8 +1,5 @@
1
- import { readFileSync } from "node:fs"
2
- import { join } from "node:path"
3
- import { gunzipSync, gzipSync } from "node:zlib"
4
- import { Context, Effect, Layer } from "effect"
5
- import { GtdError } from "../platform/index.js"
1
+ import { Context, Effect, Layer, Path } from "effect"
2
+ import { GtdError, NodeRuntime, nodeSyncIo } from "../platform/index.js"
6
3
  import { ownPackage, pinnedWorkflowSource } from "./load.js"
7
4
  import type { WorkflowModule } from "./discovery.js"
8
5
 
@@ -22,12 +19,20 @@ export class EngineCore extends Context.Service<
22
19
  source: Effect.suspend(() => {
23
20
  const file = path()
24
21
  return Effect.try({
25
- try: () => readFileSync(file, "utf8"),
22
+ try: () => {
23
+ const source = nodeSyncIo.readText(file)
24
+ if (source === undefined) throw new Error("absent")
25
+ return source
26
+ },
26
27
  catch: () => new GtdError(`gtd bundle: no engine at ${file} — build gtd first`),
27
28
  })
28
29
  }),
29
30
  })
30
- static Live = EngineCore.layer(() => join(ownPackage().root, "dist", "gtd.core.mjs"))
31
+ static Live = Layer.unwrap(
32
+ Effect.map(Path.Path, (path) =>
33
+ EngineCore.layer(() => path.join(ownPackage(path).root, "dist", "gtd.core.mjs")),
34
+ ),
35
+ )
31
36
  }
32
37
 
33
38
  /**
@@ -39,11 +44,12 @@ export const pin = (
39
44
  module: WorkflowModule,
40
45
  root: string,
41
46
  version: string,
42
- ): Effect.Effect<Uint8Array, Error, EngineCore> =>
47
+ ): Effect.Effect<Uint8Array, Error, EngineCore | Path.Path> =>
43
48
  Effect.gen(function* () {
44
49
  const core = yield* (yield* EngineCore).source
50
+ const path = yield* Path.Path
45
51
  const workflow = yield* Effect.tryPromise({
46
- try: () => pinnedWorkflowSource(module, root, version),
52
+ try: () => pinnedWorkflowSource(path, module, root, version),
47
53
  catch: (e) =>
48
54
  new GtdError(
49
55
  `gtd bundle: ${module.filepath}: ${e instanceof Error ? e.message : String(e)}`,
@@ -53,7 +59,7 @@ export const pin = (
53
59
  const source = workflow + core.replace(/^#!.*\n/, "")
54
60
  return Buffer.concat([
55
61
  Buffer.from(`gtdpin ${PIN_FORMAT} gtd ${version}\n`),
56
- gzipSync(source, { level: 9 }),
62
+ NodeRuntime.gzip(source),
57
63
  ])
58
64
  })
59
65
 
@@ -75,6 +81,6 @@ export const readPin = (
75
81
  if (header === undefined) throw new Error("not a gtd pin")
76
82
  return {
77
83
  ...header,
78
- source: gunzipSync(bytes.subarray(bytes.indexOf(0x0a) + 1)).toString("utf8"),
84
+ source: NodeRuntime.gunzip(bytes.subarray(bytes.indexOf(0x0a) + 1)),
79
85
  }
80
86
  }