rvlib-shipkit 2.3.0 → 2.5.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 (104) hide show
  1. package/CHANGELOG.md +19 -2
  2. package/package.json +2 -2
  3. package/src/assets/kits/identity-kit.ts +5 -2
  4. package/src/cli/cmd_Commands.ts +30 -5
  5. package/src/cli/cmd_FleetPush.ts +47 -0
  6. package/src/cli/cmd_GlobalProcesses.ts +5 -5
  7. package/src/cli/cmd_Review.ts +52 -11
  8. package/src/cli/cmd_Sound.ts +209 -0
  9. package/src/cli/cmd_Trailer.ts +11 -3
  10. package/src/cli/family-pages.ts +49 -0
  11. package/src/cli/kit/builtins.ts +1 -0
  12. package/src/cli/kit/verb.ts +11 -3
  13. package/src/cli/registry.ts +137 -0
  14. package/src/cli.ts +47 -133
  15. package/src/core/app-icon.ts +10 -6
  16. package/src/core/last-good.ts +217 -0
  17. package/src/core/load-probe.ts +71 -0
  18. package/src/core/reexec.ts +5 -3
  19. package/src/fleet/push-fleet.ts +80 -0
  20. package/src/home/home-routes.ts +12 -2
  21. package/src/home/lib-watch.ts +110 -20
  22. package/src/home/process-rows.ts +4 -2
  23. package/src/home/run-daemon.ts +1 -1
  24. package/src/repo-config.ts +3 -0
  25. package/src/review/format.ts +11 -3
  26. package/src/review/model.ts +66 -5
  27. package/src/review/paste.ts +30 -0
  28. package/src/review/project.ts +20 -4
  29. package/src/review/routes.ts +22 -4
  30. package/src/review/store.ts +15 -6
  31. package/src/review/unit.ts +19 -1
  32. package/src/scaffold/files/agent-index.md +2 -2
  33. package/src/scaffold/project-scaffold.ts +2 -2
  34. package/src/serve/hub-app/activity-st.ts +31 -0
  35. package/src/serve/hub-app/activity-view.tsx +9 -0
  36. package/src/serve/hub-app/activity.css +6 -0
  37. package/src/serve/hub-app/admin-svc.ts +1 -1
  38. package/src/serve/hub-app/admin-view.tsx +1 -1
  39. package/src/serve/hub-app/hash-route.ts +12 -4
  40. package/src/serve/hub-app/home-st.ts +7 -1
  41. package/src/serve/hub-app/home-strip.tsx +3 -1
  42. package/src/serve/hub-app/hub.css +38 -1
  43. package/src/serve/hub-app/icons.tsx +6 -0
  44. package/src/serve/hub-app/layout-st.ts +2 -2
  45. package/src/serve/hub-app/machine-st.ts +1 -1
  46. package/src/serve/hub-app/md.ts +32 -0
  47. package/src/serve/hub-app/project-list.css +9 -7
  48. package/src/serve/hub-app/project-list.tsx +5 -2
  49. package/src/serve/hub-app/projects-svc.ts +3 -2
  50. package/src/serve/hub-app/repo-st.ts +22 -7
  51. package/src/serve/hub-app/review/clip-st.ts +57 -7
  52. package/src/serve/hub-app/review/kind-view.tsx +78 -9
  53. package/src/serve/hub-app/review/review-answer.tsx +67 -58
  54. package/src/serve/hub-app/review/review-page.tsx +17 -15
  55. package/src/serve/hub-app/review/review-st.ts +191 -20
  56. package/src/serve/hub-app/review/review-views.tsx +154 -9
  57. package/src/serve/hub-app/review.css +73 -13
  58. package/src/serve/hub-app/wiki-st.ts +190 -0
  59. package/src/serve/hub-app/wikis-view.tsx +134 -5
  60. package/src/serve/hub-runtime.ts +4 -4
  61. package/src/serve/project-gutter-route.ts +25 -1
  62. package/src/serve/project-gutter.ts +1 -1
  63. package/src/serve/run-serve.ts +25 -0
  64. package/src/serve/serve-hub.ts +15 -1
  65. package/src/serve/stale-restart.ts +19 -2
  66. package/src/shot/client.ts +17 -5
  67. package/src/shot/shooter.ts +19 -3
  68. package/src/shot/shot-ask.ts +9 -1
  69. package/src/shot/stepped-clip.ts +53 -0
  70. package/src/trailer/adapter.ts +33 -4
  71. package/src/trailer/build.ts +182 -33
  72. package/src/trailer/ffmpeg.ts +21 -1
  73. package/src/trailer/fonts.ts +55 -0
  74. package/src/trailer/footage.ts +4 -4
  75. package/src/trailer/grammar.ts +429 -67
  76. package/src/trailer/graph.ts +130 -56
  77. package/src/trailer/look.ts +3 -0
  78. package/src/trailer/motion.ts +207 -0
  79. package/src/trailer/plan.ts +2 -2
  80. package/src/trailer/prune.ts +13 -2
  81. package/src/trailer/score/compose.ts +18 -2
  82. package/src/trailer/score/gm-render.ts +43 -0
  83. package/src/trailer/score/gm.ts +90 -0
  84. package/src/trailer/score/midi.ts +143 -0
  85. package/src/trailer/score/pattern.ts +13 -6
  86. package/src/trailer/score/score.ts +28 -14
  87. package/src/trailer/score/sound.ts +12 -1
  88. package/src/trailer/score/synth.ts +24 -2
  89. package/src/trailer/sound/audio.ts +148 -0
  90. package/src/trailer/sound/brand.ts +42 -0
  91. package/src/trailer/sound/comfy.ts +38 -0
  92. package/src/trailer/sound/eleven.ts +72 -0
  93. package/src/trailer/sound/fluid.ts +71 -0
  94. package/src/trailer/sound/gen.ts +139 -0
  95. package/src/trailer/sound/index.ts +60 -0
  96. package/src/trailer/sound/presets.ts +69 -0
  97. package/src/trailer/sound/run.ts +189 -0
  98. package/src/trailer/titles.ts +451 -235
  99. package/src/trailer/url.ts +18 -3
  100. package/src/trailer/vocab.ts +12 -1
  101. package/src/trailer/words.ts +29 -8
  102. package/src/wiki/graph-mount.ts +3 -0
  103. package/src/wiki/graph-page.ts +45 -9
  104. package/src/wiki/graph-pages.ts +128 -0
