rvlib-shipkit 1.2.0 → 1.4.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 (173) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/asks/asks-queue.ts +4 -2
  5. package/src/assets/kits/identity-kit.ts +15 -13
  6. package/src/assets/run-assets.ts +15 -10
  7. package/src/check.ts +26 -10
  8. package/src/checks/SK001-package-json-shape.check.ts +34 -5
  9. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  10. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  11. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  12. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  13. package/src/checks/SK007-github-visibility.check.ts +9 -9
  14. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  15. package/src/checks/SK009-commit-ai-tells.check.ts +103 -61
  16. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  17. package/src/checks/SK014-prose-ste.check.ts +16 -6
  18. package/src/checks/SK015-nested-repo.check.ts +5 -1
  19. package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
  20. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  21. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -2
  22. package/src/checks/SK024-identity-colors.check.ts +5 -0
  23. package/src/checks/SK025-release-drift.check.ts +58 -0
  24. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  25. package/src/checks/SK027-agent-wiki-links.check.ts +50 -0
  26. package/src/checks/SK028-readme.check.ts +83 -0
  27. package/src/cli/cmd_Assets.ts +1 -1
  28. package/src/cli/cmd_Ci.ts +5 -0
  29. package/src/cli/cmd_CrossRepoActivity.ts +119 -0
  30. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  31. package/src/cli/cmd_Keys.ts +17 -3
  32. package/src/cli/cmd_Overview.ts +98 -0
  33. package/src/cli/cmd_Planned.ts +117 -0
  34. package/src/cli.ts +9 -1
  35. package/src/collections/checks.ts +3 -2
  36. package/src/collections/collection-types.ts +15 -0
  37. package/src/collections/providers.ts +7 -3
  38. package/src/collections/test-time.ts +10 -1
  39. package/src/contracts/apply-contracts.ts +9 -8
  40. package/src/contracts/contract-types.ts +8 -2
  41. package/src/contracts/contracts-engine.ts +34 -13
  42. package/src/contracts/manifests/identity.contract.ts +6 -5
  43. package/src/contracts/manifests/social.contract.ts +4 -2
  44. package/src/contracts/manifests/steam.contract.ts +7 -3
  45. package/src/contracts/web-surface.ts +59 -0
  46. package/src/core/agent-spawn.ts +46 -0
  47. package/src/core/archived.ts +82 -0
  48. package/src/core/budget.ts +19 -9
  49. package/src/core/canonical-scripts.ts +12 -3
  50. package/src/core/check-runner.ts +66 -17
  51. package/src/core/discover-checks.ts +10 -5
  52. package/src/core/file-stamp.ts +20 -0
  53. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  54. package/src/core/identity-color.ts +1 -1
  55. package/src/core/layout.ts +2 -0
  56. package/src/core/motherhome.ts +88 -66
  57. package/src/core/repo-facts.ts +14 -0
  58. package/src/core/retention.ts +14 -5
  59. package/src/core/retired-checks.ts +7 -0
  60. package/src/core/run-check.ts +8 -7
  61. package/src/core/run-ci.ts +46 -15
  62. package/src/core/run-fix.ts +7 -2
  63. package/src/core/run-fixers.ts +10 -6
  64. package/src/core/wire.ts +49 -9
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +138 -0
  67. package/src/home/activity.ts +256 -0
  68. package/src/home/global-units.ts +3 -2
  69. package/src/home/gui-app/ShipkitGui.swift +144 -25
  70. package/src/home/gui-app.ts +93 -30
  71. package/src/home/home-launchd.ts +10 -2
  72. package/src/home/home-routes.ts +34 -6
  73. package/src/home/home-start.ts +33 -5
  74. package/src/home/machine-stores.ts +4 -2
  75. package/src/home/open-app.ts +23 -10
  76. package/src/home/pm-config.ts +15 -11
  77. package/src/home/pm-inputs.ts +76 -0
  78. package/src/home/pm.ts +40 -7
  79. package/src/home/process-rows.ts +222 -0
  80. package/src/home/resources.ts +99 -20
  81. package/src/home/run-daemon.ts +12 -7
  82. package/src/home/run-pm.ts +30 -5
  83. package/src/home/serve-web.ts +6 -3
  84. package/src/home/supervise.ts +16 -1
  85. package/src/home/widget-text.ts +119 -0
  86. package/src/home/widgets.ts +32 -3
  87. package/src/hooks/commit-message.ts +2 -3
  88. package/src/hooks/run-hooks.ts +32 -5
  89. package/src/ledger/db.ts +1 -1
  90. package/src/ledger/gate-checks.ts +20 -6
  91. package/src/ledger/house-runs.ts +43 -0
  92. package/src/ledger/live-timeline.ts +4 -19
  93. package/src/ledger/run-log.ts +39 -10
  94. package/src/ledger/store.ts +30 -2
  95. package/src/ledger/world-cache.ts +63 -14
  96. package/src/plan/cadence.ts +21 -5
  97. package/src/plan/catch-up.ts +37 -0
  98. package/src/plan/plan-timeline.ts +99 -0
  99. package/src/plan/planner.ts +138 -13
  100. package/src/plan/repo-source.ts +120 -8
  101. package/src/release/run-release.ts +9 -2
  102. package/src/release/unit-resolve.ts +8 -12
  103. package/src/repo-config.ts +17 -4
  104. package/src/serve/house-runs.ts +53 -0
  105. package/src/serve/hub-app/activity-st.ts +168 -0
  106. package/src/serve/hub-app/activity-view.tsx +218 -0
  107. package/src/serve/hub-app/activity.css +78 -0
  108. package/src/serve/hub-app/admin-svc.ts +41 -0
  109. package/src/serve/hub-app/admin-view.tsx +42 -0
  110. package/src/serve/hub-app/chart-view.ts +12 -5
  111. package/src/serve/hub-app/check-words.ts +2 -0
  112. package/src/serve/hub-app/checks-svc.ts +2 -1
  113. package/src/serve/hub-app/col-tables-view.tsx +124 -0
  114. package/src/serve/hub-app/col-tables.ts +115 -0
  115. package/src/serve/hub-app/cols-svc.ts +28 -0
  116. package/src/serve/hub-app/controls.css +32 -0
  117. package/src/serve/hub-app/cycle-button.tsx +36 -0
  118. package/src/serve/hub-app/dock-anim.ts +25 -0
  119. package/src/serve/hub-app/dock-st.ts +329 -0
  120. package/src/serve/hub-app/dock-view.tsx +68 -0
  121. package/src/serve/hub-app/dock.css +35 -0
  122. package/src/serve/hub-app/hash-route.ts +7 -1
  123. package/src/serve/hub-app/header-bar.tsx +79 -104
  124. package/src/serve/hub-app/home-st.ts +50 -13
  125. package/src/serve/hub-app/home-strip.css +81 -23
  126. package/src/serve/hub-app/home-strip.tsx +243 -102
  127. package/src/serve/hub-app/home.html +4 -0
  128. package/src/serve/hub-app/hub-app.tsx +103 -161
  129. package/src/serve/hub-app/hub-types.ts +6 -3
  130. package/src/serve/hub-app/hub.css +101 -39
  131. package/src/serve/hub-app/icons.tsx +27 -3
  132. package/src/serve/hub-app/index.html +4 -0
  133. package/src/serve/hub-app/keys.ts +44 -9
  134. package/src/serve/hub-app/live-svc.ts +24 -64
  135. package/src/serve/hub-app/machine-st.ts +65 -13
  136. package/src/serve/hub-app/md.ts +38 -5
  137. package/src/serve/hub-app/next-run.ts +1 -1
  138. package/src/serve/hub-app/palette-st.ts +13 -9
  139. package/src/serve/hub-app/palette-view.tsx +47 -0
  140. package/src/serve/hub-app/pill-row.tsx +65 -0
  141. package/src/serve/hub-app/planned-st.ts +21 -85
  142. package/src/serve/hub-app/planned-view.tsx +12 -7
  143. package/src/serve/hub-app/planned.css +5 -2
  144. package/src/serve/hub-app/project-list.css +43 -21
  145. package/src/serve/hub-app/project-list.tsx +122 -23
  146. package/src/serve/hub-app/projects-svc.ts +120 -12
  147. package/src/serve/hub-app/readme-svc.ts +38 -0
  148. package/src/serve/hub-app/readme-view.tsx +19 -0
  149. package/src/serve/hub-app/readme.css +11 -0
  150. package/src/serve/hub-app/repo-sections.tsx +61 -0
  151. package/src/serve/hub-app/repo-st.ts +26 -7
  152. package/src/serve/hub-app/runs-tabs.ts +1 -1
  153. package/src/serve/hub-app/sidebar.tsx +84 -0
  154. package/src/serve/hub-app/tests-view.tsx +31 -4
  155. package/src/serve/hub-app/timeline-chart.tsx +31 -27
  156. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  157. package/src/serve/hub-app/timeline-svc.ts +2 -2
  158. package/src/serve/hub-app/timeline-view.tsx +29 -9
  159. package/src/serve/hub-runtime.ts +17 -9
  160. package/src/serve/other-repos.ts +8 -45
  161. package/src/serve/project-gutter-route.ts +100 -20
  162. package/src/serve/project-gutter.ts +34 -13
  163. package/src/serve/readme-route.ts +57 -0
  164. package/src/serve/serve-hub.ts +13 -8
  165. package/src/serve/tracked-watch.ts +33 -1
  166. package/src/serve/warm-cell.ts +49 -16
  167. package/src/shipkit-files.ts +20 -4
  168. package/src/suite/judge-suites.ts +42 -17
  169. package/src/suite/suite-cli.ts +5 -8
  170. package/src/wiki/lint-md.ts +2 -0
  171. package/src/wiki/scan-md.ts +37 -10
  172. package/src/checks/SK023-stores-under-cap.check.ts +0 -49
  173. package/src/checks/contracts.check.ts +0 -20
