@ucsandman/legcli 0.7.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 (184) hide show
  1. package/CHANGELOG.md +560 -0
  2. package/LICENSE +58 -0
  3. package/NOTICE +4 -0
  4. package/README.md +558 -0
  5. package/bin/fake-agent.mjs +143 -0
  6. package/bin/leg.mjs +449 -0
  7. package/docs/DECISIONS.md +119 -0
  8. package/docs/DEMO.md +214 -0
  9. package/docs/DEVIATIONS.md +182 -0
  10. package/docs/ERRORS.md +326 -0
  11. package/docs/README.md +40 -0
  12. package/docs/REUSE.md +289 -0
  13. package/docs/ROADMAP-v2.md +55 -0
  14. package/docs/VOCABULARY.md +243 -0
  15. package/docs/adapters.md +300 -0
  16. package/docs/board-guide.md +579 -0
  17. package/docs/cli-contracts.md +401 -0
  18. package/docs/concepts.md +426 -0
  19. package/docs/configuration.md +220 -0
  20. package/docs/faq.md +206 -0
  21. package/docs/getting-started.md +251 -0
  22. package/docs/real-run.md +80 -0
  23. package/docs/review-2026-09-14.md +72 -0
  24. package/docs/screenshots/board-400px.png +0 -0
  25. package/docs/screenshots/board-details-open.png +0 -0
  26. package/docs/screenshots/board-done.png +0 -0
  27. package/docs/screenshots/board-drawer.png +0 -0
  28. package/docs/screenshots/board-empty.png +0 -0
  29. package/docs/screenshots/board-handoff.png +0 -0
  30. package/docs/screenshots/board-running.png +0 -0
  31. package/docs/screenshots/demo-1-claude-running.png +0 -0
  32. package/docs/screenshots/demo-2-limit-hit.png +0 -0
  33. package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
  34. package/docs/screenshots/demo-4-codex-running.png +0 -0
  35. package/docs/screenshots/demo-5-done.png +0 -0
  36. package/docs/screenshots/floor-final-1280.png +0 -0
  37. package/docs/screenshots/floor-final-400.png +0 -0
  38. package/docs/screenshots/floor-landing.png +0 -0
  39. package/docs/screenshots/floor.png +0 -0
  40. package/docs/screenshots/share-guest-1280.png +0 -0
  41. package/docs/screenshots/share-owner-1280.png +0 -0
  42. package/docs/screenshots/terminals-1280.png +0 -0
  43. package/docs/screenshots/thumb-done.jpg +0 -0
  44. package/docs/screenshots/thumb-handoff-details.jpg +0 -0
  45. package/docs/screenshots/thumb-limit-hit.jpg +0 -0
  46. package/docs/screenshots/youtube-thumbnail.jpg +0 -0
  47. package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
  48. package/fixtures/limits/claude/claude-budget-limit.json +11 -0
  49. package/fixtures/limits/claude/claude-max-turns.json +11 -0
  50. package/fixtures/limits/claude/claude-model-limit.json +11 -0
  51. package/fixtures/limits/claude/claude-session-limit.json +11 -0
  52. package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
  53. package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
  54. package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
  55. package/fixtures/limits/codex/codex-skills-notice.json +11 -0
  56. package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
  57. package/fixtures/limits/codex/codex-usage-limit.json +11 -0
  58. package/fixtures/limits/generic/generic-429.json +11 -0
  59. package/fixtures/limits/generic/generic-overloaded.json +11 -0
  60. package/fixtures/limits/generic/generic-quota.json +11 -0
  61. package/fixtures/limits/generic/generic-rate-limit.json +11 -0
  62. package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
  63. package/fixtures/limits/generic/generic-usage-limit.json +11 -0
  64. package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
  65. package/fixtures/limits/negative/auth-source-set.json +11 -0
  66. package/fixtures/limits/negative/compile-error.json +11 -0
  67. package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
  68. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
  69. package/fixtures/live/agy/cmd.txt +1 -0
  70. package/fixtures/live/agy/err.log +0 -0
  71. package/fixtures/live/agy/out.log +1 -0
  72. package/fixtures/live/agy/parsed.json +5 -0
  73. package/fixtures/live/agy/run.json +18 -0
  74. package/fixtures/live/agy/supervisor.log +2 -0
  75. package/fixtures/live/claude/cmd.txt +1 -0
  76. package/fixtures/live/claude/err.log +0 -0
  77. package/fixtures/live/claude/limit-rate_limit.json +21 -0
  78. package/fixtures/live/claude/out.log +1 -0
  79. package/fixtures/live/claude/parsed.json +11 -0
  80. package/fixtures/live/claude/run.json +18 -0
  81. package/fixtures/live/claude/supervisor.log +2 -0
  82. package/fixtures/live/codex/cmd.txt +1 -0
  83. package/fixtures/live/codex/err.log +1 -0
  84. package/fixtures/live/codex/last.md +2 -0
  85. package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
  86. package/fixtures/live/codex/out.log +8 -0
  87. package/fixtures/live/codex/parsed.json +15 -0
  88. package/fixtures/live/codex/run.json +18 -0
  89. package/fixtures/live/codex/supervisor.log +2 -0
  90. package/fixtures/live/grok/cmd.txt +1 -0
  91. package/fixtures/live/grok/err.log +32 -0
  92. package/fixtures/live/grok/out.log +7 -0
  93. package/fixtures/live/grok/parsed.json +5 -0
  94. package/fixtures/live/grok/run.json +18 -0
  95. package/fixtures/live/grok/supervisor.log +2 -0
  96. package/fixtures/verified.json +34 -0
  97. package/package.json +68 -0
  98. package/scripts/board-shots.mjs +69 -0
  99. package/scripts/build-docs-site.mjs +308 -0
  100. package/scripts/check-claims.mjs +150 -0
  101. package/scripts/clean-clone-check.sh +42 -0
  102. package/scripts/license-sign.mjs +23 -0
  103. package/scripts/limits-table.mjs +38 -0
  104. package/scripts/live-limits.mjs +36 -0
  105. package/scripts/privacy-check.mjs +63 -0
  106. package/scripts/probe.mjs +92 -0
  107. package/scripts/seed-fake-cards.mjs +34 -0
  108. package/scripts/seed-floor-board.mjs +69 -0
  109. package/scripts/seed-wes-board.mjs +91 -0
  110. package/scripts/stripe-setup.mjs +99 -0
  111. package/scripts/vercel-env.mjs +40 -0
  112. package/src/accounts.mjs +119 -0
  113. package/src/adapters/agy.mjs +56 -0
  114. package/src/adapters/claude.mjs +54 -0
  115. package/src/adapters/codex.mjs +73 -0
  116. package/src/adapters/common.mjs +32 -0
  117. package/src/adapters/fake.mjs +48 -0
  118. package/src/adapters/grok.mjs +51 -0
  119. package/src/adapters/index.mjs +27 -0
  120. package/src/adapters/resolve.mjs +40 -0
  121. package/src/attach.mjs +592 -0
  122. package/src/auth.mjs +85 -0
  123. package/src/board/board.css +543 -0
  124. package/src/board/board.js +1307 -0
  125. package/src/board/favicon.svg +1 -0
  126. package/src/board/floor.html +127 -0
  127. package/src/board/floor.js +670 -0
  128. package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
  129. package/src/board/fonts/OFL-azeret-mono.txt +94 -0
  130. package/src/board/fonts/atkinson-board.woff2 +0 -0
  131. package/src/board/fonts/azeret-board.woff2 +0 -0
  132. package/src/board/index.html +251 -0
  133. package/src/board/sessions.js +1451 -0
  134. package/src/bundle.mjs +103 -0
  135. package/src/cards.mjs +114 -0
  136. package/src/chain.mjs +264 -0
  137. package/src/commands.mjs +79 -0
  138. package/src/contract.mjs +73 -0
  139. package/src/env.mjs +19 -0
  140. package/src/fsx.mjs +72 -0
  141. package/src/git-snapshot.mjs +152 -0
  142. package/src/handoff.mjs +171 -0
  143. package/src/hook.mjs +49 -0
  144. package/src/land.mjs +106 -0
  145. package/src/launcher.mjs +272 -0
  146. package/src/leases.mjs +86 -0
  147. package/src/ledger.mjs +369 -0
  148. package/src/license.mjs +168 -0
  149. package/src/limits.mjs +97 -0
  150. package/src/live-capture.mjs +69 -0
  151. package/src/mergequeue.mjs +225 -0
  152. package/src/orchestrator.mjs +365 -0
  153. package/src/pipeline.mjs +100 -0
  154. package/src/preferences.mjs +47 -0
  155. package/src/presets.mjs +23 -0
  156. package/src/ratelimit.mjs +49 -0
  157. package/src/redact.mjs +47 -0
  158. package/src/resume.mjs +354 -0
  159. package/src/runner.mjs +438 -0
  160. package/src/scheduler.mjs +118 -0
  161. package/src/server.mjs +845 -0
  162. package/src/session-detail.mjs +129 -0
  163. package/src/sessions.mjs +202 -0
  164. package/src/share.mjs +163 -0
  165. package/src/stations/agent.mjs +42 -0
  166. package/src/stations/build.mjs +9 -0
  167. package/src/stations/human.mjs +5 -0
  168. package/src/stations/land.mjs +6 -0
  169. package/src/stations/plan.mjs +8 -0
  170. package/src/stations/pr.mjs +31 -0
  171. package/src/stations/review.mjs +8 -0
  172. package/src/stations/test.mjs +27 -0
  173. package/src/store.mjs +90 -0
  174. package/src/sync/dashclaw.mjs +70 -0
  175. package/src/sync/index.mjs +54 -0
  176. package/src/sync/workboard.mjs +54 -0
  177. package/src/taps/agy.mjs +64 -0
  178. package/src/taps/claude-usage.mjs +67 -0
  179. package/src/taps/claude.mjs +170 -0
  180. package/src/taps/codex.mjs +286 -0
  181. package/src/trust.mjs +323 -0
  182. package/src/usage.mjs +179 -0
  183. package/src/wait.mjs +30 -0
  184. package/src/worktree.mjs +207 -0
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ // limits-table — regenerate the Limit signals table in docs/cli-contracts.md
3
+ // from fixtures/limits/**/*.json (the same files src/limits.mjs classifies with).
4
+ import { readFileSync, writeFileSync } from 'node:fs'
5
+ import { dirname, resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { SIGNALS } from '../src/limits.mjs'
8
+
9
+ const DOC = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'docs', 'cli-contracts.md')
10
+ const START = '<!-- limits-table:start -->'
11
+ const END = '<!-- limits-table:end -->'
12
+
13
+ const cell = (s) => String(s).replace(/\|/g, '\\|').replace(/\r?\n/g, ' ')
14
+ const rows = SIGNALS.map((s) =>
15
+ `| ${s.id} | ${s.adapter} | ${s.classification} | ${s.where} | **${s.source}** | ${cell(s.text).slice(0, 90)} | ${cell(s.produced_by)} |`)
16
+ const counts = SIGNALS.reduce((m, s) => { m[s.source] = (m[s.source] ?? 0) + 1; return m }, {})
17
+ const table = [
18
+ `Generated by \`node scripts/limits-table.mjs\` from ${SIGNALS.length} fixtures (${counts['observed-live'] ?? 0} observed-live, ${counts['docs-only'] ?? 0} docs-only). Classification \`limit\` hands the card to the next agent as a usage limit; \`auth\` is a failed launch (never a limit); \`launch\` is a failed launch that the next agent may still try; \`budget\` is a turn or spend cap set by Baton itself; \`info\` must never classify as a limit.`,
19
+ '',
20
+ '| id | adapter | class | where | source | text (excerpt) | produced by |',
21
+ '|----|---------|-------|-------|--------|----------------|-------------|',
22
+ ...rows,
23
+ ].join('\n')
24
+
25
+ const doc = readFileSync(DOC, 'utf8')
26
+ const a = doc.indexOf(START)
27
+ const b = doc.indexOf(END)
28
+ if (a === -1 || b === -1 || b < a) {
29
+ process.stderr.write(`markers ${START} / ${END} not found in ${DOC}\n`)
30
+ process.exit(1)
31
+ }
32
+ const next = doc.slice(0, a + START.length) + '\n' + table + '\n' + doc.slice(b)
33
+ if (process.argv.includes('--check')) {
34
+ process.stdout.write(next === doc ? 'limits-table: up to date\n' : 'limits-table: STALE (run node scripts/limits-table.mjs)\n')
35
+ process.exit(next === doc ? 0 : 1)
36
+ }
37
+ writeFileSync(DOC, next)
38
+ process.stdout.write(`limits-table: wrote ${SIGNALS.length} rows into ${DOC}\n`)
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ // live-limits — flip the docs rows for the interactive walls from docs-only to
3
+ // observed-live once fixtures/live/<agent>/limit-<signal>.json exists (written
4
+ // by src/live-capture.mjs the first time the real signal arrives). Each row in
5
+ // docs/adapters.md and docs/cli-contracts.md carries a marker
6
+ // `<!-- live:<agent>/<signal> -->` right after its status word.
7
+ // node scripts/live-limits.mjs rewrite the rows
8
+ // node scripts/live-limits.mjs --check exit 1 when a row is stale
9
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
10
+ import { join, dirname, resolve } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
14
+ const LIVE = process.env.BATON_LIVE_DIR || join(ROOT, 'fixtures', 'live')
15
+ const DOCS = ['docs/adapters.md', 'docs/cli-contracts.md'].map((p) => join(ROOT, p))
16
+ const MARK = /\*\*(docs-only|observed-live(?: [0-9-]+)?)\*\* <!-- live:([a-z]+)\/([a-z_-]+) -->/g
17
+
18
+ let stale = 0
19
+ for (const doc of DOCS) {
20
+ const text = readFileSync(doc, 'utf8')
21
+ const next = text.replace(MARK, (whole, _status, agent, signal) => {
22
+ const f = join(LIVE, agent, `limit-${signal}.json`)
23
+ if (!existsSync(f)) return whole
24
+ let day = ''
25
+ try { day = String(JSON.parse(readFileSync(f, 'utf8')).captured_at ?? '').slice(0, 10) } catch {}
26
+ return `**observed-live${day ? ' ' + day : ''}** <!-- live:${agent}/${signal} -->`
27
+ })
28
+ if (next !== text) {
29
+ stale += 1
30
+ if (!process.argv.includes('--check')) { writeFileSync(doc, next); process.stdout.write(`live-limits: updated ${doc}\n`) }
31
+ else process.stdout.write(`live-limits: STALE ${doc}\n`)
32
+ }
33
+ }
34
+ const rows = DOCS.reduce((n, d) => n + (readFileSync(d, 'utf8').match(MARK) ?? []).length, 0)
35
+ process.stdout.write(`live-limits: rows=${rows} ${process.argv.includes('--check') ? `stale=${stale}` : `updated=${stale}`}\n`)
36
+ process.exit(process.argv.includes('--check') && stale ? 1 : 0)
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ // privacy-check — refuses any file in the tree that carries a string from the
3
+ // private source tooling this project was ported from. Runs in `npm test` and
4
+ // in the commit hook (`--staged`). Exit 1 on any hit.
5
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
6
+ import { execFileSync } from 'node:child_process'
7
+ import { join, relative, resolve, dirname } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ // Extend here (phase 12 may add patterns). Matching is case-insensitive.
11
+ // The first pattern skips the public GitHub handle "ucsandman" (NOTICE names
12
+ // it) but still catches the local user name and the e-mail local part.
13
+ export const PATTERNS = [
14
+ ['sandm', /(?<!uc)sandm/i],
15
+ ['1584804440', /1584804440/],
16
+ ['clawd', /clawd/i],
17
+ ]
18
+
19
+ const SELF = fileURLToPath(import.meta.url)
20
+ const ROOT = resolve(dirname(SELF), '..')
21
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.supergoal', '.leg', '.leg-worktrees', '.baton', '.baton-worktrees', '.context-handoffs'])
22
+ // plain entries in .gitignore (no globs) are never shipped, so they are not scanned
23
+ let GITIGNORED = new Set()
24
+ try { GITIGNORED = new Set(readFileSync(resolve(ROOT, '.gitignore'), 'utf8').split(/\r?\n/).map((l) => l.trim().replace(/\/$/, '')).filter((l) => l && !l.startsWith('#') && !/[*?[]/.test(l))) } catch {}
25
+ const BINARY_EXT = /\.(png|jpg|jpeg|gif|webp|ico|woff2?|ttf|pdf|zip|gz|tgz|mp4)$/i
26
+
27
+ function walk(dir, out) {
28
+ for (const name of readdirSync(dir)) {
29
+ if (SKIP_DIRS.has(name) || GITIGNORED.has(name)) continue
30
+ const full = join(dir, name)
31
+ const st = statSync(full)
32
+ if (st.isDirectory()) walk(full, out)
33
+ else if (!BINARY_EXT.test(name) && full !== SELF) out.push(full)
34
+ }
35
+ return out
36
+ }
37
+
38
+ function stagedFiles() {
39
+ const out = execFileSync('git', ['diff', '--cached', '--name-only', '--diff-filter=ACMR'],
40
+ { cwd: ROOT, encoding: 'utf8' })
41
+ return out.split(/\r?\n/).filter(Boolean).map((p) => join(ROOT, p))
42
+ .filter((p) => p !== SELF)
43
+ .filter((p) => { try { return statSync(p).isFile() && !BINARY_EXT.test(p) } catch { return false } })
44
+ }
45
+
46
+ const staged = process.argv.includes('--staged')
47
+ const files = staged ? stagedFiles() : walk(ROOT, [])
48
+ let hits = 0
49
+ for (const file of files) {
50
+ let text
51
+ try { text = readFileSync(file, 'utf8') } catch { continue }
52
+ const lines = text.split('\n')
53
+ for (let i = 0; i < lines.length; i++) {
54
+ for (const [label, re] of PATTERNS) {
55
+ if (re.test(lines[i])) {
56
+ hits += 1
57
+ process.stdout.write(`${relative(ROOT, file)}:${i + 1}: ${label}\n`)
58
+ }
59
+ }
60
+ }
61
+ }
62
+ process.stdout.write(`privacy-check: hits=${hits} files=${files.length}${staged ? ' (staged)' : ''}\n`)
63
+ process.exit(hits ? 1 : 0)
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ // probe — run one real tiny task on one CLI through the ported runner and
3
+ // keep the evidence under fixtures/live/<adapter>/.
4
+ // node scripts/probe.mjs --adapter <name> --repo <path> [--mode <m>] [--timeout-s 300]
5
+ // Prints: probe <name>: exit=<code> file=<yes|no> done=<yes|no> auth_source=<yes|no> seconds=<n>
6
+ import { execFileSync } from 'node:child_process'
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
8
+ import { join, dirname, resolve } from 'node:path'
9
+ import { homedir } from 'node:os'
10
+ import { fileURLToPath } from 'node:url'
11
+ import { get as getAdapter } from '../src/adapters/index.mjs'
12
+ import { scrub } from '../src/runner.mjs'
13
+
14
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
15
+ const SRC = join(ROOT, 'src')
16
+ const HOME = process.env.BATON_HOME || join(homedir(), '.baton')
17
+
18
+ const args = {}
19
+ const argv = process.argv.slice(2)
20
+ for (let i = 0; i < argv.length; i += 2) args[argv[i].replace(/^--/, '')] = argv[i + 1]
21
+ const name = args.adapter
22
+ const repo = args.repo && resolve(args.repo)
23
+ if (!name || !repo || !existsSync(repo)) {
24
+ process.stderr.write('usage: probe.mjs --adapter <name> --repo <existing dir> [--mode m] [--timeout-s 300]\n')
25
+ process.exit(2)
26
+ }
27
+ const timeoutS = parseInt(args['timeout-s'] ?? '300', 10)
28
+ const adapter = await getAdapter(name)
29
+ const mode = args.mode ?? adapter.modes.default
30
+
31
+ const PROMPT = `Create a file named hello-${name}.txt in the current directory containing exactly the word hi. Then create the directory .baton if it is missing and write the file .baton/DONE containing the single line: done. Do nothing else. Do not ask questions.\n`
32
+
33
+ const node = (script, a) => execFileSync(process.execPath, [script, ...a], { encoding: 'utf8', env: process.env })
34
+
35
+ // Fresh DONE marker per probe so "done=yes" is this CLI's own work.
36
+ rmSync(join(repo, '.baton', 'DONE'), { force: true })
37
+ rmSync(join(repo, `hello-${name}.txt`), { force: true })
38
+
39
+ const id = node(join(SRC, 'ledger.mjs'), ['create', '--slug', `probe-${name}`.slice(0, 30), '--task', PROMPT.trim(),
40
+ '--repo', repo, '--chain', JSON.stringify([{ adapter: name, mode }])]).trim()
41
+ const promptFile = join(HOME, 'cards', id, 'probe-prompt.txt')
42
+ writeFileSync(promptFile, PROMPT)
43
+
44
+ const t0 = Date.now()
45
+ let launch
46
+ try {
47
+ launch = JSON.parse(node(join(SRC, 'runner.mjs'), ['launch', '--card', id, '--adapter', name,
48
+ '--prompt-file', promptFile, '--cwd', repo, '--mode', mode]))
49
+ } catch (err) {
50
+ process.stdout.write(`probe ${name}: launch failed: ${String(err.stderr ?? err.message).trim()}\n`)
51
+ process.exit(1)
52
+ }
53
+ const runDir = launch.run_dir
54
+ const runJson = () => { try { return JSON.parse(readFileSync(join(runDir, 'run.json'), 'utf8')) } catch { return null } }
55
+
56
+ // LESSONS 07-10: claude -p prints nothing until the end; poll the run record,
57
+ // never a short timeout on stdout.
58
+ let run = runJson()
59
+ while (Date.now() - t0 < timeoutS * 1000) {
60
+ run = runJson()
61
+ if (run && ['exited', 'killed', 'failed', 'orphaned'].includes(run.status)) break
62
+ await new Promise((r) => setTimeout(r, 2000))
63
+ }
64
+ const seconds = Math.round((Date.now() - t0) / 1000)
65
+
66
+ // Fixtures are committed: secrets are scrubbed and the local user's home
67
+ // directory becomes "~" in every slash and escape form.
68
+ export function scrubPaths(text) {
69
+ const [drive, ...segs] = homedir().split(/[\\/]+/)
70
+ const esc = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
71
+ // C:\Users\name, C:/Users/name, C:\\Users\\name (JSON), C:\\\\… (nested JSON), /c/Users/name (MSYS)
72
+ const re = new RegExp(`(?:${esc(drive)}|/${drive[0].toLowerCase()})${segs.map((s) => `[\\\\/]+${esc(s)}`).join('')}`, 'gi')
73
+ return scrub(text).replace(re, '~')
74
+ }
75
+
76
+ const out = join(ROOT, 'fixtures', 'live', name)
77
+ mkdirSync(out, { recursive: true })
78
+ for (const f of ['out.log', 'err.log', 'run.json', 'supervisor.log', 'last.md']) {
79
+ if (existsSync(join(runDir, f))) writeFileSync(join(out, f), scrubPaths(readFileSync(join(runDir, f), 'utf8')))
80
+ }
81
+ const spec = adapter.argv({ mode, cwd: repo, prompt: PROMPT, runDir, killMs: 5400000 })
82
+ writeFileSync(join(out, 'cmd.txt'), scrubPaths([spec.bin, ...spec.args].map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ')) + '\n')
83
+ const textOf = (f) => (existsSync(join(runDir, f)) ? readFileSync(join(runDir, f), 'utf8') : '')
84
+ const authSource = /another auth source/i.test(textOf('err.log') + textOf('out.log'))
85
+ const file = existsSync(join(repo, `hello-${name}.txt`))
86
+ && readFileSync(join(repo, `hello-${name}.txt`), 'utf8').trim() === 'hi'
87
+ const done = existsSync(join(repo, '.baton', 'DONE'))
88
+ const exit = run?.status === 'exited' ? run.exit_code : `${run?.status ?? 'unknown'}`
89
+ const parsed = adapter.parseResult(textOf('out.log'))
90
+ writeFileSync(join(out, 'parsed.json'), scrubPaths(JSON.stringify({ ...parsed, raw: undefined }, null, 2)) + '\n')
91
+ process.stdout.write(`probe ${name}: exit=${exit} file=${file ? 'yes' : 'no'} done=${done ? 'yes' : 'no'} auth_source=${authSource ? 'yes' : 'no'} seconds=${seconds} session=${parsed?.session_id ?? 'none'}\n`)
92
+ process.exit(file && done && run?.exit_code === 0 ? 0 : 1)
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ // seed-fake-cards — fills a BATON_HOME with N backlog cards (fake chain, build
3
+ // pipeline) through the same path the CLI uses (src/cards.mjs createCard), for
4
+ // perf tests and manual board load-testing.
5
+ // node scripts/seed-fake-cards.mjs --home <dir> --repo <git repo path> --count 50
6
+ import { resolve } from 'node:path'
7
+
8
+ function parseArgs(argv) {
9
+ const args = {}
10
+ for (let i = 0; i < argv.length; i++) {
11
+ const a = argv[i]
12
+ if (a.startsWith('--')) { args[a.slice(2)] = argv[i + 1]; i++ }
13
+ }
14
+ return args
15
+ }
16
+
17
+ const args = parseArgs(process.argv.slice(2))
18
+ if (!args.home) { process.stderr.write('usage: seed-fake-cards.mjs --home <dir> --repo <git repo path> [--count 50]\n'); process.exit(2) }
19
+ if (!args.repo) { process.stderr.write('usage: seed-fake-cards.mjs --home <dir> --repo <git repo path> [--count 50]\n'); process.exit(2) }
20
+ const count = parseInt(args.count ?? '50', 10) || 50
21
+
22
+ // BATON_HOME must be set before store.mjs (and the ledger.mjs it imports) load.
23
+ process.env.BATON_HOME = resolve(args.home)
24
+ const { createCard } = await import('../src/cards.mjs')
25
+
26
+ const TITLES = ['Add retry to fetch', 'Fix flaky lease test', 'Refactor log tail', 'Wire up SSE health', 'Trim README', 'Bump adapter timeout', 'Dedupe blocked events', 'Guard null station', 'Speed up card list', 'Polish error copy']
27
+
28
+ let created = 0
29
+ for (let i = 0; i < count; i++) {
30
+ const title = `${TITLES[i % TITLES.length]} #${i + 1}`
31
+ await createCard({ repo: args.repo, task: title, title, chain: 'fake' }, { type: 'human', id: 'seed' })
32
+ created += 1
33
+ }
34
+ process.stdout.write(`seeded ${created} card(s) into ${process.env.BATON_HOME}\n`)
@@ -0,0 +1,69 @@
1
+ // Seeds a throwaway BATON_HOME with a floor that has something in every lane:
2
+ // two cards running, two waiting on a human, one queued and blocked by a lease
3
+ // another card holds, and a landing on the trunk. The floor is derived entirely
4
+ // from card records and their events (server.mjs floor()), so the states are
5
+ // written straight to the ledger rather than driven through real runs.
6
+ //
7
+ // BATON_HOME=<dir> node scripts/seed-floor-board.mjs <repo path>
8
+ //
9
+ // Serve it on a spare port with BATON_TRUST=never. NEVER 4747.
10
+ import { join } from 'node:path'
11
+ import { tmpdir } from 'node:os'
12
+
13
+ process.env.BATON_HOME ||= join(tmpdir(), 'baton-floor-board')
14
+ const repo = process.argv[2] || join(tmpdir(), 'toy-demo')
15
+ const { createCard } = await import('../src/cards.mjs')
16
+ const { ledgerAppend, ledgerUpdate, cardDir } = await import('../src/store.mjs')
17
+ const { mkdirSync, writeFileSync } = await import('node:fs')
18
+
19
+ // one run record per card, started far enough back that the elapsed column
20
+ // shows a real number rather than 00:00
21
+ function writeRun(id, startedMsAgo) {
22
+ const dir = join(cardDir(id), 'runs', '1')
23
+ mkdirSync(dir, { recursive: true })
24
+ writeFileSync(join(cardDir(id), 'runs', '1', 'run.json'), JSON.stringify({
25
+ run: 1, adapter: 'fake-claude', status: 'running',
26
+ started_at: new Date(Date.now() - startedMsAgo).toISOString(),
27
+ }, null, 2))
28
+ }
29
+
30
+ const min = 60_000
31
+ const ago = (ms) => new Date(Date.now() - ms).toISOString()
32
+
33
+ const rows = [
34
+ { title: 'Lease holder', task: 'Rewrite the retry path in the fetch wrapper', chain: 'fake-claude,fake-codex',
35
+ leases: 'src/**', status: 'running', leg: 0, started: 42_000,
36
+ event: ['leg_started', 'leg started: adapter=fake-claude run=6 mode=default'] },
37
+ { title: 'Bounced by land', task: 'Dedupe the blocked events on the floor', chain: 'fake-codex',
38
+ leases: 'b.mjs', status: 'running', leg: 0, started: 35_000,
39
+ event: ['leg_started', 'leg started: adapter=fake-codex run=12 mode=default'] },
40
+ { title: 'Paused', task: 'Trim the README install block', chain: 'fake-claude',
41
+ leases: 'README.md', status: 'paused', leg: 0, started: 9 * min,
42
+ event: ['paused', 'paused by local'] },
43
+ { title: 'Waiting for human', task: 'Confirm the new pricing copy before it lands', chain: 'fake-claude,fake-codex',
44
+ leases: 'site/**', status: 'needs_approval', leg: 1, started: 4 * min,
45
+ event: ['approval_needed', 'fake-claude → fake-codex needs approval before leg 1'] },
46
+ { title: 'Blocked by lease', task: 'Guard the null station in the scheduler', chain: 'fake-claude',
47
+ leases: 'src/x/**', status: 'queued', leg: 0, started: 2 * min,
48
+ event: ['blocked_by', 'blocked by card "Lease holder" on src/** (against src/x/**)'] },
49
+ ]
50
+
51
+ const made = []
52
+ for (const r of rows) {
53
+ const card = await createCard({ repo, task: r.task, title: r.title, chain: r.chain, leases: r.leases }, { type: 'human', id: 'seed' })
54
+ const id = card.card_id
55
+ ledgerUpdate(id, { status: r.status, station: 'build', leg: r.leg })
56
+ ledgerAppend(id, { type: r.event[0], summary: r.event[1], station: 'build', leg: r.leg })
57
+ // the floor clocks a running card from its active run, and falls back to
58
+ // updated_at: without a run record every row read 00:00
59
+ writeRun(id, r.started)
60
+ made.push(`${r.title} -> ${r.status}`)
61
+ }
62
+
63
+ // one card already landed on the trunk, so the trunk lane is not empty
64
+ const landed = await createCard({ repo, task: 'Add the landing note to the changelog', title: 'Add landing note', chain: 'fake-claude' }, { type: 'human', id: 'seed' })
65
+ ledgerUpdate(landed.card_id, { status: 'done', station: 'build', leg: 0 })
66
+ ledgerAppend(landed.card_id, { type: 'landed', summary: 'landed on main: 5714348 → b0c888f (1 file, +1/-0)', station: 'build', leg: 0 })
67
+ made.push(`Add landing note -> landed ${ago(90_000).slice(11, 16)}`)
68
+
69
+ console.log(`seeded ${made.length} cards into ${process.env.BATON_HOME}\n ${made.join('\n ')}`)
@@ -0,0 +1,91 @@
1
+ import { join } from 'node:path'
2
+ import { tmpdir } from 'node:os'
3
+ // Seeds a throwaway BATON_HOME shaped like a real working board: a few live
4
+ // terminals and a larger pile of lost and ended ones, long absolute temp paths,
5
+ // and an image tag in a prompt.
6
+ //
7
+ // Board UI work is measured against THIS, never against a board of healthy
8
+ // terminals. Styling scoped to live rows and measured on a clean board reported
9
+ // 40px terminal rows while the real screen showed 400px, and that single wrong
10
+ // fixture cost more than any other mistake in the v4 redesign.
11
+ //
12
+ // node scripts/seed-wes-board.mjs # seeds ./scripts/wes-home
13
+ // BATON_HOME=<dir> node scripts/seed-wes-board.mjs
14
+ //
15
+ // Serve it on an isolated port with a throwaway BATON_HOME and BATON_TRUST=never.
16
+ // NEVER 4747: that is the operator's live board with real sessions on it.
17
+ // a throwaway home under the OS temp dir, so nothing is written into the repo
18
+ process.env.BATON_HOME ||= join(tmpdir(), 'baton-seed-board')
19
+ const { createSession, updateSession } = await import('../src/sessions.mjs')
20
+ const { recordUsage, markLimited } = await import('../src/usage.mjs')
21
+ const { spawn } = await import('node:child_process')
22
+ const { writeFileSync } = await import('node:fs')
23
+
24
+ // reapLost() marks any active session lost when its runner pid is gone, so a
25
+ // live row needs a live process behind it. These sleepers are that process and
26
+ // nothing else; scratchpad/stop.mjs kills them.
27
+ const sleepers = []
28
+ function livePid() {
29
+ const p = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1e9)'], { detached: true, stdio: 'ignore' })
30
+ p.unref()
31
+ sleepers.push(p.pid)
32
+ return p.pid
33
+ }
34
+
35
+ const min = 60_000, hour = 60 * min
36
+ const ago = (ms) => new Date(Date.now() - ms).toISOString()
37
+ const B = String.fromCharCode(92) // one backslash, unmangled by any shell
38
+ const w = (...parts) => parts.join(B)
39
+ const P = (...rest) => w('C:', 'Projects', ...rest)
40
+ // the shape that matters is LENGTH: a real worktree path under a temp dir runs
41
+ // past 90 characters, which is what buried the prompt in the rejected design
42
+ const LONG = w('C:', 'Users', 'operator', 'AppData', 'Local', 'Temp', 'agent',
43
+ 'C--Projects-baton--baton-worktrees-s-20260915-000000-claude-0000',
44
+ '00000000-0000-0000-0000-000000000000')
45
+
46
+ const rows = [
47
+ { id: 's-20260915-0049-claude-2fbf', agent: 'claude', status: 'warning', started: 4 * hour + 24 * min,
48
+ cwd: P('baton'), repo: P('baton'), branch: 'main',
49
+ task: 'ultracode run a tournament of ideas to drastically redesign and improve the UI for this project. I do not like the current setup.' },
50
+ { id: 's-20260915-0213-claude-95d3', agent: 'claude', status: 'running', started: 2 * hour + 46 * min,
51
+ cwd: w('C:', 'documents'), repo: null, branch: null, task: null },
52
+ { id: 's-20260915-0257-claude-88e8', agent: 'claude', status: 'warning', started: 2 * hour + 2 * min,
53
+ cwd: P('recruiting-tool'), repo: P('recruiting-tool'), branch: 'main',
54
+ task: `<image name=screenshot.png path=${w('C:', 'Users', 'operator', 'Desktop', 'shot.png')}> [Image #1] sourcing candidates is a huge pain for my friend who recruits on LinkedIn, how can we help` },
55
+ { id: 's-20260915-0455-claude-8e8a', agent: 'claude', status: 'running', started: 5 * min,
56
+ cwd: w('C:', 'Projects'), repo: null, branch: null, task: '/handoff-load verifier-reach-contracts' },
57
+ { id: 's-20260914-2211-claude-4c10', agent: 'claude', status: 'lost', started: 6 * hour, ended: 3 * hour,
58
+ cwd: w(LONG, 'discovery-loop'), repo: P('discovery-loop'), branch: 'main', task: 'run the nightly discovery loop' },
59
+ { id: 's-20260914-2010-codex-7b31', agent: 'codex', status: 'lost', started: 7 * hour, ended: 4 * hour,
60
+ cwd: w(LONG, 'costclaw'), repo: P('costclaw'), branch: 'main', task: 'fix the per-model price table for Opus' },
61
+ { id: 's-20260914-1802-agy-d9f2', agent: 'agy', status: 'lost', started: 9 * hour, ended: 6 * hour,
62
+ cwd: w('C:', 'Projects'), repo: null, branch: null, task: 'summarise yesterday' },
63
+ { id: 's-20260914-1533-claude-a04b', agent: 'claude', status: 'ended', started: 11 * hour, ended: 8 * hour,
64
+ cwd: w(LONG, 'declick'), repo: P('declick'), branch: 'main', task: 'add the web tree verb' },
65
+ { id: 's-20260914-1120-codex-55ac', agent: 'codex', status: 'ended', started: 13 * hour, ended: 10 * hour,
66
+ cwd: P('costclaw'), repo: P('costclaw'), branch: 'main', task: 'monthly rollup excludes refunded calls' },
67
+ ]
68
+
69
+ for (const r of rows) {
70
+ createSession({ id: r.id, agent: r.agent, cwd: r.cwd, repo: r.repo, branch: r.branch, argv: [r.agent], runner_pid: r.ended ? 1 : livePid() })
71
+ updateSession(r.id, {
72
+ status: r.status,
73
+ started_at: ago(r.started),
74
+ last_activity: ago(r.ended ?? 0),
75
+ ended_at: r.ended ? ago(r.ended) : null,
76
+ task: r.task,
77
+ turns: r.task ? 12 : 0,
78
+ files_touched: r.repo ? [w(r.cwd, 'src', 'board', 'board.css'), w(r.cwd, 'src', 'board', 'sessions.js')] : [],
79
+ })
80
+ }
81
+
82
+ recordUsage('claude', 'default',
83
+ { five_hour: { pct: 38, resets_at: Math.floor((Date.now() + 3 * hour) / 1000) },
84
+ seven_day: { pct: 95, resets_at: Math.floor((Date.now() + 40 * hour) / 1000) } },
85
+ 'statusline', { observed_at: ago(2 * hour + 13 * min) })
86
+ markLimited('codex', 'default', { resets_at: Math.floor((Date.now() + 29 * hour) / 1000), reason: 'limit', source: 'hook' })
87
+ // agy publishes no usage figure, ever. Left unwritten on purpose.
88
+
89
+ writeFileSync(join(process.env.BATON_HOME, 'sleepers.json'), JSON.stringify(sleepers))
90
+ const live = rows.filter((r) => !r.ended).length
91
+ console.log(`seeded ${rows.length} sessions (${live} live, ${rows.length - live} finished) into ${process.env.BATON_HOME}`)
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ // Create (idempotently) the two Baton plans in Stripe, their payment links,
3
+ // and the webhook endpoint for the site. Test mode by default, --live for the
4
+ // live account. Secrets never print: the webhook signing secret and the link
5
+ // URLs are written to .env (gitignored); ids and public URLs print.
6
+ // node --env-file=.env scripts/stripe-setup.mjs --site https://batonagents.com [--live]
7
+ import { readFileSync, writeFileSync } from 'node:fs'
8
+
9
+ const args = Object.fromEntries(process.argv.slice(2).map((a, i, all) => a.startsWith('--') ? [a.slice(2), all[i + 1]?.startsWith('--') || all[i + 1] === undefined ? true : all[i + 1]] : []).filter((x) => x.length))
10
+ const live = args.live === true
11
+ const key = live ? process.env.STRIPE_SECRET_KEY : process.env.STRIPE_TEST_SECRET_KEY
12
+ if (!key) { console.error(live ? 'STRIPE_SECRET_KEY missing' : 'STRIPE_TEST_SECRET_KEY missing'); process.exit(2) }
13
+ if (live && !key.startsWith('sk_live')) { console.error('--live but the key is not sk_live'); process.exit(2) }
14
+ if (!live && !key.startsWith('sk_test')) { console.error('test run but the key is not sk_test'); process.exit(2) }
15
+ const SITE = args.site || 'https://legcli.com'
16
+
17
+ function form(obj, prefix = '') {
18
+ const out = []
19
+ for (const [k, v] of Object.entries(obj)) {
20
+ const name = prefix ? `${prefix}[${k}]` : k
21
+ if (v === undefined || v === null) continue
22
+ if (Array.isArray(v)) v.forEach((x, i) => out.push(typeof x === 'object' ? form(x, `${name}[${i}]`) : `${encodeURIComponent(`${name}[${i}]`)}=${encodeURIComponent(x)}`))
23
+ else if (typeof v === 'object') out.push(form(v, name))
24
+ else out.push(`${encodeURIComponent(name)}=${encodeURIComponent(v)}`)
25
+ }
26
+ return out.filter(Boolean).join('&')
27
+ }
28
+ async function stripe(path, { method = 'GET', body, query } = {}) {
29
+ const r = await fetch('https://api.stripe.com/v1' + path + (query ? '?' + form(query) : ''), { method, headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/x-www-form-urlencoded' }, body: body ? form(body) : undefined })
30
+ const j = await r.json()
31
+ if (!r.ok) throw new Error(`${method} ${path}: ${j.error?.message}`)
32
+ return j
33
+ }
34
+
35
+ async function priceByLookup(lookup) {
36
+ const r = await stripe('/prices', { query: { lookup_keys: [lookup], active: true, limit: 1 } })
37
+ return r.data[0] || null
38
+ }
39
+
40
+ async function ensurePlan({ name, description, lookup, unit_amount, recurring, statement }) {
41
+ let price = await priceByLookup(lookup)
42
+ if (price) return { price, created: false }
43
+ const product = await stripe('/products', { method: 'POST', body: { name, description, statement_descriptor: statement, metadata: { plan: lookup.replace('baton_', '') } } })
44
+ price = await stripe('/prices', { method: 'POST', body: { product: product.id, currency: 'usd', unit_amount, lookup_key: lookup, recurring, tax_behavior: 'exclusive', metadata: { plan: lookup.replace('baton_', '') } } })
45
+ return { price, created: true }
46
+ }
47
+
48
+ async function ensureLink(price, { adjustable, mode }) {
49
+ const existing = await stripe('/payment_links', { query: { active: true, limit: 100 } })
50
+ for (const l of existing.data) {
51
+ if (l.metadata?.baton_price === price.id && l.metadata?.baton_site === SITE) return { link: l, created: false }
52
+ }
53
+ const body = {
54
+ 'line_items[0][price]': price.id, 'line_items[0][quantity]': 1,
55
+ 'after_completion[type]': 'redirect', 'after_completion[redirect][url]': `${SITE}/thanks?session_id={CHECKOUT_SESSION_ID}`,
56
+ 'automatic_tax[enabled]': true, 'tax_id_collection[enabled]': true, billing_address_collection: 'auto', allow_promotion_codes: true,
57
+ 'metadata[baton_price]': price.id, 'metadata[baton_site]': SITE,
58
+ }
59
+ if (adjustable) { body['line_items[0][adjustable_quantity][enabled]'] = true; body['line_items[0][adjustable_quantity][minimum]'] = 1; body['line_items[0][adjustable_quantity][maximum]'] = 200 }
60
+ if (mode === 'payment') body['invoice_creation[enabled]'] = true
61
+ const link = await stripe('/payment_links', { method: 'POST', body })
62
+ return { link, created: true }
63
+ }
64
+
65
+ async function ensureWebhook() {
66
+ const url = `${SITE}/api/webhook`
67
+ const existing = await stripe('/webhook_endpoints', { query: { limit: 100 } })
68
+ const have = existing.data.find((w) => w.url === url && w.status === 'enabled')
69
+ if (have) return { endpoint: have, secret: null, created: false }
70
+ const endpoint = await stripe('/webhook_endpoints', { method: 'POST', body: { url, enabled_events: ['checkout.session.completed', 'invoice.paid'], description: 'Leg license delivery' } })
71
+ return { endpoint, secret: endpoint.secret, created: true }
72
+ }
73
+
74
+ function setEnv(pairs) {
75
+ let env = readFileSync('.env', 'utf8')
76
+ for (const [k, v] of Object.entries(pairs)) {
77
+ if (v === null || v === undefined) continue
78
+ env = new RegExp(`^${k}=`, 'm').test(env) ? env.replace(new RegExp(`^${k}=.*$`, 'm'), `${k}=${v}`) : env + (env.endsWith('\n') ? '' : '\n') + `${k}=${v}\n`
79
+ }
80
+ writeFileSync('.env', env)
81
+ }
82
+
83
+ // Refuse to create anything in an account that is not the seller's.
84
+ const acct = await stripe('/account')
85
+ const acctName = acct.settings?.dashboard?.display_name || acct.business_profile?.name || ''
86
+ if (!/practical systems/i.test(acctName)) { console.error(`this key belongs to "${acctName}", not Practical Systems; pin the right one with creds mint STRIPE_SECRET_KEY`); process.exit(3) }
87
+ console.log(`account: ${acctName} (${live ? 'live' : 'test'})`)
88
+
89
+ const personal = await ensurePlan({ name: 'Leg Personal', description: 'One machine or several, one human. Every Leg release for 12 months; the version you have keeps working after that.', lookup: 'leg_personal', unit_amount: 7900, statement: 'LEG PERSONAL' })
90
+ const team = await ensurePlan({ name: 'Leg Team', description: 'Per seat, per month. Everything in Personal plus the shared board (leg share) and Land for more than one human.', lookup: 'leg_team', unit_amount: 1200, recurring: { interval: 'month' }, statement: 'LEG TEAM' })
91
+ const lp = await ensureLink(personal.price, { adjustable: false, mode: 'payment' })
92
+ const lt = await ensureLink(team.price, { adjustable: true, mode: 'subscription' })
93
+ const wh = await ensureWebhook()
94
+ const mode = live ? 'LIVE' : 'TEST'
95
+ setEnv({ [`STRIPE_${mode}_PAYMENT_LINK_PERSONAL`]: lp.link.url, [`STRIPE_${mode}_PAYMENT_LINK_TEAM`]: lt.link.url, [`STRIPE_${mode}_WEBHOOK_SECRET`]: wh.secret })
96
+ console.log(`${mode} mode against ${SITE}`)
97
+ console.log(`personal ${personal.price.id} $79 once link ${lp.link.url}${personal.created ? ' (created)' : ''}`)
98
+ console.log(`team ${team.price.id} $12/seat/mo link ${lt.link.url}${team.created ? ' (created)' : ''}`)
99
+ console.log(`webhook ${wh.endpoint.id} -> ${wh.endpoint.url} ${wh.created ? '(created; secret written to .env)' : '(existing; secret unchanged)'}`)
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ // Push the site's secrets from .env to the Vercel project without printing
3
+ // them: each value goes to `vercel env add NAME production` on stdin.
4
+ // node --env-file=.env scripts/vercel-env.mjs --mode test|live --site https://legcli.com
5
+ import { spawnSync } from 'node:child_process'
6
+
7
+ const args = Object.fromEntries(process.argv.slice(2).map((a, i, all) => a.startsWith('--') ? [a.slice(2), all[i + 1]] : []).filter((x) => x.length))
8
+ if (!['test', 'live'].includes(args.mode)) {
9
+ console.error('Usage: node scripts/vercel-env.mjs --mode test|live [--site URL]')
10
+ process.exit(2)
11
+ }
12
+ const live = args.mode === 'live'
13
+ const vars = {
14
+ STRIPE_SECRET_KEY: live ? process.env.STRIPE_SECRET_KEY : process.env.STRIPE_TEST_SECRET_KEY,
15
+ STRIPE_WEBHOOK_SECRET: live ? process.env.STRIPE_LIVE_WEBHOOK_SECRET : process.env.STRIPE_TEST_WEBHOOK_SECRET,
16
+ RESEND_API_KEY: process.env.RESEND_API_KEY,
17
+ LEG_LICENSE_PRIVATE_KEY: process.env.LEG_LICENSE_PRIVATE_KEY || process.env.BATON_LICENSE_PRIVATE_KEY,
18
+ BATON_LICENSE_PRIVATE_KEY: process.env.LEG_LICENSE_PRIVATE_KEY || process.env.BATON_LICENSE_PRIVATE_KEY,
19
+ LEG_SITE_ORIGIN: args.site || 'https://legcli.com',
20
+ BATON_SITE_ORIGIN: args.site || 'https://legcli.com',
21
+ LEG_MAIL_FROM: 'Leg <legcli@practicalsystems.io>',
22
+ BATON_MAIL_FROM: 'Leg <legcli@practicalsystems.io>',
23
+ LEG_MAIL_REPLY_TO: 'legcli@practicalsystems.io',
24
+ BATON_MAIL_REPLY_TO: 'legcli@practicalsystems.io',
25
+ }
26
+ const missing = Object.entries(vars).filter(([, v]) => !v).map(([k]) => k)
27
+ if (missing.length) { console.error('missing in .env: ' + missing.join(', ')); process.exit(2) }
28
+ // The Vercel CLI's JS entry, run with this node: no shell, so a value with
29
+ // shell characters is never interpreted and the repo's no-shell rule holds.
30
+ import { existsSync } from 'node:fs'
31
+ import { join } from 'node:path'
32
+ import { homedir } from 'node:os'
33
+ const roots = [process.env.APPDATA && join(process.env.APPDATA, 'npm', 'node_modules'), '/usr/local/lib/node_modules', '/usr/lib/node_modules', join(homedir(), '.npm-global', 'lib', 'node_modules')].filter(Boolean)
34
+ const vercelJs = roots.map((r) => join(r, 'vercel', 'dist', 'index.js')).find((f) => existsSync(f))
35
+ if (!vercelJs) { console.error('vercel CLI not found in a global node_modules; npm i -g vercel'); process.exit(2) }
36
+ for (const [k, v] of Object.entries(vars)) {
37
+ spawnSync(process.execPath, [vercelJs, 'env', 'rm', k, 'production', '--yes'], { cwd: 'site', encoding: 'utf8' })
38
+ const r = spawnSync(process.execPath, [vercelJs, 'env', 'add', k, 'production'], { cwd: 'site', input: v, encoding: 'utf8' })
39
+ console.log(`${k.padEnd(28)} ${r.status === 0 ? 'set' : 'FAILED ' + (r.stderr || r.stdout).split('\n').filter(Boolean).slice(-1)[0]}`)
40
+ }