rvlib-shipkit 1.4.0 → 1.6.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 (199) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +17 -17
  3. package/package.json +3 -2
  4. package/src/assets/gen/comfyts-gen.ts +12 -8
  5. package/src/assets/server/studio-server.ts +14 -2
  6. package/src/check.ts +73 -103
  7. package/src/checks/SK001-package-json-shape.check.ts +11 -6
  8. package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
  9. package/src/checks/SK003-claude-md-shape.check.ts +7 -3
  10. package/src/checks/SK004-gitignore-policy.check.ts +7 -4
  11. package/src/checks/SK005-rvlib-versions.check.ts +53 -21
  12. package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
  13. package/src/checks/SK007-github-visibility.check.ts +4 -2
  14. package/src/checks/SK008-reviewer-agent.check.ts +6 -4
  15. package/src/checks/SK009-commit-ai-tells.check.ts +4 -2
  16. package/src/checks/SK010-undeclared-unit.check.ts +4 -0
  17. package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
  18. package/src/checks/SK012-config-barrel-import.check.ts +5 -1
  19. package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
  20. package/src/checks/SK014-prose-ste.check.ts +4 -0
  21. package/src/checks/SK015-nested-repo.check.ts +4 -2
  22. package/src/checks/SK016-legacy-check-shapes.check.ts +13 -12
  23. package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
  24. package/src/checks/SK018-test-files-run.check.ts +6 -2
  25. package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
  26. package/src/checks/SK020-self-spawning-check.check.ts +4 -0
  27. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
  28. package/src/checks/SK024-identity-colors.check.ts +5 -3
  29. package/src/checks/SK025-release-drift.check.ts +4 -5
  30. package/src/checks/SK026-asks-autoclose.check.ts +4 -4
  31. package/src/checks/SK027-agent-wiki-links.check.ts +4 -1
  32. package/src/checks/SK028-readme.check.ts +5 -1
  33. package/src/checks/SK029-explicit-check-files.check.ts +95 -0
  34. package/src/checks/SK030-cache-audit.check.ts +34 -0
  35. package/src/checks/SK031-trace-audit.check.ts +39 -0
  36. package/src/checks/SK032-ignored-inputs.check.ts +79 -0
  37. package/src/cli/cmd_Asks.ts +5 -5
  38. package/src/cli/cmd_Assets.ts +17 -13
  39. package/src/cli/cmd_Bump.ts +12 -11
  40. package/src/cli/cmd_CacheAudit.ts +43 -0
  41. package/src/cli/cmd_Check.ts +8 -7
  42. package/src/cli/cmd_Checks.ts +7 -6
  43. package/src/cli/cmd_Ci.ts +12 -11
  44. package/src/cli/cmd_Commands.ts +16 -13
  45. package/src/cli/cmd_ConfigKeys.ts +7 -6
  46. package/src/cli/cmd_Contracts.ts +3 -3
  47. package/src/cli/cmd_CrossRepoActivity.ts +8 -6
  48. package/src/cli/cmd_Fleet.ts +9 -7
  49. package/src/cli/cmd_GlobalProcesses.ts +120 -7
  50. package/src/cli/cmd_Home.ts +26 -19
  51. package/src/cli/cmd_Hooks.ts +6 -5
  52. package/src/cli/cmd_Init.ts +5 -4
  53. package/src/cli/cmd_Keys.ts +20 -8
  54. package/src/cli/cmd_Mv.ts +7 -6
  55. package/src/cli/cmd_Needs.ts +4 -3
  56. package/src/cli/cmd_Overview.ts +5 -4
  57. package/src/cli/cmd_Pb.ts +2 -2
  58. package/src/cli/cmd_Planned.ts +6 -5
  59. package/src/cli/cmd_Prose.ts +5 -5
  60. package/src/cli/cmd_Reflect.ts +7 -7
  61. package/src/cli/cmd_Release.ts +6 -5
  62. package/src/cli/cmd_Releases.ts +5 -4
  63. package/src/cli/cmd_Serve.ts +6 -4
  64. package/src/cli/cmd_Smoke.ts +5 -4
  65. package/src/cli/cmd_Start.ts +7 -6
  66. package/src/cli/cmd_Status.ts +4 -3
  67. package/src/cli/cmd_TraceAudit.ts +62 -0
  68. package/src/cli/cmd_Tui.ts +3 -2
  69. package/src/cli/cmd_When.ts +71 -0
  70. package/src/cli/cmd_Wiki.ts +4 -4
  71. package/src/cli.ts +11 -2
  72. package/src/collections/checks.ts +2 -0
  73. package/src/collections/collection-types.ts +12 -4
  74. package/src/collections/providers.ts +5 -3
  75. package/src/core/app-icon.ts +154 -0
  76. package/src/core/audit-report.ts +21 -0
  77. package/src/core/cache-audit.ts +72 -0
  78. package/src/core/canonical-scripts.ts +2 -2
  79. package/src/core/check-ctx.ts +14 -5
  80. package/src/core/check-runner.ts +43 -23
  81. package/src/core/config-rewrite.ts +37 -24
  82. package/src/core/discover-checks.ts +23 -2
  83. package/src/core/floor-resolve.ts +5 -7
  84. package/src/core/git-root.ts +13 -0
  85. package/src/core/ignored-block.ts +94 -0
  86. package/src/core/ignored-inputs.ts +47 -0
  87. package/src/core/jobs.ts +9 -2
  88. package/src/core/legacy-shapes.ts +39 -10
  89. package/src/core/one-lib.ts +59 -4
  90. package/src/core/one-way-in-policy.ts +2 -2
  91. package/src/core/outcome-words.ts +18 -2
  92. package/src/core/port-check-cli.ts +52 -0
  93. package/src/core/port-check-file.ts +295 -0
  94. package/src/core/port-check-files.ts +31 -0
  95. package/src/core/read-set.ts +97 -9
  96. package/src/core/retention.ts +0 -14
  97. package/src/core/run-check.ts +12 -9
  98. package/src/core/run-ci.ts +14 -5
  99. package/src/core/run-fix.ts +30 -23
  100. package/src/core/run-fixers.ts +48 -5
  101. package/src/core/runs-matrix.ts +179 -0
  102. package/src/core/runs.ts +242 -0
  103. package/src/core/trace-audit.ts +120 -0
  104. package/src/core/update-policy.ts +20 -0
  105. package/src/core/when-block.ts +29 -30
  106. package/src/core/when.ts +41 -103
  107. package/src/core/wire.ts +10 -14
  108. package/src/fleet/scan-fleet.ts +20 -4
  109. package/src/fleet/update-fleet.ts +6 -6
  110. package/src/home/gui-app.ts +24 -27
  111. package/src/home/home-launchd.ts +20 -4
  112. package/src/home/home-routes.ts +12 -1
  113. package/src/home/home-start.ts +11 -5
  114. package/src/home/hubs.ts +5 -5
  115. package/src/home/open-app.ts +2 -2
  116. package/src/home/pm.ts +8 -0
  117. package/src/home/run-pm.ts +1 -0
  118. package/src/home/serve-web.ts +2 -2
  119. package/src/home/supervise.ts +24 -1
  120. package/src/home/via-hub.ts +14 -0
  121. package/src/hooks/run-hooks.ts +48 -4
  122. package/src/init-repo.ts +6 -6
  123. package/src/ledger/db.ts +2 -13
  124. package/src/ledger/gate-checks.ts +133 -61
  125. package/src/ledger/git-key.ts +153 -31
  126. package/src/ledger/git-scan.ts +5 -2
  127. package/src/ledger/ledger.ts +136 -21
  128. package/src/ledger/store.ts +14 -0
  129. package/src/ledger/toolchain-identity.ts +248 -45
  130. package/src/plan/repo-source.ts +18 -39
  131. package/src/problems/extract.ts +3 -0
  132. package/src/release/run-release.ts +5 -0
  133. package/src/repo-config.ts +46 -54
  134. package/src/rules/rule.ts +2 -2
  135. package/src/scaffold/files/agent-coding.md +1 -1
  136. package/src/scaffold/files/agent-index.md +1 -1
  137. package/src/scaffold/files/verify-skill.md +2 -2
  138. package/src/serve/hub-app/activity-st.ts +5 -4
  139. package/src/serve/hub-app/activity-view.tsx +1 -1
  140. package/src/serve/hub-app/admin-svc.ts +36 -3
  141. package/src/serve/hub-app/admin-view.tsx +46 -12
  142. package/src/serve/hub-app/check-view.tsx +66 -46
  143. package/src/serve/hub-app/check-words.ts +1 -11
  144. package/src/serve/hub-app/checks-svc.ts +14 -35
  145. package/src/serve/hub-app/col-card.tsx +1 -44
  146. package/src/serve/hub-app/col-tables-view.tsx +84 -11
  147. package/src/serve/hub-app/col-tables.ts +27 -0
  148. package/src/serve/hub-app/cols-svc.ts +5 -4
  149. package/src/serve/hub-app/dock-st.ts +3 -2
  150. package/src/serve/hub-app/dock-view.tsx +36 -0
  151. package/src/serve/hub-app/dock.css +6 -0
  152. package/src/serve/hub-app/float-cards.tsx +30 -0
  153. package/src/serve/hub-app/floating.tsx +41 -4
  154. package/src/serve/hub-app/git-st.ts +42 -0
  155. package/src/serve/hub-app/git-view.tsx +61 -0
  156. package/src/serve/hub-app/hash-route.ts +45 -1
  157. package/src/serve/hub-app/header-bar.tsx +62 -72
  158. package/src/serve/hub-app/health-svc.ts +3 -2
  159. package/src/serve/hub-app/home-st.ts +14 -2
  160. package/src/serve/hub-app/home-strip.css +43 -12
  161. package/src/serve/hub-app/home-strip.tsx +115 -64
  162. package/src/serve/hub-app/home.html +1 -0
  163. package/src/serve/hub-app/hub-app.tsx +32 -8
  164. package/src/serve/hub-app/hub-types.ts +5 -2
  165. package/src/serve/hub-app/hub.css +89 -63
  166. package/src/serve/hub-app/icons.tsx +11 -0
  167. package/src/serve/hub-app/index.html +1 -0
  168. package/src/serve/hub-app/keys.ts +15 -4
  169. package/src/serve/hub-app/live-svc.ts +3 -2
  170. package/src/serve/hub-app/machine-st.ts +18 -22
  171. package/src/serve/hub-app/plan-view.tsx +51 -0
  172. package/src/serve/hub-app/planned-st.ts +9 -4
  173. package/src/serve/hub-app/processes-view.tsx +45 -0
  174. package/src/serve/hub-app/project-list.css +4 -1
  175. package/src/serve/hub-app/project-list.tsx +6 -3
  176. package/src/serve/hub-app/projects-svc.ts +25 -10
  177. package/src/serve/hub-app/prose-hits-st.ts +3 -2
  178. package/src/serve/hub-app/reach.ts +36 -0
  179. package/src/serve/hub-app/readme-svc.ts +3 -2
  180. package/src/serve/hub-app/repo-sections.ts +131 -0
  181. package/src/serve/hub-app/repo-st.ts +70 -14
  182. package/src/serve/hub-app/sidebar.tsx +17 -42
  183. package/src/serve/hub-app/timeline-svc.ts +3 -2
  184. package/src/serve/hub-app/tree-build.ts +151 -155
  185. package/src/serve/hub-app/tree-row.tsx +55 -12
  186. package/src/serve/hub-app/tree-svc.ts +97 -56
  187. package/src/serve/hub-app/when-view.tsx +89 -0
  188. package/src/serve/hub-app/when.css +17 -0
  189. package/src/serve/hub-runtime.ts +7 -18
  190. package/src/serve/project-gutter-route.ts +22 -1
  191. package/src/serve/run-serve.ts +1 -1
  192. package/src/serve/serve-hub.ts +4 -14
  193. package/src/suite/judge-suites.ts +6 -0
  194. package/src/trace/interpose.c +655 -0
  195. package/src/trace/read-trace.ts +485 -0
  196. package/src/trace/tracer.ts +282 -0
  197. package/src/serve/hub-app/processes-svc.ts +0 -63
  198. package/src/serve/hub-app/repo-sections.tsx +0 -61
  199. package/src/serve/repo-processes.ts +0 -89
