@pmelab/gtd 59.0.0 → 59.0.1
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 +5 -5
- package/dist/chunks/{testkit-CMBAAyRK.mjs → testkit-07Z_ENch.mjs} +431 -446
- package/dist/chunks/{wire-C12dahrH.mjs → wire-BTUhRGXj.mjs} +1 -1
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +410 -440
- package/dist/testkit/gherkin.mjs +145 -65
- package/dist/testkit.mjs +1 -1
- package/dist/wire.mjs +1 -1
- package/package.json +1 -1
- package/skills/authoring/SKILL.md +3 -2
- package/src/cli/Cli.test.ts +133 -52
- package/src/cli/Cli.ts +384 -461
- package/src/cli/Help.test.ts +47 -0
- package/src/cli/Help.ts +70 -0
- package/src/cli/Program.test.ts +67 -4
- package/src/cli/Program.ts +65 -18
- package/src/edge/Edge.ts +1 -1
- package/src/emit/Emit.test.ts +1 -1
- package/src/emit/Emit.ts +1 -1
- package/src/flows/factory.ts +3 -1
- package/src/gherkin/index.ts +135 -71
- package/src/gherkin/messages.test.ts +90 -0
- package/src/gherkin/messages.ts +84 -0
- package/src/testkit/TestRepo.test.ts +47 -2
- package/src/testkit/TestRepo.ts +51 -6
- package/src/twins/EmittedScriptRecognizer.ts +5 -8
- package/src/wire/Land.ts +1 -1
- package/src/workflow/load.ts +6 -6
- package/src/workflow/skills.test.ts +5 -5
- package/src/workflow/skills.ts +1 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { closest, definition, wrap } from "./Help.js"
|
|
3
|
+
|
|
4
|
+
describe("wrap", () => {
|
|
5
|
+
it("fills lines up to the width, never past it", () => {
|
|
6
|
+
expect(wrap("aaa bbb ccc ddd", 7)).toEqual(["aaa bbb", "ccc ddd"])
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it("keeps a code span on one line", () => {
|
|
10
|
+
expect(wrap("run `gtd help land` now", 12)).toEqual(["run", "`gtd help land`", "now"])
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("keeps an indented example paragraph verbatim", () => {
|
|
14
|
+
expect(wrap('text\n\n s=$(x) && sh -c "$s"', 4)).toEqual([
|
|
15
|
+
"text",
|
|
16
|
+
"",
|
|
17
|
+
' s=$(x) && sh -c "$s"',
|
|
18
|
+
])
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe("definition", () => {
|
|
23
|
+
it("puts a short term beside its description", () => {
|
|
24
|
+
expect(definition("next", "Print it", 10)).toEqual([" next Print it"])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("gives a term too wide for the column a line of its own", () => {
|
|
28
|
+
expect(definition("a-very-long-term", "Text", 10)).toEqual([
|
|
29
|
+
" a-very-long-term",
|
|
30
|
+
" Text",
|
|
31
|
+
])
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe("closest", () => {
|
|
36
|
+
const commands = ["next", "land", "history", "help"]
|
|
37
|
+
|
|
38
|
+
it("finds a dropped letter and a swapped pair", () => {
|
|
39
|
+
expect(closest("nxt", commands)).toBe("next")
|
|
40
|
+
expect(closest("lnad", commands)).toBe("land")
|
|
41
|
+
expect(closest("histroy", commands)).toBe("history")
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("suggests nothing for a word unlike every candidate", () => {
|
|
45
|
+
expect(closest("frobnicate", commands)).toBeUndefined()
|
|
46
|
+
})
|
|
47
|
+
})
|
package/src/cli/Help.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const HELP_WIDTH = 80
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Word-wraps prose to `width` columns, every line prefixed by `indent`.
|
|
5
|
+
* Paragraphs are separated by a blank line; a paragraph whose lines start
|
|
6
|
+
* with two spaces is an example and is kept verbatim (indented, not wrapped).
|
|
7
|
+
*/
|
|
8
|
+
const wrapParagraph = (paragraph: string, width: number, indent: string): string[] => {
|
|
9
|
+
const lines: string[] = []
|
|
10
|
+
let line = ""
|
|
11
|
+
// A `code span` is one word: a command split across lines can't be copied.
|
|
12
|
+
for (const word of paragraph.match(/\S*`[^`]*`\S*|\S+/g) ?? []) {
|
|
13
|
+
if (line !== "" && indent.length + line.length + 1 + word.length > width) {
|
|
14
|
+
lines.push(indent + line)
|
|
15
|
+
line = word
|
|
16
|
+
} else {
|
|
17
|
+
line = line === "" ? word : `${line} ${word}`
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (line !== "") lines.push(indent + line)
|
|
21
|
+
return lines
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const wrap = (text: string, width = HELP_WIDTH, indent = ""): string[] =>
|
|
25
|
+
text
|
|
26
|
+
.split("\n\n")
|
|
27
|
+
.flatMap((paragraph, i) => [
|
|
28
|
+
...(i > 0 ? [""] : []),
|
|
29
|
+
...(paragraph.startsWith(" ")
|
|
30
|
+
? paragraph.split("\n").map((l) => indent + l)
|
|
31
|
+
: wrapParagraph(paragraph, width, indent)),
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
/** `term` and `description` side by side, the description wrapped into the column after `column`; a term too wide for it gets a line of its own. */
|
|
35
|
+
export const definition = (term: string, description: string, column: number): string[] => {
|
|
36
|
+
const cell = ` ${term}`
|
|
37
|
+
const body = wrap(description, HELP_WIDTH, " ".repeat(column))
|
|
38
|
+
if (cell.length + 1 >= column) return [cell, ...body]
|
|
39
|
+
const [first = "", ...rest] = body
|
|
40
|
+
return [cell.padEnd(column) + first.trimStart(), ...rest]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Optimal-string-alignment distance: Levenshtein plus adjacent transpositions, so `nxet` is one edit from `next`. */
|
|
44
|
+
const distance = (a: string, b: string): number => {
|
|
45
|
+
const d: number[][] = Array.from({ length: a.length + 1 }, (_, i) =>
|
|
46
|
+
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
|
|
47
|
+
)
|
|
48
|
+
for (let i = 1; i <= a.length; i++) {
|
|
49
|
+
for (let j = 1; j <= b.length; j++) {
|
|
50
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
51
|
+
let best = Math.min(d[i - 1]![j]! + 1, d[i]![j - 1]! + 1, d[i - 1]![j - 1]! + cost)
|
|
52
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
53
|
+
best = Math.min(best, d[i - 2]![j - 2]! + 1)
|
|
54
|
+
}
|
|
55
|
+
d[i]![j] = best
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return d[a.length]![b.length]!
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The candidate closest to `word`, when it is a plausible typo of it: at most one edit per three characters, and at least one. */
|
|
62
|
+
export const closest = (word: string, candidates: readonly string[]): string | undefined => {
|
|
63
|
+
const limit = Math.max(1, Math.floor(word.length / 3))
|
|
64
|
+
let best: { readonly name: string; readonly d: number } | undefined
|
|
65
|
+
for (const name of candidates) {
|
|
66
|
+
const d = distance(word, name)
|
|
67
|
+
if (d <= limit && (best === undefined || d < best.d)) best = { name, d }
|
|
68
|
+
}
|
|
69
|
+
return best?.name
|
|
70
|
+
}
|
package/src/cli/Program.test.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* e2e tier observes. Argv parsing is pinned in `src/cli/Cli.test.ts`.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { Cause, Effect, Exit } from "effect"
|
|
7
|
+
import { Cause, Context, Effect, Exit } from "effect"
|
|
8
8
|
import { PassThrough } from "node:stream"
|
|
9
9
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
10
10
|
|
|
@@ -17,7 +17,7 @@ vi.mock("node:os", async (importOriginal) => ({
|
|
|
17
17
|
|
|
18
18
|
import { runCli, type Command } from "./index.js"
|
|
19
19
|
import { EXIT_USAGE_ERROR, stallDiagnosis, noopText, WIRE_SCHEMA } from "../wire/index.js"
|
|
20
|
-
import { needsOf, runCommand, SelectorUsageError } from "./Program.js"
|
|
20
|
+
import { needsOf, runCommand, SelectorUsageError, type CommandRequirements } from "./Program.js"
|
|
21
21
|
import { InMemRepo, makeCapturingCliIo, testLayers, applyEmittedScript } from "../twins/index.js"
|
|
22
22
|
import {
|
|
23
23
|
commitAll,
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
noteOutcome,
|
|
27
27
|
restoredOutcome,
|
|
28
28
|
} from "../emit/index.js"
|
|
29
|
-
import { HISTORY_REF } from "../platform/index.js"
|
|
29
|
+
import { GitService, HISTORY_REF, Host } from "../platform/index.js"
|
|
30
30
|
import { contentHash } from "../workflow/index.js"
|
|
31
31
|
import { WORKFLOWS_REPO } from "../install/index.js"
|
|
32
32
|
|
|
@@ -119,6 +119,19 @@ export const defaults = { greeting: "hello" }
|
|
|
119
119
|
expect(stderr).toContain("startable: hotfix")
|
|
120
120
|
})
|
|
121
121
|
|
|
122
|
+
it("lists the paths its script commits as comments a path cannot break out of", async () => {
|
|
123
|
+
const repo = seed(WORKFLOW)
|
|
124
|
+
repo.writeFile("NOTE.md", "a note\n")
|
|
125
|
+
repo.writeFile("evil\nrm -rf x", "")
|
|
126
|
+
const { stdout, exitCode } = await run(repo, "--workflow", "hotfix")
|
|
127
|
+
expect(exitCode).toBe(0)
|
|
128
|
+
expect(stdout).toContain("# commits every change in the working tree (git add -A):\n")
|
|
129
|
+
expect(stdout).toContain("# A NOTE.md\n")
|
|
130
|
+
expect(stdout).toContain("evil?rm -rf x")
|
|
131
|
+
expect(stdout).not.toMatch(/^rm -rf x/m)
|
|
132
|
+
expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
|
|
133
|
+
})
|
|
134
|
+
|
|
122
135
|
it("`default` is not startable by name", async () => {
|
|
123
136
|
const repo = seed(WORKFLOW)
|
|
124
137
|
const { exitCode, stderr } = await run(repo, "--workflow", "default")
|
|
@@ -1733,7 +1746,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
|
|
|
1733
1746
|
)
|
|
1734
1747
|
|
|
1735
1748
|
const NO_COMMITS_MESSAGE =
|
|
1736
|
-
"gtd
|
|
1749
|
+
"gtd: the repository has no commits yet — make an initial commit, then run gtd again"
|
|
1737
1750
|
|
|
1738
1751
|
it("derives exactly the eighteen non-standalone kinds — a canary for the table-driven cases below", () => {
|
|
1739
1752
|
expect(stateKinds.sort()).toEqual(
|
|
@@ -2294,3 +2307,53 @@ export const defaults = ${vars}
|
|
|
2294
2307
|
expect(repo.lastCommitMessage()).not.toContain("Gtd-Payload:")
|
|
2295
2308
|
})
|
|
2296
2309
|
})
|
|
2310
|
+
|
|
2311
|
+
describe("runCommand — the repository-root guard's messages", () => {
|
|
2312
|
+
const guarded = async (
|
|
2313
|
+
override: (
|
|
2314
|
+
git: Context.Tag.Service<GitService>,
|
|
2315
|
+
host: Context.Tag.Service<Host>,
|
|
2316
|
+
) => Effect.Effect<unknown, Error, CommandRequirements>,
|
|
2317
|
+
): Promise<string> => {
|
|
2318
|
+
const exit = await Effect.runPromiseExit(
|
|
2319
|
+
Effect.gen(function* () {
|
|
2320
|
+
return yield* override(yield* GitService, yield* Host)
|
|
2321
|
+
}).pipe(Effect.provide(testLayers(seedIdle()))),
|
|
2322
|
+
)
|
|
2323
|
+
return Exit.isFailure(exit) ? (Cause.squash(exit.cause) as Error).message : ""
|
|
2324
|
+
}
|
|
2325
|
+
const out = { write: () => {}, flush: () => {} }
|
|
2326
|
+
const next = runCommand({ kind: "next" }, { kind: "off" }, out)
|
|
2327
|
+
|
|
2328
|
+
it("outside a git repository says so instead of quoting git", async () => {
|
|
2329
|
+
const message = await guarded((git) =>
|
|
2330
|
+
next.pipe(
|
|
2331
|
+
Effect.provideService(GitService, {
|
|
2332
|
+
...git,
|
|
2333
|
+
topLevel: () =>
|
|
2334
|
+
Effect.fail(
|
|
2335
|
+
new Error(
|
|
2336
|
+
"git rev-parse --show-toplevel failed (exit 128): fatal: not a git repository (or any of the parent directories): .git",
|
|
2337
|
+
),
|
|
2338
|
+
),
|
|
2339
|
+
}),
|
|
2340
|
+
),
|
|
2341
|
+
)
|
|
2342
|
+
expect(message).toBe("gtd: not a git repository — run gtd inside one (`git init` makes one)")
|
|
2343
|
+
})
|
|
2344
|
+
|
|
2345
|
+
it("from a subdirectory names the root to cd to", async () => {
|
|
2346
|
+
const message = await guarded((_git, host) =>
|
|
2347
|
+
next.pipe(
|
|
2348
|
+
Effect.provideService(Host, {
|
|
2349
|
+
...host,
|
|
2350
|
+
root: "/repo/my sub",
|
|
2351
|
+
realPath: (path: string) => Effect.succeed(path),
|
|
2352
|
+
}),
|
|
2353
|
+
),
|
|
2354
|
+
)
|
|
2355
|
+
expect(message).toBe(
|
|
2356
|
+
"gtd: run gtd from the repository root — cd /repo (the current directory is /repo/my sub)",
|
|
2357
|
+
)
|
|
2358
|
+
})
|
|
2359
|
+
})
|
package/src/cli/Program.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
landFields,
|
|
34
34
|
noopText,
|
|
35
35
|
renderBeatJson,
|
|
36
|
+
DEMAND_BRIEFING,
|
|
36
37
|
renderBeatPlain,
|
|
37
38
|
renderLandJson,
|
|
38
39
|
renderLandPlain,
|
|
@@ -143,7 +144,7 @@ export type Command =
|
|
|
143
144
|
readonly vars: Readonly<Record<string, string>>
|
|
144
145
|
readonly label: string
|
|
145
146
|
}
|
|
146
|
-
| { readonly kind: "door"; readonly name
|
|
147
|
+
| { readonly kind: "door"; readonly name?: string; readonly args: readonly string[] }
|
|
147
148
|
| { readonly kind: "doors" }
|
|
148
149
|
| { readonly kind: "abandon" }
|
|
149
150
|
| { readonly kind: "restore" }
|
|
@@ -566,10 +567,12 @@ const runStartCommand = (
|
|
|
566
567
|
const config = yield* (yield* ConfigService).load
|
|
567
568
|
const resolved = config.workflowNamed(workflow)
|
|
568
569
|
if (resolved === undefined) {
|
|
570
|
+
const startable =
|
|
571
|
+
config.workflowNames.length > 0
|
|
572
|
+
? `startable: ${config.workflowNames.join(", ")}`
|
|
573
|
+
: "this config exports no workflow besides its default, which an ordinary start runs"
|
|
569
574
|
return yield* Effect.fail(
|
|
570
|
-
new GtdUsageError(
|
|
571
|
-
`gtd --workflow: unknown workflow "${workflow}" — startable: ${config.workflowNames.join(", ")}`,
|
|
572
|
-
),
|
|
575
|
+
new GtdUsageError(`gtd --workflow: unknown workflow "${workflow}" — ${startable}`),
|
|
573
576
|
)
|
|
574
577
|
}
|
|
575
578
|
const rest = yield* restAt(undefined, varOverrides)
|
|
@@ -591,35 +594,57 @@ const runStartCommand = (
|
|
|
591
594
|
if (plan.kind === "refusal") {
|
|
592
595
|
return yield* Effect.fail(new Error(plan.message))
|
|
593
596
|
}
|
|
594
|
-
out.write(landingScript(ScriptSurface.render(plan.steps)))
|
|
597
|
+
out.write(listingCommitted(landingScript(ScriptSurface.render(plan.steps)), rest.changes))
|
|
595
598
|
})
|
|
596
599
|
|
|
600
|
+
/**
|
|
601
|
+
* A start commits the whole tree (`git add -A`), so its script says up front
|
|
602
|
+
* which paths that is. Comment lines only: control characters are masked so a
|
|
603
|
+
* path can never end the comment and become a command.
|
|
604
|
+
*/
|
|
605
|
+
const listingCommitted = (script: string, changes: readonly PendingChange[]): string => {
|
|
606
|
+
if (changes.length === 0) return script
|
|
607
|
+
const listing = [
|
|
608
|
+
"# commits every change in the working tree (git add -A):",
|
|
609
|
+
...changes.map((change) => `# ${change.status} ${change.path.replace(/\p{Cc}/gu, "?")}`),
|
|
610
|
+
].join("\n")
|
|
611
|
+
const at = script.indexOf("\n\n")
|
|
612
|
+
return at === -1 ? script : `${script.slice(0, at)}\n\n${listing}${script.slice(at)}`
|
|
613
|
+
}
|
|
614
|
+
|
|
597
615
|
const doorSynopsis = (name: string, door: Door): string =>
|
|
598
616
|
[
|
|
599
617
|
name,
|
|
600
618
|
...(door.args ?? []).map((arg) => (arg.optional === true ? `[${arg.name}]` : `<${arg.name}>`)),
|
|
601
619
|
].join(" ")
|
|
602
620
|
|
|
621
|
+
const noSuchDoor = (name: string | undefined, doors: ReadonlyMap<string, Door>): GtdUsageError => {
|
|
622
|
+
const listing =
|
|
623
|
+
doors.size > 0
|
|
624
|
+
? `doors: ${[...doors].map(([n, d]) => doorSynopsis(n, d)).join(", ")}`
|
|
625
|
+
: "this config has no doors"
|
|
626
|
+
return new GtdUsageError(
|
|
627
|
+
name === undefined
|
|
628
|
+
? `gtd door: missing door name — ${listing}`
|
|
629
|
+
: `gtd door: unknown door "${name}" — ${listing}`,
|
|
630
|
+
)
|
|
631
|
+
}
|
|
632
|
+
|
|
603
633
|
/**
|
|
604
634
|
* `gtd door <name> [args…]`: `gtd --workflow` with the door's workflow and the
|
|
605
635
|
* process settings its `vars` derive from the positional args. A wrong name or
|
|
606
636
|
* arity is a usage error naming what would have worked.
|
|
607
637
|
*/
|
|
608
638
|
const runDoorCommand = (
|
|
609
|
-
name: string,
|
|
639
|
+
name: string | undefined,
|
|
610
640
|
args: readonly string[],
|
|
611
641
|
out: ArtifactOut,
|
|
612
642
|
): Effect.Effect<void, Error, CommandRequirements> =>
|
|
613
643
|
Effect.gen(function* () {
|
|
614
644
|
const config = yield* (yield* ConfigService).load
|
|
615
|
-
const door = config.doors.get(name)
|
|
616
|
-
if (door === undefined) {
|
|
617
|
-
|
|
618
|
-
return yield* Effect.fail(
|
|
619
|
-
new GtdUsageError(
|
|
620
|
-
`gtd door: unknown door "${name}" — doors: ${known.map(([n, d]) => doorSynopsis(n, d)).join(", ")}`,
|
|
621
|
-
),
|
|
622
|
-
)
|
|
645
|
+
const door = name === undefined ? undefined : config.doors.get(name)
|
|
646
|
+
if (name === undefined || door === undefined) {
|
|
647
|
+
return yield* Effect.fail(noSuchDoor(name, config.doors))
|
|
623
648
|
}
|
|
624
649
|
const declared = door.args ?? []
|
|
625
650
|
const required = declared.filter((arg) => arg.optional !== true).length
|
|
@@ -650,6 +675,11 @@ const runDoorsCommand = (
|
|
|
650
675
|
const config = yield* (yield* ConfigService).load
|
|
651
676
|
const doors = [...config.doors]
|
|
652
677
|
if (json.kind === "off") {
|
|
678
|
+
if (doors.length === 0) {
|
|
679
|
+
yield* (yield* Narrator).warn(
|
|
680
|
+
"gtd doors: this config has no doors — a workflow factory's `doors` option or the config's `doors` export adds one",
|
|
681
|
+
)
|
|
682
|
+
}
|
|
653
683
|
out.write(doors.map(([n, d]) => `${doorSynopsis(n, d)} → ${d.workflow}\n`).join(""))
|
|
654
684
|
return
|
|
655
685
|
}
|
|
@@ -849,6 +879,12 @@ const runNextCommand = (
|
|
|
849
879
|
const validateCommand = emitsValidatablePrompt(rendered)
|
|
850
880
|
? selfValidateCommand(rest.def, rest.state, rendered.mode!, rendered.file!)
|
|
851
881
|
: undefined
|
|
882
|
+
if (DEMAND_BRIEFING[fields.kind].suppressHeader) {
|
|
883
|
+
// A prompt's stdout is the agent's input, so its header goes to stderr;
|
|
884
|
+
// rendered as a header-showing kind to reuse the one header layout.
|
|
885
|
+
const shown = renderBeatPlain({ ...fields, kind: "message" })
|
|
886
|
+
yield* narrator.warn(shown.slice(0, shown.indexOf("\n\n")))
|
|
887
|
+
}
|
|
852
888
|
out.write(renderBeatPlain(fields, validateCommand))
|
|
853
889
|
}
|
|
854
890
|
})
|
|
@@ -1125,19 +1161,30 @@ const runUiCliCommand = (
|
|
|
1125
1161
|
*/
|
|
1126
1162
|
const assertRunningFromRepoRoot = (git: GitOperations, host: HostOps): Effect.Effect<void, Error> =>
|
|
1127
1163
|
Effect.gen(function* () {
|
|
1128
|
-
const topLevel = yield* git
|
|
1164
|
+
const topLevel = yield* git
|
|
1165
|
+
.topLevel()
|
|
1166
|
+
.pipe(
|
|
1167
|
+
Effect.mapError((e) =>
|
|
1168
|
+
/not a git repository/.test(e.message)
|
|
1169
|
+
? new Error("gtd: not a git repository — run gtd inside one (`git init` makes one)")
|
|
1170
|
+
: e,
|
|
1171
|
+
),
|
|
1172
|
+
)
|
|
1129
1173
|
const topReal = yield* host.realPath(topLevel)
|
|
1130
1174
|
const cwdReal = yield* host.realPath(host.root)
|
|
1131
1175
|
if (topReal !== cwdReal) {
|
|
1132
1176
|
return yield* Effect.fail(
|
|
1133
1177
|
new Error(
|
|
1134
|
-
`gtd
|
|
1135
|
-
`the current directory is ${host.root}`,
|
|
1178
|
+
`gtd: run gtd from the repository root — cd ${shellWord(topLevel)} ` +
|
|
1179
|
+
`(the current directory is ${host.root})`,
|
|
1136
1180
|
),
|
|
1137
1181
|
)
|
|
1138
1182
|
}
|
|
1139
1183
|
})
|
|
1140
1184
|
|
|
1185
|
+
const shellWord = (path: string): string =>
|
|
1186
|
+
/^[\w@%+=:,./-]+$/.test(path) ? path : `'${path.replace(/'/g, "'\\''")}'`
|
|
1187
|
+
|
|
1141
1188
|
/** A command that derives workflow state needs at least one commit too — a commitless repository has no HEAD to resolve a `Rest` at. */
|
|
1142
1189
|
const assertRepositoryHasCommits = (git: GitOperations): Effect.Effect<void, Error> =>
|
|
1143
1190
|
Effect.gen(function* () {
|
|
@@ -1145,7 +1192,7 @@ const assertRepositoryHasCommits = (git: GitOperations): Effect.Effect<void, Err
|
|
|
1145
1192
|
if (!hasCommits) {
|
|
1146
1193
|
return yield* Effect.fail(
|
|
1147
1194
|
new Error(
|
|
1148
|
-
"gtd
|
|
1195
|
+
"gtd: the repository has no commits yet — make an initial commit, then run gtd again",
|
|
1149
1196
|
),
|
|
1150
1197
|
)
|
|
1151
1198
|
}
|
package/src/edge/Edge.ts
CHANGED
|
@@ -995,7 +995,7 @@ export const restAt = (
|
|
|
995
995
|
if (stepDef.mode !== undefined && def.modes[stepDef.mode] === undefined) {
|
|
996
996
|
return yield* Effect.fail(
|
|
997
997
|
new Error(
|
|
998
|
-
`gtd config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`,
|
|
998
|
+
`gtd: config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`,
|
|
999
999
|
),
|
|
1000
1000
|
)
|
|
1001
1001
|
}
|
package/src/emit/Emit.test.ts
CHANGED
|
@@ -359,7 +359,7 @@ describe("real repo — an assembled script end to end", () => {
|
|
|
359
359
|
describe("combinedScript — the plain-text write commands' single pasteable script", () => {
|
|
360
360
|
it("DID_NOT_RUN_COMMENT points the reader at sh, not bash", () => {
|
|
361
361
|
expect(DID_NOT_RUN_COMMENT).toBe(
|
|
362
|
-
"# gtd emitted this and did NOT run it —
|
|
362
|
+
"# gtd emitted this and did NOT run it — run it with `sh` to apply it",
|
|
363
363
|
)
|
|
364
364
|
expect(DID_NOT_RUN_COMMENT).not.toContain("bash")
|
|
365
365
|
})
|
package/src/emit/Emit.ts
CHANGED
|
@@ -112,7 +112,7 @@ export const emitScripts = (
|
|
|
112
112
|
|
|
113
113
|
/** Exported so `src/twins/EmittedScriptRecognizer.ts` can recognize these by exact string comparison. `printf`, not `echo`, avoids shell-specific backslash-escape quirks. */
|
|
114
114
|
export const DID_NOT_RUN_COMMENT =
|
|
115
|
-
"# gtd emitted this and did NOT run it —
|
|
115
|
+
"# gtd emitted this and did NOT run it — run it with `sh` to apply it"
|
|
116
116
|
|
|
117
117
|
export const PRESENTATION_ONLY_COMMENT = "# presentation only — safe to skip"
|
|
118
118
|
|
package/src/flows/factory.ts
CHANGED
|
@@ -52,7 +52,9 @@ export const defineWorkflow = <S extends StandardSchemaV1>(
|
|
|
52
52
|
// A factory call used to be a flow; awaiting one in a composed flow must fail, not no-op.
|
|
53
53
|
// oxlint-disable-next-line unicorn/no-thenable -- awaiting a call must fail
|
|
54
54
|
then: () => {
|
|
55
|
-
throw new Error(
|
|
55
|
+
throw new Error(
|
|
56
|
+
"a configured workflow is not a flow — await the flow functions it is built from instead",
|
|
57
|
+
)
|
|
56
58
|
},
|
|
57
59
|
})
|
|
58
60
|
return Object.assign(factory, { [FACTORY]: internals }) as never
|