package/src/cli.ts CHANGED
@@ -1,144 +1,58 @@
1
1
  #!/usr/bin/env bun
2
- // shipkit — clipanion entry: one command FAMILY per file in cli/ (cmd_<Name>.ts),
3
- // logic in plain modules the commands call. runExit sets the exit code
4
- // without force-exiting, so serve commands keep the process alive.
5
- import { LIB_VERSION } from './core/lib-version.ts'
6
- import { Builtins, Cli } from 'clipanion'
7
- import { AskNewCommand, AsksCheckCommand, AsksCommand } from './cli/cmd_Asks.ts'
8
- import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenCommand, AssetsIdentityCommand } from './cli/cmd_Assets.ts'
9
- import { BumpCommand } from './cli/cmd_Bump.ts'
10
- import { CallsCommand } from './cli/cmd_Calls.ts'
11
- import { TimeCommand } from './cli/cmd_Time.ts'
12
- import { CheckCommand } from './cli/cmd_Check.ts'
13
- import { CommandsCommand } from './cli/cmd_Commands.ts'
14
- import { aliasArgs, CrossRepoActivityCommand } from './cli/cmd_CrossRepoActivity.ts'
15
- import { ConfigKeysCommand } from './cli/cmd_ConfigKeys.ts'
16
- import { GlobalProcessesCommand, GlobalProcessOpenCommand, GlobalProcessRestartCommand, GlobalProcessStartCommand, GlobalProcessStopCommand } from './cli/cmd_GlobalProcesses.ts'
17
- import { PlannedCommand } from './cli/cmd_Planned.ts'
18
- import { InsightsCommand } from './cli/cmd_Insights.ts'
19
- import { GlobalContextCommand } from './cli/cmd_GlobalContext.ts'
20
- import { OverviewCommand } from './cli/cmd_Overview.ts'
21
- import { CiCommand } from './cli/cmd_Ci.ts'
22
- import { ContractsCommand } from './cli/cmd_Contracts.ts'
23
- import { FleetCommand } from './cli/cmd_Fleet.ts'
24
- import { DaemonCommand, GuiCommand, PmCommand, PmInstallCommand, PmUninstallCommand, WebCommand } from './cli/cmd_Home.ts'
25
- import { HooksCommitMsgCommand, HooksInstallCommand, HooksListCommand, HooksPreCommitCommand } from './cli/cmd_Hooks.ts'
26
- import { InitCommand } from './cli/cmd_Init.ts'
27
- import { KeysCommand } from './cli/cmd_Keys.ts'
28
- import { MvCommand } from './cli/cmd_Mv.ts'
29
- import { NeedsCommand } from './cli/cmd_Needs.ts'
30
- import { PbCollectionCommand } from './cli/cmd_Pb.ts'
31
- import { ReflectCommand } from './cli/cmd_Reflect.ts'
32
- import { ReleaseCommand } from './cli/cmd_Release.ts'
33
- import { ReleasesCommand } from './cli/cmd_Releases.ts'
34
- import { ChecksCommand, UnitsAliasCommand } from './cli/cmd_Checks.ts'
35
- import { WhenCommand } from './cli/cmd_When.ts'
36
- import { CacheAuditCommand } from './cli/cmd_CacheAudit.ts'
37
- import { TraceAuditCommand } from './cli/cmd_TraceAudit.ts'
38
- import { ServeCommand } from './cli/cmd_Serve.ts'
39
- import { SmokeCommand } from './cli/cmd_Smoke.ts'
40
- import { ShotCommand } from './cli/cmd_Shot.ts'
41
- import { StatusCommand } from './cli/cmd_Status.ts'
42
- import { StartCommand } from './cli/cmd_Start.ts'
43
- import { TRAILER_COMMANDS } from './cli/cmd_Trailer.ts'
44
- import { REVIEW_COMMANDS } from './cli/cmd_Review.ts'
45
- import { TuiCommand } from './cli/cmd_Tui.ts'
46
- import { WikiLintCommand, WikiServeCommand } from './cli/cmd_Wiki.ts'
2
+ // the way in: the launcher, a repo's install handing off, the commit hooks
3
+ // and pm's roles all run this file. It loads the registry (cli/registry.ts)
4
+ // and the boot steps with await import, so a file of the tree that fails to
5
+ // load is caught here and the last good copy runs instead, with one line
6
+ // (core/last-good.ts). An error once the command runs is the command's own.
7
+ //
8
+ // the one constraint a reader breaks first: no static import but
9
+ // core/last-good.ts, else a broken file stops this file before it can fall
10
+ // back (tests/cli-boot.test.ts).
11
+ import { bootLoaded, runLastGood } from './core/last-good.ts'
47
12
 