@@ -0,0 +1,282 @@
1
+ // TRACING A TASK: the interposer built once per machine and version, the env a
2
+ // traced spawn carries, and the traceable copies of the binaries macOS would
3
+ // otherwise run blind.
4
+ //
5
+ // Why a copy works: dyld ignores DYLD_INSERT_LIBRARIES in a binary signed with
6
+ // the hardened runtime or library validation (Microsoft's native tsc, Xcode's
7
+ // git), and SIP drops it for everything under /usr and /bin. An ad hoc signed
8
+ // COPY of the same bytes is neither, so it runs traced. It sits beside the
9
+ // original, because a tool finds its own files next to itself (tsc's lib
10
+ // files), or for git in a prefix of links to the original's dirs. The system
11
+ // shells cannot be copied: they are arm64e, which macOS runs only when Apple
12
+ // signed them, so a script that needs /bin/sh stays untraced and says so.
13
+ //
14
+ // The tracer only AUDITS (core/trace-audit.ts): no verdict depends on a trace.
15
+ // It is on wherever it can run (Apple silicon macOS and a C compiler). A
16
+ // machine opts out with `sk trace-audit --disable`, which the daemon's health
17
+ // pass sees, and one run with SHIPKIT_TRACE=0.
18
+ import { createHash } from 'crypto'
19
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'fs'
20
+ import { homedir, tmpdir } from 'os'
21
+ import { basename, dirname, join } from 'path'
22
+ import { machineDelete, machineGet, machineSet } from '../core/machine-db.ts'
23
+
24
+ /** the env var that turns tracing on (1) or off (0) for one run, over the
25
+ * machine's setting */
26
+ export const TRACE_ENV = 'SHIPKIT_TRACE'
27
+
28
+ /** the machine setting `sk trace-audit --disable` writes (false) */
29
+ export const TRACE_SETTING = 'trace.enabled'
30
+
31
+ let settingMemo: { at: number; on: boolean } | null = null
32
+ /** whether this machine left tracing on, asked at most every few seconds:
33
+ * the composer asks it for every repo */
34
+ export function traceEnabledHere(now = Date.now()): boolean {
35
+ if (settingMemo != null && now - settingMemo.at < 5_000) return settingMemo.on
36
+ settingMemo = { at: now, on: machineGet<boolean>(TRACE_SETTING) !== false }
37
+ return settingMemo.on
38
+ }
39
+
40
+ /** turn tracing back on for this machine (the default), or off */
41
+ export function setTraceEnabled(on: boolean): void {
42
+ if (on) machineDelete(TRACE_SETTING)
43
+ else machineSet(TRACE_SETTING, false)
44
+ settingMemo = null
45
+ }
46
+
47
+ const SOURCE = join(import.meta.dir, 'interpose.c')
48
+
49
+ /** where the built interposer and the traceable copies live */
50
+ export const traceCache = (): string => process.env['SHIPKIT_TRACE_CACHE'] ?? join(homedir(), '.cache', 'shipkit', 'trace')
51
+
52
+ /** why this machine cannot trace, null when it can. Cheap: no build */
53
+ export function traceUnavailable(env: Record<string, string | undefined> = process.env): string | null {
54
+ const on = env[TRACE_ENV] === '1' || (env[TRACE_ENV] !== '0' && traceEnabledHere())
55
+ if (!on) return env[TRACE_ENV] === '0' ? 'tracing is off for this run (SHIPKIT_TRACE=0)' : 'tracing is off on this machine: sk trace-audit --enable turns it back on'
56
+ if (process.platform !== 'darwin' || process.arch !== 'arm64') return 'tracing runs on Apple silicon macOS only'
57
+ if (compiler() == null) return 'no C compiler to build the tracer (xcode-select --install)'
58
+ return null
59
+ }
60
+
61
+ const compiler = (): string | null => (existsSync('/usr/bin/clang') ? '/usr/bin/clang' : Bun.which('clang'))
62
+
63
+ let built: { path: string } | { reason: string } | null = null
64
+
65
+ /** the interposer for this source, built on first use. A build that fails is
66
+ * a reason, never a throw: the task then runs untraced and is not kept */
67
+ export function tracerLib(env: Record<string, string | undefined> = process.env): { path: string } | { reason: string } {
68
+ // the switch is asked every time: only the build is remembered
69
+ const off = traceUnavailable(env)
70
+ if (off != null) return { reason: off }
71
+ if (built != null) return built
72
+ let src: Buffer
73
+ try { src = readFileSync(SOURCE) } catch (e) { return (built = { reason: `the tracer source is missing: ${(e as Error).message}` }) }
74
+ const id = createHash('sha256').update(src).digest('hex').slice(0, 16)
75
+ const out = join(traceCache(), `interpose-${id}.dylib`)
76
+ if (existsSync(out)) return (built = { path: out })
77
+ mkdirSync(traceCache(), { recursive: true })
78
+ // built beside its final name and renamed in, so a parallel gate never
79
+ // loads a half-written library
80
+ const tmp = `${out}.${process.pid}.tmp`
81
+ const r = Bun.spawnSync([compiler()!, '-dynamiclib', '-O2', '-arch', 'arm64', '-arch', 'arm64e', '-Wno-deprecated-declarations', '-o', tmp, SOURCE], { stdout: 'pipe', stderr: 'pipe' })
82
+ if (r.exitCode !== 0) {
83
+ rmSync(tmp, { force: true })
84
+ return (built = { reason: `the tracer did not build: ${r.stderr.toString().trim().split('\n')[0] ?? `exit ${r.exitCode}`}` })
85
+ }
86
+ renameSync(tmp, out)
87
+ return (built = { path: out })
88
+ }
89
+
90
+ /** the remap file the interposer reads: original path, copy, and the size and
91
+ * mtime the copy was made from, so a replaced original is never swapped */
92
+ const remapFile = (): string => join(traceCache(), 'remap.tsv')
93
+
94
+ type Remap = { from: string; to: string; size: number; mtime: number; env: string }
95
+
96
+ function readRemaps(): Remap[] {
97
+ try {
98
+ return readFileSync(remapFile(), 'utf8').split('\n').filter((l) => l !== '' && !l.startsWith('#')).map((l) => {
99
+ const [from, to, size, mtime, env] = l.split('\t')
100
+ return { from: from!, to: to!, size: Number(size), mtime: Number(mtime), env: env ?? '' }
101
+ })
102
+ } catch {
103
+ return []
104
+ }
105
+ }
106
+
107
+ function writeRemaps(rows: Remap[]): void {
108
+ mkdirSync(traceCache(), { recursive: true })
109
+ const tmp = `${remapFile()}.${process.pid}.tmp`
110
+ writeFileSync(tmp, rows.map((r) => [r.from, r.to, r.size, r.mtime, r.env].join('\t')).join('\n') + '\n')
111
+ renameSync(tmp, remapFile())
112
+ }
113
+
114
+ const fresh = (r: Remap): boolean => {
115
+ try {
116
+ const s = statSync(r.from)
117
+ return s.size === r.size && Math.floor(s.mtimeMs / 1000) === r.mtime && existsSync(r.to)
118
+ } catch {
119
+ return false
120
+ }
121
+ }
122
+
123
+ function upsert(rows: Remap[], from: string, to: string, env = ''): Remap[] {
124
+ const s = statSync(from)
125
+ return [...rows.filter((r) => r.from !== from), { from, to, size: s.size, mtime: Math.floor(s.mtimeMs / 1000), env }]
126
+ }
127
+
128
+ /** an ad hoc signed copy of `bin` at `to`: the same bytes, no hardened runtime */
129
+ function signedCopy(bin: string, to: string): string | null {
130
+ mkdirSync(dirname(to), { recursive: true })
131
+ const tmp = `${to}.${process.pid}.tmp`
132
+ try {
133
+ copyFileSync(bin, tmp)
134
+ chmodSync(tmp, 0o755)
135
+ const r = Bun.spawnSync(['/usr/bin/codesign', '-f', '-s', '-', tmp], { stdout: 'pipe', stderr: 'pipe' })
136
+ if (r.exitCode !== 0) { rmSync(tmp, { force: true }); return `codesign refused it: ${r.stderr.toString().trim().split('\n').at(-1)}` }
137
+ renameSync(tmp, to)
138
+ return null
139
+ } catch (e) {
140
+ rmSync(tmp, { force: true })
141
+ return (e as Error).message
142
+ }
143
+ }
144
+
145
+ /** /usr/bin/git is a shim into Xcode's git, and both are blind. The copy
146
+ * lives in a prefix whose other dirs link to Xcode's, so it finds its
147
+ * exec-path, templates and system config exactly where the original does */
148
+ function gitRemap(rows: Remap[]): Remap[] {
149
+ if (!existsSync('/usr/bin/git')) return rows
150
+ const found = Bun.spawnSync(['/usr/bin/xcrun', '-f', 'git'], { stdout: 'pipe', stderr: 'pipe' })
151
+ const dev = found.exitCode === 0 ? found.stdout.toString().trim() : ''
152
+ if (dev === '' || !existsSync(dev)) return rows
153
+ const s = statSync(dev)
154
+ const prefix = join(traceCache(), `git-${createHash('sha256').update(`${dev}\0${s.size}\0${s.mtimeMs}`).digest('hex').slice(0, 12)}`)
155
+ const copy = join(prefix, 'usr', 'bin', 'git')
156
+ if (!existsSync(copy)) {
157
+ const devUsr = dirname(dirname(dev))
158
+ mkdirSync(join(prefix, 'usr'), { recursive: true })
159
+ for (const d of readdirSync(devUsr)) if (d !== 'bin' && !existsSync(join(prefix, 'usr', d))) symlinkSync(join(devUsr, d), join(prefix, 'usr', d))
160
+ if (signedCopy(dev, copy) != null) return rows
161
+ }
162
+ return upsert(upsert(rows, '/usr/bin/git', copy), dev, copy)
163
+ }
164
+
165
+ /** the remap file for this run, with every entry still describing its
166
+ * original. Rebuilt when one went stale (a `bun install` replaced a copy) */
167
+ export function remaps(): string {
168
+ let rows = readRemaps()
169
+ const git = rows.find((r) => r.from === '/usr/bin/git')
170
+ if (git == null || !fresh(git)) rows = gitRemap(rows)
171
+ const kept = rows.filter(fresh)
172
+ if (kept.length !== rows.length || !existsSync(remapFile())) writeRemaps(kept)
173
+ return remapFile()
174
+ }
175
+
176
+ /** SIP paths, where no copy runs traced */
177
+ const PROTECTED = /^\/(usr\/(bin|sbin|libexec|lib)|bin|sbin|System)\//
178
+
179
+ /** a binary that ran blind, made traceable for the next run when it can be:
180
+ * a hardened binary a repo installed. Returns what happened, in words, for
181
+ * the line that says the answer was not kept */
182
+ export function learnBlind(paths: string[]): string | null {
183
+ const said: string[] = []
184
+ let rows = readRemaps()
185
+ for (const p of paths) {
186
+ if (PROTECTED.test(p)) { said.push(`macOS lets no tool trace ${p}`); continue }
187
+ let head: Buffer
188
+ try { head = readFileSync(p).subarray(0, 256) } catch { continue }
189
+ if (head.subarray(0, 2).toString() === '#!') {
190
+ const interp = head.toString().slice(2).split('\n')[0]!.trim().split(/\s+/)[0] ?? ''
191
+ said.push(`${basename(p)} runs through ${interp}${PROTECTED.test(interp) ? ', which macOS lets no tool trace' : ''}`)
192
+ continue
193
+ }
194
+ const magic = head.readUInt32BE(0)
195
+ if (![0xcffaedfe, 0xcafebabe, 0xfeedfacf].includes(magic)) continue
196
+ // a copy goes beside the original, so only into an install the repo
197
+ // owns, and only for a binary whose signature is what blinds it
198
+ const sign = Bun.spawnSync(['/usr/bin/codesign', '-dv', p], { stdout: 'pipe', stderr: 'pipe' }).stderr.toString()
199
+ if (!p.includes('/node_modules/') || !/flags=0x[0-9a-f]+\((?:[^)]*)(runtime|library-validation)/.test(sign)) { said.push(`${p} runs untraced and cannot be made traceable`); continue }
200
+ const copy = join(dirname(p), `.${basename(p)}.shipkit-traced`)
201
+ const failed = signedCopy(p, copy)
202
+ if (failed != null) { said.push(`${p} cannot be copied: ${failed}`); continue }
203
+ rows = upsert(rows, p, copy)
204
+ said.push(`a traceable copy of ${basename(p)} is ready for the next run`)
205
+ }
206
+ writeRemaps(rows)
207
+ return said.length === 0 ? null : said.join('; ')
208
+ }
209
+
210
+ /** a plain script: commands, arguments, quotes, `&&`, `||`, `;` and `|`.
211
+ * Bun's shell runs exactly that the way sh does, in process, so a traced
212
+ * task keeps its trace; anything else is left to the system shell */
213
+ export function plainScript(body: string): boolean {
214
+ if (!/^[\w\s@%+=:,./'"&|;-]*$/.test(body)) return false
215
+ // an unclosed quote runs the rest of the line into one word
216
+ if ((body.match(/'/g) ?? []).length % 2 === 1 || (body.match(/"/g) ?? []).length % 2 === 1) return false
217
+ const words = body.split(/[\s&|;]+/).filter((w) => w !== '')
218
+ const shellOnly = new Set(['export', 'set', 'source', '.', 'eval', 'exec', 'trap', 'if', 'then', 'else', 'fi', 'for', 'do', 'done', 'while', 'case', 'esac', 'function', 'unset', 'shift', 'read'])
219
+ return !words.some((w) => shellOnly.has(w))
220
+ }
221
+
222
+ /** what a traced task actually runs, where its trace goes, and the env that
223
+ * traces it, or why it cannot be traced. `bun run <script>` runs a plain
224
+ * script through bun's shell, the only one macOS lets a trace into */
225
+ export function tracedCommand(cmd: string[], cwd: string, env: Record<string, string | undefined> = process.env): { cmd: string[]; dir: string; env: Record<string, string>; note: string | null } | { reason: string } {
226
+ const lib = tracerLib(env)
227
+ if ('reason' in lib) return lib
228
+ // the copies first: the root command may be one of the binaries they stand for
229
+ const remap = remaps()
230
+ let run = rootCommand(cmd)
231
+ let note: string | null = null
232
+ if (cmd.length === 3 && basename(cmd[0]!) === 'bun' && cmd[1] === 'run') {
233
+ let body: string | null = null
234
+ try { body = (JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')) as { scripts?: Record<string, string> }).scripts?.[cmd[2]!] ?? null } catch { body = null }
235
+ if (body != null && plainScript(body)) run = [cmd[0]!, 'run', '--shell=bun', cmd[2]!]
236
+ else if (body != null) note = `its script \`${cmd[2]}\` uses shell syntax only /bin/sh runs`
237
+ }
238
+ return { cmd: run, ...traceSpawn(lib.path, remap), note }
239
+ }
240
+
241
+ /** the task's own command, spawned the way the interposer spawns a child:
242
+ * the gate itself runs untraced, so nobody else rewrites a
243
+ * `#!/usr/bin/env node` launcher (a floor slot's `node_modules/.bin/tsc`)
244
+ * or swaps a hardened binary for its traceable copy */
245
+ export function rootCommand(cmd: string[]): string[] {
246
+ if (cmd.length === 0) return cmd
247
+ const first = cmd[0]!
248
+ const path = first.includes('/') ? first : Bun.which(first) ?? first
249
+ let head = ''
250
+ try { head = readFileSync(path).subarray(0, 512).toString() } catch { return cmd }
251
+ if (head.startsWith('#!')) {
252
+ const [interp, prog, ...rest] = head.slice(2).split('\n')[0]!.trim().split(/\s+/)
253
+ if (interp === '/usr/bin/env' && prog != null && !prog.startsWith('-') && rest.length === 0) {
254
+ const resolved = Bun.which(prog)
255
+ if (resolved != null) return [resolved, path, ...cmd.slice(1)]
256
+ }
257
+ return cmd
258
+ }
259
+ const copy = readRemaps().find((r) => r.from === path && fresh(r))
260
+ return copy == null ? cmd : [copy.to, ...cmd.slice(1)]
261
+ }
262
+
263
+ /** a fresh dir for one traced spawn, and the env that traces it. A gate
264
+ * running inside a traced task keeps the outer dir, so both see the run */
265
+ export function traceSpawn(lib: string, remap: string = remaps()): { dir: string; env: Record<string, string> } {
266
+ const dir = mkdtempSync(join(tmpdir(), 'shipkit-trace-'))
267
+ const outer = process.env['SHIPKIT_TRACE_DIR']
268
+ const inserted = process.env['DYLD_INSERT_LIBRARIES']
269
+ return {
270
+ dir,
271
+ env: {
272
+ SHIPKIT_TRACE_DIR: outer == null || outer === '' ? dir : `${outer}:${dir}`,
273
+ DYLD_INSERT_LIBRARIES: inserted == null || inserted === '' ? lib : inserted.split(':').includes(lib) ? inserted : `${lib}:${inserted}`,
274
+ SHIPKIT_TRACE_REMAP: remap,
275
+ },
276
+ }
277
+ }
278
+
279
+ /** a trace dir goes once it is read */
280
+ export function dropTrace(dir: string): void {
281
+ try { if (lstatSync(dir).isDirectory()) rmSync(dir, { recursive: true, force: true }) } catch { /* gone either way */ }
282
+ }
@@ -1,63 +0,0 @@
1
- // the repo's declared processes (serve.processes) as the hub supervises them,
2
- // polled while its repo is the one shown. A repo that declares none never
3
- // polls again after the first answer.
4
- import { makeAutoObservable, observableRef, runInAction } from 'mobx'
5
- import type { ProcessView } from '../repo-processes.ts'
6
- import type { RepoSt } from './repo-st.ts'
7
-
8
- export const PROCESSES_POLL_MS = 2_000
9
-
10
- export class ProcessesSvc {
11
- list: ProcessView[] = []
12
- error = ''
13
- private timer: ReturnType<typeof setTimeout> | null = null
14
- /** bumped by stop, so a chain that left before it dies at its next await */
15
- private gen = 0
16
- private polling = false
17
-
18
- constructor(private st: RepoSt) {
19
- makeAutoObservable<this, 'st' | 'timer' | 'gen' | 'polling'>(this, { st: false, timer: false, gen: false, polling: false, list: observableRef })
20
- }
21
-
22
- start(): void {
23
- if (this.polling) return
24
- this.polling = true
25
- const gen = ++this.gen
26
- const tick = async (): Promise<void> => {
27
- await this.load()
28
- if (gen === this.gen && this.list.length > 0) this.timer = setTimeout(() => void tick(), PROCESSES_POLL_MS)
29
- }
30
- void tick()
31
- }
32
-
33
- stop(): void {
34
- this.polling = false
35
- this.gen++
36
- if (this.timer != null) clearTimeout(this.timer)
37
- this.timer = null
38
- }
39
-
40
- async load(): Promise<void> {
41
- try {
42
- const r = await fetch(this.st.url('/api/processes.json'))
43
- if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
44
- const j = (await r.json()) as { processes: ProcessView[] }
45
- runInAction(() => {
46
- this.list = j.processes
47
- this.error = ''
48
- })
49
- } catch (e) {
50
- runInAction(() => { this.error = `processes: ${(e as Error).message}` })
51
- }
52
- }
53
-
54
- async act(id: string, act: 'start' | 'stop' | 'restart'): Promise<void> {
55
- try {
56
- const r = await fetch(this.st.url('/api/processes/act'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, act }) })
57
- if (!r.ok) throw new Error(await r.text())
58
- await this.load()
59
- } catch (e) {
60
- runInAction(() => { this.error = `${act} ${id}: ${(e as Error).message}` })
61
- }
62
- }
63
- }
@@ -1,61 +0,0 @@
1
- // A repo page's sections: the pages reached for most, one line each at the top
2
- // of the left pane, over the tree, drawn the way the project list draws its
3
- // lines (an icon, a label, a count where it says something, its ⌥ shortcut at
4
- // the end, keys.ts), the current one lit. A page this repo does not have is
5
- // not a line. The lines come from what the page already holds (the kept
6
- // screen on a reload), so none arrives late and moves the tree.
7
- import { observer } from 'mobx-react-lite'
8
- import { RUNS_ID } from '../../collections/collection-types.ts'
9
- import { Icon, ICON } from './icons.tsx'
10
- import { sectionKeyLabel, type SectionId } from './keys.ts'
11
- import type { RepoSt } from './repo-st.ts'
12
- import { openWikis, WIKIS } from './wikis-view.tsx'
13
-
14
- export type RepoSection = {
15
- id: SectionId
16
- label: string
17
- title: string
18
- /** null when a number would say nothing */
19
- count: number | null
20
- tone: 'red' | 'warn' | null
21
- on: boolean
22
- go: () => void
23
- }
24
-
25
- const counted = (n: number, tone: 'red' | 'warn'): Pick<RepoSection, 'count' | 'tone'> => (n === 0 ? { count: null, tone: null } : { count: n, tone })
26
-
27
- export function repoSections(app: RepoSt): RepoSection[] {
28
- const has = (col: string): boolean => app.cols.colMeta.some((c) => c.id === col)
29
- const sel = app.sel
30
- const onCol = (col: string): boolean => (sel?.t === 'col' && sel.id === col) || (sel?.t === 'item' && sel.col === col)
31
- const res = app.cols.resources
32
- const reds = (col: string): number => res.filter((r) => r.collection === col && r.state === 'red').length
33
- const none = { count: null, tone: null }
34
- const out: RepoSection[] = []
35
- if (has('checks')) out.push({ id: 'checks', label: 'checks', title: 'every check the gate and the health pass run, one page each', ...counted(reds('checks'), 'red'), on: onCol('checks'), go: () => app.showCol('checks') })
36
- // a page, never a resource: every hub keeps a run record
37
- out.push({ id: 'runs', label: 'runs', title: 'every run on record, the slowest checks and the longest runs', ...none, on: onCol('runs'), go: () => app.cols.select('runs', RUNS_ID) })
38
- if (has('asks')) {
39
- const open = res.filter((r) => r.collection === 'asks' && r.type === 'ask' && r.slot !== 'done').length
40
- out.push({ id: 'asks', label: 'asks', title: 'what waits on a human', ...counted(open, 'warn'), on: onCol('asks'), go: () => app.showCol('asks') })
41
- }
42
- if (has('tests')) out.push({ id: 'tests', label: 'tests', title: 'every test file, slowest first, then each test check', ...counted(reds('tests'), 'red'), on: sel?.t === 'tests' || onCol('tests'), go: () => app.showTests(null) })
43
- const firstCat = app.media.assets[0]?.cat ?? app.cols.cachedCat ?? undefined
44
- if (firstCat != null) out.push({ id: 'media', label: 'media', title: 'the assets, in the atelier', ...none, on: sel?.t === 'asset' || sel?.t === 'cat', go: () => app.showCat(firstCat) })
45
- if (has(WIKIS)) out.push({ id: 'wikis', label: 'wikis', title: 'every wiki this repo mounts, as a link graph', ...none, on: onCol(WIKIS), go: () => openWikis(app) })
46
- if (app.home.machine != null) out.push({ id: 'admin', label: 'admin', title: 'reload the hub, keep the repo active, archive it', ...none, on: sel?.t === 'admin', go: () => app.showAdmin() })
47
- return out
48
- }
49
-
50
- export const Sections = observer(({ app }: { app: RepoSt }) => (
51
- <nav className="rsecs" aria-label="sections">
52
- {repoSections(app).map((s) => (
53
- <button key={s.id} className={`rsec${s.on ? ' cur' : ''}`} aria-current={s.on ? 'page' : undefined} title={`${s.title} (${sectionKeyLabel(s.id)})`} onClick={s.go}>
54
- <Icon d={ICON[s.id]} />
55
- <span className="rsecl">{s.label}</span>
56
- {s.count == null ? null : <b className={`rsecn n${s.tone}`}>{s.count}</b>}
57
- <kbd className="pjkey">{sectionKeyLabel(s.id)}</kbd>
58
- </button>
59
- ))}
60
- </nav>
61
- ))
@@ -1,89 +0,0 @@
1
- // the long running processes a repo declares in `serve.processes` (a dev
2
- // server, a watcher), supervised by its hub with the same supervisor the home
3
- // uses for resources. Each runs detached with a pid record in the machine db
4
- // (named by its path under .shipkit/tmp, where an older copy kept a pid file),
5
- // so a hub restart in place adopts it, and only a real stop of the hub stops it.
6
- import { join, resolve } from 'path'
7
- import { Proc, realDeps, tailLines, type ProcView, type SuperviseDeps } from '../home/supervise.ts'
8
- import type { ProcessDecl } from '../repo-config.ts'
9
- import { LAYOUT } from '../core/layout.ts'
10
-
11
- export type ProcessView = ProcView & { url: string | null; auto: boolean; cmd: string; tail: string[] }
12
-
13
- export const processDir = (root: string): string => join(root, LAYOUT.tmp, 'processes')
14
-
15
- const probeUrl = async (url: string): Promise<boolean> => {
16
- try {
17
- // any answer means it serves: a dev server may 404 its root
18
- await fetch(url, { signal: AbortSignal.timeout(1_500) })
19
- return true
20
- } catch {
21
- return false
22
- }
23
- }
24
-
25
- export class RepoProcesses {
26
- private procs: { decl: ProcessDecl; proc: Proc }[]
27
-
28
- constructor(root: string, decls: ProcessDecl[], o: { deps?: SuperviseDeps; probe?: (url: string) => Promise<boolean> } = {}) {
29
- const dir = processDir(root)
30
- this.procs = decls.map((decl) => ({
31
- decl,
32
- proc: new Proc({
33
- id: decl.id,
34
- cmd: decl.cmd,
35
- cwd: resolve(root, decl.cwd ?? '.'),
36
- log: join(dir, `${decl.id}.log`),
37
- pidKey: join(dir, `${decl.id}.pid`),
38
- probe: decl.url == null ? undefined : () => (o.probe ?? probeUrl)(decl.url!),
39
- }, o.deps ?? realDeps),
40
- }))
41
- }
42
-
43
- /** the auto ones start with the hub; an adopted one is left as it runs */
44
- async startAuto(): Promise<void> {
45
- await Promise.all(this.procs.filter((p) => p.decl.auto === true && p.proc.pid == null).map((p) => p.proc.ensure()))
46
- }
47
-
48
- act(id: string, act: 'start' | 'stop' | 'restart'): ProcessView | null {
49
- const p = this.procs.find((x) => x.decl.id === id)
50
- if (p == null) return null
51
- if (act === 'stop' || act === 'restart') p.proc.stop()
52
- if (act === 'start') void p.proc.ensure()
53
- if (act === 'restart') this.restartWhenGone(p.proc)
54
- return this.view(p)
55
- }
56
-
57
- private restartWhenGone(proc: Proc): void {
58
- const wait = async (): Promise<void> => {
59
- for (let i = 0; i < 40 && proc.status === 'stopping'; i++) {
60
- await Bun.sleep(250)
61
- await proc.tick()
62
- }
63
- proc.start()
64
- }
65
- void wait()
66
- }
67
-
68
- async tick(): Promise<void> {
69
- await Promise.all(this.procs.map((p) => p.proc.tick()))
70
- }
71
-
72
- /** a real stop of the hub: every process it started stops with it */
73
- stopAll(): void {
74
- for (const p of this.procs) if (p.proc.pid != null) p.proc.stop()
75
- }
76
-
77
- private view(p: { decl: ProcessDecl; proc: Proc }): ProcessView {
78
- const v = p.proc.view()
79
- return { ...v, url: p.decl.url ?? null, auto: p.decl.auto === true, cmd: p.decl.cmd.join(' '), tail: v.status === 'up' ? [] : tailLines(p.proc.spec.log, 20) }
80
- }
81
-
82
- views(): ProcessView[] {
83
- return this.procs.map((p) => this.view(p))
84
- }
85
-
86
- get size(): number {
87
- return this.procs.length
88
- }
89
- }