@webappwiz/arbor 0.0.28 → 0.0.30
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/AGENTS.md +7 -0
- package/README.md +34 -12
- package/add.test.ts +125 -0
- package/add.ts +192 -0
- package/age.ts +11 -0
- package/arbor.test.ts +145 -0
- package/arbor.ts +429 -0
- package/assets.d.ts +9 -0
- package/attachments.ts +87 -0
- package/build.ts +98 -0
- package/claim.test.ts +89 -0
- package/claim.ts +73 -0
- package/components.json +21 -0
- package/config.ts +58 -0
- package/dev/api.ts +120 -0
- package/dev/app.test.tsx +645 -0
- package/dev/app.tsx +80 -0
- package/dev/assets.ts +16 -0
- package/dev/build/main.txt +68 -0
- package/dev/build/shell.txt +21 -0
- package/dev/build/styles.txt +2263 -0
- package/dev/components/ui/badge.tsx +51 -0
- package/dev/components/ui/button.tsx +57 -0
- package/dev/components/ui/dialog.tsx +154 -0
- package/dev/components/ui/empty.tsx +100 -0
- package/dev/components/ui/input-group.tsx +155 -0
- package/dev/components/ui/input.tsx +19 -0
- package/dev/components/ui/progress.tsx +80 -0
- package/dev/components/ui/textarea.tsx +17 -0
- package/dev/components/ui/toast.tsx +227 -0
- package/dev/feed.ts +69 -0
- package/dev/files.tsx +191 -0
- package/dev/index.html +21 -0
- package/dev/lib/utils.ts +7 -0
- package/dev/main.tsx +22 -0
- package/dev/markdown.test.tsx +133 -0
- package/dev/markdown.tsx +124 -0
- package/dev/mentions.tsx +319 -0
- package/dev/shadcn.css +649 -0
- package/dev/styles.css +107 -0
- package/dev/tags.tsx +44 -0
- package/dev/tasks.tsx +151 -0
- package/dev/todos.tsx +759 -0
- package/dev.test.ts +439 -0
- package/dev.ts +386 -0
- package/e2e.test.ts +256 -0
- package/escalate.test.ts +76 -0
- package/escalate.ts +112 -0
- package/exit.test.ts +71 -0
- package/exit.ts +74 -0
- package/git.ts +294 -0
- package/index.ts +9 -0
- package/journal.ts +89 -0
- package/list.test.ts +82 -0
- package/list.ts +137 -0
- package/load-config.test.ts +121 -0
- package/load-config.ts +62 -0
- package/log.test.ts +65 -0
- package/log.ts +40 -0
- package/merge.test.ts +332 -0
- package/merge.ts +283 -0
- package/package.json +32 -15
- package/path.test.ts +35 -0
- package/path.ts +38 -0
- package/plan.test.ts +318 -0
- package/plan.ts +302 -0
- package/progress.test.ts +54 -0
- package/progress.ts +31 -0
- package/remove.test.ts +68 -0
- package/remove.ts +88 -0
- package/repository.ts +83 -0
- package/retry.test.ts +43 -0
- package/retry.ts +48 -0
- package/shell.ts +44 -0
- package/show.test.ts +91 -0
- package/show.ts +151 -0
- package/snapshot.test.ts +33 -0
- package/snapshot.ts +84 -0
- package/table.ts +20 -0
- package/testing.ts +229 -0
- package/todo.test.ts +490 -0
- package/todo.ts +834 -0
- package/wait.test.ts +61 -0
- package/wait.ts +77 -0
- package/worktree-service.test.ts +158 -0
- package/worktree-service.ts +193 -0
- package/worktree.ts +239 -0
- package/add.d.ts +0 -23
- package/age.d.ts +0 -2
- package/arbor.d.ts +0 -11
- package/attachments.d.ts +0 -36
- package/claim.d.ts +0 -11
- package/config.d.ts +0 -55
- package/config.js +0 -7
- package/dev/assets.d.ts +0 -11
- package/dev.d.ts +0 -47
- package/escalate.d.ts +0 -28
- package/exit.d.ts +0 -42
- package/git.d.ts +0 -77
- package/index.d.ts +0 -2
- package/index.js +0 -4669
- package/journal.d.ts +0 -29
- package/list.d.ts +0 -18
- package/load-config.d.ts +0 -15
- package/log.d.ts +0 -14
- package/merge.d.ts +0 -24
- package/path.d.ts +0 -13
- package/plan.d.ts +0 -83
- package/remove.d.ts +0 -19
- package/repository.d.ts +0 -25
- package/retry.d.ts +0 -17
- package/shell.d.ts +0 -17
- package/show.d.ts +0 -53
- package/snapshot.d.ts +0 -31
- package/table.d.ts +0 -5
- package/todo.d.ts +0 -230
- package/wait.d.ts +0 -22
- package/worktree-service.d.ts +0 -51
- package/worktree.d.ts +0 -95
package/arbor.ts
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
}
|
package/build.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { dirname, resolve } from "node:path";
|
|
2
|
+
import { Scanner } from "@tailwindcss/oxide";
|
|
3
|
+
import { compile } from "tailwindcss";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds the `arbor dev` page: the React app as one script, and the stylesheet
|
|
7
|
+
* with Tailwind compiled. `dev/assets.ts` imports both as text, so they end up
|
|
8
|
+
* inside the bundle a release publishes and the server hands them straight to
|
|
9
|
+
* the browser.
|
|
10
|
+
*
|
|
11
|
+
* This runs here rather than on the first page load of every `arbor dev`, which
|
|
12
|
+
* is what keeps React and Tailwind out of the published dependencies: a person
|
|
13
|
+
* installing the CLI has no use for a toolchain that already ran. It is also
|
|
14
|
+
* the only place the compile can read our own configuration, since `arbor dev`
|
|
15
|
+
* runs inside whatever repository the user is in.
|
|
16
|
+
*
|
|
17
|
+
* `bun run build` in this package, and `bun install` at the workspace root,
|
|
18
|
+
* which is what puts the output there for a fresh checkout.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const here = import.meta.dirname;
|
|
22
|
+
const out = `${here}/dev/build`;
|
|
23
|
+
|
|
24
|
+
/** The browser script, as one file with React and the page's modules in it. */
|
|
25
|
+
async function script(): Promise<string> {
|
|
26
|
+
const built = await Bun.build({
|
|
27
|
+
entrypoints: [`${here}/dev/main.tsx`],
|
|
28
|
+
target: "browser",
|
|
29
|
+
// React's production build: the development one is several times the
|
|
30
|
+
// size, slower, and asks the person on the page to install DevTools.
|
|
31
|
+
define: { "process.env.NODE_ENV": '"production"' },
|
|
32
|
+
minify: true,
|
|
33
|
+
// Left to throw, a failure arrives as an AggregateError that says only
|
|
34
|
+
// "Bundle failed": which import went missing is in the logs, and whoever
|
|
35
|
+
// is reading the terminal needs that to know it is their install.
|
|
36
|
+
throw: false,
|
|
37
|
+
});
|
|
38
|
+
const [output] = built.outputs;
|
|
39
|
+
if (output === undefined) {
|
|
40
|
+
throw new Error(built.logs.map(String).join("\n"));
|
|
41
|
+
}
|
|
42
|
+
return output.text();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Where a package's stylesheet is: the `style` entry of its `exports`, which is
|
|
47
|
+
* where CSS packages like tw-animate-css put it. Bun's resolver does not
|
|
48
|
+
* follow that condition, so the package is found by walking up to it.
|
|
49
|
+
*/
|
|
50
|
+
async function stylesheet(name: string): Promise<string> {
|
|
51
|
+
for (let dir = here; ; dir = dirname(dir)) {
|
|
52
|
+
const root = `${dir}/node_modules/${name}`;
|
|
53
|
+
const manifest = Bun.file(`${root}/package.json`);
|
|
54
|
+
if (await manifest.exists()) {
|
|
55
|
+
const { exports, style } = await manifest.json();
|
|
56
|
+
const entry = exports?.["."]?.style ?? style ?? "index.css";
|
|
57
|
+
return resolve(root, entry);
|
|
58
|
+
}
|
|
59
|
+
if (dir === dirname(dir)) {
|
|
60
|
+
throw new Error(`no stylesheet package '${name}' above ${here}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The stylesheet, with only the utilities the page actually uses in it. */
|
|
66
|
+
async function styles(): Promise<string> {
|
|
67
|
+
const base = `${here}/dev`;
|
|
68
|
+
const compiler = await compile(await Bun.file(`${base}/styles.css`).text(), {
|
|
69
|
+
base,
|
|
70
|
+
loadStylesheet: async (id: string, from: string) => {
|
|
71
|
+
// A stylesheet is asked for by bare package name (`@import "tailwindcss"`),
|
|
72
|
+
// then the parts of it by relative path. Only the bare names need module
|
|
73
|
+
// resolution; the rest are paths off a base already inside the package.
|
|
74
|
+
const path = id.startsWith(".")
|
|
75
|
+
? resolve(from, id)
|
|
76
|
+
: await stylesheet(id);
|
|
77
|
+
return {
|
|
78
|
+
path,
|
|
79
|
+
base: dirname(path),
|
|
80
|
+
content: await Bun.file(path).text(),
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
// The `@source` lines in styles.css say which files to read class names out
|
|
85
|
+
// of. Tailwind emits only the utilities it finds there.
|
|
86
|
+
return compiler.build(new Scanner({ sources: compiler.sources }).scan());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// All three as `.txt`: what they hold is a blob the server hands to a browser,
|
|
90
|
+
// and the extensions they were authored under mean something else to a
|
|
91
|
+
// compiler. `.js` would have tsc typecheck a bundle that already compiled, and
|
|
92
|
+
// Bun's own types read `.html` and `.css` imports as something other than text.
|
|
93
|
+
await Bun.write(
|
|
94
|
+
`${out}/shell.txt`,
|
|
95
|
+
await Bun.file(`${here}/dev/index.html`).text(),
|
|
96
|
+
);
|
|
97
|
+
await Bun.write(`${out}/main.txt`, await script());
|
|
98
|
+
await Bun.write(`${out}/styles.txt`, await styles());
|