13
+ /** a long running role's runner, which its command loads by await import:
14
+ * loaded here, a role pm starts on a broken tree runs the copy too */
15
+ const loadRole = (cmd: string | undefined): Promise<unknown> | null =>
16
+ cmd === 'daemon' ? import('./home/run-daemon.ts')
17
+ : cmd === 'web' ? import('./home/run-web.ts')
18
+ : cmd === 'pm' ? import('./home/run-pm.ts')
19
+ : cmd === 'serve' ? import('./serve/run-serve.ts')
20
+ : null
48
21
 
49
- /** the registry, built but NOT run: a test drives the real binary in process
50
- * instead of paying a cold bun start per assertion (~250ms each), and the
51
- * wiring it exercises is the wiring that ships. */
52
- export function buildCli(): Cli {
53
- const cli = new Cli({
54
- binaryLabel: 'shipkit — repo toolkit',
55
- binaryName: 'sk',
56
- binaryVersion: LIB_VERSION,
57
- enableCapture: true, // console output pipes into the command streams (testable)
58
- enableColors: true,
59
- })
22
+ /** every module the boot runs, loaded, with the runner of the role `cmd`
23
+ * names. The load probe asks it too (core/load-probe.ts), so a restart is
24
+ * tried on exactly this */
25
+ export const loadBoot = (cmd?: string) =>
26
+ Promise.all([
27
+ import('./cli/registry.ts'),
28
+ import('./cli/cmd_CrossRepoActivity.ts'),
29
+ import('./home/handoff.ts'),
30
+ import('./core/one-lib.ts'),
31
+ import('./cli/kit/calls.ts'),
32
+ import('./home/autostart.ts'),
33
+ loadRole(cmd),
34
+ ])
60
35
 
61
- cli.register(Builtins.HelpCommand)
62
- cli.register(Builtins.VersionCommand)
63
- cli.register(CiCommand)
64
- cli.register(StatusCommand)
65
- cli.register(BumpCommand)
66
- cli.register(ReleaseCommand)
67
- cli.register(ReleasesCommand)
68
- cli.register(HooksInstallCommand)
69
- cli.register(HooksPreCommitCommand)
70
- cli.register(HooksCommitMsgCommand)
71
- cli.register(HooksListCommand)
72
- cli.register(MvCommand)
73
- cli.register(CheckCommand)
74
- cli.register(TuiCommand)
75
- for (const c of TRAILER_COMMANDS) cli.register(c)
76
- for (const c of REVIEW_COMMANDS) cli.register(c)
77
- cli.register(ServeCommand)
78
- cli.register(PmCommand)
79
- cli.register(DaemonCommand)
80
- cli.register(WebCommand)
81
- cli.register(PmInstallCommand)
82
- cli.register(PmUninstallCommand)
83
- cli.register(GuiCommand)
84
- cli.register(ContractsCommand)
85
- cli.register(AsksCommand)
86
- cli.register(AsksCheckCommand)
87
- cli.register(AskNewCommand)
88
- cli.register(WikiServeCommand)
89
- cli.register(WikiLintCommand)
90
- cli.register(AssetsFillCommand)
91
- cli.register(AssetsAddCommand)
92
- cli.register(AssetsGenCommand)
93
- cli.register(AssetsIdentityCommand)
94
- cli.register(AssetsCheckCommand)
95
- cli.register(InitCommand)
96
- cli.register(StartCommand)
97
- cli.register(NeedsCommand)
98
- cli.register(FleetCommand)
99
- cli.register(PbCollectionCommand)
100
- cli.register(SmokeCommand)
101
- cli.register(ShotCommand)
102
- cli.register(ReflectCommand)
103
- cli.register(KeysCommand)
104
- cli.register(GlobalProcessesCommand)
105
- cli.register(GlobalProcessStartCommand)
106
- cli.register(GlobalProcessStopCommand)
107
- cli.register(GlobalProcessRestartCommand)
108
- cli.register(GlobalProcessOpenCommand)
109
- cli.register(CrossRepoActivityCommand)
110
- cli.register(PlannedCommand)
111
- cli.register(InsightsCommand)
112
- cli.register(GlobalContextCommand)
113
- cli.register(OverviewCommand)
114
- cli.register(ChecksCommand)
115
- cli.register(WhenCommand)
116
- cli.register(CacheAuditCommand)
117
- cli.register(TraceAuditCommand)
118
- cli.register(UnitsAliasCommand)
119
- cli.register(CommandsCommand)
120
- cli.register(CallsCommand)
121
- cli.register(TimeCommand)
122
- cli.register(ConfigKeysCommand)
123
-
124
- return cli
125
- }
126
-
127
- // only when this file IS the process, so importing it costs nothing. A repo's
128
- // installed copy first hands the command to the house source (handoff.ts). One copy
129
- // of the lib per process: a repo config that imports its own install lands on
130
- // this one (one-lib.ts), so the machine's version runs in every repo. The run
131
- // is logged (cli/kit/calls.ts, read back by `sk calls`). Then pm is started
132
- // when it is down (autostart.ts), before the command runs
36
+ // only when this file IS the process. A repo's installed copy first hands the
37
+ // command to the house source (handoff.ts). One copy of the lib per process:
38
+ // a repo config that imports its own install lands on this one (one-lib.ts).
39
+ // The run is logged (cli/kit/calls.ts, read back by `sk calls`). Then pm is
40
+ // started when it is down (autostart.ts), before the command runs
133
41
  if (import.meta.main) {
134
- const argv = aliasArgs(process.argv.slice(2))
135
- const { handoff } = await import('./home/handoff.ts')
42
+ const raw = process.argv.slice(2)
43
+ let m
44
+ try {
45
+ m = await loadBoot(raw[0])
46
+ } catch (e) {
47
+ runLastGood(e, raw)
48
+ throw e
49
+ }
50
+ bootLoaded()
51
+ const [{ buildCli }, { aliasArgs }, { handoff }, { installOneLib }, { callsFile, logCall }, { autostart }] = m
52
+ const argv = aliasArgs(raw)
136
53
  handoff(argv)
137
- const { installOneLib } = await import('./core/one-lib.ts')
138
54
  installOneLib()
139
- const { callsFile, logCall } = await import('./cli/kit/calls.ts')
140
55
  logCall(callsFile((await import('os')).homedir(), 'sk'), 'sk', argv)
141
- const { autostart } = await import('./home/autostart.ts')
142
56
  await autostart(argv)
143
57
  void buildCli().runExit(argv)
144
58
  }
