@webappwiz/arbor 0.0.30 → 0.0.32

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 (120) hide show
  1. package/README.md +2 -2
  2. package/add.d.ts +23 -0
  3. package/age.d.ts +2 -0
  4. package/arbor.d.ts +11 -0
  5. package/attachments.d.ts +36 -0
  6. package/claim.d.ts +11 -0
  7. package/config.d.ts +55 -0
  8. package/config.js +7 -0
  9. package/dev/assets.d.ts +11 -0
  10. package/dev.d.ts +48 -0
  11. package/escalate.d.ts +28 -0
  12. package/exit.d.ts +42 -0
  13. package/git.d.ts +82 -0
  14. package/index.d.ts +2 -0
  15. package/index.js +4763 -0
  16. package/journal.d.ts +29 -0
  17. package/list.d.ts +18 -0
  18. package/load-config.d.ts +15 -0
  19. package/log.d.ts +14 -0
  20. package/merge.d.ts +24 -0
  21. package/package.json +15 -32
  22. package/path.d.ts +13 -0
  23. package/plan.d.ts +83 -0
  24. package/remove.d.ts +19 -0
  25. package/repository.d.ts +25 -0
  26. package/retry.d.ts +17 -0
  27. package/shell.d.ts +17 -0
  28. package/show.d.ts +53 -0
  29. package/snapshot.d.ts +36 -0
  30. package/table.d.ts +5 -0
  31. package/todo.d.ts +202 -0
  32. package/todos.d.ts +75 -0
  33. package/wait.d.ts +22 -0
  34. package/worktree-service.d.ts +51 -0
  35. package/worktree.d.ts +95 -0
  36. package/AGENTS.md +0 -7
  37. package/add.test.ts +0 -125
  38. package/add.ts +0 -192
  39. package/age.ts +0 -11
  40. package/arbor.test.ts +0 -145
  41. package/arbor.ts +0 -429
  42. package/assets.d.ts +0 -9
  43. package/attachments.ts +0 -87
  44. package/build.ts +0 -98
  45. package/claim.test.ts +0 -89
  46. package/claim.ts +0 -73
  47. package/components.json +0 -21
  48. package/config.ts +0 -58
  49. package/dev/api.ts +0 -120
  50. package/dev/app.test.tsx +0 -645
  51. package/dev/app.tsx +0 -80
  52. package/dev/assets.ts +0 -16
  53. package/dev/build/main.txt +0 -68
  54. package/dev/build/shell.txt +0 -21
  55. package/dev/build/styles.txt +0 -2263
  56. package/dev/components/ui/badge.tsx +0 -51
  57. package/dev/components/ui/button.tsx +0 -57
  58. package/dev/components/ui/dialog.tsx +0 -154
  59. package/dev/components/ui/empty.tsx +0 -100
  60. package/dev/components/ui/input-group.tsx +0 -155
  61. package/dev/components/ui/input.tsx +0 -19
  62. package/dev/components/ui/progress.tsx +0 -80
  63. package/dev/components/ui/textarea.tsx +0 -17
  64. package/dev/components/ui/toast.tsx +0 -227
  65. package/dev/feed.ts +0 -69
  66. package/dev/files.tsx +0 -191
  67. package/dev/index.html +0 -21
  68. package/dev/lib/utils.ts +0 -7
  69. package/dev/main.tsx +0 -22
  70. package/dev/markdown.test.tsx +0 -133
  71. package/dev/markdown.tsx +0 -124
  72. package/dev/mentions.tsx +0 -319
  73. package/dev/shadcn.css +0 -649
  74. package/dev/styles.css +0 -107
  75. package/dev/tags.tsx +0 -44
  76. package/dev/tasks.tsx +0 -151
  77. package/dev/todos.tsx +0 -759
  78. package/dev.test.ts +0 -439
  79. package/dev.ts +0 -386
  80. package/e2e.test.ts +0 -256
  81. package/escalate.test.ts +0 -76
  82. package/escalate.ts +0 -112
  83. package/exit.test.ts +0 -71
  84. package/exit.ts +0 -74
  85. package/git.ts +0 -294
  86. package/index.ts +0 -9
  87. package/journal.ts +0 -89
  88. package/list.test.ts +0 -82
  89. package/list.ts +0 -137
  90. package/load-config.test.ts +0 -121
  91. package/load-config.ts +0 -62
  92. package/log.test.ts +0 -65
  93. package/log.ts +0 -40
  94. package/merge.test.ts +0 -332
  95. package/merge.ts +0 -283
  96. package/path.test.ts +0 -35
  97. package/path.ts +0 -38
  98. package/plan.test.ts +0 -318
  99. package/plan.ts +0 -302
  100. package/progress.test.ts +0 -54
  101. package/progress.ts +0 -31
  102. package/remove.test.ts +0 -68
  103. package/remove.ts +0 -88
  104. package/repository.ts +0 -83
  105. package/retry.test.ts +0 -43
  106. package/retry.ts +0 -48
  107. package/shell.ts +0 -44
  108. package/show.test.ts +0 -91
  109. package/show.ts +0 -151
  110. package/snapshot.test.ts +0 -33
  111. package/snapshot.ts +0 -84
  112. package/table.ts +0 -20
  113. package/testing.ts +0 -229
  114. package/todo.test.ts +0 -490
  115. package/todo.ts +0 -834
  116. package/wait.test.ts +0 -61
  117. package/wait.ts +0 -77
  118. package/worktree-service.test.ts +0 -158
  119. package/worktree-service.ts +0 -193
  120. package/worktree.ts +0 -239
