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