@@ -91,8 +91,9 @@ export const BOOT_SWEEP_DELAY_MS = 10_000
91
91
  export type Sweeper = { stop: () => void; once: () => Promise<void> }
92
92
 
93
93
  /** the daemon's one retention sweep: every repo it loads, then the machine,
94
- * on boot and every hour. A store whose sweep throws is one printed line */
95
- export function startSweeps(o: { roots: () => string[]; dir: string; printErr: (l: string) => void; everyMs?: number; bootDelayMs?: number; now?: () => number }): Sweeper {
94
+ * then `also` (the machine's batched writes), on boot and every hour. A
95
+ * store whose sweep throws is one printed line */
96
+ export function startSweeps(o: { roots: () => string[]; dir: string; printErr: (l: string) => void; everyMs?: number; bootDelayMs?: number; now?: () => number; also?: (now: number) => void }): Sweeper {
96
97
  let running = false
97
98
  const once = async (): Promise<void> => {
98
99
  if (running) return
@@ -103,6 +104,7 @@ export function startSweeps(o: { roots: () => string[]; dir: string; printErr: (
103
104
  for (const r of await sweepStores(repoStores(root), now)) if (r.error != null) o.printErr(`WARN retention sweep of ${r.id} in ${root} failed: ${r.error}`)
104
105
  }
105
106
  for (const r of await sweepStores(machineStores(o.dir), now)) if (r.error != null) o.printErr(`WARN retention sweep of ${r.id} failed: ${r.error}`)
107
+ o.also?.(now)
106
108
  } finally {
107
109
  running = false
108
110
  }
@@ -1,16 +1,29 @@
1
- // opens one shipkit app window on a url, in its own short process, so pm
2
- // never loads what building the app needs:
1
+ // opens one shipkit app window, in its own short process, so pm never loads
2
+ // what building an app needs:
3
3
  //
4
- // bun open-app.ts <url>
4
+ // bun open-app.ts <url> one more shipkit window on a url
5
+ // bun open-app.ts --process <json> a global process's own app (WrapperOf)
5
6
  import { LIB_VERSION } from '../core/lib-version.ts'
6
- import { openWindow } from './home-start.ts'
7
+ import type { WrapperOf } from './gui-app.ts'
8
+ import { openProcessApp, openWindow } from './home-start.ts'
7
9
 
8
10
  if (import.meta.main) {
9
- const url = process.argv[2]
10
- if (url == null) {
11
- console.error('usage: bun open-app.ts <url>')
12
- process.exit(2)
11
+ const [first, second] = process.argv.slice(2)
12
+ const warn = (l: string): void => console.error(l)
13
+ try {
14
+ if (first === '--process' && second != null) {
15
+ const d = JSON.parse(second) as WrapperOf
16
+ const cmd = openProcessApp(d, { version: LIB_VERSION, warn })
17
+ console.log(`shipkit: opened ${d.id} as its own app: ${cmd.join(' ')}`)
18
+ } else if (first != null && first !== '--process') {
19
+ const cmd = openWindow(first, { version: LIB_VERSION, warn })
20
+ console.log(`shipkit: opened ${first} as an app window: ${cmd.join(' ')}`)
21
+ } else {
22
+ console.error('usage: bun open-app.ts <url> | --process <json>')
23
+ process.exit(2)
24
+ }
25
+ } catch (e) {
26
+ console.error(`shipkit: could not open the app: ${(e as Error).message}`)
27
+ process.exit(1)
13
28
  }
14
- const cmd = openWindow(url, { version: LIB_VERSION, warn: (l) => console.error(l) })
15
- console.log(`shipkit: opened ${url} as an app window: ${cmd.join(' ')}`)
16
29
  }
@@ -2,18 +2,14 @@
2
2
  // and the global processes every house repo declares in its `.shipkit.ts`
3
3
  // files), read in a short child process so pm itself never imports a repo
4
4
  // file. That graph would make pm large and reload it on every edit there.
5
+ // The answer names the paths it read (`inputs`) and their stamp, so pm runs
6
+ // the child again only once a stat of one of them moved.
5
7
  //
6
- // bun pm-config.ts <root> prints { index, processes, errors } as json on stdout
8
+ // bun pm-config.ts <root> prints a PmConfig as json on stdout
7
9
  import { homedir } from 'os'
8
- import type { GlobalProcessDecl } from './global-units.ts'
10
+ import { houseInputs, stampOf, type PmConfig } from './pm-inputs.ts'
9
11
 
10
- export type PmConfig = {
11
- /** the house wiki index the repo includes, null when it includes none */
12
- index: string | null
13
- processes: GlobalProcessDecl[]
14
- /** the unit files that would not load */
15
- errors: string[]
16
- }
12
+ export type { PmConfig }
17
13
 
18
14
  if (import.meta.main) {
19
15
  const root = process.argv[2]
@@ -22,14 +18,22 @@ if (import.meta.main) {
22
18
  process.exit(2)
23
19
  }
24
20
  const { houseRepos } = await import('../serve/project-gutter.ts')
21
+ const { repoUnitFiles } = await import('../core/discover-checks.ts')
25
22
  const house = houseRepos(root, homedir())
26
- const out: PmConfig = { index: house.index, processes: [], errors: [] }
23
+ const repos = house.index == null ? [] : house.repos
24
+ const files = new Map<string, string[]>()
25
+ const inputs = houseInputs(house.home, house.index, repos.map((r) => r.path), (r, dirs) => {
26
+ const f = repoUnitFiles(r, dirs)
27
+ files.set(r, f)
28
+ return f
29
+ })
30
+ const out: PmConfig = { index: house.index, processes: [], errors: [], inputs, stamp: stampOf(inputs) }
27
31
  if (house.index != null) {
28
32
  // a unit file imports the lib by name: this copy, like the daemon's
29
33
  const { useOneLib } = await import('../core/one-lib.ts')
30
34
  const { scanGlobalUnits } = await import('./global-units.ts')
31
35
  useOneLib()
32
- const u = await scanGlobalUnits(house.repos.map((r) => ({ name: r.name, path: r.path })))
36
+ const u = await scanGlobalUnits(repos.map((r) => ({ name: r.name, path: r.path })), { files: (r) => files.get(r) ?? [] })
33
37
  out.processes = u.processes
34
38
  out.errors = u.errors
35
39
  }
@@ -0,0 +1,76 @@
1
+ // what pm stamps to know whether the house side moved, without running the
2
+ // child that reads it (pm-config.ts): the answer's shape, the paths it
3
+ // depends on, and their stamp. Imports nothing a repo file does, so pm's own
4
+ // graph stays small.
5
+ import { readdirSync, statSync } from 'fs'
6
+ import { dirname, join } from 'path'
7
+ import { UNIT_SUFFIX } from '../core/check-file-names.ts'
8
+ import type { GlobalProcessDecl } from './global-units.ts'
9
+
10
+ export type PmConfig = {
11
+ /** the house wiki index the repo includes, null when it includes none */
12
+ index: string | null
13
+ processes: GlobalProcessDecl[]
14
+ /** the unit files that would not load */
15
+ errors: string[]
16
+ /** every path whose stat can change this answer (houseInputs) */
17
+ inputs: string[]
18
+ /** stampOf(inputs), taken before the unit files were imported */
19
+ stamp: string
20
+ }
21
+
22
+ /** the files a repo carries its includes in. Here, not in project-gutter.ts
23
+ * which reads them, so pm stamps them without loading that graph */
24
+ export const CLAUDE_FILES = ['CLAUDE.md', 'CLAUDE.local.md']
25
+
26
+ /** what the answer depends on, as paths to stat: the dir holding the repos
27
+ * (a repo added or removed), every sibling's CLAUDE files (a repo starting
28
+ * to include the house), the index, each house repo's config (archived),
29
+ * and every dir and unit file the unit walk read. A helper a unit file
30
+ * imports is not in it: an edit there shows once the unit file moves */
31
+ export function houseInputs(home: string, index: string | null, repos: string[], unitFiles: (root: string, dirs: string[]) => string[]): string[] {
32
+ const parent = dirname(home)
33
+ const out = [parent]
34
+ let names: string[] = []
35
+ try { names = readdirSync(parent) } catch { /* the stat of parent says when it appears */ }
36
+ for (const n of names.sort()) if (!n.startsWith('.')) for (const f of CLAUDE_FILES) out.push(join(parent, n, f))
37
+ if (index != null) out.push(index)
38
+ for (const r of repos) {
39
+ out.push(join(r, 'repo.config.ts'))
40
+ const dirs: string[] = []
41
+ const files = unitFiles(r, dirs)
42
+ out.push(...dirs, ...files)
43
+ }
44
+ return out
45
+ }
46
+
47
+ /** a dir's entries that can change the answer: its subdirs and its unit
48
+ * files. A temp file coming and going moves the dir's mtime, not this */
49
+ const listings = new Map<string, { mtime: number; names: string }>()
50
+ function dirNames(dir: string, mtime: number): string {
51
+ const hit = listings.get(dir)
52
+ if (hit?.mtime === mtime) return hit.names
53
+ let names = ''
54
+ try {
55
+ names = readdirSync(dir, { withFileTypes: true })
56
+ .filter((e) => !e.name.startsWith('.') && (e.isDirectory() || e.name.endsWith(UNIT_SUFFIX)))
57
+ .map((e) => e.name)
58
+ .sort()
59
+ .join('/')
60
+ } catch { /* gone between the stat and the read: the next stat says so */ }
61
+ listings.set(dir, { mtime, names })
62
+ return names
63
+ }
64
+
65
+ /** every path as one string: a file by its mtime, a dir by the entries
66
+ * that matter (read again only when its mtime moved), -1 when missing */
67
+ export function stampOf(paths: string[]): string {
68
+ return paths.map((p) => {
69
+ try {
70
+ const st = statSync(p)
71
+ return st.isDirectory() ? dirNames(p, st.mtimeMs) : st.mtimeMs
72
+ } catch {
73
+ return -1
74
+ }
75
+ }).join(',')
76
+ }
package/src/home/pm.ts CHANGED
@@ -7,9 +7,12 @@
7
7
  // the one constraint a reader breaks first: pm never takes a child down with
8
8
  // it. A reload of pm, or pm killed, leaves both running, and the next pm
9
9
  // adopts them from their pid records. Only a stop asked of pm stops them.
10
+ import { existsSync } from 'fs'
11
+ import { join } from 'path'
10
12
  import { LIB_VERSION } from '../core/lib-version.ts'
13
+ import { iconImageType } from '../shipkit-files.ts'
11
14
  import { sameOrigin } from '../serve/same-origin.ts'
12
- import { childLog, childPid } from './home-paths.ts'
15
+ import { childLog, childPid, daemonPort, homePort, pmLog, pmPort } from './home-paths.ts'
13
16
  import type { Resources, ResourceView } from './resources.ts'
14
17
  import { capLog, Proc, realDeps, tailLines, type ProcView, type SuperviseDeps } from './supervise.ts'
15
18
 
@@ -21,10 +24,22 @@ export const PM_TICK_MS = 2_000
21
24
 
22
25
  export type ChildView = ProcView & { tail: string[] }
23
26
 
27
+ /** where pm, the daemon and the web listen */
28
+ export type CorePorts = { pm: number; daemon: number; web: number }
29
+
30
+ /** how many log lines each of shipkit's own processes hands the page */
31
+ const CORE_TAIL = 20
32
+
24
33
  export type PmAnswer = {
25
34
  pm: 'shipkit-pm'
26
35
  pid: number
36
+ /** epoch ms this pm started; absent from a pm older than the field */
37
+ since?: number
27
38
  version: string
39
+ /** where pm and its children listen; absent from a pm older than the field */
40
+ ports?: CorePorts
41
+ /** the last lines of pm's own log; absent from a pm older than the field */
42
+ tail?: string[]
28
43
  children: ChildView[]
29
44
  resources: ResourceView[]
30
45
  /** the `.shipkit.ts` files that would not load, as pm last read them */
@@ -35,6 +50,8 @@ export type PmDeps = {
35
50
  dir: string
36
51
  /** the house repo: where the children run */
37
52
  root: string
53
+ /** the port pm serves on (default: SHIPKIT_PM_PORT, else 4797) */
54
+ port?: number
38
55
  /** the command that runs one role, e.g. [bun, cli.ts, 'daemon'] */
39
56
  cmd: (role: Role) => string[]
40
57
  /** true once the role serves */
@@ -50,6 +67,7 @@ const WEB_ANSWER_MS = 200
50
67
 
51
68
  export class Pm {
52
69
  readonly children: Map<Role, Proc>
70
+ private readonly startedAt = Date.now()
53
71
 
54
72
  constructor(private d: PmDeps) {
55
73
  this.children = new Map(ROLES.map((role) => [role, new Proc({
@@ -90,14 +108,30 @@ export class Pm {
90
108
  }
91
109
 
92
110
  answer(): PmAnswer {
93
- const children = [...this.children.values()].map((p) => ({ ...p.view(), tail: p.status === 'up' ? [] : tailLines(p.spec.log, 20) }))
94
- return { pm: 'shipkit-pm', pid: process.pid, version: LIB_VERSION, children, resources: this.d.resources.views(), unitErrors: this.d.resources.errors }
111
+ // up: since its pid started, so a pm reload never resets its uptime
112
+ const children = [...this.children.values()].map((p) => {
113
+ const v = p.view()
114
+ return { ...v, since: (v.status === 'up' ? p.startedAt() : null) ?? v.since, tail: tailLines(p.spec.log, CORE_TAIL) }
115
+ })
116
+ const ports = { pm: this.d.port ?? pmPort(), daemon: daemonPort(), web: homePort() }
117
+ return { pm: 'shipkit-pm', pid: process.pid, since: this.startedAt, version: LIB_VERSION, ports, tail: tailLines(pmLog(this.d.dir), CORE_TAIL), children, resources: this.d.resources.views(), unitErrors: this.d.resources.errors }
118
+ }
119
+
120
+ /** the image a global process declares as its icon, and no other file */
121
+ private icon(id: string): Response {
122
+ const d = this.d.resources.get(id)?.decl
123
+ const type = d?.icon == null ? null : iconImageType(d.icon)
124
+ if (d?.icon == null || type == null) return new Response(`no image icon for '${id}'`, { status: 404 })
125
+ const file = join(d.root, d.icon)
126
+ if (!existsSync(file)) return new Response(`${d.repo}/${d.icon} does not exist: the icon of '${id}'`, { status: 404 })
127
+ return new Response(Bun.file(file), { headers: { 'content-type': type, 'cache-control': 'no-cache' } })
95
128
  }
96
129
 
97
130
  async handle(req: Request): Promise<Response> {
98
131
  const path = new URL(req.url).pathname
99
132
  if (!sameOrigin(req)) return new Response('refused: a request that changes something must come from the home page', { status: 403 })
100
133
  if (path === '/api/pm/status.json') return Response.json(this.answer())
134
+ if (path.startsWith('/api/pm/icon/') && req.method === 'GET') return this.icon(decodeURIComponent(path.slice('/api/pm/icon/'.length)))
101
135
  if (req.method !== 'POST') return new Response('not found', { status: 404 })
102
136
  const b = (await req.json().catch(() => ({}))) as Record<string, unknown>
103
137
  if (path === '/api/pm/child') {
@@ -120,10 +154,9 @@ export class Pm {
120
154
  if (act === 'start') report(r.set('up'))
121
155
  else if (act === 'stop') report(r.set('down'))
122
156
  else if (act === 'restart') report(r.restart())
123
- else if (act === 'open' || act === 'app') {
157
+ else if (act === 'open' || act === 'chrome' || act === 'app') {
124
158
  if (r.decl.url == null) return new Response(`${r.decl.id} declares no url to open`, { status: 400 })
125
- if (act === 'app' && !r.decl.app) return new Response(`${r.decl.id} does not declare app: true, so it has no app window`, { status: 400 })
126
- report(r.open(act === 'app' ? 'app' : 'browser'))
159
+ report(r.open(act === 'open' ? 'browser' : act))
127
160
  } else if (act === 'takeover') {
128
161
  const v = r.takeOverView
129
162
  if (v == null) return new Response(`${r.decl.id} does not run outside the supervisor: nothing to take over`, { status: 409 })
@@ -133,7 +166,7 @@ export class Pm {
133
166
  if (!r.decl.actions.some((a) => a.id === b.action)) return new Response(`${r.decl.id} declares no action '${String(b.action)}'`, { status: 404 })
134
167
  if (r.running != null) return new Response(`${r.decl.id} is already running its action '${r.running}'`, { status: 409 })
135
168
  report(r.action(String(b.action)))
136
- } else return new Response('act must be start, stop, restart, open, app, takeover or action', { status: 400 })
169
+ } else return new Response('act must be start, stop, restart, open, chrome, app, takeover or action', { status: 400 })
137
170
  return Response.json(r.view())
138
171
  }
139
172
  return new Response('not found', { status: 404 })
@@ -0,0 +1,222 @@
1
+ // the processes as rows: shipkit's own first (pm, the daemon, the web: the
2
+ // core group), then one group per declaring repo, one row per process, in the
3
+ // order the page and `shipkit global-processes` both show.
4
+ // The page's table and the command's text and json are all drawn from these
5
+ // rows, so the two can never disagree. A row carries no clock: ages are words
6
+ // computed at render time from `since`.
7
+ import type { GlobalProcessDecl } from './global-units.ts'
8
+ import type { CorePorts, PmAnswer } from './pm.ts'
9
+ import type { ProcessIcon, ResourceView } from './resources.ts'
10
+ import type { ProcStatus } from './supervise.ts'
11
+
12
+ /** a status as pm reports it, unknown while pm does not answer, silent
13
+ * while pm runs it but the page gets no answer from it */
14
+ export type RowStatus = ProcStatus | 'unknown' | 'silent'
15
+
16
+ /** the dot a row wears: green up, red crashed, grey stopped or unknown, orange on its way */
17
+ export type RowDot = 'up' | 'crashed' | 'down' | 'starting' | 'unknown'
18
+
19
+ export type ProcessRow = {
20
+ id: string
21
+ label: string
22
+ repo: string
23
+ file: string
24
+ status: RowStatus
25
+ dot: RowDot
26
+ url: string | null
27
+ /** the url without its scheme, what the table shows dimmed */
28
+ host: string | null
29
+ pid: number | null
30
+ /** epoch ms its state counts from (up: its pid's start when it has one), null when pm never said */
31
+ since: number | null
32
+ note: string | null
33
+ description: string | null
34
+ where: string | null
35
+ /** the last line of its log, null when it has none */
36
+ lastLog: string | null
37
+ tail: string[]
38
+ canStop: boolean
39
+ /** the page offers restart: a global process pm runs and can stop, while
40
+ * up; the daemon and the web always, through pm; never pm itself */
41
+ canRestart: boolean
42
+ /** one of shipkit's own: always on, no stop, no open */
43
+ core: boolean
44
+ /** the shipkit version it runs, when known (core rows only) */
45
+ version: string | null
46
+ takeOver: ResourceView['takeOver']
47
+ actions: { id: string; label: string }[]
48
+ running: string | null
49
+ icon: ProcessIcon | null
50
+ /** its own app is being built or opened */
51
+ appBusy: boolean
52
+ }
53
+
54
+ /** a repo's processes, or shipkit's own (`core`, always the first group) */
55
+ export type ProcessGroup = { repo: string; core?: true; rows: ProcessRow[] }
56
+
57
+ /** the core group's name */
58
+ export const CORE_GROUP = 'shipkit'
59
+
60
+ export const dotOf = (s: RowStatus): RowDot =>
61
+ s === 'up' ? 'up' : s === 'crashed' || s === 'silent' ? 'crashed' : s === 'starting' || s === 'stopping' ? 'starting' : s === 'down' ? 'down' : 'unknown'
62
+
63
+ const hostOf = (url: string | null): string | null => (url == null ? null : url.replace(/^https?:\/\//, '').replace(/\/$/, ''))
64
+
65
+ /** a process as pm serves it. A pm older than pid and since reads them as null */
66
+ export function rowOf(r: ResourceView): ProcessRow {
67
+ return {
68
+ id: r.id,
69
+ label: r.label,
70
+ repo: r.repo,
71
+ file: r.file,
72
+ status: r.status,
73
+ dot: dotOf(r.status),
74
+ url: r.url,
75
+ host: hostOf(r.url),
76
+ pid: r.pid ?? null,
77
+ since: r.since ?? null,
78
+ note: r.note,
79
+ description: r.description,
80
+ where: r.where,
81
+ lastLog: r.tail.at(-1) ?? null,
82
+ tail: r.tail,
83
+ canStop: r.canStop,
84
+ canRestart: r.status === 'up' && r.canStop,
85
+ core: false,
86
+ version: null,
87
+ takeOver: r.takeOver,
88
+ actions: r.actions,
89
+ running: r.running,
90
+ icon: r.icon,
91
+ appBusy: r.appBusy === true,
92
+ }
93
+ }
94
+
95
+ /** a declared process while pm does not answer: everything but its state */
96
+ export function declaredRow(d: GlobalProcessDecl): ProcessRow {
97
+ return {
98
+ id: d.id,
99
+ label: d.label,
100
+ repo: d.repo,
101
+ file: d.file,
102
+ status: 'unknown',
103
+ dot: 'unknown',
104
+ url: d.url,
105
+ host: hostOf(d.url),
106
+ pid: null,
107
+ since: null,
108
+ note: null,
109
+ description: d.description,
110
+ where: d.where,
111
+ lastLog: null,
112
+ tail: [],
113
+ canStop: false,
114
+ canRestart: false,
115
+ core: false,
116
+ version: null,
117
+ takeOver: null,
118
+ actions: d.actions.map((a) => ({ id: a.id, label: a.label })),
119
+ running: null,
120
+ appBusy: false,
121
+ // an image is pm's to serve, and pm does not answer
122
+ icon: d.icon == null || /\.[a-z0-9]+$/i.test(d.icon) ? null : { text: d.icon },
123
+ }
124
+ }
125
+
126
+ /** one group per repo, in `order` (the project list), the repos it does not
127
+ * list after it by name; a group keeps pm's order */
128
+ export function groupRows(rows: ProcessRow[], order: string[]): ProcessGroup[] {
129
+ const by = new Map<string, ProcessRow[]>()
130
+ for (const r of rows) by.set(r.repo, [...(by.get(r.repo) ?? []), r])
131
+ const rank = (repo: string): number => {
132
+ const i = order.indexOf(repo)
133
+ return i < 0 ? order.length : i
134
+ }
135
+ return [...by.keys()]
136
+ .sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))
137
+ .map((repo) => ({ repo, rows: by.get(repo)! }))
138
+ }
139
+
140
+ export const CORE_ABOUT = {
141
+ pm: 'the process manager, the LaunchAgent com.shipkit.pm: launchd starts it at login and again when it dies, so the page has no restart for it (from a terminal: launchctl kickstart -k gui/$(id -u)/com.shipkit.pm)',
142
+ daemon: 'loads every house repo hub in one process and serves their api, restarted through pm',
143
+ web: 'serves this page and passes every api request to the daemon, restarted through pm',
144
+ } as const
145
+
146
+ export type CoreOpts = {
147
+ /** the daemon's own version, asked of the daemon; null when it does not answer */
148
+ daemonVersion?: string | null
149
+ /** where each listens, when pm does not say (an older pm, or pm down) */
150
+ ports?: CorePorts | null
151
+ /** one of shipkit's own not answering the page, and why: its row says
152
+ * no answer whatever pm last reported. pm silent, every other row is
153
+ * the last answer's with its state unknown, so the table keeps its lines */
154
+ silent?: Partial<Record<'pm' | 'daemon' | 'web', string>>
155
+ }
156
+
157
+ function coreRow(id: 'pm' | 'daemon' | 'web', o: { status: RowStatus; pid: number | null; since: number | null; note: string | null; tail: string[]; version: string | null; port: number | null; restart: boolean }): ProcessRow {
158
+ const url = o.port == null ? null : `http://127.0.0.1:${o.port}`
159
+ return {
160
+ id, label: id, repo: CORE_GROUP, file: '', status: o.status, dot: dotOf(o.status), url, host: hostOf(url), pid: o.pid, since: o.since, note: o.note,
161
+ description: CORE_ABOUT[id], where: null, lastLog: o.tail.at(-1) ?? null, tail: o.tail,
162
+ canStop: false, canRestart: o.restart, core: true, version: o.version, takeOver: null, actions: [], running: null, icon: null, appBusy: false,
163
+ }
164
+ }
165
+
166
+ /** shipkit's own processes, one row each: pm as it answers (down when it does
167
+ * not), then the daemon and the web as pm reports them (unknown without pm) */
168
+ export function coreGroup(pm: PmAnswer | null, o: CoreOpts = {}): ProcessGroup {
169
+ const ports = pm?.ports ?? o.ports ?? null
170
+ const rows: ProcessRow[] = [pm == null
171
+ ? coreRow('pm', { status: 'down', pid: null, since: null, note: 'pm does not answer', tail: [], version: null, port: ports?.pm ?? null, restart: false })
172
+ : coreRow('pm', { status: 'up', pid: pm.pid, since: pm.since ?? null, note: null, tail: pm.tail ?? [], version: pm.version, port: ports?.pm ?? null, restart: false })]
173
+ for (const id of ['daemon', 'web'] as const) {
174
+ const c = pm?.children.find((x) => x.id === id)
175
+ const port = ports?.[id] ?? null
176
+ const version = id === 'daemon' ? (o.daemonVersion ?? null) : null
177
+ if (c == null) rows.push(coreRow(id, { status: 'unknown', pid: null, since: null, note: null, tail: [], version, port, restart: false }))
178
+ else {
179
+ const note = [c.note, c.restarts > 0 ? `${c.restarts} restart(s)` : null].filter((x) => x != null).join(', ')
180
+ rows.push(coreRow(id, { status: c.status, pid: c.pid, since: c.since, note: note === '' ? null : note, tail: c.tail, version, port, restart: true }))
181
+ }
182
+ }
183
+ const s = o.silent ?? {}
184
+ return { repo: CORE_GROUP, core: true, rows: rows.map((r) => {
185
+ const why = s[r.id as 'pm' | 'daemon' | 'web']
186
+ return why != null && why !== '' ? { ...r, status: 'silent', dot: 'crashed', note: why } : s.pm ? stale(r) : r
187
+ }) }
188
+ }
189
+
190
+ /** what the page's table holds, from pm's answer: the core group, then the
191
+ * global processes by repo */
192
+ export const processGroups = (pm: PmAnswer | null, order: string[], o: CoreOpts = {}): ProcessGroup[] =>
193
+ [coreGroup(pm, o), ...groupRows((pm?.resources ?? []).map((r) => (o.silent?.pm ? stale(rowOf(r)) : rowOf(r))), order)]
194
+
195
+ /** a row from an answer pm can no longer confirm: its state unknown, nothing to click */
196
+ const stale = (r: ProcessRow): ProcessRow => ({ ...r, status: 'unknown', dot: 'unknown', canStop: false, canRestart: false, takeOver: null, running: null, appBusy: false })
197
+
198
+ /** the declared icon, else the repo's own, else none */
199
+ export const processIcon = (r: { icon: ProcessIcon | null }, repoIcon: string | null): ProcessIcon | null =>
200
+ r.icon ?? (repoIcon == null ? null : { src: repoIcon })
201
+
202
+ /** a span in its largest whole unit: 45s, 3m, 2h, 4d */
203
+ export function spanWords(ms: number): string {
204
+ const s = Math.max(0, Math.floor(ms / 1000))
205
+ if (s < 60) return `${s}s`
206
+ if (s < 3600) return `${Math.floor(s / 60)}m`
207
+ if (s < 86400) return `${Math.floor(s / 3600)}h`
208
+ return `${Math.floor(s / 86400)}d`
209
+ }
210
+
211
+ /** the state in words: `up 2h`, `crashed 3m ago`, `stopped`, `starting` */
212
+ export function stateWords(r: Pick<ProcessRow, 'status' | 'since'>, now: number): string {
213
+ const age = r.since == null ? null : spanWords(now - r.since)
214
+ switch (r.status) {
215
+ case 'up': return age == null ? 'up' : `up ${age}`
216
+ case 'crashed': return age == null ? 'crashed' : `crashed ${age} ago`
217
+ case 'down': return 'stopped'
218
+ case 'unknown': return 'unknown (pm down)'
219
+ case 'silent': return 'no answer'
220
+ default: return r.status
221
+ }
222
+ }