@pmelab/gtd 63.0.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -3
- package/dist/chunks/{flows-DENt0uWV.mjs → flows-B7ZRCe-B.mjs} +1 -1
- package/dist/chunks/{rolldown-runtime-8H4AJuhK.mjs → rolldown-runtime-D7D4PA-g.mjs} +0 -1
- package/dist/chunks/{steering-DF_BMadH.mjs → steering-DkA51qDa.mjs} +1 -1
- package/dist/chunks/{testkit-DwypA7cH.mjs → testkit-zGjNeQNL.mjs} +1011 -313
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +3890 -1294
- package/dist/gtd.core.mjs +119 -118
- package/dist/steering.mjs +1 -1
- package/dist/testkit/gherkin.mjs +2 -2
- package/dist/testkit.mjs +2 -2
- package/dist/workflow-kit.mjs +2 -2
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +5 -8
- package/src/cli/Cli.ts +9 -27
- package/src/cli/Output.ts +2 -1
- package/src/cli/Program.test.ts +55 -53
- package/src/cli/Program.ts +11 -30
- package/src/cli/ToolCommands.ts +3 -4
- package/src/edge/Edge.test.ts +38 -12
- package/src/edge/Edge.ts +30 -10
- package/src/emit/SteeringMode.test.ts +4 -2
- package/src/emit/SteeringMode.ts +10 -9
- package/src/gherkin/messages.test.ts +1 -1
- package/src/gherkin/module-config.feature +1 -1
- package/src/gherkin/testkit.feature +2 -2
- package/src/install/Version.ts +14 -13
- package/src/platform/Host.ts +75 -22
- package/src/platform/NodePinFile.ts +44 -0
- package/src/platform/NodeRuntime.ts +19 -0
- package/src/platform/NodeSync.ts +64 -0
- package/src/platform/PinFile.ts +0 -50
- package/src/platform/Sha.ts +121 -0
- package/src/platform/SyncIo.ts +35 -0
- package/src/platform/Workspace.test.ts +8 -3
- package/src/platform/Workspace.ts +82 -123
- package/src/platform/WorktreeState.test.ts +8 -4
- package/src/platform/WorktreeState.ts +21 -19
- package/src/platform/index.ts +18 -5
- package/src/replay/Format.test.ts +16 -13
- package/src/replay/Format.ts +4 -2
- package/src/replay/Replay.test.ts +25 -5
- package/src/replay/Replay.ts +10 -6
- package/src/replay/Trailers.test.ts +10 -10
- package/src/replay/Trailers.ts +23 -1
- package/src/replay/index.ts +1 -0
- package/src/step/planStart.test.ts +2 -2
- package/src/step/planStep.test.ts +4 -4
- package/src/testkit/TestRepo.test.ts +1 -1
- package/src/testkit/TestRepo.ts +4 -18
- package/src/twins/Host.test.ts +151 -0
- package/src/twins/Layers.test.ts +2 -2
- package/src/twins/Layers.ts +44 -18
- package/src/twins/Path.test.ts +52 -0
- package/src/twins/SyncIo.test.ts +153 -0
- package/src/twins/SyncIo.ts +159 -0
- package/src/twins/cliIo.ts +8 -1
- package/src/twins/index.ts +5 -0
- package/src/workflow/discovery.test.ts +6 -5
- package/src/workflow/discovery.ts +43 -32
- package/src/workflow/fingerprint.test.ts +23 -3
- package/src/workflow/fingerprint.ts +9 -11
- package/src/workflow/load.test.ts +16 -6
- package/src/workflow/load.ts +107 -69
- package/src/workflow/pin.test.ts +3 -2
- package/src/workflow/pin.ts +17 -11
package/src/edge/Edge.test.ts
CHANGED
|
@@ -185,7 +185,7 @@ describe("currentRun", () => {
|
|
|
185
185
|
expect(run.episode.base).toBe(boundary)
|
|
186
186
|
expect(run.episode.commits.map((c) => c.message.split("\n")[0])).toEqual([
|
|
187
187
|
"gtd(human): idle → building",
|
|
188
|
-
"gtd(check): pin → building",
|
|
188
|
+
"gtd(check): gtd:pin → building",
|
|
189
189
|
"gtd(agent): building → checking",
|
|
190
190
|
])
|
|
191
191
|
})
|
|
@@ -205,7 +205,7 @@ describe("currentRun", () => {
|
|
|
205
205
|
await land(repo, { "a.txt": "a\n" })
|
|
206
206
|
await land(repo, { "b.txt": "b\n" })
|
|
207
207
|
const closing = await land(repo, { "c.txt": "c\n" })
|
|
208
|
-
expect(repo.lastCommitSubject()).toBe("gtd(check): unpin → idle")
|
|
208
|
+
expect(repo.lastCommitSubject()).toBe("gtd(check): gtd:unpin → idle")
|
|
209
209
|
const next = await land(repo, { "d.txt": "d\n" })
|
|
210
210
|
const run = await provide(currentRun, repo)
|
|
211
211
|
expect(run.startParentHash).toBe(closing)
|
|
@@ -217,7 +217,7 @@ describe("currentRun", () => {
|
|
|
217
217
|
const before = headOf(repo)
|
|
218
218
|
const opening = await enter(repo, "side")
|
|
219
219
|
expect(repo.lastCommitMessage(opening)).toBe(
|
|
220
|
-
`gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Format:
|
|
220
|
+
`gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Format: 4`,
|
|
221
221
|
)
|
|
222
222
|
const fixed = await land(repo, { "fix.txt": "x\n" })
|
|
223
223
|
const run = await provide(currentRun, repo)
|
|
@@ -250,7 +250,7 @@ export const again = workflow(async () => {
|
|
|
250
250
|
const before = headOf(repo)
|
|
251
251
|
const opening = await enter(repo, "again")
|
|
252
252
|
expect(repo.lastCommitMessage(opening)).toBe(
|
|
253
|
-
`gtd(human): idle\n\nGtd-Workflow: again\nGtd-Format:
|
|
253
|
+
`gtd(human): idle\n\nGtd-Workflow: again\nGtd-Format: 4`,
|
|
254
254
|
)
|
|
255
255
|
const fixed = await land(repo, { "fix.txt": "x\n" })
|
|
256
256
|
const run = await provide(currentRun, repo)
|
|
@@ -264,6 +264,29 @@ export const again = workflow(async () => {
|
|
|
264
264
|
])
|
|
265
265
|
})
|
|
266
266
|
|
|
267
|
+
it("a format 3 pin commit naming the default's initial step bounds nothing either", async () => {
|
|
268
|
+
const repo = repoWith(`import { agent, human, workflow } from "@pmelab/gtd/flows"
|
|
269
|
+
|
|
270
|
+
export default async () => {
|
|
271
|
+
await human("idle", { message: "idle-message" })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export const again = workflow(async () => {
|
|
275
|
+
await human("idle", { message: "idle-message" })
|
|
276
|
+
await agent("fixing", "fix-prompt")
|
|
277
|
+
})
|
|
278
|
+
`)
|
|
279
|
+
const before = headOf(repo)
|
|
280
|
+
repo.seedCommit("gtd(human): idle\n\nGtd-Workflow: again\nGtd-Format: 3")
|
|
281
|
+
const opening = headOf(repo)
|
|
282
|
+
repo.writeFile(".gtd/process.gtdpin", "gtdpin 1 gtd in-memory\n")
|
|
283
|
+
repo.seedCommit("gtd(check): pin → idle\n\nGtd-Pin: add\nGtd-Format: 3")
|
|
284
|
+
const run = await provide(currentRun, repo)
|
|
285
|
+
expect(run.workflow).toBe("again")
|
|
286
|
+
expect(run.startHash).toBe(opening)
|
|
287
|
+
expect(run.startParentHash).toBe(before)
|
|
288
|
+
})
|
|
289
|
+
|
|
267
290
|
it("collects every process commit's Gtd-Cost, attributing a model-less one to UNATTRIBUTED_MODEL", async () => {
|
|
268
291
|
const repo = repoWith(LINEAR)
|
|
269
292
|
commit(
|
|
@@ -395,7 +418,7 @@ export const again = workflow(async () => {
|
|
|
395
418
|
it("is undefined at a pin commit, which is no turn", async () => {
|
|
396
419
|
const repo = repoWith(LINEAR)
|
|
397
420
|
await land(repo, { "a.txt": "a\n" })
|
|
398
|
-
expect(repo.lastCommitSubject()).toBe("gtd(check): pin → building")
|
|
421
|
+
expect(repo.lastCommitSubject()).toBe("gtd(check): gtd:pin → building")
|
|
399
422
|
expect((await provide(currentRun, repo)).headTurn).toBeUndefined()
|
|
400
423
|
})
|
|
401
424
|
|
|
@@ -403,7 +426,7 @@ export const again = workflow(async () => {
|
|
|
403
426
|
const repo = repoWith(LINEAR)
|
|
404
427
|
await land(repo, { "a.txt": "a\n" })
|
|
405
428
|
await land(repo)
|
|
406
|
-
expect(repo.lastCommitMessage()).toBe("gtd(agent): building\n\nGtd-Format:
|
|
429
|
+
expect(repo.lastCommitMessage()).toBe("gtd(agent): building\n\nGtd-Format: 4")
|
|
407
430
|
const run = await provide(currentRun, repo)
|
|
408
431
|
expect(run.headTurn).toEqual({ state: "building", actor: "agent", empty: true, step: false })
|
|
409
432
|
})
|
|
@@ -544,7 +567,7 @@ describe("summaryRun", () => {
|
|
|
544
567
|
expect(summary.trace).toEqual([
|
|
545
568
|
{ state: "building", hash: building, actor: "human" },
|
|
546
569
|
{ state: "checking", hash: checking, actor: "agent" },
|
|
547
|
-
{ state: "unpin", hash: headOf(repo, `${closing}~1`), actor: "agent" },
|
|
570
|
+
{ state: "gtd:unpin", hash: headOf(repo, `${closing}~1`), actor: "agent" },
|
|
548
571
|
{ state: "idle", hash: closing, actor: "check" },
|
|
549
572
|
])
|
|
550
573
|
})
|
|
@@ -831,7 +854,7 @@ export const review = workflow(async () => {
|
|
|
831
854
|
it("is the base the resting step names", async () => {
|
|
832
855
|
const repo = repoWith(LOOP)
|
|
833
856
|
const first = await land(repo, { "a.txt": "a\n" })
|
|
834
|
-
expect(repo.lastCommitSubject()).toBe("gtd(check): pin → checkpoint")
|
|
857
|
+
expect(repo.lastCommitSubject()).toBe("gtd(check): gtd:pin → checkpoint")
|
|
835
858
|
expect(reviewBaseFor(await provide(currentRest, repo))).toBe(first)
|
|
836
859
|
await land(repo, { "b.txt": "b\n" })
|
|
837
860
|
expect(reviewBaseFor(await provide(currentRest, repo))).toBe(first)
|
|
@@ -1306,7 +1329,7 @@ describe("finishing landings keep history", () => {
|
|
|
1306
1329
|
await land(repo, { "b.txt": "b\n" })
|
|
1307
1330
|
repo.writeFile("c.txt", "c\n")
|
|
1308
1331
|
const landing = await landingOf(repo)
|
|
1309
|
-
expect(landing.to).toBe("unpin")
|
|
1332
|
+
expect(landing.to).toBe("gtd:unpin")
|
|
1310
1333
|
expect(landing.keepHistory).toBeUndefined()
|
|
1311
1334
|
expect(landing.spec.version).toBeUndefined()
|
|
1312
1335
|
})
|
|
@@ -1522,13 +1545,16 @@ describe("historyTrail", () => {
|
|
|
1522
1545
|
tip,
|
|
1523
1546
|
])
|
|
1524
1547
|
expect(trail.steps[2]).toMatchObject({
|
|
1525
|
-
subject: "gtd(agent): checking → unpin",
|
|
1548
|
+
subject: "gtd(agent): checking → gtd:unpin",
|
|
1526
1549
|
actor: "agent",
|
|
1527
1550
|
from: "checking",
|
|
1528
|
-
to: "unpin",
|
|
1551
|
+
to: "gtd:unpin",
|
|
1529
1552
|
verdicts: [],
|
|
1530
1553
|
})
|
|
1531
|
-
expect(trail.steps[3]).toMatchObject({
|
|
1554
|
+
expect(trail.steps[3]).toMatchObject({
|
|
1555
|
+
subject: "gtd(check): gtd:unpin → idle",
|
|
1556
|
+
actor: "check",
|
|
1557
|
+
})
|
|
1532
1558
|
expect(trail.steps[0]).toMatchObject({ actor: "human", to: "building" })
|
|
1533
1559
|
})
|
|
1534
1560
|
|
package/src/edge/Edge.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect, Option } from "effect"
|
|
1
|
+
import { Effect, Option, Path } from "effect"
|
|
2
2
|
import {
|
|
3
3
|
Narrator,
|
|
4
4
|
GitService,
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
treeFromRecord,
|
|
43
43
|
type EpisodeCommit,
|
|
44
44
|
type JudgeVerdict,
|
|
45
|
+
LEGACY_PIN_STATES,
|
|
45
46
|
type PinChange,
|
|
46
47
|
PIN_STATES,
|
|
47
48
|
type ReachedStep,
|
|
@@ -62,7 +63,6 @@ import {
|
|
|
62
63
|
|
|
63
64
|
import { GTD_VERSION } from "../install/index.js"
|
|
64
65
|
import { keptRef, processHistoryRef } from "../platform/index.js"
|
|
65
|
-
import { isAbsolute, relative, resolve, sep } from "node:path"
|
|
66
66
|
import { accessRefusal, type Landing, type RepoSnapshot } from "../step/index.js"
|
|
67
67
|
|
|
68
68
|
export { UNATTRIBUTED_MODEL }
|
|
@@ -337,8 +337,14 @@ const subjectIsBoundary = (def: WorkflowDefinition, subject: string): boolean =>
|
|
|
337
337
|
const parsed = parseCommitMessage(subject).parsed
|
|
338
338
|
if (parsed === undefined || !ACTORS.has(parsed.actor)) return true
|
|
339
339
|
if (parsed.actor === "human" && parsed.from === undefined) return false
|
|
340
|
-
// A pin commit's subject can name the first step; it bounds nothing.
|
|
341
|
-
|
|
340
|
+
// A pin commit's subject can name the first step; it bounds nothing. The
|
|
341
|
+
// subject alone carries no format, so either spelling counts.
|
|
342
|
+
if (
|
|
343
|
+
parsed.actor === "check" &&
|
|
344
|
+
(parsed.from === PIN_STATES.add || parsed.from === LEGACY_PIN_STATES.add)
|
|
345
|
+
) {
|
|
346
|
+
return false
|
|
347
|
+
}
|
|
342
348
|
return parsed.to === def.initial
|
|
343
349
|
}
|
|
344
350
|
|
|
@@ -421,7 +427,14 @@ const computeProcessRun = (
|
|
|
421
427
|
runOf(history, locateEpisode(def, history)),
|
|
422
428
|
)
|
|
423
429
|
|
|
424
|
-
type ConfigRequirements =
|
|
430
|
+
type ConfigRequirements =
|
|
431
|
+
| GitService
|
|
432
|
+
| ConfigService
|
|
433
|
+
| ConfigDiscovery
|
|
434
|
+
| Narrator
|
|
435
|
+
| Workspace
|
|
436
|
+
| Host
|
|
437
|
+
| Path.Path
|
|
425
438
|
|
|
426
439
|
/** The run alone, never replaying — `gtd abandon` must work even when replay would refuse, short of a history it cannot read. */
|
|
427
440
|
export const currentRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> = Effect.gen(
|
|
@@ -1564,10 +1577,16 @@ export interface HistoryTrail {
|
|
|
1564
1577
|
* repository cannot be read at a ref, so it is left out. A file only the old
|
|
1565
1578
|
* config read needs no check: whichever file imported it differs too.
|
|
1566
1579
|
*/
|
|
1567
|
-
const inRepoConfigPaths = (
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1580
|
+
const inRepoConfigPaths = (
|
|
1581
|
+
path: Path.Path,
|
|
1582
|
+
root: string,
|
|
1583
|
+
files: readonly string[],
|
|
1584
|
+
): readonly string[] => {
|
|
1585
|
+
const found = new Set<string>()
|
|
1586
|
+
for (const file of files) {
|
|
1587
|
+
found.add(path.relative(root, path.resolve(root, file)).split(path.sep).join("/"))
|
|
1588
|
+
}
|
|
1589
|
+
return [...found].filter((p) => p !== "" && !p.startsWith("..") && !path.isAbsolute(p)).sort()
|
|
1571
1590
|
}
|
|
1572
1591
|
|
|
1573
1592
|
const configRefusal = (
|
|
@@ -1589,7 +1608,8 @@ const differingConfigPaths = (
|
|
|
1589
1608
|
) =>
|
|
1590
1609
|
Effect.gen(function* () {
|
|
1591
1610
|
const differing: string[] = []
|
|
1592
|
-
|
|
1611
|
+
const configPaths = inRepoConfigPaths(yield* Path.Path, root, Object.keys(config.fingerprint))
|
|
1612
|
+
for (const path of configPaths) {
|
|
1593
1613
|
const atTip = yield* Effect.orElseSucceed(git.readFileAtRef(hash, path), () => undefined)
|
|
1594
1614
|
if (atTip !== workspace.readSync(path)) differing.push(path)
|
|
1595
1615
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import { Effect } from "effect"
|
|
2
|
+
import { Effect, Layer, Path } from "effect"
|
|
3
3
|
import { resolveMode, validateScriptFor, type ModeResolution } from "./SteeringMode.js"
|
|
4
4
|
import { Host } from "../platform/index.js"
|
|
5
5
|
import { steeringFormatFor } from "../steering/index.js"
|
|
@@ -16,7 +16,9 @@ const envVarsLayer = Host.layer({ root: "/repo", home: "/repo", env: { PATH: "/u
|
|
|
16
16
|
|
|
17
17
|
const runScript = (resolved: ModeResolution, file: string) => {
|
|
18
18
|
if (resolved.kind !== "resolved") throw new Error("expected a resolved mode")
|
|
19
|
-
return Effect.runPromise(
|
|
19
|
+
return Effect.runPromise(
|
|
20
|
+
validateScriptFor(resolved, file).pipe(Effect.provide(Layer.merge(envVarsLayer, Path.layer))),
|
|
21
|
+
)
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
describe("resolveMode", () => {
|
package/src/emit/SteeringMode.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { Host } from "../platform/index.js"
|
|
1
|
+
import { Effect, Path } from "effect"
|
|
2
|
+
import { Host, type HostOps } from "../platform/index.js"
|
|
4
3
|
import {
|
|
5
4
|
isSeededValidateCommand,
|
|
6
5
|
knownModes,
|
|
@@ -191,8 +190,11 @@ const modeCommandSteps = (resolved: ResolvedMode, file: string): readonly EmitSt
|
|
|
191
190
|
}
|
|
192
191
|
|
|
193
192
|
/** `<Host.scratchDir>/gtd-mode-sample-<mode>-<pid>.md` — an absolute literal baked in at emit time, never a shell variable. `<pid>` avoids collisions between concurrent `gtd` processes. Never a `/tmp` literal or `mktemp` (`tests/tooling/no-tmp-assumption.test.ts` scans for both). */
|
|
194
|
-
const scratchSamplePath = (
|
|
195
|
-
|
|
193
|
+
const scratchSamplePath = (
|
|
194
|
+
path: Path.Path,
|
|
195
|
+
{ scratchDir, pid }: HostOps,
|
|
196
|
+
mode: StateMode,
|
|
197
|
+
): string => path.join(scratchDir, `gtd-mode-sample-${mode}-${pid}.md`)
|
|
196
198
|
|
|
197
199
|
/**
|
|
198
200
|
* The contradiction round-trip/skip-notice steps for `resolved`'s `mode:`.
|
|
@@ -204,13 +206,12 @@ const scratchSamplePath = (scratchDir: string, mode: StateMode): string =>
|
|
|
204
206
|
*/
|
|
205
207
|
const modeContradictionSteps = (
|
|
206
208
|
resolved: ResolvedMode,
|
|
207
|
-
): Effect.Effect<readonly EmitStep[], Error, Host> =>
|
|
209
|
+
): Effect.Effect<readonly EmitStep[], Error, Host | Path.Path> =>
|
|
208
210
|
Effect.gen(function* () {
|
|
209
211
|
if (resolved.formatCommand === undefined) return []
|
|
210
212
|
const { capabilities } = resolved
|
|
211
213
|
if (capabilities.format !== undefined && capabilities.liveValidate !== undefined) {
|
|
212
|
-
const
|
|
213
|
-
const samplePath = scratchSamplePath(scratchDir, resolved.mode)
|
|
214
|
+
const samplePath = scratchSamplePath(yield* Path.Path, yield* Host, resolved.mode)
|
|
214
215
|
const formatCommand = withFileVar(resolved.formatCommand, samplePath)
|
|
215
216
|
return [
|
|
216
217
|
{
|
|
@@ -245,7 +246,7 @@ export interface ValidateScript {
|
|
|
245
246
|
export const validateScriptFor = (
|
|
246
247
|
resolved: ResolvedMode,
|
|
247
248
|
file: string,
|
|
248
|
-
): Effect.Effect<ValidateScript, Error, Host> =>
|
|
249
|
+
): Effect.Effect<ValidateScript, Error, Host | Path.Path> =>
|
|
249
250
|
Effect.gen(function* () {
|
|
250
251
|
const steps: EmitStep[] = [
|
|
251
252
|
...(yield* modeContradictionSteps(resolved)),
|
|
@@ -45,7 +45,7 @@ describe("restDescription", () => {
|
|
|
45
45
|
" last commits:",
|
|
46
46
|
" chore: initial commit",
|
|
47
47
|
" gtd(agent): plan → sign-off",
|
|
48
|
-
" gtd(check): pin → sign-off",
|
|
48
|
+
" gtd(check): gtd:pin → sign-off",
|
|
49
49
|
' actors never used: "repair" (scenario), "ready" (Background)',
|
|
50
50
|
].join("\n"),
|
|
51
51
|
)
|
|
@@ -43,7 +43,7 @@ Feature: testkit drives any workflow against the real engine
|
|
|
43
43
|
And the commit subjects end with:
|
|
44
44
|
"""
|
|
45
45
|
gtd(human): plan
|
|
46
|
-
gtd(check): pin → plan
|
|
46
|
+
gtd(check): gtd:pin → plan
|
|
47
47
|
gtd(agent): plan → check
|
|
48
48
|
gtd(check): check → ready
|
|
49
49
|
gtd(judge): ready → ship
|
|
@@ -107,7 +107,7 @@ Feature: testkit drives any workflow against the real engine
|
|
|
107
107
|
When gtd starts workflow "tiny"
|
|
108
108
|
And the process is driven
|
|
109
109
|
Then the process rests at "plan"
|
|
110
|
-
And the last commit subject is "gtd(check): pin → plan"
|
|
110
|
+
And the last commit subject is "gtd(check): gtd:pin → plan"
|
|
111
111
|
|
|
112
112
|
Scenario: a prompt asserted beside a script is checked at every visit, before the step acts
|
|
113
113
|
Given step "check" is prompted with "npm test"
|
package/src/install/Version.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createRequire } from "node:module"
|
|
3
|
-
import { dirname, join } from "node:path"
|
|
4
|
-
import { fileURLToPath } from "node:url"
|
|
5
|
-
|
|
6
|
-
const _require = createRequire(import.meta.url)
|
|
1
|
+
import { NodeRuntime, nodeSyncIo } from "../platform/index.js"
|
|
7
2
|
|
|
8
3
|
// A source-relative literal (`../../package.json`) breaks the built single-file
|
|
9
4
|
// bundle: bundling doesn't rewrite `import.meta.url` per original module, so a
|
|
@@ -11,13 +6,19 @@ const _require = createRequire(import.meta.url)
|
|
|
11
6
|
// dist/ vs src/ — they differ (dist/gtd.bundle.mjs is always one level below
|
|
12
7
|
// repo root; src/install/Version.ts is two). Walking up from wherever this module
|
|
13
8
|
// actually runs from resolves correctly in both the unbundled (vitest/tsc) and
|
|
14
|
-
// bundled (dist/gtd.bundle.mjs) case.
|
|
15
|
-
|
|
16
|
-
|
|
9
|
+
// bundled (dist/gtd.bundle.mjs) case. URLs, not Path: this runs at import,
|
|
10
|
+
// before any Effect service exists.
|
|
11
|
+
const readPackageJson = (fromUrl: string): string => {
|
|
12
|
+
let dir = new URL(".", fromUrl)
|
|
17
13
|
for (let i = 0; i < 5; i++) {
|
|
18
|
-
const candidate =
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
const candidate = new URL("package.json", dir)
|
|
15
|
+
try {
|
|
16
|
+
const source = nodeSyncIo.readText(NodeRuntime.fileURLToPath(candidate))
|
|
17
|
+
if (source !== undefined) return source
|
|
18
|
+
} catch {
|
|
19
|
+
// Unreadable counts as absent, as it did for `existsSync`.
|
|
20
|
+
}
|
|
21
|
+
dir = new URL("..", dir)
|
|
21
22
|
}
|
|
22
23
|
throw new Error(`package.json not found by walking up from ${fromUrl}`)
|
|
23
24
|
}
|
|
@@ -30,4 +31,4 @@ export const PIN_KEY = Symbol.for("@pmelab/gtd/pin")
|
|
|
30
31
|
const pinned = (globalThis as { [PIN_KEY]?: { readonly version: string } })[PIN_KEY]?.version
|
|
31
32
|
|
|
32
33
|
export const GTD_VERSION: string =
|
|
33
|
-
pinned ?? (
|
|
34
|
+
pinned ?? (JSON.parse(readPackageJson(import.meta.url)) as { version: string }).version
|
package/src/platform/Host.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { realpathSync } from "node:fs"
|
|
2
|
-
import { homedir, tmpdir } from "node:os"
|
|
3
1
|
import { Context, Effect, Layer } from "effect"
|
|
2
|
+
import { homedir, tmpdir } from "./NodeRuntime.js"
|
|
3
|
+
import { nodeSyncIo } from "./NodeSync.js"
|
|
4
4
|
|
|
5
5
|
const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e)))
|
|
6
6
|
|
|
@@ -8,35 +8,88 @@ export interface HostOps {
|
|
|
8
8
|
readonly root: string
|
|
9
9
|
readonly home: string
|
|
10
10
|
readonly env: Readonly<Record<string, string | undefined>>
|
|
11
|
-
/** `env["TMPDIR"]` when set,
|
|
11
|
+
/** `env["TMPDIR"]` when set, the host's temp dir otherwise — never a `/tmp` literal or `mktemp` (`tests/tooling/no-tmp-assumption.test.ts` scans for both). */
|
|
12
12
|
readonly scratchDir: string
|
|
13
13
|
/** The real, symlink-resolved form of `path` — used to compare a possibly-symlinked cwd (macOS `/tmp` → `/private/tmp`) against git's own resolution. */
|
|
14
14
|
readonly realPath: (path: string) => Effect.Effect<string, Error>
|
|
15
|
+
/** Carried by every scratch or temp file name, so concurrent gtd runs never share one. */
|
|
16
|
+
readonly pid: number
|
|
17
|
+
/** `undefined` where the platform has no POSIX user ids. */
|
|
18
|
+
readonly uid: number | undefined
|
|
19
|
+
/** The command that runs this gtd again: its script under the running runtime, or `gtd` off `PATH` when there is none. */
|
|
20
|
+
readonly self: readonly [string, ...string[]]
|
|
21
|
+
/** Standard input read to its end, as UTF-8. */
|
|
22
|
+
readonly stdin: Effect.Effect<string, Error>
|
|
15
23
|
}
|
|
16
24
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
readonly
|
|
20
|
-
readonly
|
|
21
|
-
readonly
|
|
22
|
-
|
|
25
|
+
/** The output half of the process's stdio, which the CLI writes before any layer is built. */
|
|
26
|
+
export interface Stdio {
|
|
27
|
+
readonly stdout: (chunk: string | Uint8Array) => void
|
|
28
|
+
readonly stderr: (chunk: string) => void
|
|
29
|
+
readonly exit: (code: number) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The slice of Node's `process` the live host reads; a contract test hands in a fake one. */
|
|
33
|
+
export type NodeProcess = Pick<
|
|
34
|
+
NodeJS.Process,
|
|
35
|
+
"argv" | "execPath" | "pid" | "getuid" | "stdin" | "stdout" | "stderr" | "exitCode"
|
|
36
|
+
>
|
|
37
|
+
|
|
38
|
+
type HostOpsInput = Pick<HostOps, "root" | "home" | "env"> & Partial<HostOps>
|
|
39
|
+
|
|
40
|
+
/** `TMPDIR` when set and non-empty, else the host's own `fallback`. */
|
|
41
|
+
export const scratchDirOf = (env: HostOps["env"], fallback: string): string => {
|
|
42
|
+
const dir = env["TMPDIR"]
|
|
43
|
+
return dir !== undefined && dir.length > 0 ? dir : fallback
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const readAll = (stream: AsyncIterable<Buffer | string>): Effect.Effect<string, Error> =>
|
|
47
|
+
Effect.tryPromise({
|
|
48
|
+
try: async () => {
|
|
49
|
+
const chunks: Buffer[] = []
|
|
50
|
+
for await (const chunk of stream) chunks.push(Buffer.from(chunk))
|
|
51
|
+
return Buffer.concat(chunks).toString("utf8")
|
|
52
|
+
},
|
|
53
|
+
catch: toError,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const makeHostOps = (opts: HostOpsInput, proc: NodeProcess): HostOps => {
|
|
57
|
+
const script = proc.argv[1]
|
|
58
|
+
return {
|
|
59
|
+
scratchDir: scratchDirOf(opts.env, tmpdir()),
|
|
60
|
+
realPath: (path: string) =>
|
|
61
|
+
Effect.try({ try: () => nodeSyncIo.realPath(path), catch: toError }),
|
|
62
|
+
pid: proc.pid,
|
|
63
|
+
uid: proc.getuid?.(),
|
|
64
|
+
self: script === undefined ? ["gtd"] : [proc.execPath, script],
|
|
65
|
+
// Lazy: touching `process.stdin` opens it, which the LSP and the pin hand-over must not see.
|
|
66
|
+
stdin: Effect.suspend(() => readAll(proc.stdin)),
|
|
67
|
+
...opts,
|
|
68
|
+
}
|
|
23
69
|
}
|
|
24
70
|
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
71
|
+
export const nodeStdio = (proc: NodeProcess = process): Stdio => ({
|
|
72
|
+
// `stdout.write` is asynchronous whenever stdout is a pipe or socket —
|
|
73
|
+
// passing a completion callback (and never force-closing, see `exit`
|
|
74
|
+
// below) keeps a queued remainder from being discarded, so a large
|
|
75
|
+
// artifact still reaches a slow reader even after a non-zero exit.
|
|
76
|
+
stdout: (chunk) => {
|
|
77
|
+
proc.stdout.write(chunk, () => {})
|
|
78
|
+
},
|
|
79
|
+
stderr: (chunk) => {
|
|
80
|
+
proc.stderr.write(chunk)
|
|
81
|
+
},
|
|
82
|
+
// Never `process.exit(code)`: that tears the process down out from under
|
|
83
|
+
// an undrained `stdout.write` above and truncates the artifact mid-pipe.
|
|
84
|
+
// Setting `exitCode` lets the event loop drain any queued write first.
|
|
85
|
+
exit: (code) => {
|
|
86
|
+
proc.exitCode = code
|
|
87
|
+
},
|
|
37
88
|
})
|
|
38
89
|
|
|
90
|
+
/** Every member not given in `opts` comes from Node: `proc`, its OS and its file system. */
|
|
39
91
|
export class Host extends Context.Service<Host, HostOps>()("Host") {
|
|
40
|
-
static layer = (opts: HostOpsInput): Layer.Layer<Host> =>
|
|
92
|
+
static layer = (opts: HostOpsInput, proc: NodeProcess = process): Layer.Layer<Host> =>
|
|
93
|
+
Layer.succeed(Host, makeHostOps(opts, proc))
|
|
41
94
|
static Live = Host.layer({ root: process.cwd(), home: homedir(), env: process.env })
|
|
42
95
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { registerHooks } from "node:module"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { pathToFileURL } from "node:url"
|
|
4
|
+
import { nodeSyncIo } from "./NodeSync.js"
|
|
5
|
+
import type { PinFile } from "./PinFile.js"
|
|
6
|
+
|
|
7
|
+
// `cwd`, as the engine's own root is: gtd runs only at the worktree root, and
|
|
8
|
+
// a relocated `$GIT_DIR` leaves no `.git` to walk up to. A file that cannot be
|
|
9
|
+
// read counts as no pin, so the launcher refuses it rather than crashing.
|
|
10
|
+
export const livePinFile =
|
|
11
|
+
(cwd: string, path: string): PinFile =>
|
|
12
|
+
() => {
|
|
13
|
+
const file = join(cwd, path)
|
|
14
|
+
try {
|
|
15
|
+
const bytes = nodeSyncIo.readFile(file)
|
|
16
|
+
if (bytes !== undefined) return { path: file, bytes }
|
|
17
|
+
} catch {
|
|
18
|
+
return { path: file, bytes: new Uint8Array() }
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
return { path: file, bytes: nodeSyncIo.gitBytes(cwd, ["show", `HEAD:${path}`]) }
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Runs a pin's module as this process's main program. Node refuses an unknown
|
|
29
|
+
* extension, so a load hook serves the unpacked source under the pin's own
|
|
30
|
+
* URL — `import.meta.url` stays a file in the repository.
|
|
31
|
+
*/
|
|
32
|
+
export const runPinModule = async (path: string, source: string): Promise<void> => {
|
|
33
|
+
const url = pathToFileURL(path).href
|
|
34
|
+
// A pin read from HEAD has no file behind its URL, which Node's own resolve refuses.
|
|
35
|
+
registerHooks({
|
|
36
|
+
resolve: (specifier, context, nextResolve) =>
|
|
37
|
+
specifier === url ? { url, shortCircuit: true } : nextResolve(specifier, context),
|
|
38
|
+
load: (requested, context, nextLoad) =>
|
|
39
|
+
requested === url
|
|
40
|
+
? { format: "module", source, shortCircuit: true }
|
|
41
|
+
: nextLoad(requested, context),
|
|
42
|
+
})
|
|
43
|
+
await import(url)
|
|
44
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createRequire, isBuiltin } from "node:module"
|
|
2
|
+
import { homedir, tmpdir } from "node:os"
|
|
3
|
+
import { fileURLToPath } from "node:url"
|
|
4
|
+
import { stripVTControlCharacters } from "node:util"
|
|
5
|
+
import { runInThisContext } from "node:vm"
|
|
6
|
+
import { gunzipSync, gzipSync } from "node:zlib"
|
|
7
|
+
|
|
8
|
+
// What the core needs of Node beyond the sync port: module loading, the
|
|
9
|
+
// user's directories, file URLs and gzip. Each is a Node fact with no in-memory twin.
|
|
10
|
+
|
|
11
|
+
export { createRequire, fileURLToPath, homedir, isBuiltin, stripVTControlCharacters, tmpdir }
|
|
12
|
+
|
|
13
|
+
export const gzip = (source: string): Uint8Array => gzipSync(source, { level: 9 })
|
|
14
|
+
|
|
15
|
+
export const gunzip = (bytes: Uint8Array): string => gunzipSync(bytes).toString("utf8")
|
|
16
|
+
|
|
17
|
+
/** Compiles `source` as a script in this realm, reporting errors against `filename`. */
|
|
18
|
+
export const compileScript = (source: string, filename: string): unknown =>
|
|
19
|
+
runInThisContext(source, { filename })
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process"
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
3
|
+
import {
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readlinkSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
statSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
type Stats,
|
|
13
|
+
} from "node:fs"
|
|
14
|
+
import type { FileInfo, SyncIo } from "./SyncIo.js"
|
|
15
|
+
|
|
16
|
+
// A tree listing, or one committed file, can overflow execFileSync's 1 MB default.
|
|
17
|
+
const MAX_BUFFER = 256 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
const isAbsent = (e: unknown): boolean => {
|
|
20
|
+
const code = (e as NodeJS.ErrnoException).code
|
|
21
|
+
return code === "ENOENT" || code === "ENOTDIR"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const orAbsent = <A>(read: () => A): A | undefined => {
|
|
25
|
+
try {
|
|
26
|
+
return read()
|
|
27
|
+
} catch (e) {
|
|
28
|
+
if (isAbsent(e)) return undefined
|
|
29
|
+
throw e
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const infoOf = (stats: Stats): FileInfo => ({
|
|
34
|
+
type: stats.isSymbolicLink()
|
|
35
|
+
? "symlink"
|
|
36
|
+
: stats.isDirectory()
|
|
37
|
+
? "directory"
|
|
38
|
+
: stats.isFile()
|
|
39
|
+
? "file"
|
|
40
|
+
: "other",
|
|
41
|
+
uid: process.platform === "win32" ? undefined : stats.uid,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
export const nodeSyncIo: SyncIo = {
|
|
45
|
+
readFile: (path) => orAbsent(() => readFileSync(path)),
|
|
46
|
+
readText: (path) => orAbsent(() => readFileSync(path, "utf8")),
|
|
47
|
+
writeText: (path, content) => writeFileSync(path, content, "utf8"),
|
|
48
|
+
makeDirectory: (path) => {
|
|
49
|
+
mkdirSync(path, { recursive: true })
|
|
50
|
+
},
|
|
51
|
+
rename: renameSync,
|
|
52
|
+
stat: (path) => orAbsent(() => infoOf(statSync(path))),
|
|
53
|
+
lstat: (path) => orAbsent(() => infoOf(lstatSync(path))),
|
|
54
|
+
readLink: (path) => readlinkSync(path, "utf8"),
|
|
55
|
+
realPath: (path) => realpathSync(path),
|
|
56
|
+
git: (cwd, args) => execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: MAX_BUFFER }),
|
|
57
|
+
gitBytes: (cwd, args) =>
|
|
58
|
+
execFileSync("git", args, { cwd, maxBuffer: MAX_BUFFER, stdio: ["ignore", "pipe", "ignore"] }),
|
|
59
|
+
hash: (algorithm, ...parts) => {
|
|
60
|
+
const hash = createHash(algorithm)
|
|
61
|
+
for (const part of parts) hash.update(part)
|
|
62
|
+
return hash.digest("hex")
|
|
63
|
+
},
|
|
64
|
+
}
|