package/arbor.test.ts DELETED
@@ -1,145 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
- import { basename, join } from "node:path";
3
- import { arbor } from "./arbor";
4
- import { Testing } from "./testing";
5
-
6
- /**
7
- * The cli in this process, run with fakes: the same commands `e2e.test.ts`
8
- * spawns, minus the subprocess. What it proves is the wiring, that every
9
- * dependency an action uses arrives through `run`.
10
- */
11
- describe("arbor cli", () => {
12
- it("runs its commands against the dependencies it is given", async () => {
13
- await using env = await Testing.open();
14
- env.ps.cd(env.root);
15
- const deps = {
16
- log: env.log,
17
- fs: env.fs,
18
- ps: env.ps,
19
- assets: env.assets,
20
- };
21
-
22
- await arbor.run(deps, ["add", "alpha"]);
23
- await arbor.run(deps, ["list", "--json"]);
24
-
25
- const rows = JSON.parse(String(env.log.entries.at(-1)?.message));
26
- expect(rows).toMatchObject([{ task: "alpha", status: "working" }]);
27
- expect(await env.fs.exists(join(`${env.root}-arbor`, "alpha"))).toBe(true);
28
- });
29
-
30
- it("escalates a task and edits its todos", async () => {
31
- await using env = await Testing.open();
32
- env.ps.cd(env.root);
33
- const deps = {
34
- log: env.log,
35
- fs: env.fs,
36
- ps: env.ps,
37
- assets: env.assets,
38
- };
39
- await arbor.run(deps, ["add", "alpha"]);
40
- const worktree = await env.service.find("alpha");
41
- await env.fs.write(
42
- join(worktree.path, "ARBOR.md"),
43
- "# alpha\n\n## Blocked\n\n- [ ] 1. Open it.\n",
44
- );
45
- await env.fs.write(join(env.root, "a.png"), "a");
46
- env.ps.cd(worktree.path);
47
- await arbor.run(deps, ["escalate", "needs a person"]);
48
-
49
- env.ps.cd(env.root);
50
- await arbor.run(deps, [
51
- "todo",
52
- "add",
53
- "write docs",
54
- "the CLI first",
55
- "--file",
56
- "a.png",
57
- ]);
58
- const [attached = ""] = (await env.todos.find(1)).files;
59
- await arbor.run(deps, [
60
- "todo",
61
- "update",
62
- "1",
63
- "--subject",
64
- "write the docs",
65
- "--position",
66
- "1",
67
- "--remove-file",
68
- basename(attached),
69
- ]);
70
- expect((await env.todos.find(1)).state).toMatchObject({
71
- subject: "write the docs",
72
- text: "the CLI first",
73
- position: 1,
74
- files: [],
75
- });
76
-
77
- await arbor.run(deps, ["log", "--json"]);
78
- const entries = JSON.parse(String(env.log.entries.at(-1)?.message));
79
- expect(entries.map((entry: { action: string }) => entry.action)).toContain(
80
- "escalate",
81
- );
82
- });
83
-
84
- it("asks for a review from the command line", async () => {
85
- await using env = await Testing.open();
86
- env.ps.cd(env.root);
87
- const deps = {
88
- log: env.log,
89
- fs: env.fs,
90
- ps: env.ps,
91
- assets: env.assets,
92
- };
93
- await arbor.run(deps, ["add", "alpha"]);
94
-
95
- await arbor.run(deps, [
96
- "escalate",
97
- "check the header",
98
- "--task",
99
- "alpha",
100
- "--review",
101
- ]);
102
-
103
- const worktree = await env.service.find("alpha");
104
- expect(worktree.state?.escalations?.at(-1)?.review).toBe("1");
105
- expect(await env.fs.read(join(worktree.path, "ARBOR.md"))).toContain(
106
- "- [ ] 1. Ready to merge?\n check the header\n",
107
- );
108
- });
109
-
110
- it("reports a refusal as a reason, a message and an exit code", async () => {
111
- await using env = await Testing.open();
112
- env.ps.cd(env.root);
113
-
114
- await arbor.run(
115
- {
116
- log: env.log,
117
- fs: env.fs,
118
- ps: env.ps,
119
- assets: env.assets,
120
- },
121
- ["claim", "nope"],
122
- );
123
-
124
- expect(env.out()).toContain('"reason":"not_found"');
125
- expect(env.proc.lastExit()).toBe(8);
126
- });
127
-
128
- it("refuses a port that cannot exist rather than trying to listen", async () => {
129
- await using env = await Testing.open();
130
- env.ps.cd(env.root);
131
-
132
- await arbor.run(
133
- {
134
- log: env.log,
135
- fs: env.fs,
136
- ps: env.ps,
137
- assets: env.assets,
138
- },
139
- ["dev", "--port", "99999"],
140
- );
141
-
142
- expect(env.out()).not.toContain(" reply ");
143
- expect(env.proc.lastExit()).toBe(1);
144
- });
145
- });
package/arbor.ts DELETED
@@ -1,429 +0,0 @@
1
- import { cli, type Deps } from "webappwiz/cmd";
2
- import type { Fs } from "webappwiz/system";
3
- import { Duration } from "webappwiz/time";
4
- import { z } from "zod";
5
- import { add } from "./add";
6
- import { claim } from "./claim";
7
- import { DEFAULT_PORT, dev, devPorts } from "./dev";
8
- import type { Assets } from "./dev/assets";
9
- import { escalate } from "./escalate";
10
- import { exits, fail } from "./exit";
11
- import { list } from "./list";
12
- import { DEFAULT_COUNT, log as showLog } from "./log";
13
- import { merge } from "./merge";
14
- import { path } from "./path";
15
- import { remove } from "./remove";
16
- import { type Repository, repository } from "./repository";
17
- import { retry } from "./retry";
18
- import { show } from "./show";
19
- import {
20
- todoAdd,
21
- todoList,
22
- todoRelease,
23
- todoRemove,
24
- todoShow,
25
- todoTags,
26
- todoTake,
27
- todoUpdate,
28
- } from "./todo";
29
- import { DEFAULT_TIMEOUT, wait } from "./wait";
30
-
31
- /** Everything `arbor` is run with, before the repository middleware adds to it. */
32
- export interface ArborDeps extends Deps {
33
- fs: Fs;
34
- /** The built page `dev` serves as its preview. */
35
- assets: Assets;
36
- }
37
-
38
- // Outermost first: a refusal raised in an action unwinds past `repository` and
39
- // stops at `exits`, which is the only thing that ends the process.
40
- export const arbor = cli<ArborDeps>("arbor")
41
- .use(exits<ArborDeps>())
42
- .use(repository<ArborDeps>());
43
-
44
- // Every action is handed the dependencies it runs with, so it passes the
45
- // context straight on: each command takes the few it names off it.
46
- arbor
47
- .command("add")
48
- .description(
49
- "start a new task: create branch task/<task>, a worktree at ../<repo>-arbor/<task> and a state record",
50
- )
51
- .arg("task", z.string(), { description: "task name (lowercase-with-dashes)" })
52
- .option("base", z.string(), {
53
- default: "",
54
- description:
55
- "branch this task starts from and merges onto (default: trunk); `task/<other>` stacks this task on that one and lands the work in its worktree",
56
- })
57
- .option("todo", z.string(), {
58
- default: "",
59
- description:
60
- "take up these todos, comma separated: their words become the plan's Goal, merging removes them, and removing the task puts them back",
61
- })
62
- .action((opts, ctx) =>
63
- ctx.journal.record("add", opts.task, () =>
64
- add(ctx, opts.task, {
65
- base: opts.base || undefined,
66
- todos: todoIds(opts.todo),
67
- }),
68
- ),
69
- );
70
-
71
- arbor
72
- .command("claim")
73
- .description(
74
- "resume an existing task: take ownership of its worktree and print its path, status and any half-finished rebase; an escalated task goes back to working; refuses while another agent holds the lease, but takes a stale one silently, so `arbor show` first if the tree may not be abandoned",
75
- )
76
- .arg("task", z.string(), { description: "task name" })
77
- .action((opts, ctx) =>
78
- ctx.journal.record("claim", opts.task, () => claim(ctx, opts.task)),
79
- );
80
-
81
- arbor
82
- .command("merge")
83
- .description(
84
- "land this worktree's branch on its base (trunk unless created with --base): rebase onto it, run tests on the rebased code, fast-forward it in whichever worktree has it checked out, then discard the worktree, branch and record (linear history, never a merge commit, no flag to skip tests); requires committed work, refusing a dirty worktree",
85
- )
86
- .action(async (_opts, ctx) =>
87
- ctx.journal.record("merge", await here(ctx), () =>
88
- merge(ctx, ctx.ps.cwd()),
89
- ),
90
- );
91
-
92
- arbor
93
- .command("remove")
94
- .description(
95
- "discard a task: worktree, branch and state file; cheap and encouraged, since redoing a task against current trunk often beats a hard rebase",
96
- )
97
- .arg("task", z.string(), { description: "task name" })
98
- .option("force", z.boolean(), {
99
- default: false,
100
- description: "discard even when another agent holds the lease",
101
- })
102
- .action((opts, ctx) =>
103
- ctx.journal.record("remove", opts.task, () =>
104
- remove(ctx, opts.task, { force: opts.force }),
105
- ),
106
- );
107
-
108
- arbor
109
- .command("list")
110
- .description(
111
- "list every task: name, status, lease (held: an agent is on it now; stale: gone quiet, normal for a task mid-edit; none), commits ahead of trunk, age",
112
- )
113
- .option("json", z.boolean(), { default: false, description: "emit JSON" })
114
- .option("files", z.boolean(), {
115
- default: false,
116
- description:
117
- "add each task's changed files (committed or not) and the files its ARBOR.md plans to touch, to check for overlap before starting",
118
- })
119
- .action((opts, ctx) => list(ctx, { json: opts.json, files: opts.files }));
120
-
121
- arbor
122
- .command("show")
123
- .description(
124
- "read one task without touching it: everything `list` shows for it, plus the ARBOR.md its agent left at the worktree root; takes no lease, so it cannot knock that agent off its own tree",
125
- )
126
- .arg("task", z.string(), { description: "task name" })
127
- .option("json", z.boolean(), { default: false, description: "emit JSON" })
128
- .action((opts, ctx) => show(ctx, opts.task, { json: opts.json }));
129
-
130
- arbor
131
- .command("wait")
132
- .description(
133
- "block until a task stops moving: escalated, or gone (merged or removed), or broken; takes no lease, and gives up with `timeout` rather than waiting forever",
134
- )
135
- .arg("task", z.string(), { description: "task name" })
136
- .option("timeout-secs", z.coerce.number(), {
137
- default: DEFAULT_TIMEOUT.secs,
138
- description: "how long to wait before giving up",
139
- })
140
- .action((opts, ctx) =>
141
- wait(ctx, opts.task, {
142
- timeout: Duration.secs(opts["timeout-secs"]),
143
- }),
144
- );
145
-
146
- arbor
147
- .command("log")
148
- .description(
149
- "show what has been done here recently: one line per add, claim, merge, remove, escalate, retry, todo and more, with how it ended; outlives the tasks themselves",
150
- )
151
- .option("count", z.coerce.number(), {
152
- default: DEFAULT_COUNT,
153
- description: "how many entries to show",
154
- })
155
- .option("json", z.boolean(), { default: false, description: "emit JSON" })
156
- .action((opts, ctx) => showLog(ctx, { count: opts.count, json: opts.json }));
157
-
158
- arbor
159
- .command("dev")
160
- .description(
161
- "serve the todos and the tasks as a web page on this machine, where todos can be added, updated, reordered or removed",
162
- )
163
- .option("port", z.coerce.number(), {
164
- default: DEFAULT_PORT,
165
- description: "port to listen on, or the next open one above it",
166
- })
167
- .option("allow-hosts", z.string(), {
168
- default: "",
169
- description:
170
- "comma-separated host names the page may also be reached by, like a tunnel's; putting a login in front of them is the tunnel's job",
171
- })
172
- .action((opts, ctx) =>
173
- dev(ctx, {
174
- ports: devPorts(opts.port),
175
- hosts: opts["allow-hosts"]
176
- .split(",")
177
- .map((host) => host.trim())
178
- .filter(Boolean),
179
- }),
180
- );
181
-
182
- arbor
183
- .command("path")
184
- .description(
185
- "print a task's worktree path, or the main tree with no task; names another agent's tree without taking its lease",
186
- )
187
- .arg("task", z.string(), {
188
- default: "",
189
- description: "task name; omit for the main tree",
190
- })
191
- .action((opts, ctx) => path(ctx, opts.task || undefined));
192
-
193
- arbor
194
- .command("escalate")
195
- .description(
196
- "hand this task to a human and stop: records the reason, drops the lease and leaves the worktree exactly as it is; use instead of resolving a genuine conflict badly just to finish",
197
- )
198
- .arg("reason", z.string(), {
199
- description:
200
- "why this needs a human; with --review, what to look at before approving",
201
- })
202
- .option("task", z.string(), {
203
- default: "",
204
- description: "task name, when run outside its worktree",
205
- })
206
- .option("review", z.boolean(), {
207
- default: false,
208
- description:
209
- "ask a person to approve merging: adds a `Ready to merge?` question under ## Blocked with the reason as its detail, answered in chat; refused while another question is unchecked",
210
- })
211
- .action(async (opts, ctx) =>
212
- ctx.journal.record("escalate", opts.task || (await here(ctx)), () =>
213
- escalate(ctx, opts.reason, ctx.ps.cwd(), {
214
- task: opts.task || undefined,
215
- review: opts.review,
216
- }),
217
- ),
218
- );
219
-
220
- arbor
221
- .command("retry")
222
- .description(
223
- "give an escalated task another mergeRetryCount merge attempts and put it back to working; the way out of `budget_exhausted` that is not remove and redo, and only from escalated, so a human has seen the tree first",
224
- )
225
- .arg("task", z.string(), { description: "task name" })
226
- .action((opts, ctx) =>
227
- ctx.journal.record("retry", opts.task, () => retry(ctx, opts.task)),
228
- );
229
-
230
- /** A place in the todo list, 1 at the top; absent leaves it to the command. */
231
- const position = z.coerce.number().int().positive().optional();
232
-
233
- const todo = arbor
234
- .group("todo")
235
- .description(
236
- "defer work for later instead of growing the task you are in: `merge` recommends the next one when a task lands",
237
- );
238
-
239
- todo
240
- .command("add")
241
- .description(
242
- "note something to do later and move on; run from a worktree, it records the task it came up in",
243
- )
244
- .arg("subject", z.string(), { description: "what is left to do, in a line" })
245
- .arg("text", z.string(), {
246
- default: "",
247
- description: "whatever more there is to say about it, in markdown",
248
- })
249
- .option("position", position, {
250
- description:
251
- "where it goes in the list, 1 at the top, pushing those from there down (default: the bottom)",
252
- })
253
- .option("file", z.string(), {
254
- default: "",
255
- description:
256
- "files to attach, comma separated: copied under .git/arbor/todos/<id>/",
257
- })
258
- .option("tag", z.string(), {
259
- default: "",
260
- description:
261
- "tags, comma separated, each one lowercase word (or a few joined by hyphens): the areas or goals it belongs to; `arbor todo tags` lists those in use",
262
- })
263
- .action(async (opts, ctx) => {
264
- const from = await here(ctx);
265
- await ctx.journal.record("todo add", from, () =>
266
- todoAdd(ctx, opts.subject, from, {
267
- text: opts.text,
268
- position: opts.position,
269
- files: commaList(opts.file),
270
- tags: commaList(opts.tag),
271
- }),
272
- );
273
- });
274
-
275
- todo
276
- .command("list")
277
- .description(
278
- "every todo, top of the list first, with the task it came from and the task that took it up",
279
- )
280
- .option("json", z.boolean(), { default: false, description: "emit JSON" })
281
- .option("open", z.boolean(), {
282
- default: false,
283
- description: "only todos no task has taken up: the ones free to pick up",
284
- })
285
- .option("tag", z.string(), {
286
- default: "",
287
- description: "only todos with this tag",
288
- })
289
- .action((opts, ctx) =>
290
- todoList(ctx, {
291
- json: opts.json,
292
- open: opts.open,
293
- tag: opts.tag || undefined,
294
- }),
295
- );
296
-
297
- todo
298
- .command("tags")
299
- .description(
300
- "every tag in use, with how many todos have it: reuse one before making up another",
301
- )
302
- .option("json", z.boolean(), { default: false, description: "emit JSON" })
303
- .action((opts, ctx) => todoTags(ctx, { json: opts.json }));
304
-
305
- todo
306
- .command("show")
307
- .description("one todo in full: where it stands, its files, and its detail")
308
- .arg("id", z.coerce.number().int().positive(), { description: "todo id" })
309
- .option("json", z.boolean(), { default: false, description: "emit JSON" })
310
- .action((opts, ctx) => todoShow(ctx, opts.id, { json: opts.json }));
311
-
312
- todo
313
- .command("update")
314
- .description(
315
- "say what a todo is in other words, move it up or down the list, tag it, or attach and drop files; it keeps its id",
316
- )
317
- .arg("id", z.coerce.number().int().positive(), { description: "todo id" })
318
- .arg("text", z.string(), {
319
- default: "",
320
- description:
321
- "whatever more there is to say about it, in markdown; leave out to keep the detail",
322
- })
323
- .option("subject", z.string(), {
324
- default: "",
325
- description: "what is left to do, in a line; leave out to keep it",
326
- })
327
- .option("position", position, {
328
- description:
329
- "where to move it in the list, 1 at the top; the others close up around it",
330
- })
331
- .option("file", z.string(), {
332
- default: "",
333
- description: "files to attach, comma separated",
334
- })
335
- .option("remove-file", z.string(), {
336
- default: "",
337
- description:
338
- "attached files to drop, comma separated, by path or stored name",
339
- })
340
- .option("tag", z.string(), {
341
- default: "",
342
- description: "tags to add, comma separated",
343
- })
344
- .option("remove-tag", z.string(), {
345
- default: "",
346
- description: "tags to drop, comma separated",
347
- })
348
- .action((opts, ctx) =>
349
- ctx.journal.record("todo update", null, () =>
350
- todoUpdate(ctx, opts.id, {
351
- subject: opts.subject,
352
- text: opts.text,
353
- position: opts.position,
354
- files: commaList(opts.file),
355
- removeFiles: commaList(opts["remove-file"]),
356
- tags: commaList(opts.tag),
357
- removeTags: commaList(opts["remove-tag"]),
358
- }),
359
- ),
360
- );
361
-
362
- todo
363
- .command("take")
364
- .description(
365
- "take up todos for the task whose worktree you are in, as when they turn out to be part of its work: merging removes them, removing the task puts them back",
366
- )
367
- .rest("ids", z.coerce.number().int().positive(), {
368
- description: "todo ids",
369
- })
370
- .action(async (opts, ctx) => {
371
- const task = await here(ctx);
372
- await ctx.journal.record("todo take", task, () =>
373
- todoTake(ctx, opts.ids, task),
374
- );
375
- });
376
-
377
- todo
378
- .command("release")
379
- .description(
380
- "put taken todos back on the list, as when a task lands without finishing them: `arbor todo update` the leftover first; from a worktree, only its own task's",
381
- )
382
- .rest("ids", z.coerce.number().int().positive(), {
383
- description: "todo ids",
384
- })
385
- .action(async (opts, ctx) => {
386
- const task = await here(ctx);
387
- await ctx.journal.record("todo release", task, () =>
388
- todoRelease(ctx, opts.ids, task),
389
- );
390
- });
391
-
392
- todo
393
- .command("remove")
394
- .description("drop a todo that was done some other way or no longer applies")
395
- .arg("id", z.coerce.number().int().positive(), { description: "todo id" })
396
- .action((opts, ctx) =>
397
- ctx.journal.record("todo remove", null, () => todoRemove(ctx, opts.id)),
398
- );
399
-
400
- /** `--todo 3,5` as ids, refusing anything that is not one. */
401
- function todoIds(raw: string): number[] {
402
- return commaList(raw).map((value) => {
403
- const id = Number(value);
404
- if (!Number.isInteger(id) || id <= 0) {
405
- fail("usage", `'${value}' is not a todo id`, { todo: value });
406
- }
407
- return id;
408
- });
409
- }
410
-
411
- /** A comma-separated flag's values, with no empty ones. */
412
- function commaList(raw: string): string[] {
413
- return raw
414
- .split(",")
415
- .map((value) => value.trim())
416
- .filter(Boolean);
417
- }
418
-
419
- /**
420
- * Which task a command that takes no task name is about, so the journal can
421
- * name it. `merge` and `escalate` read it off the current branch.
422
- */
423
- async function here({
424
- service,
425
- git,
426
- ps,
427
- }: ArborDeps & Repository): Promise<string | null> {
428
- return service.taskFor(await git.currentBranch(ps.cwd()).catch(() => ""));
429
- }
package/assets.d.ts DELETED
@@ -1,9 +0,0 @@
1
- // Bun imports any file as text with `with { type: "text" }`, but its types only
2
- // declare the extensions it treats as text by default, and for `.html` and
3
- // `.css` it declares something else entirely. The three files the dev page is
4
- // served from are written as `.txt` for that reason: they are blobs a server
5
- // hands to a browser, not modules anything here imports for their exports.
6
- declare module "*.txt" {
7
- const text: string;
8
- export default text;
9
- }
package/attachments.ts DELETED
@@ -1,87 +0,0 @@
1
- import { basename, resolve } from "node:path";
2
- import { type IdProvider, UuidProvider } from "webappwiz/id";
3
- import type { Fs, Ps } from "webappwiz/system";
4
- import { fail } from "./exit";
5
-
6
- /** A file handed over, as bytes so a pasted image needs no temp file. */
7
- export interface Attachment {
8
- /** What it was called, kept in the stored name so the files stay apart. */
9
- name: string;
10
- bytes: Uint8Array;
11
- }
12
-
13
- /**
14
- * The folder of files that belong to a todo: todo 7's beside its record, in
15
- * `todos/7/`.
16
- * Under `.git/arbor`, so nothing here is ever committed and every tree can
17
- * open what it holds.
18
- */
19
- export class Attachments {
20
- constructor(
21
- readonly dir: string,
22
- private readonly fs: Fs,
23
- private readonly ids: IdProvider = new UuidProvider(),
24
- ) {}
25
-
26
- /** Copies each file in, returning the absolute path each was stored at. */
27
- async store(files: Attachment[]): Promise<string[]> {
28
- if (files.length === 0) {
29
- return [];
30
- }
31
- await this.fs.mkdir(this.dir);
32
- const stored: string[] = [];
33
- for (const file of files) {
34
- const path = `${this.dir}/${this.ids.next()}-${safe(file.name)}`;
35
- await this.fs.writeBytes(path, file.bytes);
36
- stored.push(path);
37
- }
38
- return stored;
39
- }
40
-
41
- /** Whether `path` is one of this folder's files, and not a way out of it. */
42
- owns(path: string): boolean {
43
- return resolve(path).startsWith(`${resolve(this.dir)}/`);
44
- }
45
-
46
- /** Deletes the files given that are this folder's; others are left alone. */
47
- async remove(paths: string[]): Promise<void> {
48
- for (const path of paths.filter((path) => this.owns(path))) {
49
- await this.fs.rm(path, { force: true });
50
- }
51
- }
52
-
53
- /** Deletes the folder and everything in it. */
54
- async clear(): Promise<void> {
55
- await this.fs.rm(this.dir, { recursive: true, force: true });
56
- }
57
- }
58
-
59
- /**
60
- * Reads files named on the command line, relative to the current directory or
61
- * absolute, refusing the whole command over one that cannot be read.
62
- */
63
- export async function readFiles(
64
- { fs, ps }: { fs: Fs; ps: Ps },
65
- paths: string[],
66
- ): Promise<Attachment[]> {
67
- const read: Attachment[] = [];
68
- for (const given of paths) {
69
- const path = resolve(ps.cwd(), given);
70
- const bytes = await fs.readBytes(path).catch(() => null);
71
- if (bytes === null) {
72
- fail("usage", `cannot read ${path}: nothing was changed`, {
73
- file: path,
74
- });
75
- }
76
- read.push({ name: basename(path), bytes });
77
- }
78
- return read;
79
- }
80
-
81
- /**
82
- * A stored name that survives being written into a one-line reply: paths are
83
- * separated by spaces there, so nothing in them may be one.
84
- */
85
- function safe(name: string): string {
86
- return basename(name).replace(/[^\w.-]+/g, "-") || "attachment";
87
- }