@nickmeriano/task 0.9.0 → 0.11.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.
Files changed (46) hide show
  1. package/README.md +129 -0
  2. package/dist/asks.test.js +6 -0
  3. package/dist/asks.test.js.map +1 -1
  4. package/dist/claim-io.d.ts.map +1 -1
  5. package/dist/claim-io.js +4 -3
  6. package/dist/claim-io.js.map +1 -1
  7. package/dist/claim.d.ts +99 -1
  8. package/dist/claim.d.ts.map +1 -1
  9. package/dist/claim.js +271 -6
  10. package/dist/claim.js.map +1 -1
  11. package/dist/claim.test.d.ts +8 -0
  12. package/dist/claim.test.d.ts.map +1 -1
  13. package/dist/claim.test.js +211 -2
  14. package/dist/claim.test.js.map +1 -1
  15. package/dist/cli.js +193 -7
  16. package/dist/cli.js.map +1 -1
  17. package/dist/export.d.ts +132 -0
  18. package/dist/export.d.ts.map +1 -0
  19. package/dist/export.js +199 -0
  20. package/dist/export.js.map +1 -0
  21. package/dist/export.test.d.ts +12 -0
  22. package/dist/export.test.d.ts.map +1 -0
  23. package/dist/export.test.js +185 -0
  24. package/dist/export.test.js.map +1 -0
  25. package/dist/server.d.ts +6 -0
  26. package/dist/server.d.ts.map +1 -1
  27. package/dist/server.js +12 -3
  28. package/dist/server.js.map +1 -1
  29. package/dist/trailers.d.ts +51 -0
  30. package/dist/trailers.d.ts.map +1 -0
  31. package/dist/trailers.js +32 -0
  32. package/dist/trailers.js.map +1 -0
  33. package/package.json +1 -1
  34. package/skill/SKILL.md +34 -1
  35. package/src/asks.test.ts +15 -0
  36. package/src/claim-io.ts +12 -3
  37. package/src/claim.test.ts +286 -2
  38. package/src/claim.ts +375 -6
  39. package/src/cli.ts +208 -7
  40. package/src/export.test.ts +254 -0
  41. package/src/export.ts +276 -0
  42. package/src/server.ts +12 -3
  43. package/src/trailers.ts +65 -0
  44. package/ui/dist/assets/{index-CoKCUYic.css → index-eHsqltgs.css} +1 -1
  45. package/ui/dist/assets/{index-BjsorZOU.js → index-mJmm4sWq.js} +53 -53
  46. package/ui/dist/index.html +3 -3
package/src/cli.ts CHANGED
@@ -12,25 +12,28 @@
12
12
  import { spawn } from "node:child_process"
13
13
  import { readFileSync } from "node:fs"
14
14
  import type { Server } from "node:http"
15
- import { basename, join, relative, sep } from "node:path"
15
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path"
16
16
  import process from "node:process"
17
17
  import { resolveAuthor } from "./author.ts"
18
18
  import { checkBoards, checkRoot } from "./check.ts"
19
19
  import {
20
20
  ClaimError,
21
21
  claim,
22
+ claimLockOnly,
22
23
  claimNext,
23
24
  claimableTasks,
24
25
  promote,
25
26
  release,
26
27
  selectionBoards,
28
+ sweep,
27
29
  } from "./claim.ts"
28
30
  import { addAskRouted, setAskResolvedRouted } from "./claim-io.ts"
29
31
  import { buildInbox, type InboxEntry } from "./inbox.ts"
30
32
  import { STALE_DAYS, buildOverview } from "./overview.ts"
31
33
  import { searchStore } from "./search.ts"
32
34
  import { detectRepo, parseSlug, publish, resolveHost } from "./publish.ts"
33
- import { createTaskServer } from "./server.ts"
35
+ import { DEFAULT_OUT_DIR, writeExport } from "./export.ts"
36
+ import { UI_DIR, createTaskServer } from "./server.ts"
34
37
  import { initProject, openBoard } from "./file-store.ts"
