@pmelab/gtd 60.0.0 → 61.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +21 -2
- package/dist/chunks/testkit-C9sq0x07.mjs +30758 -0
- package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
- package/dist/gtd.bundle.mjs +12238 -20706
- package/dist/gtd.core.mjs +593 -0
- package/dist/testkit/gherkin.mjs +2 -2
- package/dist/testkit.mjs +1 -1
- package/dist/wire.mjs +2 -2
- package/package.json +7 -7
- package/src/cli/Cli.test.ts +2 -2
- package/src/cli/Cli.ts +68 -51
- package/src/cli/History.test.ts +10 -1
- package/src/cli/Metrics.test.ts +10 -1
- package/src/cli/Output.test.ts +1 -1
- package/src/cli/Output.ts +3 -2
- package/src/cli/Program.test.ts +44 -25
- package/src/cli/Program.ts +39 -11
- package/src/cli/Strays.ts +3 -2
- package/src/cli/ToolCommands.ts +2 -2
- package/src/edge/Edge.test.ts +86 -31
- package/src/edge/Edge.ts +164 -31
- package/src/emit/GitScript.ts +21 -3
- package/src/emit/index.ts +3 -0
- package/src/flows/runtime.ts +1 -1
- package/src/gherkin/messages.test.ts +1 -0
- package/src/gherkin/module-config.feature +1 -0
- package/src/gherkin/more-steps.feature +1 -0
- package/src/gherkin/testkit.feature +2 -1
- package/src/install/Install.ts +9 -2
- package/src/install/Version.ts +9 -3
- package/src/install/index.ts +1 -1
- package/src/platform/CommandRunner.ts +13 -11
- package/src/platform/Commentary.ts +2 -2
- package/src/platform/Git.test.ts +4 -5
- package/src/platform/Git.ts +42 -38
- package/src/platform/Host.ts +1 -1
- package/src/platform/NodeServices.ts +11 -0
- package/src/platform/Workspace.test.ts +4 -5
- package/src/platform/Workspace.ts +3 -3
- package/src/platform/WorktreeState.test.ts +2 -3
- package/src/platform/WorktreeState.ts +1 -1
- package/src/platform/index.ts +2 -1
- package/src/replay/Format.test.ts +35 -8
- package/src/replay/Format.ts +7 -4
- package/src/replay/Replay.test.ts +54 -0
- package/src/replay/Replay.ts +61 -17
- package/src/replay/Trailers.test.ts +12 -12
- package/src/replay/Trailers.ts +35 -20
- package/src/replay/Tree.ts +11 -0
- package/src/replay/index.ts +3 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/RepoSnapshot.ts +2 -0
- package/src/step/planStart.test.ts +2 -2
- package/src/step/planStart.ts +3 -3
- package/src/step/planStep.test.ts +4 -4
- package/src/step/planStep.ts +10 -8
- package/src/testkit/TestRepo.test.ts +4 -1
- package/src/testkit/TestRepo.ts +3 -1
- package/src/twins/EmittedScriptRecognizer.ts +32 -1
- package/src/twins/EmittedScripts.test.ts +1 -1
- package/src/twins/GitTiers.ts +29 -7
- package/src/twins/InMemRepo.ts +14 -0
- package/src/twins/Layers.test.ts +1 -1
- package/src/twins/Layers.ts +10 -3
- package/src/twins/PinBeats.ts +33 -0
- package/src/twins/cliIo.ts +9 -4
- package/src/twins/index.ts +1 -0
- package/src/wire/BeatDocument.ts +26 -5
- package/src/wire/Demand.ts +16 -6
- package/src/wire/constants.ts +3 -0
- package/src/wire/types.ts +4 -0
- package/src/workflow/discovery.test.ts +24 -7
- package/src/workflow/discovery.ts +5 -6
- package/src/workflow/index.ts +1 -0
- package/src/workflow/load.test.ts +60 -3
- package/src/workflow/load.ts +316 -166
- package/src/workflow/pin.test.ts +159 -0
- package/src/workflow/pin.ts +72 -0
- package/src/workflow/vars.ts +3 -3
- package/dist/chunks/testkit-BvecVjQ0.mjs +0 -32355
package/src/workflow/load.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
-
import { createRequire } from "node:module"
|
|
3
|
-
import { dirname, join } from "node:path"
|
|
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
4
|
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
5
|
+
import { stripVTControlCharacters } from "node:util"
|
|
6
|
+
import { runInThisContext } from "node:vm"
|
|
7
|
+
import type { Plugin as RolldownPlugin } from "rolldown"
|
|
5
8
|
import { Context, Effect, Layer } from "effect"
|
|
6
9
|
import { GtdError, Narrator } from "../platform/index.js"
|
|
7
10
|
import { accessShapeFault, replay, type TreeView } from "../replay/index.js"
|
|
@@ -15,6 +18,7 @@ import { compileConfig, type CompiledConfig } from "./compile.js"
|
|
|
15
18
|
import { SETTING_NAME_RULE, isSettingName } from "./vars.js"
|
|
16
19
|
import { ConfigDiscovery, WORKFLOW_MODULE, type WorkflowModule } from "./discovery.js"
|
|
17
20
|
import { configFingerprint, type ConfigFingerprint } from "./fingerprint.js"
|
|
21
|
+
import { PIN_KEY } from "../install/index.js"
|
|
18
22
|
import {
|
|
19
23
|
dedupeDiagnostics,
|
|
20
24
|
formatDiagnostic,
|
|
@@ -100,9 +104,25 @@ export const refuseLegacyRc: Effect.Effect<void, GtdError, Host | ConfigDiscover
|
|
|
100
104
|
)
|
|
101
105
|
})
|
|
102
106
|
|
|
107
|
+
const configModule: Effect.Effect<WorkflowModule, Error, Host | ConfigDiscovery> = Effect.gen(
|
|
108
|
+
function* () {
|
|
109
|
+
const host = yield* Host
|
|
110
|
+
const module = yield* (yield* ConfigDiscovery).workflowModule(host.root, host.home)
|
|
111
|
+
if (module !== undefined) return module
|
|
112
|
+
const missing: Diagnostic = {
|
|
113
|
+
severity: "error",
|
|
114
|
+
path: [],
|
|
115
|
+
message: `no workflows configured: no ${WORKFLOW_MODULE} here or above — gtd ships no workflow; \`gtd guide\` says where to get one`,
|
|
116
|
+
origin: host.root,
|
|
117
|
+
}
|
|
118
|
+
return yield* Effect.fail(configError([missing]))
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
|
|
103
122
|
/**
|
|
104
|
-
* Find and evaluate `gtd.config.ts
|
|
105
|
-
*
|
|
123
|
+
* Find and evaluate `gtd.config.ts` — or, inside a pin, the workflow bundled
|
|
124
|
+
* into it — then check what it exports. Every finding is collected into ONE
|
|
125
|
+
* sorted report rather than failing on the first.
|
|
106
126
|
*/
|
|
107
127
|
export const load: Effect.Effect<
|
|
108
128
|
ConfigOperations,
|
|
@@ -110,25 +130,30 @@ export const load: Effect.Effect<
|
|
|
110
130
|
Narrator | Workspace | Host | ConfigDiscovery
|
|
111
131
|
> = Effect.gen(function* () {
|
|
112
132
|
const host = yield* Host
|
|
113
|
-
const discovery = yield* ConfigDiscovery
|
|
114
133
|
yield* refuseLegacyRc
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const loaded = yield* Effect.
|
|
131
|
-
try: () =>
|
|
134
|
+
const pinned = pinnedWorkflow()
|
|
135
|
+
const module: WorkflowModule =
|
|
136
|
+
pinned === undefined
|
|
137
|
+
? yield* configModule
|
|
138
|
+
: { filepath: resolvePath(host.root, pinned.origin), source: "" }
|
|
139
|
+
yield* (yield* Narrator).narrate(
|
|
140
|
+
`config: ${pinned === undefined ? "module" : "pinned module"} ${module.filepath}`,
|
|
141
|
+
)
|
|
142
|
+
const shipped =
|
|
143
|
+
pinned === undefined
|
|
144
|
+
? yield* Effect.tryPromise({
|
|
145
|
+
try: shippedModules,
|
|
146
|
+
catch: (e) => new GtdError(`gtd: ${e instanceof Error ? e.message : String(e)}`),
|
|
147
|
+
})
|
|
148
|
+
: undefined
|
|
149
|
+
const loaded = yield* Effect.tryPromise({
|
|
150
|
+
try: async () =>
|
|
151
|
+
loadWorkflows(
|
|
152
|
+
module.filepath,
|
|
153
|
+
shipped === undefined
|
|
154
|
+
? runPinned(pinned!, module.filepath, host.root)
|
|
155
|
+
: await evaluate(module, shipped),
|
|
156
|
+
),
|
|
132
157
|
catch: (e) =>
|
|
133
158
|
new GtdError(
|
|
134
159
|
`gtd: config:\n - ${module.filepath}: ${e instanceof Error ? e.message : String(e)}`,
|
|
@@ -176,11 +201,9 @@ export const load: Effect.Effect<
|
|
|
176
201
|
},
|
|
177
202
|
...(compiled.ui !== undefined ? { ui: compiled.ui } : {}),
|
|
178
203
|
warnings: diagnostics.filter((d) => d.severity === "warning"),
|
|
179
|
-
fingerprint:
|
|
180
|
-
|
|
181
|
-
host.root,
|
|
182
|
-
module.read ?? readFromDisk,
|
|
183
|
-
),
|
|
204
|
+
fingerprint:
|
|
205
|
+
pinned?.fingerprint ??
|
|
206
|
+
configFingerprint(loaded.file.dependencyFiles, host.root, module.read ?? readFromDisk),
|
|
184
207
|
}
|
|
185
208
|
})
|
|
186
209
|
|
|
@@ -200,13 +223,11 @@ interface Settings {
|
|
|
200
223
|
interface LoadedFile {
|
|
201
224
|
readonly origin: string
|
|
202
225
|
/**
|
|
203
|
-
* Every
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
* `gtd.config.ts` re-exporting a sibling module is a documented pattern,
|
|
209
|
-
* and jiti resolves that import off disk same as any other require.
|
|
226
|
+
* Every file bundled evaluating this module — `origin` itself plus every
|
|
227
|
+
* local module it (transitively) imports, in NO particular order. A caller
|
|
228
|
+
* that needs to know when a workflow's DEFINITION might have changed (the
|
|
229
|
+
* LSP's steering-map memo) must watch this whole set, not just `origin`: a
|
|
230
|
+
* `gtd.config.ts` re-exporting a sibling module is a documented pattern.
|
|
210
231
|
*/
|
|
211
232
|
readonly dependencyFiles: readonly string[]
|
|
212
233
|
}
|
|
@@ -231,76 +252,8 @@ interface Loaded {
|
|
|
231
252
|
readonly diagnostics: readonly Diagnostic[]
|
|
232
253
|
}
|
|
233
254
|
|
|
234
|
-
type JitiInstance = {
|
|
235
|
-
evalModule: (
|
|
236
|
-
source: string,
|
|
237
|
-
options: { filename: string; async?: false; cache?: Record<string, unknown> },
|
|
238
|
-
) => unknown
|
|
239
|
-
}
|
|
240
|
-
type JitiModule = {
|
|
241
|
-
createJiti: (id: string, options: Record<string, unknown>) => JitiInstance
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
type Transform = (options: { readonly source: string }) => { code: string; error?: unknown }
|
|
245
|
-
|
|
246
|
-
// Transpiling is most of a load's cost, and a load re-transpiles
|
|
247
|
-
// gtd.config.ts and every shipped module it imports. The same options (the
|
|
248
|
-
// source included) always transpile to the same code, so an in-process memo
|
|
249
|
-
// is safe; evaluation stays fresh on every load.
|
|
250
|
-
const TRANSFORM_MEMO_SIZE = 512
|
|
251
|
-
const memoized = (transform: Transform): Transform => {
|
|
252
|
-
const memo = new Map<string, ReturnType<Transform>>()
|
|
253
|
-
return (options) => {
|
|
254
|
-
const key = JSON.stringify(options)
|
|
255
|
-
const hit = memo.get(key)
|
|
256
|
-
if (hit !== undefined) return hit
|
|
257
|
-
const result = transform(options)
|
|
258
|
-
if (result.error !== undefined) return result
|
|
259
|
-
if (memo.size >= TRANSFORM_MEMO_SIZE) memo.delete(memo.keys().next().value!)
|
|
260
|
-
memo.set(key, result)
|
|
261
|
-
return result
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
let jitiModule: JitiModule | undefined
|
|
266
|
-
let transform: Transform | undefined
|
|
267
|
-
/** Relative specifiers a module imports, served from somewhere other than the disk. */
|
|
268
|
-
interface LocalModules {
|
|
269
|
-
/** The path a specifier names, or nothing when it names no module. */
|
|
270
|
-
readonly resolve: (specifier: string) => string | undefined
|
|
271
|
-
readonly load: (path: string) => unknown
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const jiti = (shipped: ShippedModules, local?: LocalModules): JitiInstance => {
|
|
275
|
-
const require = createRequire(import.meta.url)
|
|
276
|
-
jitiModule ??= require("jiti") as JitiModule
|
|
277
|
-
// jiti's own Babel transform, which its package exports don't name.
|
|
278
|
-
transform ??= memoized(
|
|
279
|
-
require(join(dirname(require.resolve("jiti/package.json")), "dist", "babel.cjs")) as Transform,
|
|
280
|
-
)
|
|
281
|
-
return jitiModule.createJiti(import.meta.url, {
|
|
282
|
-
// Consulted when a module is required, so a local module is evaluated
|
|
283
|
-
// only once an import actually runs.
|
|
284
|
-
virtualModules: new Proxy(shipped.modules, {
|
|
285
|
-
has: (modules, id) =>
|
|
286
|
-
id in modules || (typeof id === "string" && local?.resolve(id) !== undefined),
|
|
287
|
-
get: (modules, id) => {
|
|
288
|
-
const path = typeof id === "string" ? local?.resolve(id) : undefined
|
|
289
|
-
return path === undefined ? modules[id as string] : local!.load(path)
|
|
290
|
-
},
|
|
291
|
-
}),
|
|
292
|
-
alias: shipped.missing,
|
|
293
|
-
// No transpile cache on disk, and a fresh module every load — nothing a
|
|
294
|
-
// later command could read back instead of the source.
|
|
295
|
-
fsCache: false,
|
|
296
|
-
moduleCache: false,
|
|
297
|
-
transform,
|
|
298
|
-
// The whole module, not just its default: the named exports are read too.
|
|
299
|
-
interopDefault: false,
|
|
300
|
-
})
|
|
301
|
-
}
|
|
302
|
-
|
|
303
255
|
const PACKAGE = "@pmelab/gtd"
|
|
256
|
+
const FLOWS = `${PACKAGE}/flows`
|
|
304
257
|
|
|
305
258
|
interface PackageJson {
|
|
306
259
|
readonly name?: string
|
|
@@ -309,7 +262,7 @@ interface PackageJson {
|
|
|
309
262
|
|
|
310
263
|
// Only gtd's own package.json: whatever other one sits nearest (a vendored
|
|
311
264
|
// bundle inside a project) must never get its exports executed.
|
|
312
|
-
const ownPackage = (): { readonly root: string; readonly pkg: PackageJson } => {
|
|
265
|
+
export const ownPackage = (): { readonly root: string; readonly pkg: PackageJson } => {
|
|
313
266
|
const start = dirname(fileURLToPath(import.meta.url))
|
|
314
267
|
for (let dir = start; ; dir = dirname(dir)) {
|
|
315
268
|
const file = join(dir, "package.json")
|
|
@@ -340,7 +293,7 @@ const shippedModules = (): Promise<ShippedModules> =>
|
|
|
340
293
|
(shippedMemo ??= (async () => {
|
|
341
294
|
const fromSource = import.meta.url.endsWith(".ts")
|
|
342
295
|
const files = subpathFiles(fromSource ? "source" : "default")
|
|
343
|
-
const modules: Record<string, unknown> = { [
|
|
296
|
+
const modules: Record<string, unknown> = { [FLOWS]: flows }
|
|
344
297
|
const missing: Record<string, string> = {}
|
|
345
298
|
if (process.features.require_module && !fromSource) {
|
|
346
299
|
return { modules: requiredOnUse(modules, files), missing }
|
|
@@ -582,18 +535,11 @@ const namespaceOf = (exported: unknown): Record<string, unknown> =>
|
|
|
582
535
|
/**
|
|
583
536
|
* Evaluate `gtd.config.ts`. Every export but the reserved names that went
|
|
584
537
|
* through `workflow()` or is a factory workflow is a workflow named by its
|
|
585
|
-
* export.
|
|
586
|
-
* instance's `moduleCache: false`) — passing an empty object in has the side
|
|
587
|
-
* effect of collecting every real file jiti resolved evaluating this module,
|
|
588
|
-
* module AND every relative import it pulled in, transitively.
|
|
538
|
+
* export.
|
|
589
539
|
*/
|
|
590
|
-
const loadWorkflows = (
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
? evaluateFromDisk(module, shipped)
|
|
594
|
-
: evaluateRead(module, module.read, shipped)
|
|
595
|
-
const exports = namespaceOf(evaluated)
|
|
596
|
-
const file: LoadedFile = { origin: module.filepath, dependencyFiles: files }
|
|
540
|
+
const loadWorkflows = (origin: string, evaluated: Evaluated): Loaded => {
|
|
541
|
+
const exports = namespaceOf(evaluated.exports)
|
|
542
|
+
const file: LoadedFile = { origin, dependencyFiles: evaluated.files }
|
|
597
543
|
const exported = Object.entries(exports).filter(
|
|
598
544
|
([name, value]) => !RESERVED_EXPORTS.has(name) && isWorkflow(value),
|
|
599
545
|
)
|
|
@@ -625,7 +571,7 @@ const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded
|
|
|
625
571
|
catalogue,
|
|
626
572
|
doors,
|
|
627
573
|
default: defaultWorkflow && { ...defaultWorkflow, name: defaultName },
|
|
628
|
-
config: compileConfig(exports["config"],
|
|
574
|
+
config: compileConfig(exports["config"], origin),
|
|
629
575
|
diagnostics: [
|
|
630
576
|
...retiredExports(exports, file.origin),
|
|
631
577
|
...nameRejections(rejections),
|
|
@@ -658,17 +604,8 @@ const shadowingDoors = (
|
|
|
658
604
|
origin,
|
|
659
605
|
}))
|
|
660
606
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
shipped: ShippedModules,
|
|
664
|
-
): { readonly exports: unknown; readonly files: readonly string[] } => {
|
|
665
|
-
const cache: Record<string, unknown> = {}
|
|
666
|
-
const exports = jiti(shipped).evalModule(module.source, { filename: module.filepath, cache })
|
|
667
|
-
return { exports, files: Object.keys(cache) }
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
// How jiti finds a TypeScript module behind a specifier: as written, its
|
|
671
|
-
// `.js` spelling as `.ts`, with `.ts` appended, or as a directory index.
|
|
607
|
+
// How a TypeScript module is found behind a relative specifier: as written,
|
|
608
|
+
// its `.js` spelling as `.ts`, with `.ts` appended, or as a directory index.
|
|
672
609
|
const moduleCandidates = (path: string): readonly string[] => [
|
|
673
610
|
path,
|
|
674
611
|
path.replace(/\.js$/, ".ts"),
|
|
@@ -677,41 +614,255 @@ const moduleCandidates = (path: string): readonly string[] => [
|
|
|
677
614
|
join(path, "index.ts"),
|
|
678
615
|
]
|
|
679
616
|
|
|
617
|
+
interface Evaluated {
|
|
618
|
+
readonly exports: unknown
|
|
619
|
+
readonly files: readonly string[]
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** A bundle's one chunk: CommonJS, so it runs as a plain function. */
|
|
623
|
+
type Chunk = (
|
|
624
|
+
exports: unknown,
|
|
625
|
+
require: (id: string) => unknown,
|
|
626
|
+
module: { exports: unknown },
|
|
627
|
+
__filename: string,
|
|
628
|
+
__dirname: string,
|
|
629
|
+
) => void
|
|
630
|
+
|
|
631
|
+
const execute = (chunk: Chunk, filepath: string, require: (id: string) => unknown): unknown => {
|
|
632
|
+
const cjs = { exports: {} as unknown }
|
|
633
|
+
chunk(cjs.exports, require, cjs, filepath, dirname(filepath))
|
|
634
|
+
return cjs.exports
|
|
635
|
+
}
|
|
636
|
+
|
|
680
637
|
/**
|
|
681
|
-
*
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
638
|
+
* Bundles the config and every local module it imports in memory, then
|
|
639
|
+
* evaluates the bundle. With `read`, a relative import `read` can serve is
|
|
640
|
+
* taken from it instead of the disk; one it cannot falls through to the disk,
|
|
641
|
+
* and fails the way a missing file does. An ES module or TypeScript package is
|
|
642
|
+
* bundled too; a CommonJS one is required from where its importer resolved
|
|
643
|
+
* it, as Node would. `@pmelab/gtd/*` is the running gtd's own copy, and
|
|
644
|
+
* `files` leaves out every package file.
|
|
686
645
|
*/
|
|
687
|
-
const
|
|
646
|
+
const evaluate = async (module: WorkflowModule, shipped: ShippedModules): Promise<Evaluated> => {
|
|
647
|
+
const { code, files } = await bundleConfig(module, { kind: "evaluate", shipped })
|
|
648
|
+
const fromConfig = createRequire(module.filepath)
|
|
649
|
+
const require = Object.assign(
|
|
650
|
+
(id: string): unknown =>
|
|
651
|
+
id in shipped.modules
|
|
652
|
+
? shipped.modules[id]
|
|
653
|
+
: fromConfig(id in shipped.missing ? shipped.missing[id]! : id),
|
|
654
|
+
{ resolve: fromConfig.resolve },
|
|
655
|
+
)
|
|
656
|
+
const chunk = runInThisContext(
|
|
657
|
+
`(function (exports, require, module, __filename, __dirname) {${code}\n})`,
|
|
658
|
+
{ filename: module.filepath },
|
|
659
|
+
) as Chunk
|
|
660
|
+
return { exports: execute(chunk, module.filepath, require), files }
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* What a pin registers: its workflow bundled, its origin relative to the
|
|
665
|
+
* repository root, and the fingerprint of the files it was bundled from — the
|
|
666
|
+
* content it runs, whatever the disk holds now.
|
|
667
|
+
*/
|
|
668
|
+
interface PinnedWorkflow {
|
|
669
|
+
readonly version: string
|
|
670
|
+
readonly origin: string
|
|
671
|
+
readonly fingerprint: ConfigFingerprint
|
|
672
|
+
readonly run: Chunk
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const pinnedWorkflow = (): PinnedWorkflow | undefined =>
|
|
676
|
+
(globalThis as { [PIN_KEY]?: PinnedWorkflow })[PIN_KEY]
|
|
677
|
+
|
|
678
|
+
const runPinned = (pinned: PinnedWorkflow, filepath: string, root: string): Evaluated => ({
|
|
679
|
+
// Everything but the engine's own flows and Node's builtins is in the bundle.
|
|
680
|
+
exports: execute(pinned.run, filepath, (id) =>
|
|
681
|
+
id === FLOWS ? flows : createRequire(import.meta.url)(id),
|
|
682
|
+
),
|
|
683
|
+
files: Object.keys(pinned.fingerprint).map((file) => resolvePath(root, file)),
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* The statement that registers `module`'s workflow for a pin: the config and
|
|
688
|
+
* everything it imports — npm packages and gtd's own subpaths included — in
|
|
689
|
+
* one function, so the engine runs it without a config, a package or rolldown.
|
|
690
|
+
*/
|
|
691
|
+
export const pinnedWorkflowSource = async (
|
|
688
692
|
module: WorkflowModule,
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
):
|
|
692
|
-
const
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
693
|
+
root: string,
|
|
694
|
+
version: string,
|
|
695
|
+
): Promise<string> => {
|
|
696
|
+
const gtd = realpathSync(ownPackage().root) + sep
|
|
697
|
+
const { code, files } = await bundleConfig(module, {
|
|
698
|
+
kind: "pin",
|
|
699
|
+
shipped: subpathFiles(import.meta.url.endsWith(".ts") ? "source" : "default"),
|
|
700
|
+
})
|
|
701
|
+
// gtd's own subpaths are bundled from its package, which only a config
|
|
702
|
+
// living in gtd's own repository may count among its files.
|
|
703
|
+
const own = (file: string) => file.startsWith(gtd) && !module.filepath.startsWith(gtd)
|
|
704
|
+
const registered = {
|
|
705
|
+
version,
|
|
706
|
+
origin: relative(root, module.filepath),
|
|
707
|
+
fingerprint: configFingerprint(
|
|
708
|
+
files.filter((file) => !own(file)),
|
|
709
|
+
root,
|
|
710
|
+
module.read ?? readFromDisk,
|
|
711
|
+
),
|
|
712
|
+
}
|
|
713
|
+
return `globalThis[Symbol.for(${JSON.stringify(PIN_KEY.description)})] = { ...${JSON.stringify(registered)}, run: function (exports, require, module, __filename, __dirname) {\n${code}\n} };\n`
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
type BundleMode =
|
|
717
|
+
| { readonly kind: "evaluate"; readonly shipped: ShippedModules }
|
|
718
|
+
// Every import goes into the bundle but flows, the engine's own.
|
|
719
|
+
| { readonly kind: "pin"; readonly shipped: ReadonlyMap<string, string> }
|
|
720
|
+
|
|
721
|
+
const shippedImport = (
|
|
722
|
+
source: string,
|
|
723
|
+
mode: BundleMode,
|
|
724
|
+
): string | { readonly id: string; readonly external: true } | undefined => {
|
|
725
|
+
if (mode.kind === "pin")
|
|
726
|
+
return source === FLOWS ? { id: source, external: true } : mode.shipped.get(source)
|
|
727
|
+
return source in mode.shipped.modules || source in mode.shipped.missing
|
|
728
|
+
? { id: source, external: true }
|
|
729
|
+
: undefined
|
|
713
730
|
}
|
|
714
731
|
|
|
732
|
+
const bundleConfig = async (
|
|
733
|
+
module: WorkflowModule,
|
|
734
|
+
mode: BundleMode,
|
|
735
|
+
): Promise<{ readonly code: string; readonly files: readonly string[] }> => {
|
|
736
|
+
const { read } = module
|
|
737
|
+
const served = new Set<string>()
|
|
738
|
+
const fromRead = (path: string): string | null => {
|
|
739
|
+
const hit = moduleCandidates(path).find((candidate) => read?.(candidate) !== undefined)
|
|
740
|
+
if (hit === undefined) return null
|
|
741
|
+
served.add(hit)
|
|
742
|
+
return hit
|
|
743
|
+
}
|
|
744
|
+
const plugin: RolldownPlugin = {
|
|
745
|
+
name: "gtd-config",
|
|
746
|
+
async resolveId(source, importer) {
|
|
747
|
+
if (importer === undefined) return source === module.filepath ? source : null
|
|
748
|
+
const shipped = shippedImport(source, mode)
|
|
749
|
+
if (shipped !== undefined) return shipped
|
|
750
|
+
if (/^\.\.?(\/|$)/.test(source)) return fromRead(join(dirname(importer), source))
|
|
751
|
+
if (!isPackage(source)) return null
|
|
752
|
+
const resolved = await this.resolve(source, importer, { skipSelf: true })
|
|
753
|
+
return mode.kind === "pin" ? resolved : packageImport(source, resolved)
|
|
754
|
+
},
|
|
755
|
+
load(id) {
|
|
756
|
+
if (mode.kind === "pin" && isNativeAddon(id)) throw new Error(nativeAddon(id))
|
|
757
|
+
if (id === module.filepath) return module.source
|
|
758
|
+
return served.has(id) ? read!(id) : null
|
|
759
|
+
},
|
|
760
|
+
// One bundle has one file; each module keeps its own location.
|
|
761
|
+
transform(code, id) {
|
|
762
|
+
return code.replace(/\bimport\.meta\.(url|filename|dirname)\b/g, (_, key: string) =>
|
|
763
|
+
JSON.stringify(
|
|
764
|
+
key === "url" ? pathToFileURL(id).href : key === "filename" ? id : dirname(id),
|
|
765
|
+
),
|
|
766
|
+
)
|
|
767
|
+
},
|
|
768
|
+
}
|
|
769
|
+
const bundle = await (rolldownModule ??= requireRolldown()).rolldown({
|
|
770
|
+
input: module.filepath,
|
|
771
|
+
platform: "node",
|
|
772
|
+
plugins: [plugin],
|
|
773
|
+
external: (id) => isBuiltin(id),
|
|
774
|
+
resolve: { extensionAlias: { ".js": [".js", ".ts"], ".mjs": [".mjs", ".mts"] } },
|
|
775
|
+
// Every statement runs, as it would unbundled.
|
|
776
|
+
treeshake: false,
|
|
777
|
+
tsconfig: false,
|
|
778
|
+
logLevel: "silent",
|
|
779
|
+
})
|
|
780
|
+
try {
|
|
781
|
+
const { output } = await bundle
|
|
782
|
+
// One chunk, and no `import()` left: the bundle runs as a plain function.
|
|
783
|
+
.generate({
|
|
784
|
+
format: "cjs",
|
|
785
|
+
exports: "named",
|
|
786
|
+
codeSplitting: false,
|
|
787
|
+
dynamicImportInCjs: false,
|
|
788
|
+
})
|
|
789
|
+
.catch((e) => {
|
|
790
|
+
throw bundleError(e)
|
|
791
|
+
})
|
|
792
|
+
const chunk = output[0]
|
|
793
|
+
return {
|
|
794
|
+
code: chunk.code,
|
|
795
|
+
files: chunk.moduleIds.filter((id) => !id.startsWith("\0") && !inPackage(id)),
|
|
796
|
+
}
|
|
797
|
+
} finally {
|
|
798
|
+
await bundle.close()
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// A pin is plain JavaScript: a compiled addon stays a file on one machine.
|
|
803
|
+
// A package node-gyp builds loads its addon by a path no bundler sees.
|
|
804
|
+
const isNativeAddon = (id: string): boolean => {
|
|
805
|
+
if (id.endsWith(".node")) return true
|
|
806
|
+
const at = id.lastIndexOf(`node_modules${sep}`)
|
|
807
|
+
if (at < 0) return false
|
|
808
|
+
const rest = id.slice(at + `node_modules${sep}`.length).split(sep)
|
|
809
|
+
const root =
|
|
810
|
+
id.slice(0, at) + ["node_modules", ...rest.slice(0, rest[0]!.startsWith("@") ? 2 : 1)].join(sep)
|
|
811
|
+
return existsSync(join(root, "binding.gyp"))
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const nativeAddon = (path: string): string => {
|
|
815
|
+
const at = path.lastIndexOf(`node_modules${sep}`)
|
|
816
|
+
const rest = at < 0 ? [] : path.slice(at + `node_modules${sep}`.length).split(sep)
|
|
817
|
+
const name = rest[0]?.startsWith("@") ? rest.slice(0, 2).join("/") : rest[0]
|
|
818
|
+
return `${name === undefined ? path : `package "${name}"`} loads a native addon (${path}), which a pin cannot carry`
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Bundled, an ES module gets the shipped `@pmelab/gtd/*` like the config
|
|
822
|
+
// does. CommonJS stays Node's: it may read `__dirname`, which a bundle loses.
|
|
823
|
+
const packageImport = (
|
|
824
|
+
source: string,
|
|
825
|
+
resolved: { readonly id: string } | null,
|
|
826
|
+
): string | { readonly id: string; readonly external: true } =>
|
|
827
|
+
resolved !== null && isEsModule(resolved.id)
|
|
828
|
+
? resolved.id
|
|
829
|
+
: { id: resolved?.id ?? source, external: true }
|
|
830
|
+
|
|
831
|
+
const isEsModule = (path: string): boolean => {
|
|
832
|
+
if (/\.(mjs|[cm]?tsx?)$/.test(path)) return true
|
|
833
|
+
if (!/\.jsx?$/.test(path)) return false
|
|
834
|
+
for (let dir = dirname(path); dirname(dir) !== dir; dir = dirname(dir)) {
|
|
835
|
+
const file = join(dir, "package.json")
|
|
836
|
+
if (existsSync(file)) {
|
|
837
|
+
return (JSON.parse(readFileSync(file, "utf8")) as { type?: string }).type === "module"
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
return false
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const inPackage = (path: string): boolean => /[\\/]node_modules[\\/]/.test(path)
|
|
844
|
+
|
|
845
|
+
const isPackage = (specifier: string): boolean => !isAbsolute(specifier) && !isBuiltin(specifier)
|
|
846
|
+
|
|
847
|
+
// rolldown reports each error with a colored code frame; a diagnostic takes
|
|
848
|
+
// its headline and location.
|
|
849
|
+
const bundleError = (e: unknown): unknown => {
|
|
850
|
+
const errors = (e as { errors?: readonly { message: string }[] } | null)?.errors
|
|
851
|
+
if (errors === undefined) return e
|
|
852
|
+
const lines = errors.map(({ message }) => {
|
|
853
|
+
const plain = stripVTControlCharacters(message)
|
|
854
|
+
const at = /\[ (.+:\d+:\d+) \]/.exec(plain)?.[1]
|
|
855
|
+
const headline = plain.split("\n")[0]!
|
|
856
|
+
return at === undefined ? headline : `${headline} (${at})`
|
|
857
|
+
})
|
|
858
|
+
return new Error(lines.join("\n"))
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
type Rolldown = typeof import("rolldown")
|
|
862
|
+
let rolldownModule: Rolldown | undefined
|
|
863
|
+
// Required at run time, never bundled: rolldown ships a native binding.
|
|
864
|
+
const requireRolldown = (): Rolldown => createRequire(import.meta.url)("rolldown") as Rolldown
|
|
865
|
+
|
|
715
866
|
const isWorkflow = (value: unknown): boolean =>
|
|
716
867
|
markedOptions(value) !== undefined || isFactoryWorkflow(value)
|
|
717
868
|
|
|
@@ -928,7 +1079,7 @@ const firstStep = (
|
|
|
928
1079
|
const empty = watchedEmptyTree()
|
|
929
1080
|
const vars = watchedEmptySettings()
|
|
930
1081
|
const env = watchedEmptySettings()
|
|
931
|
-
const outcome = yield* Effect.
|
|
1082
|
+
const outcome = yield* Effect.result(
|
|
932
1083
|
replayToFirstStep(loaded, vars.settings, env.settings, empty.tree),
|
|
933
1084
|
)
|
|
934
1085
|
const reads = [...settingReads("process", vars), ...settingReads("environment", env)]
|
|
@@ -939,8 +1090,8 @@ const firstStep = (
|
|
|
939
1090
|
),
|
|
940
1091
|
)
|
|
941
1092
|
}
|
|
942
|
-
if (outcome._tag === "
|
|
943
|
-
const name = outcome.
|
|
1093
|
+
if (outcome._tag === "Failure") return yield* Effect.fail(outcome.failure)
|
|
1094
|
+
const name = outcome.success
|
|
944
1095
|
if (!empty.read()) return name
|
|
945
1096
|
const atHead = yield* replayToFirstStep(loaded, vars.settings, env.settings, head)
|
|
946
1097
|
if (atHead === name) return name
|
|
@@ -959,9 +1110,8 @@ interface ConfigServiceOperations {
|
|
|
959
1110
|
>
|
|
960
1111
|
}
|
|
961
1112
|
|
|
962
|
-
export class ConfigService extends Context.
|
|
963
|
-
ConfigService,
|
|
964
|
-
|
|
965
|
-
>() {
|
|
1113
|
+
export class ConfigService extends Context.Service<ConfigService, ConfigServiceOperations>()(
|
|
1114
|
+
"ConfigService",
|
|
1115
|
+
) {
|
|
966
1116
|
static Live = Layer.succeed(ConfigService, { load })
|
|
967
1117
|
}
|