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.
- package/CHANGELOG.md +114 -0
- package/README.md +17 -17
- package/package.json +3 -2
- package/src/assets/gen/comfyts-gen.ts +12 -8
- package/src/assets/server/studio-server.ts +14 -2
- package/src/check.ts +73 -103
- package/src/checks/SK001-package-json-shape.check.ts +11 -6
- package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
- package/src/checks/SK003-claude-md-shape.check.ts +7 -3
- package/src/checks/SK004-gitignore-policy.check.ts +7 -4
- package/src/checks/SK005-rvlib-versions.check.ts +53 -21
- package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
- package/src/checks/SK007-github-visibility.check.ts +4 -2
- package/src/checks/SK008-reviewer-agent.check.ts +6 -4
- package/src/checks/SK009-commit-ai-tells.check.ts +4 -2
- package/src/checks/SK010-undeclared-unit.check.ts +4 -0
- package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
- package/src/checks/SK012-config-barrel-import.check.ts +5 -1
- package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
- package/src/checks/SK014-prose-ste.check.ts +4 -0
- package/src/checks/SK015-nested-repo.check.ts +4 -2
- package/src/checks/SK016-legacy-check-shapes.check.ts +13 -12
- package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
- package/src/checks/SK018-test-files-run.check.ts +6 -2
- package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
- package/src/checks/SK020-self-spawning-check.check.ts +4 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
- package/src/checks/SK024-identity-colors.check.ts +5 -3
- package/src/checks/SK025-release-drift.check.ts +4 -5
- package/src/checks/SK026-asks-autoclose.check.ts +4 -4
- package/src/checks/SK027-agent-wiki-links.check.ts +4 -1
- package/src/checks/SK028-readme.check.ts +5 -1
- package/src/checks/SK029-explicit-check-files.check.ts +95 -0
- package/src/checks/SK030-cache-audit.check.ts +34 -0
- package/src/checks/SK031-trace-audit.check.ts +39 -0
- package/src/checks/SK032-ignored-inputs.check.ts +79 -0
- package/src/cli/cmd_Asks.ts +5 -5
- package/src/cli/cmd_Assets.ts +17 -13
- package/src/cli/cmd_Bump.ts +12 -11
- package/src/cli/cmd_CacheAudit.ts +43 -0
- package/src/cli/cmd_Check.ts +8 -7
- package/src/cli/cmd_Checks.ts +7 -6
- package/src/cli/cmd_Ci.ts +12 -11
- package/src/cli/cmd_Commands.ts +16 -13
- package/src/cli/cmd_ConfigKeys.ts +7 -6
- package/src/cli/cmd_Contracts.ts +3 -3
- package/src/cli/cmd_CrossRepoActivity.ts +8 -6
- package/src/cli/cmd_Fleet.ts +9 -7
- package/src/cli/cmd_GlobalProcesses.ts +120 -7
- package/src/cli/cmd_Home.ts +26 -19
- package/src/cli/cmd_Hooks.ts +6 -5
- package/src/cli/cmd_Init.ts +5 -4
- package/src/cli/cmd_Keys.ts +20 -8
- package/src/cli/cmd_Mv.ts +7 -6
- package/src/cli/cmd_Needs.ts +4 -3
- package/src/cli/cmd_Overview.ts +5 -4
- package/src/cli/cmd_Pb.ts +2 -2
- package/src/cli/cmd_Planned.ts +6 -5
- package/src/cli/cmd_Prose.ts +5 -5
- package/src/cli/cmd_Reflect.ts +7 -7
- package/src/cli/cmd_Release.ts +6 -5
- package/src/cli/cmd_Releases.ts +5 -4
- package/src/cli/cmd_Serve.ts +6 -4
- package/src/cli/cmd_Smoke.ts +5 -4
- package/src/cli/cmd_Start.ts +7 -6
- package/src/cli/cmd_Status.ts +4 -3
- package/src/cli/cmd_TraceAudit.ts +62 -0
- package/src/cli/cmd_Tui.ts +3 -2
- package/src/cli/cmd_When.ts +71 -0
- package/src/cli/cmd_Wiki.ts +4 -4
- package/src/cli.ts +11 -2
- package/src/collections/checks.ts +2 -0
- package/src/collections/collection-types.ts +12 -4
- package/src/collections/providers.ts +5 -3
- package/src/core/app-icon.ts +154 -0
- package/src/core/audit-report.ts +21 -0
- package/src/core/cache-audit.ts +72 -0
- package/src/core/canonical-scripts.ts +2 -2
- package/src/core/check-ctx.ts +14 -5
- package/src/core/check-runner.ts +43 -23
- package/src/core/config-rewrite.ts +37 -24
- package/src/core/discover-checks.ts +23 -2
- package/src/core/floor-resolve.ts +5 -7
- package/src/core/git-root.ts +13 -0
- package/src/core/ignored-block.ts +94 -0
- package/src/core/ignored-inputs.ts +47 -0
- package/src/core/jobs.ts +9 -2
- package/src/core/legacy-shapes.ts +39 -10
- package/src/core/one-lib.ts +59 -4
- package/src/core/one-way-in-policy.ts +2 -2
- package/src/core/outcome-words.ts +18 -2
- package/src/core/port-check-cli.ts +52 -0
- package/src/core/port-check-file.ts +295 -0
- package/src/core/port-check-files.ts +31 -0
- package/src/core/read-set.ts +97 -9
- package/src/core/retention.ts +0 -14
- package/src/core/run-check.ts +12 -9
- package/src/core/run-ci.ts +14 -5
- package/src/core/run-fix.ts +30 -23
- package/src/core/run-fixers.ts +48 -5
- package/src/core/runs-matrix.ts +179 -0
- package/src/core/runs.ts +242 -0
- package/src/core/trace-audit.ts +120 -0
- package/src/core/update-policy.ts +20 -0
- package/src/core/when-block.ts +29 -30
- package/src/core/when.ts +41 -103
- package/src/core/wire.ts +10 -14
- package/src/fleet/scan-fleet.ts +20 -4
- package/src/fleet/update-fleet.ts +6 -6
- package/src/home/gui-app.ts +24 -27
- package/src/home/home-launchd.ts +20 -4
- package/src/home/home-routes.ts +12 -1
- package/src/home/home-start.ts +11 -5
- package/src/home/hubs.ts +5 -5
- package/src/home/open-app.ts +2 -2
- package/src/home/pm.ts +8 -0
- package/src/home/run-pm.ts +1 -0
- package/src/home/serve-web.ts +2 -2
- package/src/home/supervise.ts +24 -1
- package/src/home/via-hub.ts +14 -0
- package/src/hooks/run-hooks.ts +48 -4
- package/src/init-repo.ts +6 -6
- package/src/ledger/db.ts +2 -13
- package/src/ledger/gate-checks.ts +133 -61
- package/src/ledger/git-key.ts +153 -31
- package/src/ledger/git-scan.ts +5 -2
- package/src/ledger/ledger.ts +136 -21
- package/src/ledger/store.ts +14 -0
- package/src/ledger/toolchain-identity.ts +248 -45
- package/src/plan/repo-source.ts +18 -39
- package/src/problems/extract.ts +3 -0
- package/src/release/run-release.ts +5 -0
- package/src/repo-config.ts +46 -54
- package/src/rules/rule.ts +2 -2
- package/src/scaffold/files/agent-coding.md +1 -1
- package/src/scaffold/files/agent-index.md +1 -1
- package/src/scaffold/files/verify-skill.md +2 -2
- package/src/serve/hub-app/activity-st.ts +5 -4
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/admin-svc.ts +36 -3
- package/src/serve/hub-app/admin-view.tsx +46 -12
- package/src/serve/hub-app/check-view.tsx +66 -46
- package/src/serve/hub-app/check-words.ts +1 -11
- package/src/serve/hub-app/checks-svc.ts +14 -35
- package/src/serve/hub-app/col-card.tsx +1 -44
- package/src/serve/hub-app/col-tables-view.tsx +84 -11
- package/src/serve/hub-app/col-tables.ts +27 -0
- package/src/serve/hub-app/cols-svc.ts +5 -4
- package/src/serve/hub-app/dock-st.ts +3 -2
- package/src/serve/hub-app/dock-view.tsx +36 -0
- package/src/serve/hub-app/dock.css +6 -0
- package/src/serve/hub-app/float-cards.tsx +30 -0
- package/src/serve/hub-app/floating.tsx +41 -4
- package/src/serve/hub-app/git-st.ts +42 -0
- package/src/serve/hub-app/git-view.tsx +61 -0
- package/src/serve/hub-app/hash-route.ts +45 -1
- package/src/serve/hub-app/header-bar.tsx +62 -72
- package/src/serve/hub-app/health-svc.ts +3 -2
- package/src/serve/hub-app/home-st.ts +14 -2
- package/src/serve/hub-app/home-strip.css +43 -12
- package/src/serve/hub-app/home-strip.tsx +115 -64
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +32 -8
- package/src/serve/hub-app/hub-types.ts +5 -2
- package/src/serve/hub-app/hub.css +89 -63
- package/src/serve/hub-app/icons.tsx +11 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/keys.ts +15 -4
- package/src/serve/hub-app/live-svc.ts +3 -2
- package/src/serve/hub-app/machine-st.ts +18 -22
- package/src/serve/hub-app/plan-view.tsx +51 -0
- package/src/serve/hub-app/planned-st.ts +9 -4
- package/src/serve/hub-app/processes-view.tsx +45 -0
- package/src/serve/hub-app/project-list.css +4 -1
- package/src/serve/hub-app/project-list.tsx +6 -3
- package/src/serve/hub-app/projects-svc.ts +25 -10
- package/src/serve/hub-app/prose-hits-st.ts +3 -2
- package/src/serve/hub-app/reach.ts +36 -0
- package/src/serve/hub-app/readme-svc.ts +3 -2
- package/src/serve/hub-app/repo-sections.ts +131 -0
- package/src/serve/hub-app/repo-st.ts +70 -14
- package/src/serve/hub-app/sidebar.tsx +17 -42
- package/src/serve/hub-app/timeline-svc.ts +3 -2
- package/src/serve/hub-app/tree-build.ts +151 -155
- package/src/serve/hub-app/tree-row.tsx +55 -12
- package/src/serve/hub-app/tree-svc.ts +97 -56
- package/src/serve/hub-app/when-view.tsx +89 -0
- package/src/serve/hub-app/when.css +17 -0
- package/src/serve/hub-runtime.ts +7 -18
- package/src/serve/project-gutter-route.ts +22 -1
- package/src/serve/run-serve.ts +1 -1
- package/src/serve/serve-hub.ts +4 -14
- package/src/suite/judge-suites.ts +6 -0
- package/src/trace/interpose.c +655 -0
- package/src/trace/read-trace.ts +485 -0
- package/src/trace/tracer.ts +282 -0
- package/src/serve/hub-app/processes-svc.ts +0 -63
- package/src/serve/hub-app/repo-sections.tsx +0 -61
- 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
|
-
}
|