35
38
  import {
36
39
  CONFIG_FILE,
@@ -94,6 +97,8 @@ const BOOLEAN_FLAGS = new Set([
94
97
  "next",
95
98
  "force",
96
99
  "reopen",
100
+ "lock-only",
101
+ "dry-run",
97
102
  ])
98
103
 
99
104
  /**
@@ -827,7 +832,7 @@ function cmdLink(args: Args, action: "link" | "unlink"): void {
827
832
  }
828
833
 
829
834
  const CLAIM_USAGE =
830
- 'usage: task claim <id> | task claim --next | task claim --release <id> --comment "<why>"'
835
+ 'usage: task claim <id> [--lock-only] [--dry-run] | task claim --next [--dry-run] | task claim --release <id> --comment "<why>"'
831
836
 
832
837
  /**
833
838
  * `task claim <id>` / `task claim --next` / `task claim --release <id>` — see
@@ -837,12 +842,33 @@ const CLAIM_USAGE =
837
842
  */
838
843
  function cmdClaim(args: Args): void {
839
844
  const ref = args.positional[0]
840
- const options = { force: Boolean(args.flags.force) }
841
- const printClaim = (result: { task: Task; branch: string; base: string }): void => {
845
+ const dryRun = Boolean(args.flags["dry-run"])
846
+ const options = { force: Boolean(args.flags.force), dryRun }
847
+ const printClaim = (result: { task: Task; branch: string; base: string; previous: string }): void => {
848
+ if (dryRun) {
849
+ // Same exit-code contract as the real claim, nothing written: the
850
+ // scripted consumer branches on the code, the human reads the verdict.
851
+ if (args.flags.json) {
852
+ console.log(JSON.stringify({ task: result.task, branch: result.branch, dryRun: true }, null, 2))
853
+ } else {
854
+ console.log(`would claim ${result.task.id} — ${result.branch} (from ${result.base}); dry run, nothing written`)
855
+ }
856
+ return
857
+ }
842
858
  if (args.flags.json) {
843
859
  console.log(JSON.stringify({ task: result.task, branch: result.branch }, null, 2))
844
860
  } else {
845
861
  console.log(`claimed ${result.task.id} — on ${result.branch} (from ${result.base}), status in_progress`)
862
+ // The backstop for branch-pinned sessions: a harness that assigned this
863
+ // checkout a branch expects the work to land there, and a plain claim
864
+ // just moved HEAD somewhere else. The skill carries the rule; this line
865
+ // is for the worker that hasn't read it.
866
+ const defaultLocal = result.base.replace(/^origin\//, "")
867
+ if (result.previous !== defaultLocal && result.previous !== result.branch) {
868
+ console.log(
869
+ `note: this moved you off ${result.previous} — if your work must deliver on ${result.previous}, \`task claim --release ${result.task.id} --comment "…"\` and re-claim with --lock-only`,
870
+ )
871
+ }
846
872
  }
847
873
  }
848
874
  try {
@@ -851,7 +877,7 @@ function cmdClaim(args: Args): void {
851
877
  // ticket across the selection scope (--board > root `boards` > nearest),
852
878
  // retrying past lost races internally.
853
879
  if (ref) fail("pass an id or --next, not both")
854
- if (args.flags.release) fail(CLAIM_USAGE)
880
+ if (args.flags.release || args.flags["lock-only"]) fail(CLAIM_USAGE)
855
881
  const result = claimNext(selectionBoards(process.cwd(), boardFlag(args)), options)
856
882
  if (!result) {
857
883
  if (args.flags.json) {
@@ -865,8 +891,37 @@ function cmdClaim(args: Args): void {
865
891
  return
866
892
  }
867
893
  if (!ref) fail(CLAIM_USAGE)
894
+ if (dryRun && (args.flags["lock-only"] || args.flags.release)) {
895
+ // --lock-only builds its flip commit as it validates and --release only
896
+ // deletes; neither has a meaningful stop-before-writing seam.
897
+ fail("--dry-run applies to plain claim and --next only")
898
+ }
868
899
  const store = openStoreFor(ref)
869
900
  const key = store.parseId(ref)
901
+ if (args.flags["lock-only"]) {
902
+ // The branch-pinned session's claim: same lock, no checkout switch —
903
+ // the work delivers on the branch this session was handed, and the flip
904
+ // commit's Delivered-By: trailer says so. See claimLockOnly.
905
+ if (args.flags.release) fail(CLAIM_USAGE)
906
+ const result = claimLockOnly(store, key, options)
907
+ if (args.flags.json) {
908
+ console.log(
909
+ JSON.stringify(
910
+ { task: result.task, branch: result.branch, deliveredBy: result.deliveredBy },
911
+ null,
912
+ 2,
913
+ ),
914
+ )
915
+ } else {
916
+ console.log(
917
+ `claimed ${result.task.id} — ${result.branch} on origin (lock only, from ${result.base}), status in_progress`,
918
+ )
919
+ console.log(
920
+ `checkout untouched — keep working on ${result.deliveredBy} (recorded as Delivered-By in the claim commit)`,
921
+ )
922
+ }
923
+ return
924
+ }
870
925
  if (args.flags.release) {
871
926
  // A released claim must leave its failure context behind: the branch
872
927
  // (and whatever was tried on it) is about to evaporate, so the reason
@@ -906,6 +961,50 @@ function cmdClaim(args: Args): void {
906
961
  }
907
962
  }
908
963
 
964
+ /**
965
+ * `task sweep` — the janitor: delete every claim branch on origin whose ticket
966
+ * is done, canceled, or missing on the current checkout. Zero judgment on
967
+ * purpose — the trigger belongs to a runner (CI on push-to-main or cron), not
968
+ * an agent. Exit codes: 0 clean (including nothing to do), 1 a deletion was
969
+ * attempted and failed — a failing sweep means this runner can't delete
970
+ * branches either, which is worth an alert.
971
+ */
972
+ function cmdSweep(args: Args): void {
973
+ try {
974
+ const result = sweep(process.cwd())
975
+ if (args.flags.json) {
976
+ console.log(JSON.stringify({ sweep: result }, null, 2))
977
+ } else {
978
+ for (const entry of result.swept) {
979
+ console.log(
980
+ `swept ${entry.branch} — ${entry.reason === "missing" ? `no ticket ${entry.ticket}` : `${entry.ticket} is ${entry.reason}`}`,
981
+ )
982
+ }
983
+ for (const entry of result.kept) {
984
+ console.log(`kept ${entry.branch} — ${entry.ticket} is ${entry.status}`)
985
+ }
986
+ for (const branch of result.skipped) {
987
+ console.log(
988
+ `skipped ${branch} — not a name \`task claim\` mints (foreign or pre-migration); delete it by hand if it's stale`,
989
+ )
990
+ }
991
+ for (const entry of result.failed) {
992
+ console.error(`error: couldn't delete ${entry.branch} — ${entry.error}`)
993
+ }
994
+ if (!result.swept.length && !result.kept.length && !result.skipped.length && !result.failed.length) {
995
+ console.log("no claim branches on origin — nothing to sweep")
996
+ }
997
+ }
998
+ if (result.failed.length) process.exit(1)
999
+ } catch (error) {
1000
+ if (error instanceof ClaimError) {
1001
+ console.error(`error: ${error.message}`)
1002
+ process.exit(2)
1003
+ }
1004
+ throw error
1005
+ }
1006
+ }
1007
+
909
1008
  /** `task instructions` — the shipped agent conventions, self-served at runtime. */
910
1009
  function cmdInstructions(): void {
911
1010
  // skill/ ships in the npm package; `../` from both dist/cli.js and
@@ -1215,6 +1314,49 @@ function cmdServe(args: Args): void {
1215
1314
  })
1216
1315
  }
1217
1316
 
1317
+ /**
1318
+ * `task export` — the same board, as files a static host can serve.
1319
+ *
1320
+ * Anchored exactly like `cmdServe`, because that's the promise: what comes out
1321
+ * is what `task serve` would have served, read-only and honestly stale. The
1322
+ * output is the prebuilt UI plus a `snapshot.json` the app reads instead of an
1323
+ * API — no server, no network calls beyond the static files.
1324
+ */
1325
+ function cmdExport(args: Args): void {
1326
+ const serveRoot = findRoot(process.cwd()) ?? process.cwd()
1327
+ const boards = findBoards(serveRoot)
1328
+ if (boards.length === 0) {
1329
+ fail("no .task directory found in this directory, any parent, or below — run `task init` first")
1330
+ }
1331
+ // Deliberately not under `.task/`: `task serve` watches that tree
1332
+ // recursively, so an export written there would fire the change feed on
1333
+ // every asset copied.
1334
+ const out = str(args.flags, "out") ?? join(serveRoot, DEFAULT_OUT_DIR)
1335
+ const outDir = isAbsolute(out) ? out : resolve(process.cwd(), out)
1336
+
1337
+ let summary
1338
+ try {
1339
+ summary = writeExport({
1340
+ serveRoot,
1341
+ outDir,
1342
+ uiDir: UI_DIR,
1343
+ force: Boolean(args.flags.force),
1344
+ base: str(args.flags, "base"),
1345
+ })
1346
+ } catch (error) {
1347
+ fail(error instanceof Error ? error.message : String(error))
1348
+ }
1349
+
1350
+ if (args.flags.json) {
1351
+ console.log(JSON.stringify(summary, null, 2))
1352
+ return
1353
+ }
1354
+ const what = `${summary.tasks} task${summary.tasks === 1 ? "" : "s"} across ${summary.boards} board${summary.boards === 1 ? "" : "s"}`
1355
+ console.log(`Exported ${what}${summary.commit ? ` at ${summary.commit.slice(0, 7)}` : ""}`)
1356
+ console.log(` ${summary.outDir}`)
1357
+ console.log("Serve that directory from any static host — the board reads snapshot.json.")
1358
+ }
1359
+
1218
1360
  /**
1219
1361
  * `task publish` — the board, at a URL.
1220
1362
  *
@@ -1396,6 +1538,20 @@ Usage
1396
1538
  is refused (--force overrides). Exit codes:
1397
1539
  0 claimed, 1 already claimed, 2 not claimable
1398
1540
  (not todo, blocked, dirty tree, at the cap)
1541
+ task claim <id> --lock-only [--force]
1542
+ take the lock without touching the checkout:
1543
+ the in_progress flip commit is built against
1544
+ origin's default branch in a temporary index
1545
+ and pushed as the claim branch — same atomic
1546
+ lock, same exit codes, but HEAD, the working
1547
+ tree and the index stay exactly where they
1548
+ are (dirty is fine). For sessions pinned to
1549
+ a provisioned branch (CI, a Claude web
1550
+ session): the lock lives on the claim
1551
+ branch, the work delivers on yours, and the
1552
+ flip commit's Delivered-By: trailer records
1553
+ which. --release works on a lock-only claim
1554
+ the same way
1399
1555
  task claim --next [--board <P>] [--force]
1400
1556
  claim the top claimable ticket in one call,
1401
1557
  retrying past lost races internally — the
@@ -1405,13 +1561,41 @@ Usage
1405
1561
  the nearest board. Exit codes: 0 claimed
1406
1562
  (prints the ticket), 1 queue empty, 2
1407
1563
  preconditions failed
1564
+ task claim <id|--next> --dry-run
1565
+ the same decision path — every gate, against
1566
+ live origin state — stopped before the first
1567
+ write: no branch, no commit, checkout
1568
+ untouched. Same exit codes as the real
1569
+ command, so automation asks "would a claim
1570
+ succeed right now?" through the code that
1571
+ would perform it. A yes is a reading, not a
1572
+ reservation — no lock is taken. Plain claim
1573
+ and --next only
1408
1574
  task claim --release <id> --comment "<why>"
1409
1575
  abandon a claim: delete the branch on origin
1410
1576
  and locally — the status flip only lived on
1411
1577
  the branch, so deleting it is the revert. The
1412
1578
  comment is required and lands on the ticket
1413
1579
  (attributed like task comment, uncommitted),
1414
- so the next worker inherits what was tried
1580
+ so the next worker inherits what was tried.
1581
+ The deletion is verified with ls-remote: an
1582
+ environment that silently drops deletion
1583
+ pushes fails loudly instead of reporting a
1584
+ release that didn't happen
1585
+ task sweep the janitor: delete every claim branch on
1586
+ origin whose ticket is done, canceled, or
1587
+ missing on the current checkout, across all
1588
+ the repo's boards. Scoped to names \`claim\`
1589
+ mints (<prefix>-<valid key>): anything else
1590
+ under the namespace — pre-migration numeric
1591
+ claims, hand-made branches — is reported and
1592
+ left alone, since it may be an open PR's
1593
+ head. Pure git, zero judgment — run it from
1594
+ a runner that can delete remote branches (CI
1595
+ on push-to-main or cron) to heal stale
1596
+ claims left by environments that can't.
1597
+ Prints swept/kept/skipped; exits non-zero if
1598
+ a deletion was attempted and failed
1415
1599
  task list --claimable [--board <P>]
1416
1600
  the claim queue: todo tickets in position
1417
1601
  order, minus blocked / already claimed on
@@ -1464,6 +1648,19 @@ Usage
1464
1648
  fails instead). Serves every board at or below
1465
1649
  here — in a monorepo the header becomes a
1466
1650
  board switcher
1651
+ task export [--out <dir>] [--base <path>] [--force]
1652
+ write the board out as static files — the same
1653
+ UI plus a snapshot.json it reads instead of an
1654
+ API, so any static host serves it read-only
1655
+ with no server behind it. Same boards as
1656
+ \`task serve\`; defaults to ./${DEFAULT_OUT_DIR}.
1657
+ --base is the URL path the export will live
1658
+ under (default /) — pass /board/ when it's
1659
+ hosted at example.com/board/ instead of a
1660
+ domain root. Re-exporting refreshes an earlier
1661
+ export in place and leaves anything else in
1662
+ there alone; a non-empty directory that isn't
1663
+ one needs --force
1467
1664
  task publish [--public | --private] [--repo <owner/name>]
1468
1665
  give this repo's board a URL at
1469
1666
  task.nickmeriano.com, read-only, updated from
@@ -1536,6 +1733,8 @@ function main(): void | Promise<void> {
1536
1733
  return cmdLink(args, "unlink")
1537
1734
  case "claim":
1538
1735
  return cmdClaim(args)
1736
+ case "sweep":
1737
+ return cmdSweep(args)
1539
1738
  case "instructions":
1540
1739
  return cmdInstructions()
1541
1740
  case "ask":
@@ -1564,6 +1763,8 @@ function main(): void | Promise<void> {
1564
1763
  return cmdCheck(args)
1565
1764
  case "serve":
1566
1765
  return cmdServe(args)
1766
+ case "export":
1767
+ return cmdExport(args)
1567
1768
  case "publish":
1568
1769
  return cmdPublish(args)
1569
1770
  case "unpublish":
@@ -0,0 +1,254 @@
1
+ /**
2
+ * `task export` writes a file another program reads, so what's checked here is
3
+ * the contract rather than the code: every field comes from the same store
4
+ * call the matching `/api/…` route makes, and the directory it lands in
5
+ * refreshes without eating what someone else put there.
6
+ *
7
+ * The round trip through the actual reader (`parseSnapshot` + `snapshotSource`)
8
+ * lives in `ui/src/deployment.test.ts` — it's the UI's tsconfig that can see
9
+ * both sides.
10
+ */
11
+
12
+ import assert from "node:assert/strict"
13
+ import { test } from "node:test"
14
+ import { execFileSync } from "node:child_process"
15
+ import {
16
+ existsSync,
17
+ mkdirSync,
18
+ mkdtempSync,
19
+ readFileSync,
20
+ rmSync,
21
+ writeFileSync,
22
+ } from "node:fs"
23
+ import { tmpdir } from "node:os"
24
+ import { join } from "node:path"
25
+ import { initProject, openBoard } from "./file-store.ts"
26
+ import { buildSnapshot, writeExport, SNAPSHOT_FILE, SNAPSHOT_VERSION, type Snapshot } from "./export.ts"
27
+ import { STATUSES } from "./types.ts"
28
+
29
+ function tempRoot(): string {
30
+ const dir = mkdtempSync(join(tmpdir(), "task-export-"))
31
+ process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
32
+ return dir
33
+ }
34
+
35
+ /** A fake `ui/dist` — the shape of one is all the copy step cares about. */
36
+ function fakeUi(): string {
37
+ const dir = tempRoot()
38
+ writeFileSync(join(dir, "index.html"), "<!doctype html><html><head><title>board</title></head></html>")
39
+ mkdirSync(join(dir, "assets"))
40
+ writeFileSync(join(dir, "assets", "index-abc123.js"), "// bundle")
41
+ return dir
42
+ }
43
+
44
+ /** A single-board repo with enough in it to be worth exporting. */
45
+ function board(): string {
46
+ const root = tempRoot()
47
+ const store = initProject(root, { name: "acme", prefix: "ACM" })
48
+ const talked = store.create({ title: "Ship the export", status: "todo" })
49
+ store.addComment(talked.key, "on it", "claude")
50
+ store.addComment(talked.key, "still on it", "claude")
51
+ store.create({ title: "Quiet ticket", status: "backlog" })
52
+ const finished = store.create({ title: "Already shipped", status: "done" })
53
+ store.archive(finished.key)
54
+ store.createGoal({ title: "Launch", slug: "launch" })
55
+ store.createGoal({ title: "Old plan", slug: "old-plan" })
56
+ store.archiveGoal("old-plan")
57
+ store.close()
58
+ return root
59
+ }
60
+
61
+ test("the snapshot answers what the serve API answers", () => {
62
+ const root = board()
63
+ const store = openBoard(root)
64
+
65
+ const snapshot = buildSnapshot(root)
66
+ const data = snapshot.data["."]
67
+
68
+ // /api/project — field for field, minus readOnly (the reader forces it).
69
+ assert.equal(data.project.name, store.config.name)
70
+ assert.equal(data.project.prefix, store.config.prefix)
71
+ assert.deepEqual(data.project.statuses, STATUSES)
72
+ assert.equal(typeof data.project.author, "string")
73
+ assert.ok(!("readOnly" in data.project))
74
+
75
+ // /api/tasks — the same list, in the same order, with the same counts.
76
+ const tasks = store.list()
77
+ const counts = store.commentCounts()
78
+ assert.deepEqual(
79
+ data.tasks,
80
+ tasks.map((t) => ({ ...t, commentCount: counts.get(t.key) ?? 0 })),
81
+ )
82
+
83
+ // /api/goals — live first, archived riding along flagged.
84
+ assert.deepEqual(data.goals, [...store.goals(), ...store.goals(true)])
85
+ assert.ok(data.goals.some((g) => g.archived))
86
+
87
+ // /api/tasks/:n — comments per task, oldest first, empty ones omitted.
88
+ const talkative = tasks.find((t) => t.title === "Ship the export")!
89
+ const quiet = tasks.find((t) => t.title === "Quiet ticket")!
90
+ assert.deepEqual(data.comments[talkative.key], store.comments(talkative.key))
91
+ assert.equal(data.comments[quiet.key], undefined)
92
+
93
+ store.close()
94
+ })
95
+
96
+ test("an archived ticket is off the board, in the export too", () => {
97
+ const root = board()
98
+
99
+ const snapshot = buildSnapshot(root)
100
+
101
+ assert.ok(!snapshot.data["."].tasks.some((t) => t.title === "Already shipped"))
102
+ })
103
+
104
+ test("a monorepo root exports every board, keyed by what /boards says", () => {
105
+ const root = tempRoot()
106
+ initProject(root, { name: "acme", prefix: "ACM" }).close()
107
+ const nested = join(root, "projects", "widget")
108
+ mkdirSync(nested, { recursive: true })
109
+ initProject(nested, { name: "widget", prefix: "WID" }).close()
110
+
111
+ const snapshot = buildSnapshot(root)
112
+
113
+ assert.deepEqual(
114
+ snapshot.boards.map((b) => b.id),
115
+ [".", "projects/widget"],
116
+ )
117
+ assert.deepEqual(snapshot.boards[1], { id: "projects/widget", name: "widget", prefix: "WID" })
118
+ // Every board in the list has an entry, and nothing else does.
119
+ assert.deepEqual(Object.keys(snapshot.data).sort(), [".", "projects/widget"])
120
+ })
121
+
122
+ test("a board with nothing above or below it is not an export", () => {
123
+ assert.throws(() => buildSnapshot(tempRoot()), /run `task init`/)
124
+ })
125
+
126
+ test("the commit is the HEAD it was exported from, or null outside a repo", () => {
127
+ const loose = board()
128
+
129
+ assert.equal(buildSnapshot(loose).meta.commit, null)
130
+
131
+ const repo = board()
132
+ const git = (...args: string[]) =>
133
+ execFileSync("git", args, { cwd: repo, stdio: ["ignore", "pipe", "ignore"] })
134
+ git("init", "-q")
135
+ git("config", "user.email", "t@example.com")
136
+ git("config", "user.name", "Test")
137
+ git("add", "-A")
138
+ git("commit", "-qm", "board")
139
+ const head = git("rev-parse", "HEAD").toString().trim()
140
+
141
+ assert.equal(buildSnapshot(repo).meta.commit, head)
142
+ })
143
+
144
+ test("the export is the assets plus the snapshot", () => {
145
+ const root = board()
146
+ const out = join(tempRoot(), "board")
147
+
148
+ const summary = writeExport({ serveRoot: root, outDir: out, uiDir: fakeUi() })
149
+
150
+ assert.ok(existsSync(join(out, "index.html")))
151
+ assert.ok(existsSync(join(out, "assets", "index-abc123.js")))
152
+ const snapshot = JSON.parse(readFileSync(join(out, SNAPSHOT_FILE), "utf8")) as Snapshot
153
+ assert.equal(snapshot.version, SNAPSHOT_VERSION)
154
+ assert.equal(snapshot.boards.length, 1)
155
+ assert.deepEqual(summary, {
156
+ outDir: out,
157
+ base: "/",
158
+ boards: 1,
159
+ tasks: snapshot.data["."].tasks.length,
160
+ commit: null,
161
+ builtAt: snapshot.meta.builtAt,
162
+ })
163
+ })
164
+
165
+ test("exporting without a built UI says which build to run", () => {
166
+ assert.throws(
167
+ () =>
168
+ writeExport({
169
+ serveRoot: board(),
170
+ outDir: join(tempRoot(), "board"),
171
+ uiDir: join(tempRoot(), "never-built"),
172
+ }),
173
+ /run the package build/,
174
+ )
175
+ })
176
+
177
+ test("exporting onto the UI build is refused, not a way to delete it", () => {
178
+ const ui = fakeUi()
179
+
180
+ assert.throws(
181
+ () => writeExport({ serveRoot: board(), outDir: ui, uiDir: ui, force: true }),
182
+ /the UI build itself/,
183
+ )
184
+ assert.ok(existsSync(join(ui, "index.html")))
185
+ })
186
+
187
+ test("re-exporting refreshes in place and leaves a deployed function alone", () => {
188
+ const root = board()
189
+ const out = join(tempRoot(), "board")
190
+ const ui = fakeUi()
191
+ writeExport({ serveRoot: root, outDir: out, uiDir: ui })
192
+ // TAS-33's room: something the export didn't put there and must not remove.
193
+ mkdirSync(join(out, "functions"))
194
+ writeFileSync(join(out, "functions", "handler.js"), "// live proxy")
195
+ // A stale bundle from the previous build, which it *should* remove.
196
+ writeFileSync(join(out, "assets", "index-old.js"), "// last time")
197
+
198
+ const store = openBoard(root)
199
+ store.create({ title: "Added since the last export", status: "todo" })
200
+ store.close()
201
+ writeExport({ serveRoot: root, outDir: out, uiDir: ui })
202
+
203
+ const snapshot = JSON.parse(readFileSync(join(out, SNAPSHOT_FILE), "utf8")) as Snapshot
204
+ assert.ok(snapshot.data["."].tasks.some((t) => t.title === "Added since the last export"))
205
+ assert.equal(readFileSync(join(out, "functions", "handler.js"), "utf8"), "// live proxy")
206
+ assert.ok(!existsSync(join(out, "assets", "index-old.js")))
207
+ })
208
+
209
+ test("a directory that isn't a previous export is refused, not overwritten", () => {
210
+ const root = board()
211
+ const out = join(tempRoot(), "not-an-export")
212
+ mkdirSync(out)
213
+ writeFileSync(join(out, "thesis.md"), "years of work")
214
+
215
+ assert.throws(
216
+ () => writeExport({ serveRoot: root, outDir: out, uiDir: fakeUi() }),
217
+ /--force/,
218
+ )
219
+ assert.equal(readFileSync(join(out, "thesis.md"), "utf8"), "years of work")
220
+
221
+ // --force writes in; it still only ever overwrites what it writes.
222
+ writeExport({ serveRoot: root, outDir: out, uiDir: fakeUi(), force: true })
223
+ assert.ok(existsSync(join(out, SNAPSHOT_FILE)))
224
+ assert.equal(readFileSync(join(out, "thesis.md"), "utf8"), "years of work")
225
+ })
226
+
227
+ // ── The mount point, stamped into the page ──────────────────────────────────
228
+
229
+ test("the exported page carries its mount point as a <base> tag", () => {
230
+ const root = board()
231
+
232
+ const atRoot = join(tempRoot(), "root")
233
+ writeExport({ serveRoot: root, outDir: atRoot, uiDir: fakeUi() })
234
+ assert.match(readFileSync(join(atRoot, "index.html"), "utf8"), /<head><base href="\/" \/>/)
235
+
236
+ const atSubpath = join(tempRoot(), "sub")
237
+ const summary = writeExport({ serveRoot: root, outDir: atSubpath, uiDir: fakeUi(), base: "board" })
238
+ assert.equal(summary.base, "/board/")
239
+ assert.match(
240
+ readFileSync(join(atSubpath, "index.html"), "utf8"),
241
+ /<head><base href="\/board\/" \/>/,
242
+ )
243
+ })
244
+
245
+ test("a base that isn't a plain URL path is refused", () => {
246
+ const root = board()
247
+ for (const bad of ["//cdn.example.com", "/a b/", '/"/']) {
248
+ assert.throws(
249
+ () => writeExport({ serveRoot: root, outDir: join(tempRoot(), "out"), uiDir: fakeUi(), base: bad }),
250
+ /--base/,
251
+ bad,
252
+ )
253
+ }
254
+ })