@erclx/canon 5.54.0 → 5.56.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/CHANGELOG.md +16 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/SKILL.md +8 -10
- package/claude/skills/auto-ship/SKILL.md +4 -5
- package/claude/skills/auto-ship/references/failure-recovery.md +37 -17
- package/claude/skills/auto-ship/references/tier-failures.md +9 -7
- package/claude/skills/auto-ship/references/worktree-name.md +8 -5
- package/claude/skills/backlog-triage/SKILL.md +10 -10
- package/claude/skills/backlog-triage/references/apply.md +6 -6
- package/claude/skills/context-fold/references/classify.md +17 -4
- package/claude/skills/create-rule/references/rule.md +4 -0
- package/claude/skills/create-skill/SKILL.md +3 -3
- package/claude/skills/deploy-app/SKILL.md +2 -0
- package/claude/skills/deprecation-migration/SKILL.md +7 -9
- package/claude/skills/document-health/SKILL.md +6 -6
- package/claude/skills/document-health/references/axes.md +1 -1
- package/claude/skills/document-repair/SKILL.md +5 -5
- package/claude/skills/git-followup/SKILL.md +16 -5
- package/claude/skills/git-issue/references/issue.md +9 -11
- package/claude/skills/git-pr/references/publish.md +4 -5
- package/claude/skills/git-split/SKILL.md +11 -4
- package/claude/skills/markdown-craft/SKILL.md +1 -1
- package/claude/skills/markdown-craft/references/markdown.md +2 -2
- package/claude/skills/markdown-craft/references/tables.md +9 -7
- package/claude/skills/plan-feature/SKILL.md +2 -0
- package/claude/skills/plan-feature/references/plan.md +2 -0
- package/claude/skills/review-address/SKILL.md +9 -7
- package/claude/skills/review-craft/SKILL.md +7 -9
- package/claude/skills/review-craft/references/adopted.md +10 -8
- package/claude/skills/review-pr/SKILL.md +2 -0
- package/claude/skills/review-pr/references/key-changes.md +8 -6
- package/claude/skills/review-ui/references/driving.md +10 -8
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +6 -4
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +9 -3
- package/claude/skills/role-reviewer/SKILL.md +5 -5
- package/claude/skills/search-craft/SKILL.md +5 -7
- package/claude/skills/session-worktree/SKILL.md +2 -0
- package/claude/skills/session-worktree/references/main-root-writes.md +2 -0
- package/claude/skills/systematic-debugging/SKILL.md +9 -9
- package/claude/skills/systematic-debugging/references/adopted.md +1 -1
- package/claude/skills/target-check/SKILL.md +6 -8
- package/claude/skills/target-check/references/domains.md +1 -4
- package/claude/skills/target-setup/SKILL.md +8 -8
- package/claude/skills/target-update/SKILL.md +5 -5
- package/claude/skills/task-board/SKILL.md +2 -4
- package/claude/skills/task-board/references/tasks.md +5 -5
- package/claude/skills/task-complete/references/archive.md +5 -2
- package/claude/skills/test-craft/SKILL.md +2 -0
- package/claude/skills/test-craft/references/adopted.md +2 -0
- package/claude/skills/ui-checklist/SKILL.md +8 -9
- package/claude/skills/write-doc/SKILL.md +8 -8
- package/claude/skills/write-doc/references/readme.md +2 -0
- package/claude/skills/write-doc/references/wiki-kind.md +11 -4
- package/docs/agents/context-audit.md +16 -14
- package/docs/agents/install-and-sync.md +2 -2
- package/docs/agents/markdown-audit.md +11 -4
- package/docs/agents/migrate.md +15 -13
- package/docs/agents/output-shape.md +13 -10
- package/docs/target/migrations.md +3 -3
- package/docs/target/sync.md +1 -1
- package/docs/workflow/operating-model.md +2 -0
- package/package.json +1 -1
- package/scripts/core/install-check.sh +1 -1
- package/scripts/lib/ui.sh +12 -65
- package/src/audits/run.ts +1 -1
- package/src/claude/routing.ts +1 -1
- package/src/cli/run.ts +5 -6
- package/src/cli/ui.ts +87 -28
- package/src/cli.ts +6 -0
- package/src/commands/claude.ts +2 -3
- package/src/commands/design.ts +8 -13
- package/src/commands/gate.ts +5 -3
- package/src/commands/gov.ts +11 -21
- package/src/commands/labels.ts +1 -1
- package/src/commands/markdown.ts +25 -29
- package/src/commands/pr.ts +1 -1
- package/src/commands/sync.ts +1 -1
- package/src/commands/tooling.ts +5 -8
- package/src/commands/transcripts.ts +6 -11
- package/src/gate/sequencer.ts +1 -1
- package/src/gate/stages.ts +0 -10
- package/src/hooks/post-merge.ts +2 -5
- package/src/labels/density.ts +5 -46
- package/src/markdown/density.ts +41 -6
- package/src/markdown/scope.ts +6 -1
- package/src/markdown/structure.ts +18 -9
- package/src/markdown/tables.ts +32 -18
- package/src/process/harness.ts +11 -4
- package/src/process/seeded-package.ts +21 -0
- package/src/sandbox/equivalence.ts +0 -1
- package/src/sandbox/provision.ts +8 -13
- package/src/sandbox/scenario.ts +6 -12
- package/src/serve/static.ts +2 -60
- package/src/sync/engine.ts +3 -8
- package/src/sync/workflow.ts +3 -5
- package/src/tooling/verify-stack.ts +1 -1
- package/tooling/base/configs/.husky/post-merge +6 -10
- package/tooling/base/reference.md +7 -6
- package/tooling/claude/reference.md +7 -5
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +7 -6
- package/scripts/core/check-color-source.sh +0 -51
- package/src/serve/live.ts +0 -113
package/src/serve/static.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'
|
|
2
2
|
import { join, resolve, sep } from 'node:path'
|
|
3
|
-
import type { LiveStream } from '@/serve/live'
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
5
|
* The loopback interface, never a wildcard bind. A preview serves whatever
|
|
@@ -79,25 +78,11 @@ export interface ServeStarted {
|
|
|
79
78
|
|
|
80
79
|
export type ServeOutcome = ServeStarted | ServeRefused
|
|
81
80
|
|
|
82
|
-
/** The route a live page's script opens its event stream on. */
|
|
83
|
-
export const LIVE_EVENTS_PATH = '/__live'
|
|
84
|
-
|
|
85
|
-
export interface LiveServe {
|
|
86
|
-
readonly stream: Pick<LiveStream<unknown>, 'answer'>
|
|
87
|
-
/**
|
|
88
|
-
* The `<script>` element spliced into every page served, built by the caller
|
|
89
|
-
* so this module stays free of what a reload means to any one surface.
|
|
90
|
-
*/
|
|
91
|
-
readonly script: string
|
|
92
|
-
}
|
|
93
|
-
|
|
94
81
|
export interface ServeOptions {
|
|
95
82
|
readonly port?: number
|
|
96
83
|
readonly entry?: string
|
|
97
84
|
/** Answers a directory request with no index page by listing it. Off by default. */
|
|
98
85
|
readonly index?: boolean
|
|
99
|
-
/** Answers the events route and splices a script into each page. Off by default. */
|
|
100
|
-
readonly live?: LiveServe
|
|
101
86
|
}
|
|
102
87
|
|
|
103
88
|
function escapeHtml(text: string): string {
|
|
@@ -267,59 +252,16 @@ export function bindFirstFree(
|
|
|
267
252
|
return undefined
|
|
268
253
|
}
|
|
269
254
|
|
|
270
|
-
/**
|
|
271
|
-
* Splices the script in before the last closing body tag of a page. A page
|
|
272
|
-
* carrying none goes out as the bytes on disk, since guessing a place for it
|
|
273
|
-
* risks breaking markup the author wrote. The splice happens per response and
|
|
274
|
-
* never on disk, so a page the reader saves or commits carries no hook.
|
|
275
|
-
*/
|
|
276
|
-
export async function injectLive(
|
|
277
|
-
response: Response,
|
|
278
|
-
script: string,
|
|
279
|
-
): Promise<Response> {
|
|
280
|
-
const type = response.headers.get('content-type') ?? ''
|
|
281
|
-
if (response.status !== 200 || !type.startsWith('text/html')) return response
|
|
282
|
-
|
|
283
|
-
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
284
|
-
const html = new TextDecoder().decode(bytes)
|
|
285
|
-
/*
|
|
286
|
-
* Searched in the original rather than a lowercased copy, since lowercasing
|
|
287
|
-
* changes the length of some characters, such as İ, and an index read off
|
|
288
|
-
* the copy would cut the original inside the tag.
|
|
289
|
-
*/
|
|
290
|
-
const at = [...html.matchAll(/<\/body>/gi)].at(-1)?.index
|
|
291
|
-
const init = { status: response.status, headers: response.headers }
|
|
292
|
-
if (at === undefined) return new Response(bytes, init)
|
|
293
|
-
return new Response(`${html.slice(0, at)}${script}${html.slice(at)}`, init)
|
|
294
|
-
}
|
|
295
|
-
|
|
296
255
|
function listen(
|
|
297
256
|
root: string,
|
|
298
257
|
first: number,
|
|
299
258
|
index: boolean,
|
|
300
|
-
live: LiveServe | undefined,
|
|
301
259
|
): { server: BoundServer; port: number } | undefined {
|
|
302
|
-
if (!live) {
|
|
303
|
-
return bindFirstFree(first, (port) =>
|
|
304
|
-
Bun.serve({
|
|
305
|
-
hostname: SERVE_HOST,
|
|
306
|
-
port,
|
|
307
|
-
fetch: (request) => respond(root, request, index),
|
|
308
|
-
}),
|
|
309
|
-
)
|
|
310
|
-
}
|
|
311
260
|
return bindFirstFree(first, (port) =>
|
|
312
261
|
Bun.serve({
|
|
313
262
|
hostname: SERVE_HOST,
|
|
314
263
|
port,
|
|
315
|
-
|
|
316
|
-
idleTimeout: 0,
|
|
317
|
-
fetch: async (request, server) => {
|
|
318
|
-
if (new URL(request.url).pathname === LIVE_EVENTS_PATH) {
|
|
319
|
-
return live.stream.answer(request, server.port ?? 0)
|
|
320
|
-
}
|
|
321
|
-
return injectLive(await respond(root, request, index), live.script)
|
|
322
|
-
},
|
|
264
|
+
fetch: (request) => respond(root, request, index),
|
|
323
265
|
}),
|
|
324
266
|
)
|
|
325
267
|
}
|
|
@@ -435,7 +377,7 @@ export function startServer(
|
|
|
435
377
|
*/
|
|
436
378
|
let bound: ReturnType<typeof listen>
|
|
437
379
|
try {
|
|
438
|
-
bound = listen(root, first, options.index ?? false
|
|
380
|
+
bound = listen(root, first, options.index ?? false)
|
|
439
381
|
} catch (error) {
|
|
440
382
|
const code = (error as NodeJS.ErrnoException).code ?? 'unknown'
|
|
441
383
|
return refuse(
|
package/src/sync/engine.ts
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
logStep,
|
|
27
27
|
logWarn,
|
|
28
28
|
outro,
|
|
29
|
-
|
|
29
|
+
outroSuccess,
|
|
30
30
|
select,
|
|
31
31
|
} from '@/cli/ui'
|
|
32
32
|
import { compareVersions, parseVersion } from '@/version/compare'
|
|
@@ -436,7 +436,6 @@ export async function runDomainSync(
|
|
|
436
436
|
}
|
|
437
437
|
|
|
438
438
|
const plan = planSync(adapter, resolved)
|
|
439
|
-
const { GREEN, GREY, NC } = palette(process.stderr)
|
|
440
439
|
|
|
441
440
|
if (
|
|
442
441
|
!existsSync(adapter.installedRoot(resolved)) &&
|
|
@@ -453,8 +452,7 @@ export async function runDomainSync(
|
|
|
453
452
|
if (count === 0) {
|
|
454
453
|
await adapter.onComplete?.(resolved)
|
|
455
454
|
await recordStamp(adapter, resolved, new Date())
|
|
456
|
-
|
|
457
|
-
process.stderr.write(`${GREEN}✓ Everything up to date${NC}\n`)
|
|
455
|
+
outroSuccess('Everything up to date')
|
|
458
456
|
return 0
|
|
459
457
|
}
|
|
460
458
|
|
|
@@ -489,10 +487,7 @@ export async function runDomainSync(
|
|
|
489
487
|
await adapter.onComplete?.(resolved)
|
|
490
488
|
await recordStamp(adapter, resolved, new Date())
|
|
491
489
|
|
|
492
|
-
|
|
493
|
-
process.stderr.write(
|
|
494
|
-
`${GREEN}✓ Sync complete${NC} ${GREY}(${count} ${adapter.unit})${NC}\n`,
|
|
495
|
-
)
|
|
490
|
+
outroSuccess('Sync complete', `(${count} ${adapter.unit})`)
|
|
496
491
|
return 0
|
|
497
492
|
}
|
|
498
493
|
|
package/src/sync/workflow.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
logStep,
|
|
20
20
|
logWarn,
|
|
21
21
|
outro,
|
|
22
|
-
|
|
22
|
+
outroSuccess,
|
|
23
23
|
pipeOutput,
|
|
24
24
|
select,
|
|
25
25
|
} from '@/cli/ui'
|
|
@@ -75,7 +75,7 @@ export async function collectChanges(git: GitRunner): Promise<DomainChange[]> {
|
|
|
75
75
|
*
|
|
76
76
|
* This is the only toolkit code that writes to a git remote, which is why a
|
|
77
77
|
* headless run refuses it outright. `select_option` resolved to its first
|
|
78
|
-
* option
|
|
78
|
+
* option with no terminal to answer it, and the first option here was
|
|
79
79
|
* `Commit and open PR` whenever `gh` was installed, so an agent following the
|
|
80
80
|
* documented non-interactive path pushed a branch and opened a pull request on
|
|
81
81
|
* someone's repository without being asked.
|
|
@@ -193,8 +193,6 @@ function promptChoice(canOpenPullRequest: boolean): Promise<WorkflowChoice> {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
function succeed(message: string): number {
|
|
196
|
-
|
|
197
|
-
outro()
|
|
198
|
-
process.stderr.write(`\n${GREEN}✓ ${message}${NC}\n`)
|
|
196
|
+
outroSuccess(message)
|
|
199
197
|
return 0
|
|
200
198
|
}
|
|
@@ -27,20 +27,16 @@ if ! command -v canon >/dev/null 2>&1; then
|
|
|
27
27
|
exit 0
|
|
28
28
|
fi
|
|
29
29
|
|
|
30
|
-
# A global binary older than the verb cannot run it, and
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
30
|
+
# A global binary older than the verb cannot run it, and its owner upgrades it
|
|
31
|
+
# by hand. The probe reads the verb's own usage line rather than the exit,
|
|
32
|
+
# since an older binary answers an unknown command's `--help` with its root
|
|
33
|
+
# help and exits zero.
|
|
34
34
|
has_verb() {
|
|
35
35
|
canon hooks post-merge --help 2>/dev/null | grep -q '^Usage: canon hooks post-merge'
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
if ! has_verb; then
|
|
39
|
-
if [ -z "$CANON_SKIP_UPGRADE" ]; then
|
|
40
|
-
CANON_NON_INTERACTIVE=1 canon upgrade >/dev/null 2>&1 || true
|
|
41
|
-
fi
|
|
42
|
-
fi
|
|
43
|
-
|
|
44
38
|
if has_verb; then
|
|
45
39
|
canon hooks post-merge --root "$root" || true
|
|
40
|
+
else
|
|
41
|
+
echo 'canon is too old to run the post-merge hook. Run `canon upgrade`.' >&2
|
|
46
42
|
fi
|
|
@@ -91,18 +91,19 @@ Negative knowledge that costs a failed run to relearn.
|
|
|
91
91
|
|
|
92
92
|
## CLI
|
|
93
93
|
|
|
94
|
-
| Script | What it does
|
|
95
|
-
| ---------------------- |
|
|
96
|
-
| `bun run check` | Gates a checkout through `scripts/verify.ts`, writing nothing
|
|
97
|
-
| `bun run check:format` | Asserts oxfmt formatting without writing
|
|
98
|
-
| `bun run format` | Writes oxfmt formatting in place
|
|
99
|
-
| `bun run prepare` | Initializes husky hooks, run automatically on `bun install`
|
|
94
|
+
| Script | What it does |
|
|
95
|
+
| ---------------------- | ------------------------------------------------------------- |
|
|
96
|
+
| `bun run check` | Gates a checkout through `scripts/verify.ts`, writing nothing |
|
|
97
|
+
| `bun run check:format` | Asserts oxfmt formatting without writing |
|
|
98
|
+
| `bun run format` | Writes oxfmt formatting in place |
|
|
99
|
+
| `bun run prepare` | Initializes husky hooks, run automatically on `bun install` |
|
|
100
100
|
|
|
101
101
|
### The check script
|
|
102
102
|
|
|
103
103
|
Base writes `check` as a `[scripts.override]`, so a sync replaces whatever `check` a target declared, including one that chained further checks after the runner. Re-add a chained check as its own script after a sync.
|
|
104
104
|
|
|
105
105
|
- `bun run check` writes nothing. Formatting drift fails it at `check:format`, and the failure line names `bun run format`, the one command that writes.
|
|
106
|
+
- `VERIFY_NESTED=true` suppresses timeline boundaries when `check` runs inside another call
|
|
106
107
|
- Auto-fixing drift inside `check` rewrote files in a shared checkout on every push and left a gate that could not fail, so the writing pass stays in `bun run format`.
|
|
107
108
|
- The pull request workflow calls `check:format` directly as well.
|
|
108
109
|
|
|
@@ -47,11 +47,13 @@ A project installed before the board became a folder still holds `.claude/TASKS.
|
|
|
47
47
|
|
|
48
48
|
## CLI
|
|
49
49
|
|
|
50
|
-
| Command | What it does
|
|
51
|
-
| -------------------- |
|
|
52
|
-
| `canon claude init` | Seeds `.claude/` workflow docs, updates `.gitignore`
|
|
53
|
-
| `canon claude sync` | Reconciles `.gitignore` against the manifest. Reports seeded file status.
|
|
54
|
-
| `canon claude setup` | Installs user-level config to `~/.claude/` once per machine
|
|
50
|
+
| Command | What it does |
|
|
51
|
+
| -------------------- | ------------------------------------------------------------------------- |
|
|
52
|
+
| `canon claude init` | Seeds `.claude/` workflow docs, updates `.gitignore` |
|
|
53
|
+
| `canon claude sync` | Reconciles `.gitignore` against the manifest. Reports seeded file status. |
|
|
54
|
+
| `canon claude setup` | Installs user-level config to `~/.claude/` once per machine |
|
|
55
|
+
|
|
56
|
+
`canon claude setup` writes the statusline and the permissions allow and deny lists, and removes the empty attribution pair an earlier setup wrote.
|
|
55
57
|
|
|
56
58
|
## CLAUDE.md
|
|
57
59
|
|
|
@@ -79,12 +79,13 @@ colons=$(printf '%s' "$record" |
|
|
|
79
79
|
# returns them for a path in its scope and a finding the push does not fail on
|
|
80
80
|
# yet is the one a rewrite has to clear before its folder is gated.
|
|
81
81
|
density=$(printf '%s' "$record" |
|
|
82
|
-
jq -r '.entries[]?.density[]? | ":\(.line) \(.kind) " + (if .limit == null then "opens with neither a paragraph nor a table" else "\(.value) past \(.limit)" end) + (if .gating then " gating" else "" end)' 2>/dev/null)
|
|
82
|
+
jq -r '.entries[]?.density[]? | ":\(.line) \(.kind) " + (if .kind == "table-then-list" then "a list follows the table with no lead sentence" elif .limit == null then "opens with neither a paragraph nor a table" else "\(.value) past \(.limit)" end) + (if .gating then " gating" else "" end)' 2>/dev/null)
|
|
83
83
|
|
|
84
|
-
#
|
|
85
|
-
# fix its shape takes rather than the seam a density finding
|
|
84
|
+
# The row count and the wide token report and never gate, so each kind is handed
|
|
85
|
+
# back with the fix its shape takes rather than the seam a density finding
|
|
86
|
+
# splits at. The cell and the trailing list arrive as density findings.
|
|
86
87
|
tables=$(printf '%s' "$record" |
|
|
87
|
-
jq -r '.entries[]?.tables[]? | ":\(.line) \(.kind) " +
|
|
88
|
+
jq -r '.entries[]?.tables[]? | ":\(.line) \(.kind) " + "\(.value) past \(.limit)"' 2>/dev/null)
|
|
88
89
|
|
|
89
90
|
# A page marked `kind: reference` carries reference shape findings, handed back
|
|
90
91
|
# whole the same way, since a gated one fails the push like a density finding.
|
|
@@ -126,12 +127,12 @@ if [ -n "$colons" ]; then
|
|
|
126
127
|
fi
|
|
127
128
|
|
|
128
129
|
if [ -n "$density" ]; then
|
|
129
|
-
found=$(printf 'Standards-audit: %s breaks the markdown density limits, being a 350-character paragraph, 2 paragraphs in a row, a 200-character bullet, 5 plain bullets in a row, no H4, and a paragraph or table opening every H3. Split each block at its seam rather than trimming words to pass. A finding marked gating fails the push.\n%s' "$file" "$density")
|
|
130
|
+
found=$(printf 'Standards-audit: %s breaks the markdown density limits, being a 350-character paragraph, 2 paragraphs in a row, a 200-character bullet, 5 plain bullets in a row, no H4, and a paragraph or table opening every H3, a 120-character table cell, and no list directly after a table. Split each block at its seam rather than trimming words to pass. A finding marked gating fails the push.\n%s' "$file" "$density")
|
|
130
131
|
msg="${msg:+$msg$nl}$found"
|
|
131
132
|
fi
|
|
132
133
|
|
|
133
134
|
if [ -n "$tables" ]; then
|
|
134
|
-
found=$(printf 'Standards-audit: %s breaks the markdown table limits, being
|
|
135
|
+
found=$(printf 'Standards-audit: %s breaks the markdown table limits that report, being 8 body rows and a code span holding a run past 30 characters. These never fail a push. Split a long table into groups each under a lead sentence, and move a wide token out of the table.\n%s' "$file" "$tables")
|
|
135
136
|
msg="${msg:+$msg$nl}$found"
|
|
136
137
|
fi
|
|
137
138
|
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
set -e
|
|
3
|
-
set -o pipefail
|
|
4
|
-
|
|
5
|
-
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
-
PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
|
|
7
|
-
|
|
8
|
-
SCRIPTS_ROOT="$PROJECT_ROOT/scripts"
|
|
9
|
-
SANDBOX_ROOT="$PROJECT_ROOT/sandbox"
|
|
10
|
-
PALETTE_SOURCE="$SCRIPTS_ROOT/lib/ui.sh"
|
|
11
|
-
|
|
12
|
-
# `find` writes to stderr and returns non-zero for a missing root, inside a
|
|
13
|
-
# process substitution whose status nothing reads. Without this the walk covers
|
|
14
|
-
# nothing and the check reports the tree clean having never measured it.
|
|
15
|
-
if [ ! -f "$PALETTE_SOURCE" ]; then
|
|
16
|
-
echo "No palette source at ${PALETTE_SOURCE#"$PROJECT_ROOT/"}, color sourcing unverifiable." >&2
|
|
17
|
-
exit 1
|
|
18
|
-
fi
|
|
19
|
-
|
|
20
|
-
# The same silence follows a root that moved, so a walked root that is gone
|
|
21
|
-
# fails here rather than shrinking the corpus with no message.
|
|
22
|
-
for root in "$SCRIPTS_ROOT" "$SANDBOX_ROOT"; do
|
|
23
|
-
if [ ! -d "$root" ]; then
|
|
24
|
-
echo "No shell root at ${root#"$PROJECT_ROOT/"}, color sourcing unverifiable." >&2
|
|
25
|
-
exit 1
|
|
26
|
-
fi
|
|
27
|
-
done
|
|
28
|
-
|
|
29
|
-
# Any SGR sequence in either spelling. The cursor and erase sequences an
|
|
30
|
-
# interactive prompt writes end in a letter instead, and they run only where a
|
|
31
|
-
# terminal already exists, so they are not what this measures.
|
|
32
|
-
SGR='\\(033|e|x1b)\[[0-9;]*m'
|
|
33
|
-
|
|
34
|
-
# Sixteen copies of one decision is what made color impossible to turn off on
|
|
35
|
-
# the TypeScript side. A rule nothing enforces rebuilds them the next time a
|
|
36
|
-
# script wants grey, so the escape lives in the shared library and every other
|
|
37
|
-
# script reads the palette that library derives for its stream.
|
|
38
|
-
offenders=""
|
|
39
|
-
while IFS= read -r file; do
|
|
40
|
-
[ "$file" = "$PALETTE_SOURCE" ] && continue
|
|
41
|
-
if grep -qE "$SGR" "$file"; then
|
|
42
|
-
offenders="$offenders ${file#"$PROJECT_ROOT/"}"$'\n'
|
|
43
|
-
fi
|
|
44
|
-
done < <(find "$SCRIPTS_ROOT" "$SANDBOX_ROOT" -type f -name '*.sh')
|
|
45
|
-
|
|
46
|
-
if [ -n "$offenders" ]; then
|
|
47
|
-
echo "A color escape is defined outside the shared library:" >&2
|
|
48
|
-
printf '%s' "$offenders" >&2
|
|
49
|
-
echo "Source scripts/lib/ui.sh and read its palette, so one answer covers every writer." >&2
|
|
50
|
-
exit 1
|
|
51
|
-
fi
|
package/src/serve/live.ts
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import { type FSWatcher, watch } from 'node:fs'
|
|
2
|
-
import { SERVE_HOST } from '@/serve/static'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* How long a burst of watch events for one path waits before it is sent. One
|
|
6
|
-
* editor save raises several, and each would otherwise reach a reader again.
|
|
7
|
-
*/
|
|
8
|
-
const SETTLE_MS = 40
|
|
9
|
-
|
|
10
|
-
export interface LiveOptions<T> {
|
|
11
|
-
/**
|
|
12
|
-
* Turns a changed path, relative to the watched directory, into what a change
|
|
13
|
-
* carries, or into undefined to drop it, such as a write's staging file.
|
|
14
|
-
*/
|
|
15
|
-
readonly map: (path: string) => T | undefined
|
|
16
|
-
/**
|
|
17
|
-
* Takes each settled change in place of the clients. A caller that has work
|
|
18
|
-
* to do before a reader may look, such as regenerating pages, sends its own
|
|
19
|
-
* payload through `send` once that work lands.
|
|
20
|
-
*/
|
|
21
|
-
readonly onChange?: (payload: T) => void
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Whether a request names this server by a loopback name and its own port.
|
|
26
|
-
* The bind keeps other machines out, and this keeps out a page the operator
|
|
27
|
-
* visits whose domain was rebound to 127.0.0.1, which reaches the socket with
|
|
28
|
-
* its own name in the Host header.
|
|
29
|
-
*/
|
|
30
|
-
export function isLoopbackHost(host: string | null, port: number): boolean {
|
|
31
|
-
return host === `${SERVE_HOST}:${port}` || host === `localhost:${port}`
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Watches a directory and fans each settled change out as a server-sent event
|
|
36
|
-
* to every open client, or to `onChange` when the caller takes changes itself.
|
|
37
|
-
*/
|
|
38
|
-
export class LiveStream<T> {
|
|
39
|
-
private readonly clients = new Set<ReadableStreamDefaultController<string>>()
|
|
40
|
-
private readonly pending = new Map<string, ReturnType<typeof setTimeout>>()
|
|
41
|
-
private readonly watcher: FSWatcher
|
|
42
|
-
|
|
43
|
-
constructor(
|
|
44
|
-
dir: string,
|
|
45
|
-
private readonly options: LiveOptions<T>,
|
|
46
|
-
) {
|
|
47
|
-
this.watcher = watch(dir, { recursive: true }, (_event, filename) => {
|
|
48
|
-
if (filename) this.queue(filename.toString())
|
|
49
|
-
})
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
private queue(path: string): void {
|
|
53
|
-
const payload = this.options.map(path)
|
|
54
|
-
if (payload === undefined) return
|
|
55
|
-
clearTimeout(this.pending.get(path))
|
|
56
|
-
this.pending.set(
|
|
57
|
-
path,
|
|
58
|
-
setTimeout(() => {
|
|
59
|
-
this.pending.delete(path)
|
|
60
|
-
if (this.options.onChange) this.options.onChange(payload)
|
|
61
|
-
else this.send(payload)
|
|
62
|
-
}, SETTLE_MS),
|
|
63
|
-
)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
send(payload: unknown): void {
|
|
67
|
-
const frame = `data: ${JSON.stringify(payload)}\n\n`
|
|
68
|
-
for (const client of this.clients) client.enqueue(frame)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
open(): Response {
|
|
72
|
-
let self: ReadableStreamDefaultController<string> | undefined
|
|
73
|
-
const stream = new ReadableStream<string>({
|
|
74
|
-
start: (controller) => {
|
|
75
|
-
self = controller
|
|
76
|
-
this.clients.add(controller)
|
|
77
|
-
controller.enqueue(': open\n\n')
|
|
78
|
-
},
|
|
79
|
-
cancel: () => {
|
|
80
|
-
if (self) this.clients.delete(self)
|
|
81
|
-
},
|
|
82
|
-
})
|
|
83
|
-
return new Response(stream, {
|
|
84
|
-
headers: {
|
|
85
|
-
'content-type': 'text/event-stream',
|
|
86
|
-
'cache-control': 'no-store',
|
|
87
|
-
},
|
|
88
|
-
})
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Opens a stream for a request naming this server, and refuses any other. */
|
|
92
|
-
answer(request: Request, port: number): Response {
|
|
93
|
-
if (isLoopbackHost(request.headers.get('host'), port)) return this.open()
|
|
94
|
-
return new Response('Forbidden\n', {
|
|
95
|
-
status: 403,
|
|
96
|
-
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
97
|
-
})
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
close(): void {
|
|
101
|
-
this.watcher.close()
|
|
102
|
-
for (const timer of this.pending.values()) clearTimeout(timer)
|
|
103
|
-
this.pending.clear()
|
|
104
|
-
for (const client of this.clients) {
|
|
105
|
-
try {
|
|
106
|
-
client.close()
|
|
107
|
-
} catch {
|
|
108
|
-
/* Already closed by the browser going away. */
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
this.clients.clear()
|
|
112
|
-
}
|
|
113
|
-
}
|