@@ -10,8 +10,9 @@ import { identityOfName, identityOfText, type Identity } from './identity-color.
10
10
  import { configName, CONFIG_FILE } from './identity.ts'
11
11
  import { LAYOUT } from './layout.ts'
12
12
 
13
- /** the mark an app wears: the repo's own, largest first. Rasters only */
14
- export const APP_ICON_FILES = [`${LAYOUT.identity}/icon.png`, 'public/logo.png', 'public/favicon.png']
13
+ /** the mark an app wears: the repo's own, largest first; an svg mark is drawn
14
+ * as an image too (the canvas decodes it) */
15
+ export const APP_ICON_FILES = [`${LAYOUT.identity}/icon.png`, `${LAYOUT.identity}/icon.svg`, 'public/logo.png', 'public/favicon.png']
15
16
 
16
17
  export function appIconOf(root: string): string | null {
17
18
  for (const f of APP_ICON_FILES) if (existsSync(join(root, f))) return join(root, f)
@@ -133,15 +134,18 @@ export async function drawAppIcon(bytes: Uint8Array, id: Identity): Promise<{ ca
133
134
  return { canvas: out, plate: colour }
134
135
  }
135
136
 
136
- /** the icon as pngs, one per size asked, drawn once */
137
- export async function appIconPngs(bytes: Uint8Array, id: Identity, sizes: number[] = [ICON_PX]): Promise<Buffer[]> {
137
+ /** the icon as pngs, one per size asked, drawn once. `plate` crops the macOS
138
+ * grid's margin away, the rounded plate edge to edge (the hub's rail) */
139
+ export async function appIconPngs(bytes: Uint8Array, id: Identity, sizes: number[] = [ICON_PX], crop: 'canvas' | 'plate' = 'canvas'): Promise<Buffer[]> {
138
140
  const { canvas } = await drawAppIcon(bytes, id)
141
+ const o = crop === 'plate' ? (ICON_PX - PLATE) / 2 : 0
142
+ const side = crop === 'plate' ? PLATE : ICON_PX
139
143
  return sizes.map((px) => {
140
- if (px === ICON_PX) return canvas.toBuffer('image/png')
144
+ if (px === ICON_PX && crop === 'canvas') return canvas.toBuffer('image/png')
141
145
  const small = createCanvas(px, px)
142
146
  const ctx = small.getContext('2d')
143
147
  ctx.imageSmoothingQuality = 'high'
144
- ctx.drawImage(canvas, 0, 0, px, px)
148
+ ctx.drawImage(canvas, o, o, side, side, 0, 0, px, px)
145
149
  return small.toBuffer('image/png')
146
150
  })
147
151
  }
@@ -0,0 +1,217 @@
1
+ // the last good copy of the toolkit: what `sk` and the home run while the
2
+ // checkout fails to load. The daemon clones the source after a load that
3
+ // works (home/lib-watch.ts); the boot (cli.ts) runs the copy when the tree
4
+ // fails to load, with one line that names the file.
5
+ //
6
+ // the one constraint a reader breaks first: cli.ts imports this file
7
+ // statically, so it imports node builtins only. A lib import here would let
8
+ // a broken file stop the boot before it can fall back.
9
+ import { constants, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'fs'
10
+ import { homedir } from 'os'
11
+ import { join, relative } from 'path'
12
+
13
+ /** set on a process that runs the copy: the Held record, as json */
14
+ export const LAST_GOOD_ENV = 'SHIPKIT_LAST_GOOD'
15
+
16
+ /** why a process runs code older than its tree */
17
+ export type Held = {
18
+ /** epoch ms the tree was first seen failing */
19
+ since: number
20
+ /** the file that fails, relative to the lib (`src/trailer/graph.ts:12`) */
21
+ file: string
22
+ error: string
23
+ /** the lib root whose tree fails */
24
+ from: string
25
+ /** epoch ms the copy was taken, null when this process runs its own boot image */
26
+ copyAt: number | null
27
+ }
28
+
29
+ /** how long a replaced copy stays, from when it stopped being current: a
30
+ * process that runs it may still import a module from it lazily */
31
+ export const OLD_COPY_MS = 10 * 60_000
32
+
33
+ /** this lib's root (its package.json beside src) */
34
+ export const OWN_LIB = join(import.meta.dir, '../..')
35
+
36
+ const real = (p: string): string => {
37
+ try {
38
+ return realpathSync(p)
39
+ } catch {
40
+ return p
41
+ }
42
+ }
43
+
44
+ export const lastGoodHome = (env: Record<string, string | undefined> = process.env): string =>
45
+ env.SHIPKIT_LAST_GOOD_HOME != null && env.SHIPKIT_LAST_GOOD_HOME !== '' ? env.SHIPKIT_LAST_GOOD_HOME : join(homedir(), '.cache/shipkit/last-good')
46
+
47
+ /** one folder per lib checkout, so a worktree never runs the main checkout's copy */
48
+ export const lastGoodDir = (lib: string, home = lastGoodHome()): string => join(home, Bun.hash(real(lib)).toString(16).padStart(16, '0'))
49
+
50
+ const STAMP = 'stamp.json'
51
+ /** in a copy that is no longer current: when it stopped being */
52
+ const RETIRED = 'retired'
53
+
54
+ /** a copy carries a stamp: the lib it was taken from and when */
55
+ export function copyStamp(root: string): { at: number; from: string } | null {
56
+ try {
57
+ const j = JSON.parse(readFileSync(join(root, STAMP), 'utf8')) as { at?: number; from?: string }
58
+ return typeof j.at === 'number' && typeof j.from === 'string' ? { at: j.at, from: j.from } : null
59
+ } catch {
60
+ return null
61
+ }
62
+ }
63
+
64
+ /** the copy's cli and when it was taken, null when the lib has none */
65
+ export function lastGood(lib: string, home = lastGoodHome()): { cli: string; at: number } | null {
66
+ const cur = join(lastGoodDir(lib, home), 'current')
67
+ const s = copyStamp(cur)
68
+ const cli = join(cur, 'src/cli.ts')
69
+ return s == null || !existsSync(cli) ? null : { cli, at: s.at }
70
+ }
71
+
72
+ /** clone the lib's source as the last good copy (APFS clone: about 0.2 s for
73
+ * the whole source), its node_modules linked, probe it in its own folder,
74
+ * then point `current` at it. Throws when the clone does not load */
75
+ export async function takeLastGood(lib: string, o: { home?: string; now?: number } = {}): Promise<{ dir: string; ms: number }> {
76
+ const t0 = performance.now()
77
+ const now = o.now ?? Date.now()
78
+ const base = lastGoodDir(lib, o.home)
79
+ const name = `v${now}`
80
+ const dir = join(base, name)
81
+ mkdirSync(dir, { recursive: true })
82
+ const cp = Bun.spawn(['cp', '-c', '-R', join(lib, 'src'), join(dir, 'src')], { stdout: 'ignore', stderr: 'pipe' })
83
+ if ((await cp.exited) !== 0) {
84
+ const err = (await new Response(cp.stderr).text()).trim()
85
+ rmSync(dir, { recursive: true, force: true })
86
+ throw new Error(`could not clone ${lib}/src: ${err}`)
87
+ }
88
+ for (const f of ['package.json', 'tsconfig.json']) if (existsSync(join(lib, f))) copyFileSync(join(lib, f), join(dir, f), constants.COPYFILE_FICLONE)
89
+ symlinkSync(join(real(lib), 'node_modules'), join(dir, 'node_modules'))
90
+ writeFileSync(join(dir, STAMP), `${JSON.stringify({ at: now, from: real(lib) })}\n`)
91
+ // a write that landed during the clone is in it: the copy loads in its own folder, or never becomes current
92
+ const { probeLoad, bootEntries } = await import('./load-probe.ts')
93
+ const a = await probeLoad(dir, bootEntries(dir, 'all'))
94
+ if (!a.ok) {
95
+ rmSync(dir, { recursive: true, force: true })
96
+ throw new Error(`the clone fails to load (${a.file}: ${a.error}), so the copy before it stays current`)
97
+ }
98
+ // a link renamed over the old one: a reader sees the old copy or the new, never none
99
+ const link = join(base, `.current-${process.pid}`)
100
+ rmSync(link, { force: true })
101
+ symlinkSync(name, link)
102
+ let was: string | null = null
103
+ try { was = readlinkSync(join(base, 'current')) } catch { /* the first copy */ }
104
+ renameSync(link, join(base, 'current'))
105
+ // a copy ages from when it stopped being current: a process that runs it keeps its modules
106
+ if (was != null && was !== name && existsSync(join(base, was))) writeFileSync(join(base, was, RETIRED), `${now}\n`)
107
+ for (const e of readdirSync(base)) {
108
+ const at = /^v(\d+)$/.exec(e)
109
+ if (at == null || e === name) continue
110
+ let since = Number(at[1])
111
+ try { since = Number(readFileSync(join(base, e, RETIRED), 'utf8')) } catch { /* never current: a clone that failed or was cut */ }
112
+ if (now - since > OLD_COPY_MS) rmSync(join(base, e), { recursive: true, force: true })
113
+ }
114
+ return { dir, ms: performance.now() - t0 }
115
+ }
116
+
117
+ /** the lib file that imports `name` from `module`, read only on a failure */
118
+ function importerOf(lib: string, name: string, module: string): string | null {
119
+ const want = real(module).replace(/\.[cm]?[jt]sx?$/, '')
120
+ const re = new RegExp(`(?:import|export)\\s*(?:type\\s*)?\\{[^}]*\\b${name.replace(/[^\w$]/g, '')}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`, 'g')
121
+ for (const f of new Bun.Glob('src/**/*.{ts,tsx}').scanSync(lib)) {
122
+ const full = join(lib, f)
123
+ let text: string
124
+ try { text = readFileSync(full, 'utf8') } catch { continue }
125
+ if (!text.includes(name)) continue
126
+ for (const m of text.matchAll(re)) {
127
+ if (!m[1]!.startsWith('.')) continue
128
+ if (real(join(full, '..', m[1]!)).replace(/\.[cm]?[jt]sx?$/, '') === want) return full
129
+ }
130
+ }
131
+ return null
132
+ }
133
+
134
+ /** the file a load error names, relative to `lib`, and its first line */
135
+ export function loadFailure(e: unknown, lib: string): { file: string; error: string } {
136
+ const x = (e ?? {}) as { message?: string; position?: { file?: string; line?: number } | null; referrer?: string; stack?: string }
137
+ const rel = (p: string): string => {
138
+ const r = relative(real(lib), real(p))
139
+ return r.startsWith('..') ? p : r
140
+ }
141
+ const error = String(x.message ?? e).split('\n')[0]!.slice(0, 200)
142
+ // bun names the module a missing export was asked of, never the file that asked
143
+ const miss = /Export named '([^']+)' not found in module '([^']+)'/.exec(error)
144
+ if (miss != null) {
145
+ const importer = importerOf(lib, miss[1]!, miss[2]!)
146
+ return { file: rel(importer ?? miss[2]!), error: `imports '${miss[1]}', which ${rel(miss[2]!)} does not export` }
147
+ }
148
+ if (x.position?.file != null) return { file: `${rel(x.position.file)}${x.position.line != null ? `:${x.position.line}` : ''}`, error }
149
+ if (typeof x.referrer === 'string' && x.referrer !== '') return { file: rel(x.referrer), error }
150
+ const at = /\(?((?:\/[^\s():]+)+\.[cm]?[jt]sx?):(\d+)/.exec(x.stack ?? '')
151
+ return { file: at == null ? '?' : `${rel(at[1]!)}:${at[2]}`, error }
152
+ }
153
+
154
+ const hhmm = (t: number): string => new Date(t).toTimeString().slice(0, 5)
155
+
156
+ /** when a copy was taken: HH:MM within the last hour of today, else its
157
+ * date, time and age, so an old copy is never mistaken for a fresh one */
158
+ export function copyWhen(at: number, now = Date.now()): string {
159
+ const d = new Date(at)
160
+ if (d.toDateString() === new Date(now).toDateString() && now - at < 3_600_000) return hhmm(at)
161
+ const age = now - at
162
+ const old = age < 48 * 3_600_000 ? `${Math.round(age / 3_600_000)} h old` : `${Math.floor(age / 86_400_000)} d old`
163
+ const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
164
+ return `${day} ${hhmm(at)}, ${old}`
165
+ }
166
+
167
+ /** the one line every surface shows (the hub, the overview, a log) */
168
+ export const heldText = (h: Held): string =>
169
+ `running last good code since ${hhmm(h.since)} because ${h.file} fails to load: ${h.error}${h.copyAt == null ? '' : ` (the copy from ${copyWhen(h.copyAt)})`}`
170
+
171
+ let held: Held | null = null
172
+
173
+ /** this process's own state: a refused restart set it, or the boot that ran
174
+ * the copy left it in the env */
175
+ export function heldNow(): Held | null {
176
+ if (held != null) return held
177
+ const raw = process.env[LAST_GOOD_ENV]
178
+ if (raw == null || raw === '' || copyStamp(OWN_LIB) == null) return null
179
+ try {
180
+ return JSON.parse(raw) as Held
181
+ } catch {
182
+ return null
183
+ }
184
+ }
185
+
186
+ export function setHeld(h: Held | null): void {
187
+ held = h
188
+ }
189
+
190
+ /** the tree this process should go back to: its own, or the one its copy stands in for */
191
+ export const homeLib = (): string => (copyStamp(OWN_LIB)?.from ?? OWN_LIB)
192
+
193
+ /** the boot of a tree that loads: an env a copy left is not this process's */
194
+ export function bootLoaded(): void {
195
+ if (copyStamp(OWN_LIB) == null) delete process.env[LAST_GOOD_ENV]
196
+ }
197
+
198
+ /** the boot of a tree that failed to load: runs the copy (never returns), or
199
+ * returns when there is none so the caller throws the error itself */
200
+ export function runLastGood(e: unknown, argv: string[]): void {
201
+ const f = loadFailure(e, OWN_LIB)
202
+ // the copy itself failing is the error to show, never a loop
203
+ if (copyStamp(OWN_LIB) != null) return
204
+ const copy = lastGood(OWN_LIB)
205
+ if (copy == null) {
206
+ process.stderr.write(`FAIL shipkit: ${f.file} fails to load (${f.error}), and this checkout has no last good copy: the home's daemon keeps one\n`)
207
+ return
208
+ }
209
+ const h: Held = { since: Date.now(), file: f.file, error: f.error, from: real(OWN_LIB), copyAt: copy.at }
210
+ process.stderr.write(`WARN shipkit: ${f.file} fails to load (${f.error}), so this runs the last good copy from ${copyWhen(copy.at)}\n`)
211
+ const env = Object.fromEntries(Object.entries({ ...process.env, [LAST_GOOD_ENV]: JSON.stringify(h) }).filter((x): x is [string, string] => x[1] != null))
212
+ const args = [process.execPath, ...process.execArgv, copy.cli, ...argv]
213
+ const execve = (process as { execve?: (file: string, args: string[], env: Record<string, string>) => never }).execve
214
+ if (typeof execve === 'function') execve.call(process, process.execPath, args, env)
215
+ const child = Bun.spawnSync(args, { cwd: process.cwd(), stdout: 'inherit', stderr: 'inherit', stdin: 'inherit', env })
216
+ process.exit(child.exitCode ?? 1)
217
+ }
@@ -0,0 +1,71 @@
1
+ // does a tree load: a child bun imports its entries and says which file
2
+ // fails. Asked before a machine process restarts onto new code, and before
3
+ // the daemon keeps a copy as the last good one. Run as a child so a module
4
+ // that fails, or one whose top level runs, never touches the asking process.
5
+ import { join } from 'path'
6
+ import { loadFailure } from './last-good.ts'
7
+ import type { LoadVerdict } from '../serve/stale-restart.ts'
8
+
9
+ export type LoadAnswer = { ok: true; ms: number } | { ok: false; ms: number; file: string; error: string }
10
+
11
+ export type Role = 'daemon' | 'pm' | 'web' | 'serve'
12
+
13
+ const RUNNER: Record<Role, string> = { daemon: 'src/home/run-daemon.ts', pm: 'src/home/run-pm.ts', web: 'src/home/run-web.ts', serve: 'src/serve/run-serve.ts' }
14
+
15
+ /** the modules a restart through the cli loads: the boot's (cli.ts
16
+ * `loadBoot`: the registry and what runs before it) and the role's runner;
17
+ * `all`: every role's, what a copy every process may run must load */
18
+ export const bootEntries = (lib: string, role?: Role | 'all'): string[] => [
19
+ join(lib, 'src/cli.ts'),
20
+ ...(role == null ? [] : (role === 'all' ? Object.values(RUNNER) : [RUNNER[role]]).map((r) => join(lib, r))),
21
+ ]
22
+
23
+ /** import `entries` in a child bun from `lib`'s own probe file */
24
+ export async function probeLoad(lib: string, entries: string[]): Promise<LoadAnswer> {
25
+ const t0 = performance.now()
26
+ const p = Bun.spawn([process.execPath, join(lib, 'src/core/load-probe.ts'), ...entries], { cwd: lib, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, SHIPKIT_NO_HOME: '1' } })
27
+ const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
28
+ const ms = performance.now() - t0
29
+ if (code === 0) return { ok: true, ms }
30
+ try {
31
+ const j = JSON.parse(out.trim().split('\n').at(-1) ?? '') as { file: string; error: string }
32
+ return { ok: false, ms, file: j.file, error: j.error }
33
+ } catch {
34
+ // the probe file itself did not load
35
+ const line = err.split('\n').find((l) => /error/i.test(l)) ?? err.trim().split('\n')[0] ?? `exit ${code}`
36
+ return { ok: false, ms, file: 'src/core/load-probe.ts', error: line.trim().slice(0, 200) }
37
+ }
38
+ }
39
+
40
+ /** one probe per state of the tree: asked again only once the key moves */
41
+ export class TreeProbe {
42
+ private key: string | null = null
43
+ private verdict: LoadVerdict = 'pending'
44
+
45
+ constructor(private readonly run: () => Promise<LoadAnswer>, private readonly keyOf: () => string) {}
46
+
47
+ loads(): LoadVerdict {
48
+ const k = this.keyOf()
49
+ if (k !== this.key) {
50
+ this.key = k
51
+ this.verdict = 'pending'
52
+ const land = (v: LoadVerdict): void => { if (this.key === k) this.verdict = v }
53
+ void this.run().then((a) => land(a.ok ? 'ok' : { file: a.file, error: a.error }), (e: Error) => land({ file: '?', error: e.message }))
54
+ }
55
+ return this.verdict
56
+ }
57
+ }
58
+
59
+ if (import.meta.main) {
60
+ const lib = join(import.meta.dir, '../..')
61
+ for (const e of process.argv.slice(2)) {
62
+ try {
63
+ const m = (await import(e)) as { loadBoot?: () => Promise<unknown> }
64
+ if (typeof m.loadBoot === 'function') await m.loadBoot()
65
+ } catch (x) {
66
+ console.log(JSON.stringify(loadFailure(x, lib)))
67
+ process.exit(3)
68
+ }
69
+ }
70
+ process.exit(0)
71
+ }
@@ -5,7 +5,9 @@
5
5
  // code, which keeps the terminal but leaves this process waiting under it.
6
6
  import { existsSync } from 'fs'
7
7
 
8
- const argvOf = (): string[] => [process.execPath, ...process.execArgv, ...process.argv.slice(1)]
8
+ /** `entry` in place of the running script: a process on the last good copy
9
+ * goes back to the tree's own cli (core/last-good.ts) */
10
+ const argvOf = (entry?: string): string[] => [process.execPath, ...process.execArgv, ...(entry == null ? process.argv.slice(1) : [entry, ...process.argv.slice(2)])]
9
11
 
10
12
  /** why this invocation cannot run again, null when it can. Asked BEFORE a
11
13
  * caller tears anything down, so a refusal leaves the process serving */
@@ -16,8 +18,8 @@ export function reexecBlocker(): string | null {
16
18
  }
17
19
 
18
20
  /** never returns. `env` defaults to this process's own */
19
- export function reexecSelf(env: Record<string, string | undefined> = process.env): never {
20
- const argv = argvOf()
21
+ export function reexecSelf(env: Record<string, string | undefined> = process.env, entry?: string): never {
22
+ const argv = argvOf(entry)
21
23
  const clean = Object.fromEntries(Object.entries(env).filter((e): e is [string, string] => e[1] != null))
22
24
  const execve = (process as { execve?: (file: string, args: string[], env: Record<string, string>) => never }).execve
23
25
  if (typeof execve === 'function') execve.call(process, process.execPath, argv, clean)
@@ -0,0 +1,80 @@
1
+ // push every house repo that holds commits its remote does not: `sk fleet
2
+ // push` and the activity page's push button run this one path. A plain
3
+ // `git push`, never a force: a branch that also fell behind its remote waits
4
+ // for a pull, a branch with no upstream goes to its one remote with -u, and
5
+ // git itself refuses anything that is not a fast-forward. Local git only, no
6
+ // fetch first: "behind" is as of the last fetch, and the push is what finds
7
+ // out for sure.
8
+ import { parseStatus } from '../home/activity.ts'
9
+
10
+ export type PushAction = 'push' | 'push-upstream' | 'none' | 'skip'
11
+ export type PushPlan = { name: string; path: string; branch: string | null; remote: string | null; ahead: number; action: PushAction; why: string }
12
+ export type PushResult = PushPlan & { ok: boolean; line: string }
13
+
14
+ /** at most this many pushes at once */
15
+ const PARALLEL = 4
16
+ const PUSH_MS = 60_000
17
+
18
+ async function git(cwd: string, args: string[], ms = 15_000): Promise<{ code: number; out: string; err: string }> {
19
+ // a push that would ask for a password fails instead of hanging the page
20
+ const p = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
21
+ const timer = setTimeout(() => p.kill(), ms)
22
+ const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
23
+ clearTimeout(timer)
24
+ return { code, out, err }
25
+ }
26
+
27
+ /** what to do with one repo, from `git status --porcelain=v2 --branch`, its
28
+ * remotes and its commit count. Pure: the tests feed it text */
29
+ export function planOf(repo: { name: string; path: string }, status: string, remotes: string[], commits: number): PushPlan {
30
+ const s = parseStatus(status)
31
+ const base = { name: repo.name, path: repo.path, branch: s.branch, remote: null as string | null, ahead: s.ahead ?? 0 }
32
+ if (s.branch == null) return { ...base, action: 'skip', why: s.detached != null ? `detached at ${s.detached}: no branch to push` : 'no commit yet' }
33
+ if (s.upstream == null) {
34
+ if (commits === 0) return { ...base, action: 'none', why: 'no commit yet' }
35
+ const remote = remotes.length === 1 ? remotes[0]! : remotes.includes('origin') ? 'origin' : null
36
+ if (remote == null) return { ...base, action: 'skip', why: remotes.length === 0 ? 'no remote to push to' : `no upstream, and ${remotes.length} remotes: set one with git push -u <remote> ${s.branch}` }
37
+ return { ...base, remote, ahead: commits, action: 'push-upstream', why: `no upstream: push -u ${remote} ${s.branch}` }
38
+ }
39
+ if (s.gone) return { ...base, action: 'skip', why: `its upstream ${s.upstream} is gone` }
40
+ const ahead = s.ahead ?? 0
41
+ const behind = s.behind ?? 0
42
+ if (ahead === 0) return { ...base, action: 'none', why: 'pushed' }
43
+ if (behind > 0) return { ...base, action: 'skip', why: `${ahead} ahead and ${behind} behind ${s.upstream}: pull first, never a force` }
44
+ return { ...base, remote: s.upstream.split('/')[0] ?? null, action: 'push', why: `${ahead} unpushed to ${s.upstream}` }
45
+ }
46
+
47
+ export async function pushPlan(repo: { name: string; path: string }): Promise<PushPlan> {
48
+ const [status, remotes, count] = await Promise.all([
49
+ git(repo.path, ['status', '--porcelain=v2', '--branch']),
50
+ git(repo.path, ['remote']),
51
+ git(repo.path, ['rev-list', '--count', 'HEAD']),
52
+ ])
53
+ if (status.code !== 0) return { name: repo.name, path: repo.path, branch: null, remote: null, ahead: 0, action: 'skip', why: `git status failed: ${status.err.trim().split('\n')[0]}` }
54
+ return planOf(repo, status.out, remotes.out.split('\n').filter((r) => r !== ''), Number(count.out.trim()) || 0)
55
+ }
56
+
57
+ async function pushOne(p: PushPlan): Promise<PushResult> {
58
+ if (p.action !== 'push' && p.action !== 'push-upstream') return { ...p, ok: p.action === 'none', line: `${p.action === 'none' ? 'ok' : 'skip'}: ${p.name}: ${p.why}` }
59
+ const args = p.action === 'push' ? ['push'] : ['push', '-u', p.remote!, p.branch!]
60
+ const r = await git(p.path, args, PUSH_MS)
61
+ if (r.code === 0) return { ...p, ok: true, line: `ok: ${p.name}: pushed ${p.ahead} commit${p.ahead === 1 ? '' : 's'} (${p.branch})` }
62
+ const why = r.err.split('\n').map((l) => l.trim()).filter((l) => l !== '' && !l.startsWith('hint:')).slice(-1)[0] ?? `git push exited ${r.code}`
63
+ return { ...p, ok: false, line: `FAIL: ${p.name}: git ${args.join(' ')}: ${why}` }
64
+ }
65
+
66
+ /** every repo planned, the ones that need it pushed, a few at a time; `dry`
67
+ * plans only. Results come back in the repos' order */
68
+ export async function pushFleet(repos: { name: string; path: string }[], o: { dry?: boolean } = {}): Promise<PushResult[]> {
69
+ const plans = await Promise.all(repos.map(pushPlan))
70
+ if (o.dry === true) return plans.map((p) => ({ ...p, ok: p.action !== 'skip', line: `${p.action === 'skip' ? 'skip' : p.action === 'none' ? 'ok' : 'would push'}: ${p.name}: ${p.why}` }))
71
+ const out: PushResult[] = new Array(plans.length)
72
+ let next = 0
73
+ await Promise.all(Array.from({ length: PARALLEL }, async () => {
74
+ while (next < plans.length) {
75
+ const i = next++
76
+ out[i] = await pushOne(plans[i]!)
77
+ }
78
+ }))
79
+ return out
80
+ }