@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.
- package/README.md +129 -0
- package/dist/asks.test.js +6 -0
- package/dist/asks.test.js.map +1 -1
- package/dist/claim-io.d.ts.map +1 -1
- package/dist/claim-io.js +4 -3
- package/dist/claim-io.js.map +1 -1
- package/dist/claim.d.ts +99 -1
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +271 -6
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.d.ts +8 -0
- package/dist/claim.test.d.ts.map +1 -1
- package/dist/claim.test.js +211 -2
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +193 -7
- package/dist/cli.js.map +1 -1
- package/dist/export.d.ts +132 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +199 -0
- package/dist/export.js.map +1 -0
- package/dist/export.test.d.ts +12 -0
- package/dist/export.test.d.ts.map +1 -0
- package/dist/export.test.js +185 -0
- package/dist/export.test.js.map +1 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +12 -3
- package/dist/server.js.map +1 -1
- package/dist/trailers.d.ts +51 -0
- package/dist/trailers.d.ts.map +1 -0
- package/dist/trailers.js +32 -0
- package/dist/trailers.js.map +1 -0
- package/package.json +1 -1
- package/skill/SKILL.md +34 -1
- package/src/asks.test.ts +15 -0
- package/src/claim-io.ts +12 -3
- package/src/claim.test.ts +286 -2
- package/src/claim.ts +375 -6
- package/src/cli.ts +208 -7
- package/src/export.test.ts +254 -0
- package/src/export.ts +276 -0
- package/src/server.ts +12 -3
- package/src/trailers.ts +65 -0
- package/ui/dist/assets/{index-CoKCUYic.css → index-eHsqltgs.css} +1 -1
- package/ui/dist/assets/{index-BjsorZOU.js → index-mJmm4sWq.js} +53 -53
- 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 {
|
|
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
|
|
841
|
-
const
|
|
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
|
+
})
|