rvlib-shipkit 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +105 -0
- package/README.md +17 -17
- package/package.json +3 -2
- package/src/asks/asks-queue.ts +4 -2
- package/src/assets/kits/identity-kit.ts +15 -13
- package/src/assets/run-assets.ts +12 -2
- package/src/check.ts +73 -98
- package/src/checks/SK001-package-json-shape.check.ts +26 -4
- package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
- package/src/checks/SK003-claude-md-shape.check.ts +5 -1
- 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 +94 -74
- 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 +20 -6
- package/src/checks/SK015-nested-repo.check.ts +4 -2
- package/src/checks/SK016-legacy-check-shapes.check.ts +23 -16
- 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 +34 -9
- package/src/checks/SK028-readme.check.ts +10 -2
- package/src/checks/SK029-explicit-check-files.check.ts +95 -0
- package/src/cli/cmd_Asks.ts +5 -5
- package/src/cli/cmd_Assets.ts +7 -7
- package/src/cli/cmd_Bump.ts +12 -11
- 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 +10 -6
- package/src/cli/cmd_Fleet.ts +9 -7
- package/src/cli/cmd_GlobalProcesses.ts +116 -4
- package/src/cli/cmd_Home.ts +23 -17
- package/src/cli/cmd_Hooks.ts +6 -5
- package/src/cli/cmd_Init.ts +5 -4
- package/src/cli/cmd_Keys.ts +8 -7
- package/src/cli/cmd_Mv.ts +7 -6
- package/src/cli/cmd_Needs.ts +4 -3
- package/src/cli/cmd_Overview.ts +4 -3
- 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 +4 -3
- 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_Tui.ts +3 -2
- package/src/cli/cmd_When.ts +71 -0
- package/src/cli/cmd_Wiki.ts +4 -4
- package/src/cli.ts +7 -2
- package/src/collections/checks.ts +2 -0
- package/src/collections/collection-types.ts +24 -4
- package/src/collections/providers.ts +6 -2
- package/src/contracts/apply-contracts.ts +9 -8
- package/src/contracts/contract-types.ts +8 -2
- package/src/contracts/contracts-engine.ts +34 -13
- package/src/contracts/manifests/identity.contract.ts +6 -5
- package/src/contracts/manifests/social.contract.ts +4 -2
- package/src/contracts/manifests/steam.contract.ts +7 -3
- package/src/contracts/web-surface.ts +59 -0
- package/src/core/budget.ts +19 -9
- package/src/core/canonical-scripts.ts +1 -1
- package/src/core/check-runner.ts +63 -25
- package/src/core/config-rewrite.ts +37 -24
- package/src/core/discover-checks.ts +31 -7
- package/src/core/layout.ts +2 -0
- package/src/core/legacy-shapes.ts +39 -10
- package/src/core/motherhome.ts +88 -66
- 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/retention.ts +0 -14
- package/src/core/run-check.ts +14 -14
- package/src/core/run-ci.ts +11 -7
- package/src/core/run-fix.ts +30 -23
- package/src/core/run-fixers.ts +42 -5
- package/src/core/runs-matrix.ts +179 -0
- package/src/core/runs.ts +242 -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 +34 -9
- package/src/fleet/scan-fleet.ts +20 -4
- package/src/fleet/update-fleet.ts +6 -6
- package/src/home/activity-shape.ts +4 -1
- package/src/home/activity.ts +22 -15
- package/src/home/gui-app/ShipkitGui.swift +144 -25
- package/src/home/gui-app.ts +93 -30
- package/src/home/home-launchd.ts +30 -6
- package/src/home/home-routes.ts +14 -2
- package/src/home/home-start.ts +33 -5
- package/src/home/hubs.ts +5 -5
- package/src/home/machine-stores.ts +4 -2
- package/src/home/open-app.ts +23 -10
- package/src/home/pm-config.ts +15 -11
- package/src/home/pm-inputs.ts +76 -0
- package/src/home/pm.ts +11 -3
- package/src/home/process-rows.ts +23 -6
- package/src/home/resources.ts +67 -15
- package/src/home/run-daemon.ts +6 -1
- package/src/home/run-pm.ts +30 -5
- package/src/home/serve-web.ts +2 -2
- package/src/home/supervise.ts +24 -1
- package/src/home/widgets.ts +32 -3
- package/src/hooks/commit-message.ts +2 -3
- package/src/hooks/run-hooks.ts +60 -6
- package/src/init-repo.ts +6 -6
- package/src/ledger/gate-checks.ts +115 -61
- package/src/ledger/git-key.ts +5 -1
- package/src/ledger/ledger.ts +5 -5
- package/src/ledger/store.ts +11 -0
- package/src/ledger/toolchain-identity.ts +45 -27
- package/src/ledger/world-cache.ts +17 -15
- package/src/plan/repo-source.ts +18 -39
- package/src/release/run-release.ts +5 -0
- package/src/repo-config.ts +36 -56
- 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 +4 -4
- package/src/serve/hub-app/admin-view.tsx +13 -11
- 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 +16 -36
- package/src/serve/hub-app/col-tables-view.tsx +131 -0
- package/src/serve/hub-app/col-tables.ts +115 -0
- package/src/serve/hub-app/cols-svc.ts +33 -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 +7 -1
- 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 +15 -3
- package/src/serve/hub-app/home-strip.css +69 -19
- package/src/serve/hub-app/home-strip.tsx +192 -118
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +21 -5
- package/src/serve/hub-app/hub-types.ts +11 -5
- package/src/serve/hub-app/hub.css +88 -44
- package/src/serve/hub-app/icons.tsx +15 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/keys.ts +11 -8
- package/src/serve/hub-app/live-svc.ts +3 -2
- package/src/serve/hub-app/machine-st.ts +34 -49
- package/src/serve/hub-app/palette-st.ts +5 -5
- package/src/serve/hub-app/palette-view.tsx +1 -1
- 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 +38 -0
- package/src/serve/hub-app/project-list.tsx +3 -3
- package/src/serve/hub-app/projects-svc.ts +15 -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 +117 -0
- package/src/serve/hub-app/repo-st.ts +47 -10
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/sidebar.tsx +12 -37
- package/src/serve/hub-app/timeline-svc.ts +3 -2
- package/src/serve/hub-app/timeline-view.tsx +14 -11
- package/src/serve/hub-app/tree-build.ts +149 -155
- package/src/serve/hub-app/tree-row.tsx +39 -9
- package/src/serve/hub-app/tree-svc.ts +70 -49
- 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 +3 -16
- package/src/serve/project-gutter.ts +1 -2
- package/src/serve/run-serve.ts +1 -1
- package/src/serve/serve-hub.ts +4 -14
- package/src/serve/warm-cell.ts +49 -16
- package/src/suite/judge-suites.ts +42 -17
- package/src/suite/suite-cli.ts +5 -8
- package/src/wiki/lint-md.ts +2 -0
- package/src/wiki/scan-md.ts +37 -10
- 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
package/src/home/gui-app.ts
CHANGED
|
@@ -1,90 +1,122 @@
|
|
|
1
|
-
// the macOS
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// the macOS apps shipkit opens web pages in: ONE source, ShipkitGui.swift,
|
|
2
|
+
// built with swiftc into a bundle per page it serves. `shipkit gui` is that
|
|
3
|
+
// app pointed at the home; each global process with a url gets its own, named
|
|
4
|
+
// and iconed after it, in its repo's `.shipkit/wrapper/`. The bundle's
|
|
5
|
+
// Info.plist carries what differs (name, bundle id, start url), so a fix to
|
|
6
|
+
// the window (find, the inspector, zoom) lands in every app at its next
|
|
7
|
+
// build. Rebuilt only when the source, the icon or the start url changed (a
|
|
8
|
+
// stamp in the bundle). No compiler, or not macOS: the caller falls back.
|
|
6
9
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
7
10
|
import { homedir } from 'os'
|
|
8
11
|
import { dirname, join } from 'path'
|
|
9
12
|
import { fileURLToPath } from 'url'
|
|
13
|
+
import { LAYOUT } from '../core/layout.ts'
|
|
10
14
|
|
|
11
15
|
const SOURCE = fileURLToPath(new URL('./gui-app/ShipkitGui.swift', import.meta.url))
|
|
12
16
|
|
|
13
17
|
export const BUNDLE_ID = 'dev.shipkit.gui'
|
|
14
18
|
export const guiAppPath = (): string => process.env.SHIPKIT_GUI_APP ?? join(homedir(), 'Applications', 'Shipkit.app')
|
|
15
19
|
|
|
16
|
-
/** the icon the app wears: the
|
|
20
|
+
/** the icon the app wears: the repo's own mark, largest first. Only a
|
|
17
21
|
* png can become an icns */
|
|
18
|
-
export const APP_ICON_FILES = [
|
|
22
|
+
export const APP_ICON_FILES = [`${LAYOUT.identity}/icon.png`, 'public/logo.png', 'public/favicon.png']
|
|
19
23
|
|
|
20
24
|
export function appIconOf(root: string): string | null {
|
|
21
25
|
for (const f of APP_ICON_FILES) if (existsSync(join(root, f))) return join(root, f)
|
|
22
26
|
return null
|
|
23
27
|
}
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
/** what one app bundle is: the rest comes from the shared source */
|
|
30
|
+
export type AppSpec = {
|
|
31
|
+
/** CFBundleName: the dock, cmd-tab and the app menu say it */
|
|
32
|
+
name: string
|
|
33
|
+
bundleId: string
|
|
34
|
+
/** the page a launch with no url opens; null opens the home */
|
|
35
|
+
url: string | null
|
|
36
|
+
icon: string | null
|
|
37
|
+
/** where the bundle lands */
|
|
38
|
+
app: string
|
|
39
|
+
version: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const xml = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
43
|
+
|
|
44
|
+
/** plain http to a host that is not local needs its own ATS exception; the
|
|
45
|
+
* local network is allowed to every app */
|
|
46
|
+
function atsOf(url: string | null): string {
|
|
47
|
+
let u: URL | null = null
|
|
48
|
+
try { u = url == null ? null : new URL(url) } catch { /* not a url: the local network only */ }
|
|
49
|
+
const host = u?.protocol === 'http:' ? u.hostname : null
|
|
50
|
+
const local = host == null || host === 'localhost' || /^[\d.]+$/.test(host) || !host.includes('.') || host.endsWith('.local')
|
|
51
|
+
const exception = local ? '' : `<key>NSExceptionDomains</key><dict><key>${xml(host)}</key><dict><key>NSExceptionAllowsInsecureHTTPLoads</key><true/></dict></dict>`
|
|
52
|
+
return `<dict><key>NSAllowsLocalNetworking</key><true/>${exception}</dict>`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function infoPlist(o: Omit<AppSpec, 'app' | 'icon'>): string {
|
|
26
56
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
27
57
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
28
58
|
<plist version="1.0">
|
|
29
59
|
<dict>
|
|
30
|
-
\t<key>CFBundleName</key><string
|
|
31
|
-
\t<key>CFBundleDisplayName</key><string
|
|
32
|
-
\t<key>CFBundleIdentifier</key><string>${
|
|
60
|
+
\t<key>CFBundleName</key><string>${xml(o.name)}</string>
|
|
61
|
+
\t<key>CFBundleDisplayName</key><string>${xml(o.name)}</string>
|
|
62
|
+
\t<key>CFBundleIdentifier</key><string>${xml(o.bundleId)}</string>
|
|
33
63
|
\t<key>CFBundleExecutable</key><string>shipkit-gui</string>
|
|
34
64
|
\t<key>CFBundleIconFile</key><string>AppIcon</string>
|
|
35
65
|
\t<key>CFBundlePackageType</key><string>APPL</string>
|
|
36
|
-
\t<key>CFBundleShortVersionString</key><string>${o.version}</string>
|
|
37
|
-
\t<key>CFBundleVersion</key><string>${o.version}</string>
|
|
38
|
-
\t<key>LSMinimumSystemVersion</key><string>12.0</string>
|
|
66
|
+
\t<key>CFBundleShortVersionString</key><string>${xml(o.version)}</string>
|
|
67
|
+
\t<key>CFBundleVersion</key><string>${xml(o.version)}</string>
|
|
68
|
+
${o.url == null ? '' : `\t<key>ShipkitStartURL</key><string>${xml(o.url)}</string>\n`}\t<key>LSMinimumSystemVersion</key><string>12.0</string>
|
|
39
69
|
\t<key>NSHighResolutionCapable</key><true/>
|
|
40
|
-
\t<key>NSAppTransportSecurity</key
|
|
70
|
+
\t<key>NSAppTransportSecurity</key>${atsOf(o.url)}
|
|
41
71
|
</dict>
|
|
42
72
|
</plist>
|
|
43
73
|
`
|
|
44
74
|
}
|
|
45
75
|
|
|
46
|
-
type Run = (cmd: string[]) => { code: number; err: string }
|
|
76
|
+
export type Run = (cmd: string[]) => { code: number; err: string }
|
|
47
77
|
const runSync: Run = (cmd) => {
|
|
48
78
|
const p = Bun.spawnSync(cmd, { stdout: 'ignore', stderr: 'pipe' })
|
|
49
79
|
return { code: p.exitCode ?? 1, err: p.stderr.toString().trim() }
|
|
50
80
|
}
|
|
51
81
|
|
|
52
|
-
/** the stamp a built bundle carries: what it was built from
|
|
53
|
-
|
|
54
|
-
|
|
82
|
+
/** the stamp a built bundle carries: what it was built from. The version is
|
|
83
|
+
* left out, so a shipkit release rebuilds nothing */
|
|
84
|
+
export function stampOf(source: string, icon: string | null, identity = ''): string {
|
|
85
|
+
const h = new Bun.CryptoHasher('sha256').update(source).update(identity)
|
|
55
86
|
if (icon != null) h.update(readFileSync(icon))
|
|
56
87
|
return h.digest('hex').slice(0, 16)
|
|
57
88
|
}
|
|
58
89
|
|
|
59
|
-
/**
|
|
90
|
+
/** one app, built or rebuilt when stale. Throws with the step that failed.
|
|
60
91
|
* `rebuilt` tells the caller a running copy is now the old one */
|
|
61
|
-
export function
|
|
62
|
-
const app
|
|
92
|
+
export function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {}): { app: string; rebuilt: boolean } {
|
|
93
|
+
const { app, icon } = spec
|
|
63
94
|
const run = o.run ?? runSync
|
|
64
|
-
const
|
|
65
|
-
const stamp = stampOf(readFileSync(
|
|
95
|
+
const source = o.source ?? SOURCE
|
|
96
|
+
const stamp = stampOf(readFileSync(source, 'utf8'), icon, JSON.stringify([spec.name, spec.bundleId, spec.url]))
|
|
66
97
|
const stampFile = join(app, 'Contents', 'Resources', 'stamp')
|
|
67
98
|
if (existsSync(stampFile) && readFileSync(stampFile, 'utf8') === stamp) return { app, rebuilt: false }
|
|
68
99
|
const tmp = `${app}.building-${process.pid}`
|
|
100
|
+
const fail = (msg: string): never => { rmSync(tmp, { recursive: true, force: true }); throw new Error(msg) }
|
|
69
101
|
rmSync(tmp, { recursive: true, force: true })
|
|
70
102
|
const res = join(tmp, 'Contents', 'Resources')
|
|
71
103
|
mkdirSync(join(tmp, 'Contents', 'MacOS'), { recursive: true })
|
|
72
104
|
mkdirSync(res, { recursive: true })
|
|
73
|
-
writeFileSync(join(tmp, 'Contents', 'Info.plist'), infoPlist(
|
|
74
|
-
const built = run(['swiftc', '-O', '-o', join(tmp, 'Contents', 'MacOS', 'shipkit-gui'),
|
|
75
|
-
if (built.code !== 0)
|
|
105
|
+
writeFileSync(join(tmp, 'Contents', 'Info.plist'), infoPlist(spec))
|
|
106
|
+
const built = run(['swiftc', '-O', '-o', join(tmp, 'Contents', 'MacOS', 'shipkit-gui'), source])
|
|
107
|
+
if (built.code !== 0) fail(`swiftc could not build ${spec.name}.app: ${built.err.slice(0, 400)}`)
|
|
76
108
|
if (icon != null) {
|
|
77
109
|
const set = join(res, 'AppIcon.iconset')
|
|
78
110
|
mkdirSync(set, { recursive: true })
|
|
79
111
|
for (const n of [16, 32, 128, 256, 512]) {
|
|
80
112
|
for (const [scale, px] of [['', n], ['@2x', n * 2]] as const) {
|
|
81
|
-
const r = run(['sips', '-z', String(px), String(px), icon, '--out', join(set, `icon_${n}x${n}${scale}.png`)])
|
|
82
|
-
if (r.code !== 0)
|
|
113
|
+
const r = run(['sips', '-s', 'format', 'png', '-z', String(px), String(px), icon, '--out', join(set, `icon_${n}x${n}${scale}.png`)])
|
|
114
|
+
if (r.code !== 0) fail(`sips could not size the icon ${icon}: ${r.err.slice(0, 200)}`)
|
|
83
115
|
}
|
|
84
116
|
}
|
|
85
117
|
const r = run(['iconutil', '-c', 'icns', set, '-o', join(res, 'AppIcon.icns')])
|
|
86
118
|
rmSync(set, { recursive: true, force: true })
|
|
87
|
-
if (r.code !== 0)
|
|
119
|
+
if (r.code !== 0) fail(`iconutil could not build the icon: ${r.err.slice(0, 200)}`)
|
|
88
120
|
}
|
|
89
121
|
writeFileSync(join(res, 'stamp'), stamp)
|
|
90
122
|
mkdirSync(dirname(app), { recursive: true })
|
|
@@ -95,5 +127,36 @@ export function ensureGuiApp(o: { root: string; version: string; app?: string; r
|
|
|
95
127
|
return { app, rebuilt: true }
|
|
96
128
|
}
|
|
97
129
|
|
|
130
|
+
/** 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 })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** what a global process gives its app */
|
|
136
|
+
export type WrapperOf = { id: string; label: string; url: string | null; icon: string | null; root: string }
|
|
137
|
+
|
|
138
|
+
/** the image icons sips reads; an emoji or an svg leaves the repo's mark */
|
|
139
|
+
const RASTER = /\.(png|jpe?g|gif|webp|bmp|tiff?)$/i
|
|
140
|
+
|
|
141
|
+
export const wrapperBundleId = (id: string): string => `dev.shipkit.app.${id.replace(/[^A-Za-z0-9-]+/g, '-')}`
|
|
142
|
+
export const wrapperPath = (root: string, id: string): string => join(root, LAYOUT.wrapper, `${id.replace(/[^A-Za-z0-9._-]+/g, '-')}.app`)
|
|
143
|
+
|
|
144
|
+
/** the spec of a process's own app: its label, its declared image icon or
|
|
145
|
+
* else its repo's mark, in its repo's `.shipkit/wrapper/`. Throws for a
|
|
146
|
+
* process with no url: there is no page to wrap */
|
|
147
|
+
export function wrapperSpec(d: WrapperOf, version: string): AppSpec {
|
|
148
|
+
if (d.url == null) throw new Error(`${d.id} declares no url: there is no page to open as an app`)
|
|
149
|
+
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 }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const ensureWrapperApp = (d: WrapperOf, o: { version: string; run?: Run }): { app: string; rebuilt: boolean; bundleId: string } => {
|
|
154
|
+
const spec = wrapperSpec(d, o.version)
|
|
155
|
+
return { ...ensureApp(spec, { run: o.run }), bundleId: spec.bundleId }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** quits a running copy of an app, and launches nothing when none runs */
|
|
159
|
+
export const quitCmd = (bundleId: string): string[] => ['osascript', '-e', `if application id "${bundleId}" is running then quit application id "${bundleId}"`]
|
|
160
|
+
|
|
98
161
|
/** true when this machine can build the app */
|
|
99
162
|
export const canBuildGui = (platform = process.platform): boolean => platform === 'darwin' && Bun.which('swiftc') != null && Bun.which('iconutil') != null
|
package/src/home/home-launchd.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// dies. macOS only; launchd is the one supervisor of the one process that must
|
|
3
3
|
// always run, and pm supervises everything else (the daemon, the web, the
|
|
4
4
|
// machine resources).
|
|
5
|
-
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
6
6
|
import { homedir } from 'os'
|
|
7
7
|
import { join } from 'path'
|
|
8
8
|
import { homeDir, pmLog } from './home-paths.ts'
|
|
@@ -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>
|
|
@@ -55,7 +60,15 @@ ${args}
|
|
|
55
60
|
`
|
|
56
61
|
}
|
|
57
62
|
|
|
58
|
-
|
|
63
|
+
/** the house repo the installed pm runs from, read back from its agent: the
|
|
64
|
+
* one answer to "which house" that does not depend on cwd */
|
|
65
|
+
export function installedHouseRoot(file = plistPath()): string | null {
|
|
66
|
+
if (!existsSync(file)) return null
|
|
67
|
+
const m = /<key>WorkingDirectory<\/key>\s*<string>([^<]*)<\/string>/.exec(readFileSync(file, 'utf8'))
|
|
68
|
+
return m == null ? null : m[1]!.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type Launchctl =(args: string[]) => Promise<{ code: number; out: string }>
|
|
59
72
|
|
|
60
73
|
const launchctl: Launchctl = async (args) => {
|
|
61
74
|
const p = Bun.spawn(['launchctl', ...args], { stdout: 'pipe', stderr: 'pipe' })
|
|
@@ -65,7 +78,13 @@ const launchctl: Launchctl = async (args) => {
|
|
|
65
78
|
|
|
66
79
|
const domain = (): string => `gui/${process.getuid?.() ?? 501}`
|
|
67
80
|
|
|
68
|
-
|
|
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> {
|
|
69
88
|
if (process.platform !== 'darwin') {
|
|
70
89
|
o.print(`FAIL: the pm LaunchAgent is macOS only. Run \`shipkit pm\` under your own supervisor on ${process.platform}`)
|
|
71
90
|
return 1
|
|
@@ -81,9 +100,14 @@ export async function installHome(o: { root: string; cli: string; print: (l: str
|
|
|
81
100
|
await run(['bootout', `${domain()}/${LABEL}`])
|
|
82
101
|
await run(['bootout', `${domain()}/${LEGACY_LABEL}`])
|
|
83
102
|
rmSync(plistPath(homedir(), LEGACY_LABEL), { force: true })
|
|
84
|
-
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
|
+
}
|
|
85
109
|
if (r.code !== 0) {
|
|
86
|
-
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`)
|
|
87
111
|
return 1
|
|
88
112
|
}
|
|
89
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,13 +176,22 @@ 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() ?? [] }
|
|
179
190
|
return Response.json(view, { headers: { 'cache-control': 'no-store' } })
|
|
180
191
|
}
|
|
181
192
|
if (path === '/api/home/widgets.json') {
|
|
182
|
-
|
|
193
|
+
if (d.widgets != null && repos.length === 0) await refresh()
|
|
194
|
+
const out: WidgetsAnswer = { widgets: (await d.widgets?.look(repos.map((r) => ({ name: r.name, path: r.path })))) ?? [], errors: d.widgets?.errors ?? [] }
|
|
183
195
|
return Response.json(out)
|
|
184
196
|
}
|
|
185
197
|
if (path === '/api/home.json') {
|
package/src/home/home-start.ts
CHANGED
|
@@ -6,8 +6,8 @@ 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 { BUNDLE_ID, canBuildGui, ensureGuiApp } from './gui-app.ts'
|
|
10
|
-
import { LABEL, plistPath } from './home-launchd.ts'
|
|
9
|
+
import { BUNDLE_ID, canBuildGui, ensureGuiApp, ensureWrapperApp, quitCmd, type WrapperOf } from './gui-app.ts'
|
|
10
|
+
import { installedHouseRoot, LABEL, plistPath } from './home-launchd.ts'
|
|
11
11
|
import { childLog, daemonPort, daemonUp, homeDir, homePort, homeUp, pmLog } from './home-paths.ts'
|
|
12
12
|
|
|
13
13
|
/** the pm LaunchAgent is installed: on this machine no hub runs outside the home */
|
|
@@ -65,7 +65,11 @@ export function houseRootOf(cwd: string): string | null {
|
|
|
65
65
|
return gitRoot(dirname(index))?.top ?? null
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
/** the house the shipkit app wears the mark of: the one cwd sits in, else the
|
|
69
|
+
* one the installed pm runs from. Never cwd itself, which carries no mark */
|
|
70
|
+
export const guiRoot = (cwd: string, plist = plistPath()): string | null => houseRootOf(cwd) ?? installedHouseRoot(plist)
|
|
71
|
+
|
|
72
|
+
export const CHROME =process.env.CHROME ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
|
69
73
|
/** the window's own Chrome profile: its own dock icon and cookies, apart from the everyday browser */
|
|
70
74
|
export const guiProfile = (): string => join(homeDir(), 'gui-profile')
|
|
71
75
|
const GUI_DISK_CACHE = 32 * 1024 * 1024
|
|
@@ -87,9 +91,9 @@ export function windowCmd(url: string, o: { chrome?: string; platform?: string }
|
|
|
87
91
|
export function openWindow(url: string, o: { root?: string | null; version?: string; warn?: (l: string) => void } = {}): string[] {
|
|
88
92
|
if (canBuildGui()) {
|
|
89
93
|
try {
|
|
90
|
-
const { app, rebuilt } = ensureGuiApp({ root: o.root ??
|
|
94
|
+
const { app, rebuilt } = ensureGuiApp({ root: o.root ?? guiRoot(process.cwd()),version: o.version ?? '0' })
|
|
91
95
|
// a running copy is the old build: it quits, and the new one opens the window
|
|
92
|
-
if (rebuilt) Bun.spawnSync(
|
|
96
|
+
if (rebuilt) Bun.spawnSync(quitCmd(BUNDLE_ID), { stdout: 'ignore', stderr: 'ignore' })
|
|
93
97
|
const cmd = ['open', '-a', app, url]
|
|
94
98
|
Bun.spawn(cmd, { stdio: ['ignore', 'ignore', 'ignore'] })
|
|
95
99
|
return cmd
|
|
@@ -97,6 +101,30 @@ export function openWindow(url: string, o: { root?: string | null; version?: str
|
|
|
97
101
|
o.warn?.(`WARN the shipkit app could not be built, so this window is a Chrome one: ${(e as Error).message}`)
|
|
98
102
|
}
|
|
99
103
|
}
|
|
104
|
+
return chromeWindow(url)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** a global process's own app: built on first use in its repo's
|
|
108
|
+
* `.shipkit/wrapper/`, then opened with no url, so a running copy comes to
|
|
109
|
+
* the front instead of stacking windows. Where no app can be built, a
|
|
110
|
+
* Chrome app window on its url */
|
|
111
|
+
export function openProcessApp(d: WrapperOf, o: { version: string; warn?: (l: string) => void }): string[] {
|
|
112
|
+
if (d.url == null) throw new Error(`${d.id} declares no url: there is no page to open as an app`)
|
|
113
|
+
if (canBuildGui()) {
|
|
114
|
+
try {
|
|
115
|
+
const { app, rebuilt, bundleId } = ensureWrapperApp(d, { version: o.version })
|
|
116
|
+
if (rebuilt) Bun.spawnSync(quitCmd(bundleId), { stdout: 'ignore', stderr: 'ignore' })
|
|
117
|
+
const cmd = ['open', '-a', app]
|
|
118
|
+
Bun.spawnSync(cmd, { stdout: 'ignore', stderr: 'ignore' })
|
|
119
|
+
return cmd
|
|
120
|
+
} catch (e) {
|
|
121
|
+
o.warn?.(`WARN the app for ${d.id} could not be built, so this window is a Chrome one: ${(e as Error).message}`)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return chromeWindow(d.url)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function chromeWindow(url: string): string[] {
|
|
100
128
|
mkdirSync(guiProfile(), { recursive: true })
|
|
101
129
|
const cmd = windowCmd(url)
|
|
102
130
|
// detached: the window outlives the command that opened it
|
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
|
}
|
|
@@ -91,8 +91,9 @@ export const BOOT_SWEEP_DELAY_MS = 10_000
|
|
|
91
91
|
export type Sweeper = { stop: () => void; once: () => Promise<void> }
|
|
92
92
|
|
|
93
93
|
/** the daemon's one retention sweep: every repo it loads, then the machine,
|
|
94
|
-
* on boot and every hour. A
|
|
95
|
-
|
|
94
|
+
* then `also` (the machine's batched writes), on boot and every hour. A
|
|
95
|
+
* store whose sweep throws is one printed line */
|
|
96
|
+
export function startSweeps(o: { roots: () => string[]; dir: string; printErr: (l: string) => void; everyMs?: number; bootDelayMs?: number; now?: () => number; also?: (now: number) => void }): Sweeper {
|
|
96
97
|
let running = false
|
|
97
98
|
const once = async (): Promise<void> => {
|
|
98
99
|
if (running) return
|
|
@@ -103,6 +104,7 @@ export function startSweeps(o: { roots: () => string[]; dir: string; printErr: (
|
|
|
103
104
|
for (const r of await sweepStores(repoStores(root), now)) if (r.error != null) o.printErr(`WARN retention sweep of ${r.id} in ${root} failed: ${r.error}`)
|
|
104
105
|
}
|
|
105
106
|
for (const r of await sweepStores(machineStores(o.dir), now)) if (r.error != null) o.printErr(`WARN retention sweep of ${r.id} failed: ${r.error}`)
|
|
107
|
+
o.also?.(now)
|
|
106
108
|
} finally {
|
|
107
109
|
running = false
|
|
108
110
|
}
|
package/src/home/open-app.ts
CHANGED
|
@@ -1,16 +1,29 @@
|
|
|
1
|
-
// opens one shipkit app window
|
|
2
|
-
//
|
|
1
|
+
// opens one shipkit app window, in its own short process, so pm never loads
|
|
2
|
+
// what building an app needs:
|
|
3
3
|
//
|
|
4
|
-
// bun open-app.ts <url>
|
|
4
|
+
// bun open-app.ts <url> one more shipkit window on a url
|
|
5
|
+
// bun open-app.ts --process <json> a global process's own app (WrapperOf)
|
|
5
6
|
import { LIB_VERSION } from '../core/lib-version.ts'
|
|
6
|
-
import {
|
|
7
|
+
import type { WrapperOf } from './gui-app.ts'
|
|
8
|
+
import { openProcessApp, openWindow } from './home-start.ts'
|
|
7
9
|
|
|
8
10
|
if (import.meta.main) {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
process
|
|
11
|
+
const [first, second] = process.argv.slice(2)
|
|
12
|
+
const warn = (l: string): void => console.error(l)
|
|
13
|
+
try {
|
|
14
|
+
if (first === '--process' && second != null) {
|
|
15
|
+
const d = JSON.parse(second) as WrapperOf
|
|
16
|
+
const cmd = openProcessApp(d, { version: LIB_VERSION, warn })
|
|
17
|
+
console.log(`shipkit: opened ${d.id} as its own app: ${cmd.join(' ')}`)
|
|
18
|
+
} else if (first != null && first !== '--process') {
|
|
19
|
+
const cmd = openWindow(first, { version: LIB_VERSION, warn })
|
|
20
|
+
console.log(`shipkit: opened ${first} as an app window: ${cmd.join(' ')}`)
|
|
21
|
+
} else {
|
|
22
|
+
console.error('usage: bun open-app.ts <url> | --process <json>')
|
|
23
|
+
process.exit(2)
|
|
24
|
+
}
|
|
25
|
+
} catch (e) {
|
|
26
|
+
console.error(`shipkit: could not open the app: ${(e as Error).message}`)
|
|
27
|
+
process.exit(1)
|
|
13
28
|
}
|
|
14
|
-
const cmd = openWindow(url, { version: LIB_VERSION, warn: (l) => console.error(l) })
|
|
15
|
-
console.log(`shipkit: opened ${url} as an app window: ${cmd.join(' ')}`)
|
|
16
29
|
}
|
package/src/home/pm-config.ts
CHANGED
|
@@ -2,18 +2,14 @@
|
|
|
2
2
|
// and the global processes every house repo declares in its `.shipkit.ts`
|
|
3
3
|
// files), read in a short child process so pm itself never imports a repo
|
|
4
4
|
// file. That graph would make pm large and reload it on every edit there.
|
|
5
|
+
// The answer names the paths it read (`inputs`) and their stamp, so pm runs
|
|
6
|
+
// the child again only once a stat of one of them moved.
|
|
5
7
|
//
|
|
6
|
-
// bun pm-config.ts <root> prints
|
|
8
|
+
// bun pm-config.ts <root> prints a PmConfig as json on stdout
|
|
7
9
|
import { homedir } from 'os'
|
|
8
|
-
import type
|
|
10
|
+
import { houseInputs, stampOf, type PmConfig } from './pm-inputs.ts'
|
|
9
11
|
|
|
10
|
-
export type PmConfig
|
|
11
|
-
/** the house wiki index the repo includes, null when it includes none */
|
|
12
|
-
index: string | null
|
|
13
|
-
processes: GlobalProcessDecl[]
|
|
14
|
-
/** the unit files that would not load */
|
|
15
|
-
errors: string[]
|
|
16
|
-
}
|
|
12
|
+
export type { PmConfig }
|
|
17
13
|
|
|
18
14
|
if (import.meta.main) {
|
|
19
15
|
const root = process.argv[2]
|
|
@@ -22,14 +18,22 @@ if (import.meta.main) {
|
|
|
22
18
|
process.exit(2)
|
|
23
19
|
}
|
|
24
20
|
const { houseRepos } = await import('../serve/project-gutter.ts')
|
|
21
|
+
const { repoUnitFiles } = await import('../core/discover-checks.ts')
|
|
25
22
|
const house = houseRepos(root, homedir())
|
|
26
|
-
const
|
|
23
|
+
const repos = house.index == null ? [] : house.repos
|
|
24
|
+
const files = new Map<string, string[]>()
|
|
25
|
+
const inputs = houseInputs(house.home, house.index, repos.map((r) => r.path), (r, dirs) => {
|
|
26
|
+
const f = repoUnitFiles(r, dirs)
|
|
27
|
+
files.set(r, f)
|
|
28
|
+
return f
|
|
29
|
+
})
|
|
30
|
+
const out: PmConfig = { index: house.index, processes: [], errors: [], inputs, stamp: stampOf(inputs) }
|
|
27
31
|
if (house.index != null) {
|
|
28
32
|
// a unit file imports the lib by name: this copy, like the daemon's
|
|
29
33
|
const { useOneLib } = await import('../core/one-lib.ts')
|
|
30
34
|
const { scanGlobalUnits } = await import('./global-units.ts')
|
|
31
35
|
useOneLib()
|
|
32
|
-
const u = await scanGlobalUnits(
|
|
36
|
+
const u = await scanGlobalUnits(repos.map((r) => ({ name: r.name, path: r.path })), { files: (r) => files.get(r) ?? [] })
|
|
33
37
|
out.processes = u.processes
|
|
34
38
|
out.errors = u.errors
|
|
35
39
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// what pm stamps to know whether the house side moved, without running the
|
|
2
|
+
// child that reads it (pm-config.ts): the answer's shape, the paths it
|
|
3
|
+
// depends on, and their stamp. Imports nothing a repo file does, so pm's own
|
|
4
|
+
// graph stays small.
|
|
5
|
+
import { readdirSync, statSync } from 'fs'
|
|
6
|
+
import { dirname, join } from 'path'
|
|
7
|
+
import { UNIT_SUFFIX } from '../core/check-file-names.ts'
|
|
8
|
+
import type { GlobalProcessDecl } from './global-units.ts'
|
|
9
|
+
|
|
10
|
+
export type PmConfig = {
|
|
11
|
+
/** the house wiki index the repo includes, null when it includes none */
|
|
12
|
+
index: string | null
|
|
13
|
+
processes: GlobalProcessDecl[]
|
|
14
|
+
/** the unit files that would not load */
|
|
15
|
+
errors: string[]
|
|
16
|
+
/** every path whose stat can change this answer (houseInputs) */
|
|
17
|
+
inputs: string[]
|
|
18
|
+
/** stampOf(inputs), taken before the unit files were imported */
|
|
19
|
+
stamp: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** the files a repo carries its includes in. Here, not in project-gutter.ts
|
|
23
|
+
* which reads them, so pm stamps them without loading that graph */
|
|
24
|
+
export const CLAUDE_FILES = ['CLAUDE.md', 'CLAUDE.local.md']
|
|
25
|
+
|
|
26
|
+
/** what the answer depends on, as paths to stat: the dir holding the repos
|
|
27
|
+
* (a repo added or removed), every sibling's CLAUDE files (a repo starting
|
|
28
|
+
* to include the house), the index, each house repo's config (archived),
|
|
29
|
+
* and every dir and unit file the unit walk read. A helper a unit file
|
|
30
|
+
* imports is not in it: an edit there shows once the unit file moves */
|
|
31
|
+
export function houseInputs(home: string, index: string | null, repos: string[], unitFiles: (root: string, dirs: string[]) => string[]): string[] {
|
|
32
|
+
const parent = dirname(home)
|
|
33
|
+
const out = [parent]
|
|
34
|
+
let names: string[] = []
|
|
35
|
+
try { names = readdirSync(parent) } catch { /* the stat of parent says when it appears */ }
|
|
36
|
+
for (const n of names.sort()) if (!n.startsWith('.')) for (const f of CLAUDE_FILES) out.push(join(parent, n, f))
|
|
37
|
+
if (index != null) out.push(index)
|
|
38
|
+
for (const r of repos) {
|
|
39
|
+
out.push(join(r, 'repo.config.ts'))
|
|
40
|
+
const dirs: string[] = []
|
|
41
|
+
const files = unitFiles(r, dirs)
|
|
42
|
+
out.push(...dirs, ...files)
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** a dir's entries that can change the answer: its subdirs and its unit
|
|
48
|
+
* files. A temp file coming and going moves the dir's mtime, not this */
|
|
49
|
+
const listings = new Map<string, { mtime: number; names: string }>()
|
|
50
|
+
function dirNames(dir: string, mtime: number): string {
|
|
51
|
+
const hit = listings.get(dir)
|
|
52
|
+
if (hit?.mtime === mtime) return hit.names
|
|
53
|
+
let names = ''
|
|
54
|
+
try {
|
|
55
|
+
names = readdirSync(dir, { withFileTypes: true })
|
|
56
|
+
.filter((e) => !e.name.startsWith('.') && (e.isDirectory() || e.name.endsWith(UNIT_SUFFIX)))
|
|
57
|
+
.map((e) => e.name)
|
|
58
|
+
.sort()
|
|
59
|
+
.join('/')
|
|
60
|
+
} catch { /* gone between the stat and the read: the next stat says so */ }
|
|
61
|
+
listings.set(dir, { mtime, names })
|
|
62
|
+
return names
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** every path as one string: a file by its mtime, a dir by the entries
|
|
66
|
+
* that matter (read again only when its mtime moved), -1 when missing */
|
|
67
|
+
export function stampOf(paths: string[]): string {
|
|
68
|
+
return paths.map((p) => {
|
|
69
|
+
try {
|
|
70
|
+
const st = statSync(p)
|
|
71
|
+
return st.isDirectory() ? dirNames(p, st.mtimeMs) : st.mtimeMs
|
|
72
|
+
} catch {
|
|
73
|
+
return -1
|
|
74
|
+
}
|
|
75
|
+
}).join(',')
|
|
76
|
+
}
|