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
|
@@ -7,7 +7,7 @@ import { join } from 'path'
|
|
|
7
7
|
import { spawnWithTimeout } from '../core/spawn-timeout.ts'
|
|
8
8
|
import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
|
|
9
9
|
import { ALLOW_MAJOR_ENV, breakingAdvice } from '../core/update-policy.ts'
|
|
10
|
-
import {
|
|
10
|
+
import { isBehind, judgedVersion, updateRefusal, type FleetRepo } from './scan-fleet.ts'
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
export const short = (name: string): string => name.replace(/^rvlib-/, '')
|
|
@@ -64,15 +64,15 @@ export async function updateFleet(behind: FleetRepo[], o: FleetUpdateOpts): Prom
|
|
|
64
64
|
const env: Record<string, string> = Object.fromEntries(Object.entries(process.env).filter((e): e is [string, string] => e[1] != null && e[0] !== 'SHIPKIT_SELF_UPDATED' && e[0] !== ALLOW_MAJOR_ENV))
|
|
65
65
|
if (o.major) env[ALLOW_MAJOR_ENV] = '1'
|
|
66
66
|
const res = await spawnWithTimeout(['bun', cli, 'check', '--fresh'], { cwd: r.dir, env, timeoutMs: o.timeoutMs ?? 600_000 })
|
|
67
|
-
// judge by what node_modules carries now
|
|
68
|
-
// printed line: the code covers every rule
|
|
69
|
-
// format belongs to whatever version that repo runs
|
|
67
|
+
// judge by what node_modules carries now (the pin, for a linked dep),
|
|
68
|
+
// never by the exit code or a printed line: the code covers every rule
|
|
69
|
+
// in that repo, and the line format belongs to whatever version that repo runs
|
|
70
70
|
const stuck = wanted.filter((d) => {
|
|
71
|
-
const now =
|
|
71
|
+
const now = judgedVersion(r.dir, d.name)
|
|
72
72
|
return now == null || isBehind(now, d.latest!)
|
|
73
73
|
})
|
|
74
74
|
if (stuck.length === 0) {
|
|
75
|
-
w(`ok: ${r.name} — ${wanted.map((d) => `${short(d.name)} ${d.
|
|
75
|
+
w(`ok: ${r.name} — ${wanted.map((d) => `${short(d.name)}${d.linkedInstall ? ' pin' : ''} ${d.at} → ${judgedVersion(r.dir, d.name)}`).join(', ')}`)
|
|
76
76
|
continue
|
|
77
77
|
}
|
|
78
78
|
failed++
|
package/src/home/gui-app.ts
CHANGED
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
// and iconed after it, in its repo's `.shipkit/wrapper/`. The bundle's
|
|
5
5
|
// Info.plist carries what differs (name, bundle id, start url), so a fix to
|
|
6
6
|
// the window (find, the inspector, zoom) lands in every app at its next
|
|
7
|
-
// build.
|
|
8
|
-
//
|
|
7
|
+
// build. The icon is core/app-icon.ts: the repo's mark, its margin cut, on
|
|
8
|
+
// a plate in the repo's colour. Rebuilt only when the source, the icon, the
|
|
9
|
+
// colours or the start url changed (a stamp in the bundle). No compiler, or not macOS: the caller falls back.
|
|
9
10
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
10
11
|
import { homedir } from 'os'
|
|
11
12
|
import { dirname, join } from 'path'
|
|
12
13
|
import { fileURLToPath } from 'url'
|
|
14
|
+
import { appIconOf, appIconPngs, identityOfRoot } from '../core/app-icon.ts'
|
|
15
|
+
import { identityOfName, type Identity } from '../core/identity-color.ts'
|
|
13
16
|
import { LAYOUT } from '../core/layout.ts'
|
|
14
17
|
|
|
15
18
|
const SOURCE = fileURLToPath(new URL('./gui-app/ShipkitGui.swift', import.meta.url))
|
|
@@ -17,15 +20,6 @@ const SOURCE = fileURLToPath(new URL('./gui-app/ShipkitGui.swift', import.meta.u
|
|
|
17
20
|
export const BUNDLE_ID = 'dev.shipkit.gui'
|
|
18
21
|
export const guiAppPath = (): string => process.env.SHIPKIT_GUI_APP ?? join(homedir(), 'Applications', 'Shipkit.app')
|
|
19
22
|
|
|
20
|
-
/** the icon the app wears: the repo's own mark, largest first. Only a
|
|
21
|
-
* png can become an icns */
|
|
22
|
-
export const APP_ICON_FILES = [`${LAYOUT.identity}/icon.png`, 'public/logo.png', 'public/favicon.png']
|
|
23
|
-
|
|
24
|
-
export function appIconOf(root: string): string | null {
|
|
25
|
-
for (const f of APP_ICON_FILES) if (existsSync(join(root, f))) return join(root, f)
|
|
26
|
-
return null
|
|
27
|
-
}
|
|
28
|
-
|
|
29
23
|
/** what one app bundle is: the rest comes from the shared source */
|
|
30
24
|
export type AppSpec = {
|
|
31
25
|
/** CFBundleName: the dock, cmd-tab and the app menu say it */
|
|
@@ -34,6 +28,8 @@ export type AppSpec = {
|
|
|
34
28
|
/** the page a launch with no url opens; null opens the home */
|
|
35
29
|
url: string | null
|
|
36
30
|
icon: string | null
|
|
31
|
+
/** the repo's colours: the plate behind the icon's mark */
|
|
32
|
+
identity: Identity
|
|
37
33
|
/** where the bundle lands */
|
|
38
34
|
app: string
|
|
39
35
|
version: string
|
|
@@ -52,7 +48,7 @@ function atsOf(url: string | null): string {
|
|
|
52
48
|
return `<dict><key>NSAllowsLocalNetworking</key><true/>${exception}</dict>`
|
|
53
49
|
}
|
|
54
50
|
|
|
55
|
-
export function infoPlist(o: Omit<AppSpec, 'app' | 'icon'>): string {
|
|
51
|
+
export function infoPlist(o: Omit<AppSpec, 'app' | 'icon' | 'identity'>): string {
|
|
56
52
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
57
53
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
58
54
|
<plist version="1.0">
|
|
@@ -79,6 +75,9 @@ const runSync: Run = (cmd) => {
|
|
|
79
75
|
return { code: p.exitCode ?? 1, err: p.stderr.toString().trim() }
|
|
80
76
|
}
|
|
81
77
|
|
|
78
|
+
/** bumped when the icon drawing changes, so every built app redraws its icon */
|
|
79
|
+
const ICON_DRAWING = 'plate-1'
|
|
80
|
+
|
|
82
81
|
/** the stamp a built bundle carries: what it was built from. The version is
|
|
83
82
|
* left out, so a shipkit release rebuilds nothing */
|
|
84
83
|
export function stampOf(source: string, icon: string | null, identity = ''): string {
|
|
@@ -89,11 +88,11 @@ export function stampOf(source: string, icon: string | null, identity = ''): str
|
|
|
89
88
|
|
|
90
89
|
/** one app, built or rebuilt when stale. Throws with the step that failed.
|
|
91
90
|
* `rebuilt` tells the caller a running copy is now the old one */
|
|
92
|
-
export function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {}): { app: string; rebuilt: boolean } {
|
|
91
|
+
export async function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {}): Promise<{ app: string; rebuilt: boolean }> {
|
|
93
92
|
const { app, icon } = spec
|
|
94
93
|
const run = o.run ?? runSync
|
|
95
94
|
const source = o.source ?? SOURCE
|
|
96
|
-
const stamp = stampOf(readFileSync(source, 'utf8'), icon, JSON.stringify([spec.name, spec.bundleId, spec.url]))
|
|
95
|
+
const stamp = stampOf(readFileSync(source, 'utf8'), icon, JSON.stringify([spec.name, spec.bundleId, spec.url, spec.identity, ICON_DRAWING]))
|
|
97
96
|
const stampFile = join(app, 'Contents', 'Resources', 'stamp')
|
|
98
97
|
if (existsSync(stampFile) && readFileSync(stampFile, 'utf8') === stamp) return { app, rebuilt: false }
|
|
99
98
|
const tmp = `${app}.building-${process.pid}`
|
|
@@ -108,12 +107,10 @@ export function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {})
|
|
|
108
107
|
if (icon != null) {
|
|
109
108
|
const set = join(res, 'AppIcon.iconset')
|
|
110
109
|
mkdirSync(set, { recursive: true })
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
}
|
|
110
|
+
const files = [16, 32, 128, 256, 512].flatMap((n) => [[`icon_${n}x${n}.png`, n], [`icon_${n}x${n}@2x.png`, n * 2]] as const)
|
|
111
|
+
const pngs = await appIconPngs(readFileSync(icon), spec.identity, files.map(([, px]) => px))
|
|
112
|
+
.catch((e: Error) => fail(`could not draw the icon from ${icon}: ${e.message}`))
|
|
113
|
+
files.forEach(([file], i) => writeFileSync(join(set, file), pngs[i]!))
|
|
117
114
|
const r = run(['iconutil', '-c', 'icns', set, '-o', join(res, 'AppIcon.icns')])
|
|
118
115
|
rmSync(set, { recursive: true, force: true })
|
|
119
116
|
if (r.code !== 0) fail(`iconutil could not build the icon: ${r.err.slice(0, 200)}`)
|
|
@@ -128,15 +125,15 @@ export function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {})
|
|
|
128
125
|
}
|
|
129
126
|
|
|
130
127
|
/** the shipkit app: the shared source on the home, the house repo's icon */
|
|
131
|
-
export function ensureGuiApp(o: { root: string | null; version: string; app?: string; run?: Run }): { app: string; rebuilt: boolean } {
|
|
132
|
-
return ensureApp({ name: 'shipkit', bundleId: BUNDLE_ID, url: null, icon: o.root == null ? null : appIconOf(o.root), app: o.app ?? guiAppPath(), version: o.version }, { run: o.run })
|
|
128
|
+
export function ensureGuiApp(o: { root: string | null; version: string; app?: string; run?: Run }): Promise<{ app: string; rebuilt: boolean }> {
|
|
129
|
+
return ensureApp({ name: 'shipkit', bundleId: BUNDLE_ID, url: null, icon: o.root == null ? null : appIconOf(o.root), identity: o.root == null ? identityOfName('shipkit') : identityOfRoot(o.root), app: o.app ?? guiAppPath(), version: o.version }, { run: o.run })
|
|
133
130
|
}
|
|
134
131
|
|
|
135
132
|
/** what a global process gives its app */
|
|
136
133
|
export type WrapperOf = { id: string; label: string; url: string | null; icon: string | null; root: string }
|
|
137
134
|
|
|
138
|
-
/** the
|
|
139
|
-
const RASTER = /\.(png|jpe?g|gif|webp
|
|
135
|
+
/** the images the icon renderer reads; an emoji or an svg leaves the repo's mark */
|
|
136
|
+
const RASTER = /\.(png|jpe?g|gif|webp)$/i
|
|
140
137
|
|
|
141
138
|
export const wrapperBundleId = (id: string): string => `dev.shipkit.app.${id.replace(/[^A-Za-z0-9-]+/g, '-')}`
|
|
142
139
|
export const wrapperPath = (root: string, id: string): string => join(root, LAYOUT.wrapper, `${id.replace(/[^A-Za-z0-9._-]+/g, '-')}.app`)
|
|
@@ -147,12 +144,12 @@ export const wrapperPath = (root: string, id: string): string => join(root, LAYO
|
|
|
147
144
|
export function wrapperSpec(d: WrapperOf, version: string): AppSpec {
|
|
148
145
|
if (d.url == null) throw new Error(`${d.id} declares no url: there is no page to open as an app`)
|
|
149
146
|
const declared = d.icon != null && RASTER.test(d.icon) && existsSync(join(d.root, d.icon)) ? join(d.root, d.icon) : null
|
|
150
|
-
return { name: d.label, bundleId: wrapperBundleId(d.id), url: d.url, icon: declared ?? appIconOf(d.root), app: wrapperPath(d.root, d.id), version }
|
|
147
|
+
return { name: d.label, bundleId: wrapperBundleId(d.id), url: d.url, icon: declared ?? appIconOf(d.root), identity: identityOfRoot(d.root), app: wrapperPath(d.root, d.id), version }
|
|
151
148
|
}
|
|
152
149
|
|
|
153
|
-
export const ensureWrapperApp = (d: WrapperOf, o: { version: string; run?: Run }): { app: string; rebuilt: boolean; bundleId: string } => {
|
|
150
|
+
export const ensureWrapperApp = async (d: WrapperOf, o: { version: string; run?: Run }): Promise<{ app: string; rebuilt: boolean; bundleId: string }> => {
|
|
154
151
|
const spec = wrapperSpec(d, o.version)
|
|
155
|
-
return { ...ensureApp(spec, { run: o.run }), bundleId: spec.bundleId }
|
|
152
|
+
return { ...(await ensureApp(spec, { run: o.run })), bundleId: spec.bundleId }
|
|
156
153
|
}
|
|
157
154
|
|
|
158
155
|
/** quits a running copy of an app, and launches nothing when none runs */
|
package/src/home/home-launchd.ts
CHANGED
|
@@ -22,7 +22,10 @@ const esc = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<'
|
|
|
22
22
|
* every command, so any edit would restart pm. pm,
|
|
23
23
|
* the daemon and the web each watch their own code and restart in place on
|
|
24
24
|
* it (lib-watch.ts). PATH is the installing shell's, since launchd starts
|
|
25
|
-
* with almost none and the daemon needs bun, git, ps, lsof and ssh
|
|
25
|
+
* with almost none and the daemon needs bun, git, ps, lsof and ssh.
|
|
26
|
+
* ProcessType Interactive: with none, launchd throttles the agent's disk
|
|
27
|
+
* reads under load, every child inherits it, and a busy disk left the
|
|
28
|
+
* daemon's boot reads minutes long while its port was already open */
|
|
26
29
|
export function renderPlist(o: { bun: string; cli: string; root: string; path: string; log: string }): string {
|
|
27
30
|
const args = [o.bun, o.cli, 'pm'].map((a) => `\t\t<string>${esc(a)}</string>`).join('\n')
|
|
28
31
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
@@ -42,6 +45,8 @@ ${args}
|
|
|
42
45
|
\t\t<key>PATH</key>
|
|
43
46
|
\t\t<string>${esc(o.path)}</string>
|
|
44
47
|
\t</dict>
|
|
48
|
+
\t<key>ProcessType</key>
|
|
49
|
+
\t<string>Interactive</string>
|
|
45
50
|
\t<key>RunAtLoad</key>
|
|
46
51
|
\t<true/>
|
|
47
52
|
\t<key>KeepAlive</key>
|
|
@@ -73,7 +78,13 @@ const launchctl: Launchctl = async (args) => {
|
|
|
73
78
|
|
|
74
79
|
const domain = (): string => `gui/${process.getuid?.() ?? 501}`
|
|
75
80
|
|
|
76
|
-
|
|
81
|
+
/** `bootout` returns before launchd lets go of the label, and a bootstrap in
|
|
82
|
+
* that window fails (exit 5, EIO) with the old agent already gone: retried
|
|
83
|
+
* this many times, BOOTSTRAP_STEP_MS apart, before the install fails */
|
|
84
|
+
export const BOOTSTRAP_TRIES = 20
|
|
85
|
+
const BOOTSTRAP_STEP_MS = 250
|
|
86
|
+
|
|
87
|
+
export async function installHome(o: { root: string; cli: string; print: (l: string) => void; run?: Launchctl; sleep?: (ms: number) => Promise<void> }): Promise<number> {
|
|
77
88
|
if (process.platform !== 'darwin') {
|
|
78
89
|
o.print(`FAIL: the pm LaunchAgent is macOS only. Run \`shipkit pm\` under your own supervisor on ${process.platform}`)
|
|
79
90
|
return 1
|
|
@@ -89,9 +100,14 @@ export async function installHome(o: { root: string; cli: string; print: (l: str
|
|
|
89
100
|
await run(['bootout', `${domain()}/${LABEL}`])
|
|
90
101
|
await run(['bootout', `${domain()}/${LEGACY_LABEL}`])
|
|
91
102
|
rmSync(plistPath(homedir(), LEGACY_LABEL), { force: true })
|
|
92
|
-
const
|
|
103
|
+
const sleep = o.sleep ?? Bun.sleep
|
|
104
|
+
let r = await run(['bootstrap', domain(), file])
|
|
105
|
+
for (let i = 1; i < BOOTSTRAP_TRIES && r.code !== 0; i++) {
|
|
106
|
+
await sleep(BOOTSTRAP_STEP_MS)
|
|
107
|
+
r = await run(['bootstrap', domain(), file])
|
|
108
|
+
}
|
|
93
109
|
if (r.code !== 0) {
|
|
94
|
-
o.print(`FAIL: launchctl bootstrap refused ${file} (exit ${r.code}): ${r.out}`)
|
|
110
|
+
o.print(`FAIL: launchctl bootstrap refused ${file} (exit ${r.code}, ${BOOTSTRAP_TRIES} tries): ${r.out}. pm is not loaded now: run \`shipkit pm install\` again`)
|
|
95
111
|
return 1
|
|
96
112
|
}
|
|
97
113
|
o.print(`ok: pm runs now and at every login, from ${o.root}, and runs the daemon and the web`)
|
package/src/home/home-routes.ts
CHANGED
|
@@ -16,8 +16,11 @@ import { tailLines } from './supervise.ts'
|
|
|
16
16
|
import { Hubs, type WorkerView } from './hubs.ts'
|
|
17
17
|
import type { Planner, PlanRow } from '../plan/planner.ts'
|
|
18
18
|
import type { Widgets, WidgetView } from './widgets.ts'
|
|
19
|
-
import { ActivityCache, windowMinutes } from './activity.ts'
|
|
19
|
+
import { ActivityCache, repoActivity, windowMinutes } from './activity.ts'
|
|
20
20
|
import { HouseRuns } from '../serve/house-runs.ts'
|
|
21
|
+
|
|
22
|
+
/** how far back a repo's git page lists its commits */
|
|
23
|
+
export const GIT_PAGE_MS = 7 * 24 * 3600_000
|
|
21
24
|
import type { HouseView } from '../ledger/house-runs.ts'
|
|
22
25
|
|
|
23
26
|
/** how often the daemon looks at every repo */
|
|
@@ -173,6 +176,14 @@ export function homeRoutes(d: HomeDeps) {
|
|
|
173
176
|
const q = new URL(req.url).searchParams
|
|
174
177
|
return Response.json(await activity.get(repos, windowMinutes(q.get('m')), { fresh: q.get('fresh') === '1' }))
|
|
175
178
|
}
|
|
179
|
+
// one repo's git for its git page: a week of commits, and HEAD's last
|
|
180
|
+
// one past that, whatever window the activity page shows
|
|
181
|
+
if (path === '/api/home/git.json') {
|
|
182
|
+
if (repos.length === 0) await refresh()
|
|
183
|
+
const r = find(new URL(req.url).searchParams.get('repo'))
|
|
184
|
+
if (r == null) return new Response('not a house repo', { status: 404 })
|
|
185
|
+
return Response.json(await repoActivity(r, Date.now() - GIT_PAGE_MS, (n) => d.plan?.declared(n) ?? null, (n) => d.plan?.titles(n) ?? null))
|
|
186
|
+
}
|
|
176
187
|
if (path === '/api/house-runs.json') {
|
|
177
188
|
if (repos.length === 0) await refresh()
|
|
178
189
|
const view: HouseView = { ...house.view(), held: d.plan?.held() ?? [] }
|
package/src/home/home-start.ts
CHANGED
|
@@ -6,7 +6,11 @@ import { homedir } from 'os'
|
|
|
6
6
|
import { dirname, join, resolve } from 'path'
|
|
7
7
|
import { gitRoot } from '../core/git-root.ts'
|
|
8
8
|
import { homeRepo, houseIndex } from '../serve/project-gutter.ts'
|
|
9
|
-
import {
|
|
9
|
+
import type { WrapperOf } from './gui-app.ts'
|
|
10
|
+
|
|
11
|
+
// the app builder draws icons with canvas: loaded when a window opens, never
|
|
12
|
+
// at the startup of every command that imports this module
|
|
13
|
+
const gui = (): Promise<typeof import('./gui-app.ts')> => import('./gui-app.ts')
|
|
10
14
|
import { installedHouseRoot, LABEL, plistPath } from './home-launchd.ts'
|
|
11
15
|
import { childLog, daemonPort, daemonUp, homeDir, homePort, homeUp, pmLog } from './home-paths.ts'
|
|
12
16
|
|
|
@@ -88,10 +92,11 @@ export function windowCmd(url: string, o: { chrome?: string; platform?: string }
|
|
|
88
92
|
/** one more home window: the native app where it can be built (its own
|
|
89
93
|
* dock icon, the house mark), else a Chrome app window. `warn` hears why the
|
|
90
94
|
* native one was not used */
|
|
91
|
-
export function openWindow(url: string, o: { root?: string | null; version?: string; warn?: (l: string) => void } = {}): string[] {
|
|
95
|
+
export async function openWindow(url: string, o: { root?: string | null; version?: string; warn?: (l: string) => void } = {}): Promise<string[]> {
|
|
96
|
+
const { BUNDLE_ID, canBuildGui, ensureGuiApp, quitCmd } = await gui()
|
|
92
97
|
if (canBuildGui()) {
|
|
93
98
|
try {
|
|
94
|
-
const { app, rebuilt } = ensureGuiApp({ root: o.root ?? guiRoot(process.cwd()),version: o.version ?? '0' })
|
|
99
|
+
const { app, rebuilt } = await ensureGuiApp({ root: o.root ?? guiRoot(process.cwd()),version: o.version ?? '0' })
|
|
95
100
|
// a running copy is the old build: it quits, and the new one opens the window
|
|
96
101
|
if (rebuilt) Bun.spawnSync(quitCmd(BUNDLE_ID), { stdout: 'ignore', stderr: 'ignore' })
|
|
97
102
|
const cmd = ['open', '-a', app, url]
|
|
@@ -108,11 +113,12 @@ export function openWindow(url: string, o: { root?: string | null; version?: str
|
|
|
108
113
|
* `.shipkit/wrapper/`, then opened with no url, so a running copy comes to
|
|
109
114
|
* the front instead of stacking windows. Where no app can be built, a
|
|
110
115
|
* Chrome app window on its url */
|
|
111
|
-
export function openProcessApp(d: WrapperOf, o: { version: string; warn?: (l: string) => void }): string[] {
|
|
116
|
+
export async function openProcessApp(d: WrapperOf, o: { version: string; warn?: (l: string) => void }): Promise<string[]> {
|
|
112
117
|
if (d.url == null) throw new Error(`${d.id} declares no url: there is no page to open as an app`)
|
|
118
|
+
const { canBuildGui, ensureWrapperApp, quitCmd } = await gui()
|
|
113
119
|
if (canBuildGui()) {
|
|
114
120
|
try {
|
|
115
|
-
const { app, rebuilt, bundleId } = ensureWrapperApp(d, { version: o.version })
|
|
121
|
+
const { app, rebuilt, bundleId } = await ensureWrapperApp(d, { version: o.version })
|
|
116
122
|
if (rebuilt) Bun.spawnSync(quitCmd(bundleId), { stdout: 'ignore', stderr: 'ignore' })
|
|
117
123
|
const cmd = ['open', '-a', app]
|
|
118
124
|
Bun.spawnSync(cmd, { stdout: 'ignore', stderr: 'ignore' })
|
package/src/home/hubs.ts
CHANGED
|
@@ -132,7 +132,7 @@ export class Hubs {
|
|
|
132
132
|
const rt = await this.o.load(e.repo, io, { fresh: fresh || e.loads > 0 })
|
|
133
133
|
// a stop by hand came while it loaded, and wins
|
|
134
134
|
if (e.held) {
|
|
135
|
-
rt.stop(
|
|
135
|
+
rt.stop()
|
|
136
136
|
this.set(e, 'down', 'stopped by hand')
|
|
137
137
|
return
|
|
138
138
|
}
|
|
@@ -158,7 +158,7 @@ export class Hubs {
|
|
|
158
158
|
const names = new Set(repos.map((r) => r.name))
|
|
159
159
|
for (const [name, e] of this.entries) {
|
|
160
160
|
if (names.has(name)) continue
|
|
161
|
-
e.rt?.stop(
|
|
161
|
+
e.rt?.stop()
|
|
162
162
|
this.entries.delete(name)
|
|
163
163
|
}
|
|
164
164
|
for (const r of repos) {
|
|
@@ -179,7 +179,7 @@ export class Hubs {
|
|
|
179
179
|
stop(repo: WiredRepo): WorkerView {
|
|
180
180
|
const e = this.entry(repo)
|
|
181
181
|
e.held = true
|
|
182
|
-
e.rt?.stop(
|
|
182
|
+
e.rt?.stop()
|
|
183
183
|
e.rt = null
|
|
184
184
|
this.set(e, 'down', 'stopped by hand')
|
|
185
185
|
return this.view(e)
|
|
@@ -190,7 +190,7 @@ export class Hubs {
|
|
|
190
190
|
const e = this.entry(repo)
|
|
191
191
|
await e.loading
|
|
192
192
|
e.held = false
|
|
193
|
-
e.rt?.stop(
|
|
193
|
+
e.rt?.stop()
|
|
194
194
|
e.rt = null
|
|
195
195
|
await this.schedule(e, true)
|
|
196
196
|
return this.view(e)
|
|
@@ -279,7 +279,7 @@ export class Hubs {
|
|
|
279
279
|
return repos.map((r) => this.view(this.entry(r)))
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
-
/** every hub stops
|
|
282
|
+
/** every hub stops */
|
|
283
283
|
stopAll(): void {
|
|
284
284
|
for (const e of this.entries.values()) e.rt?.stop()
|
|
285
285
|
}
|
package/src/home/open-app.ts
CHANGED
|
@@ -13,10 +13,10 @@ if (import.meta.main) {
|
|
|
13
13
|
try {
|
|
14
14
|
if (first === '--process' && second != null) {
|
|
15
15
|
const d = JSON.parse(second) as WrapperOf
|
|
16
|
-
const cmd = openProcessApp(d, { version: LIB_VERSION, warn })
|
|
16
|
+
const cmd = await openProcessApp(d, { version: LIB_VERSION, warn })
|
|
17
17
|
console.log(`shipkit: opened ${d.id} as its own app: ${cmd.join(' ')}`)
|
|
18
18
|
} else if (first != null && first !== '--process') {
|
|
19
|
-
const cmd = openWindow(first, { version: LIB_VERSION, warn })
|
|
19
|
+
const cmd = await openWindow(first, { version: LIB_VERSION, warn })
|
|
20
20
|
console.log(`shipkit: opened ${first} as an app window: ${cmd.join(' ')}`)
|
|
21
21
|
} else {
|
|
22
22
|
console.error('usage: bun open-app.ts <url> | --process <json>')
|
package/src/home/pm.ts
CHANGED
|
@@ -27,6 +27,10 @@ export type ChildView = ProcView & { tail: string[] }
|
|
|
27
27
|
/** where pm, the daemon and the web listen */
|
|
28
28
|
export type CorePorts = { pm: number; daemon: number; web: number }
|
|
29
29
|
|
|
30
|
+
/** a child whose pid lives but has not answered for this long is hung and
|
|
31
|
+
* gets killed: a healthy daemon answers within about 20 s of its start */
|
|
32
|
+
export const CHILD_HANG_MS = 90_000
|
|
33
|
+
|
|
30
34
|
/** how many log lines each of shipkit's own processes hands the page */
|
|
31
35
|
const CORE_TAIL = 20
|
|
32
36
|
|
|
@@ -61,6 +65,8 @@ export type PmDeps = {
|
|
|
61
65
|
/** runs a stop or restart of the web once its answer is out: the web
|
|
62
66
|
* proxies that very request (default: 200 ms later) */
|
|
63
67
|
later?: (f: () => void) => void
|
|
68
|
+
/** where pm says what it did on its own, a hung child killed */
|
|
69
|
+
say?: (line: string) => void
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
const WEB_ANSWER_MS = 200
|
|
@@ -79,6 +85,8 @@ export class Pm {
|
|
|
79
85
|
probe: () => d.probe(role),
|
|
80
86
|
// a crash loop while its code is being edited heals itself
|
|
81
87
|
neverGiveUp: true,
|
|
88
|
+
hangMs: CHILD_HANG_MS,
|
|
89
|
+
say: d.say,
|
|
82
90
|
}, d.deps ?? realDeps)]))
|
|
83
91
|
}
|
|
84
92
|
|
package/src/home/run-pm.ts
CHANGED
|
@@ -100,6 +100,7 @@ export async function runPm(root: string, io: HomeIo, o: { port?: number } = {})
|
|
|
100
100
|
cmd: (role) => [process.execPath, CLI, role],
|
|
101
101
|
probe: roleAnswers,
|
|
102
102
|
resources: new Resources(c.processes, dir),
|
|
103
|
+
say: io.printErr,
|
|
103
104
|
})
|
|
104
105
|
let server: Bun.Server<unknown>
|
|
105
106
|
try {
|
package/src/home/serve-web.ts
CHANGED
|
@@ -51,8 +51,8 @@ export const REPO_ANSWER_MS = 30_000
|
|
|
51
51
|
export const LIST_EVERY_MS = 30_000
|
|
52
52
|
export const LIST_RETRY_MS = 1_000
|
|
53
53
|
|
|
54
|
-
/** the
|
|
55
|
-
export const readsGit = (path: string): boolean => path === '/api/home/activity.json'
|
|
54
|
+
/** the home answers that can wait on git: the activity page's first read of a window, and a repo's git page */
|
|
55
|
+
export const readsGit = (path: string): boolean => path === '/api/home/activity.json' || path === '/api/home/git.json'
|
|
56
56
|
|
|
57
57
|
/** a run or a check run streams for minutes, and a silent one is never cut */
|
|
58
58
|
export const streams = (path: string): boolean => /\/api\/(run|check\/run)$/.test(path)
|
package/src/home/supervise.ts
CHANGED
|
@@ -29,6 +29,12 @@ export type ProcSpec = {
|
|
|
29
29
|
/** never left down after CRASH_LIMIT crashes: it keeps retrying at the
|
|
30
30
|
* longest backoff, so a code fix brings it back with no hand */
|
|
31
31
|
neverGiveUp?: boolean
|
|
32
|
+
/** a pid that lives but has not answered its probe for this long is hung
|
|
33
|
+
* (a main thread stuck in one synchronous step still holds its port):
|
|
34
|
+
* killed, then started again after the crash backoff. None = never */
|
|
35
|
+
hangMs?: number
|
|
36
|
+
/** a line for every act the supervisor takes on its own */
|
|
37
|
+
say?: (line: string) => void
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export type ProcView = {
|
|
@@ -137,6 +143,8 @@ export class Proc {
|
|
|
137
143
|
private retryAt: number | null = null
|
|
138
144
|
private killAt: number | null = null
|
|
139
145
|
private probing = false
|
|
146
|
+
/** why the kill in flight was a hang kill, for the crash note */
|
|
147
|
+
private hung: string | null = null
|
|
140
148
|
/** a restart: start again once the stop landed */
|
|
141
149
|
private again = false
|
|
142
150
|
/** it answers its probe with no pid of ours behind it */
|
|
@@ -275,6 +283,8 @@ export class Proc {
|
|
|
275
283
|
const now = this.d.now()
|
|
276
284
|
if (this.pid != null && !this.d.alive(this.pid)) {
|
|
277
285
|
this.gone()
|
|
286
|
+
const hung = this.hung
|
|
287
|
+
this.hung = null
|
|
278
288
|
if (this.again) {
|
|
279
289
|
this.again = false
|
|
280
290
|
this.want = 'up'
|
|
@@ -287,7 +297,7 @@ export class Proc {
|
|
|
287
297
|
this.set('crashed', `exited ${this.crashes.length} times in ${Math.round(CRASH_WINDOW_MS / 60_000)} min, left down: see the log`)
|
|
288
298
|
} else {
|
|
289
299
|
this.retryAt = now + BACKOFF_MS[Math.min(this.crashes.length - 1, BACKOFF_MS.length - 1)]!
|
|
290
|
-
this.set('crashed', `exited, restart in ${Math.round((this.retryAt - now) / 1000)}s`)
|
|
300
|
+
this.set('crashed', `${hung != null ? `${hung}, killed` : 'exited'}, restart in ${Math.round((this.retryAt - now) / 1000)}s`)
|
|
291
301
|
}
|
|
292
302
|
}
|
|
293
303
|
}
|
|
@@ -318,6 +328,19 @@ export class Proc {
|
|
|
318
328
|
} finally {
|
|
319
329
|
this.probing = false
|
|
320
330
|
}
|
|
331
|
+
this.killIfHung(now)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** a pid that lives and still has not answered, hangMs after it last
|
|
335
|
+
* changed status, is stopped: the crash path starts it again */
|
|
336
|
+
private killIfHung(now: number): void {
|
|
337
|
+
const hang = this.spec.hangMs
|
|
338
|
+
if (hang == null || this.status !== 'starting' || this.pid == null || this.want !== 'up' || now - this.since < hang) return
|
|
339
|
+
this.hung = `did not answer its probe for ${Math.round((now - this.since) / 1000)}s`
|
|
340
|
+
this.spec.say?.(`WARN ${this.spec.id} (pid ${this.pid}) ${this.hung}: killed, it starts again`)
|
|
341
|
+
this.d.kill(this.pid, 'SIGTERM')
|
|
342
|
+
this.killAt = now + KILL_AFTER_MS
|
|
343
|
+
this.set('stopping', `${this.hung}, killing it`)
|
|
321
344
|
}
|
|
322
345
|
|
|
323
346
|
view(): ProcView {
|
package/src/home/via-hub.ts
CHANGED
|
@@ -17,6 +17,18 @@ import { daemonUp, daemonUrl, hubBase, WORKER_ENV } from './home-paths.ts'
|
|
|
17
17
|
import { homeWanted, startHome } from './home-start.ts'
|
|
18
18
|
import { RUN_EXIT, RUN_JOB } from './home-words.ts'
|
|
19
19
|
import { followJob, type FollowOpts } from '../core/job-follow.ts'
|
|
20
|
+
import { TRACE_ENV } from '../trace/tracer.ts'
|
|
21
|
+
import { FRESH_ENV } from '../ledger/store.ts'
|
|
22
|
+
import { LEDGER_ENV } from '../ledger/ledger.ts'
|
|
23
|
+
import { PARALLEL_ENV } from '../core/parallel.ts'
|
|
24
|
+
|
|
25
|
+
/** the env switches a gate reads, which a terminal may set for one run: the
|
|
26
|
+
* job the home starts runs with the daemon's env, so they travel with it */
|
|
27
|
+
export const FORWARDED_ENV = [TRACE_ENV, FRESH_ENV, LEDGER_ENV, PARALLEL_ENV] as const
|
|
28
|
+
|
|
29
|
+
/** the forwarded switches this env sets */
|
|
30
|
+
export const forwardedEnv = (env: Env = process.env): Record<string, string> =>
|
|
31
|
+
Object.fromEntries(FORWARDED_ENV.filter((k) => env[k] != null && env[k] !== '').map((k) => [k, env[k]!]))
|
|
20
32
|
|
|
21
33
|
type Env = Record<string, string | undefined>
|
|
22
34
|
const set = (v: string | undefined): boolean => v != null && v !== ''
|
|
@@ -46,10 +58,12 @@ export async function runViaHub(root: string, kind: 'ci' | 'check', opts: Record
|
|
|
46
58
|
}
|
|
47
59
|
// what the terminal knows and the hub does not: its tier and whether it is a terminal
|
|
48
60
|
const full = process.env.SHIPKIT_TEST_FULL === '1'
|
|
61
|
+
const env = forwardedEnv()
|
|
49
62
|
const sent = {
|
|
50
63
|
...opts,
|
|
51
64
|
...(full ? (kind === 'ci' ? { tests: opts.tests ?? 'full' } : { tier: opts.tier ?? 'full' }) : {}),
|
|
52
65
|
color: process.stdout.isTTY === true && process.env.NO_COLOR == null,
|
|
66
|
+
...(Object.keys(env).length > 0 ? { env } : {}),
|
|
53
67
|
}
|
|
54
68
|
// the home waits for a hub still loading before it answers
|
|
55
69
|
const post = (): Promise<Response> => fetch(`${daemonUrl()}${hubBase(name)}/api/run`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind, opts: sent }) })
|
package/src/hooks/run-hooks.ts
CHANGED
|
@@ -14,6 +14,11 @@ import { gitRoot } from '../core/git-root.ts'
|
|
|
14
14
|
import { spawnWithTimeout } from '../core/spawn-timeout.ts'
|
|
15
15
|
import type { RepoConfig } from '../repo-config.ts'
|
|
16
16
|
import { gitBin } from '../core/git-bin.ts'
|
|
17
|
+
import { discoverChecks } from '../core/discover-checks.ts'
|
|
18
|
+
import { CheckCtx } from '../core/check-ctx.ts'
|
|
19
|
+
import { runCheckBody, type CheckDef } from '../check.ts'
|
|
20
|
+
import { repoOff } from '../core/when.ts'
|
|
21
|
+
import { formatLine } from '../core/wire.ts'
|
|
17
22
|
|
|
18
23
|
export type HooksConfig = {
|
|
19
24
|
/** repo-specific commands run in the pre-commit gate, after the built-ins.
|
|
@@ -210,7 +215,7 @@ export function blockedStaged(staged: string[], config: RepoConfig): string[] {
|
|
|
210
215
|
const draftsOpen = (c: RepoConfig): boolean => c.visibility === 'private'
|
|
211
216
|
|
|
212
217
|
/** the pre-commit gate: built-in leak guard, then the repo's own commands.
|
|
213
|
-
* A commit takes ~1s (build/shipkit.md): the gate times itself, and going
|
|
218
|
+
* A commit takes ~1s (build/shipkit/commit-gates.md): the gate times itself, and going
|
|
214
219
|
* over budget WARNs naming the fix, so drift back to slow commits is loud. */
|
|
215
220
|
export async function runPreCommit(root: string, config: RepoConfig): Promise<GateOutcome[]> {
|
|
216
221
|
const started = performance.now()
|
|
@@ -273,6 +278,7 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
|
|
|
273
278
|
],
|
|
274
279
|
})
|
|
275
280
|
}
|
|
281
|
+
out.push(...(await runPreCommitChecks(root, config)))
|
|
276
282
|
for (const c of h.preCommit ?? []) {
|
|
277
283
|
const r = await spawnWithTimeout(c.cmd, { cwd: root, timeoutMs: 120_000 })
|
|
278
284
|
out.push({
|
|
@@ -282,15 +288,52 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
|
|
|
282
288
|
})
|
|
283
289
|
}
|
|
284
290
|
const elapsed = performance.now() - started
|
|
285
|
-
if (elapsed >
|
|
291
|
+
if (elapsed > HOOK_BUDGET_MS)
|
|
286
292
|
out.push({
|
|
287
293
|
id: 'hook-budget',
|
|
288
294
|
ok: true,
|
|
289
|
-
lines: [`WARN pre-commit took ${(elapsed / 1000).toFixed(1)}s (budget
|
|
295
|
+
lines: [`WARN pre-commit took ${(elapsed / 1000).toFixed(1)}s (budget ${HOOK_BUDGET_MS / 1000}s) — move slow preCommit commands to ci tasks, or a check off onPreCommit (build/shipkit/runs.md)`],
|
|
290
296
|
})
|
|
291
297
|
return out
|
|
292
298
|
}
|
|
293
299
|
|
|
300
|
+
/** the whole hook, boot included, past which it warns */
|
|
301
|
+
export const HOOK_BUDGET_MS = 1_000
|
|
302
|
+
|
|
303
|
+
/** the checks that declare `onPreCommit` and apply here, with their budget */
|
|
304
|
+
export type PreCommitCheck = { id: string; title: string; owner: string; budgetMs: number; run: NonNullable<CheckDef['run']>; repair: unknown }
|
|
305
|
+
|
|
306
|
+
export async function preCommitChecks(root: string, config: RepoConfig): Promise<PreCommitCheck[]> {
|
|
307
|
+
const out: PreCommitCheck[] = []
|
|
308
|
+
for (const u of await discoverChecks(root)) {
|
|
309
|
+
const p = u.def.runs.onPreCommit
|
|
310
|
+
if (p === false || u.def.run == null) continue
|
|
311
|
+
if (repoOff(config, u.id) != null) continue
|
|
312
|
+
if (typeof u.def.applies === 'function') {
|
|
313
|
+
try { if (u.def.applies({ root, config }) != null) continue } catch (e) { console.error(`WARN ${u.id} applies threw, so it applies: ${(e as Error).message}`) }
|
|
314
|
+
}
|
|
315
|
+
out.push({ id: u.id, title: u.title, owner: u.owner, budgetMs: p.budgetMs, run: u.def.run, repair: u.def.repair })
|
|
316
|
+
}
|
|
317
|
+
return out
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** every `onPreCommit` check, run RAW on the working tree: no ledger, no
|
|
321
|
+
* cache, no repair. A failure refuses the commit, a pass over its budget
|
|
322
|
+
* warns by name and never blocks on time */
|
|
323
|
+
async function runPreCommitChecks(root: string, config: RepoConfig): Promise<GateOutcome[]> {
|
|
324
|
+
const out: GateOutcome[] = []
|
|
325
|
+
for (const k of await preCommitChecks(root, config)) {
|
|
326
|
+
const c = CheckCtx.open({ root, config })
|
|
327
|
+
const t = performance.now()
|
|
328
|
+
await runCheckBody({ id: k.id, title: k.title, owner: k.owner, run: k.run, fix: k.repair }, c)
|
|
329
|
+
const ms = performance.now() - t
|
|
330
|
+
const lines = c.failed ? c.lines.map((l) => formatLine(l, false)) : [`ok: ${k.id} (${Math.round(ms)}ms)`]
|
|
331
|
+
if (ms > k.budgetMs) lines.push(`WARN ${k.id} took ${Math.round(ms)}ms at commit, over its ${k.budgetMs}ms onPreCommit budget — make it cheaper, or move it off onPreCommit`)
|
|
332
|
+
out.push({ id: k.id, ok: !c.failed, lines })
|
|
333
|
+
}
|
|
334
|
+
return out
|
|
335
|
+
}
|
|
336
|
+
|
|
294
337
|
/** the commit-msg gate: the shared message predicate, then the repo's own. */
|
|
295
338
|
export async function runCommitMsg(root: string, config: RepoConfig, messageFile: string): Promise<GateOutcome[]> {
|
|
296
339
|
const h = config.hooks ?? {}
|
|
@@ -339,7 +382,7 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
|
|
|
339
382
|
}
|
|
340
383
|
|
|
341
384
|
/** what blocks a commit here, the enumeration hand-rolled hooks never had. */
|
|
342
|
-
export function listGates(config: RepoConfig, root = process.cwd()): string[] {
|
|
385
|
+
export function listGates(config: RepoConfig, root = process.cwd(), checks: { id: string; budgetMs: number }[] = []): string[] {
|
|
343
386
|
const h = config.hooks ?? {}
|
|
344
387
|
const off = new Set(h.disable ?? [])
|
|
345
388
|
const kw = h.keywordFile ?? KEYWORD_FILE
|
|
@@ -354,6 +397,7 @@ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
|
|
|
354
397
|
`pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.shipkit/private/banned-keywords.txt${leakAllowState}`,
|
|
355
398
|
...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git${draftsOpen(config) && blockedStaged([COMFY_DRAFTS], config).length === 0 && h.blockedPaths!.some((p) => COMFY_DRAFTS.startsWith(p)) ? `, except ${COMFY_DRAFTS} (declared private)` : ''}`] : []),
|
|
356
399
|
`pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
|
|
400
|
+
...checks.map((c) => `pre-commit ${c.id} — the check, onPreCommit, budget ${c.budgetMs}ms`),
|
|
357
401
|
...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
|
|
358
402
|
off.has('commit-message')
|
|
359
403
|
? `commit-msg commit-message (DISABLED by config) — dash punctuation + French; ai-attribution stays on, never disabled`
|
package/src/init-repo.ts
CHANGED
|
@@ -100,7 +100,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
100
100
|
// its .gitignore policy block (same fixer as `check --fix SK004`),
|
|
101
101
|
// with the repo's own contributed lines honored
|
|
102
102
|
const cfg = await loadRepoConfig(root)
|
|
103
|
-
const g = await gitignorePolicy.
|
|
103
|
+
const g = await gitignorePolicy.repair!(CheckCtx.open({ root, config: cfg }))
|
|
104
104
|
p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
|
|
105
105
|
// the repos that need ONE WAY IN most are the ones that already exist,
|
|
106
106
|
// so a re-run REPAIRS rather than only seeding a fresh checkout
|
|
@@ -136,7 +136,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
136
136
|
}
|
|
137
137
|
// the .gitignore policy block (contract git policy) — same fixer as
|
|
138
138
|
// `shipkit check --fix SK004`, one code path
|
|
139
|
-
const g = await gitignorePolicy.
|
|
139
|
+
const g = await gitignorePolicy.repair!(CheckCtx.open({ root, config: defineRepo({ name }) }))
|
|
140
140
|
p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
|
|
141
141
|
await repairOneWayIn(root, defineRepo({ name }), p)
|
|
142
142
|
await furnishEditor(root, { name }, p)
|
|
@@ -194,7 +194,7 @@ async function furnishEditor(root: string, cfg: { name?: string; serve?: { port?
|
|
|
194
194
|
// a file SK017 cannot read is still the repo's own: it is never
|
|
195
195
|
// reported as missing, and it is never rewritten
|
|
196
196
|
let hub: { changed: boolean; summary: string }
|
|
197
|
-
try { hub = await vscodeHubTask.
|
|
197
|
+
try { hub = await vscodeHubTask.repair!(CheckCtx.open({ root, config: defineRepo({ name }) })) } catch (e) {
|
|
198
198
|
p('ok: .vscode/ is this repo\'s own, left untouched')
|
|
199
199
|
return void p(`WARN .vscode/tasks.json does not parse (${e instanceof Error ? e.message : String(e)}): the hub task gets no problem matcher until it does`)
|
|
200
200
|
}
|
|
@@ -208,14 +208,14 @@ async function furnishEditor(root: string, cfg: { name?: string; serve?: { port?
|
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
/** ONE WAY IN, both halves, through the SAME fixers `shipkit check --fix` runs
|
|
211
|
-
* (build/shipkit.md). Nothing here is a second implementation: rule SK001
|
|
211
|
+
* (build/shipkit/one-way-in.md). Nothing here is a second implementation: rule SK001
|
|
212
212
|
* routes the package.json scripts, rule SK013 writes the harness policy, and
|
|
213
213
|
* init just calls them so a wired repo is born enforced and a re-run repairs
|
|
214
214
|
* one that was not. The deny is a RESTRICTION, which is why it lands
|
|
215
215
|
* unconditionally where the scaffold's allow list stays opt in. */
|
|
216
216
|
async function repairOneWayIn(root: string, config: Parameters<typeof defineRepo>[0], p: (line: string) => void): Promise<void> {
|
|
217
|
-
const pkg = await packageJsonShape.
|
|
217
|
+
const pkg = await packageJsonShape.repair!(CheckCtx.open({ root, config }))
|
|
218
218
|
if (pkg.changed) p(`ok: package.json — ${pkg.summary}`)
|
|
219
|
-
const policy = await oneWayInPolicy.
|
|
219
|
+
const policy = await oneWayInPolicy.repair!(CheckCtx.open({ root, config }))
|
|
220
220
|
p(policy.changed ? `ok: ${policy.summary}` : `ok: .claude/settings.json already closes every raw runner`)
|
|
221
221
|
}
|