rvlib-shipkit 1.2.0 → 1.3.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 +67 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/assets/run-assets.ts +3 -8
- package/src/check.ts +20 -6
- package/src/checks/SK001-package-json-shape.check.ts +14 -3
- 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 +29 -5
- package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
- package/src/checks/SK015-nested-repo.check.ts +5 -1
- 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 +28 -0
- package/src/checks/SK028-readme.check.ts +79 -0
- package/src/cli/cmd_Assets.ts +1 -1
- package/src/cli/cmd_Ci.ts +5 -0
- package/src/cli/cmd_CrossRepoActivity.ts +116 -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/providers.ts +1 -1
- package/src/collections/test-time.ts +10 -1
- package/src/core/agent-spawn.ts +46 -0
- package/src/core/archived.ts +82 -0
- package/src/core/canonical-scripts.ts +12 -3
- package/src/core/check-runner.ts +40 -12
- package/src/core/discover-checks.ts +2 -0
- 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/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 +6 -2
- package/src/core/run-ci.ts +43 -12
- package/src/core/run-fix.ts +7 -2
- package/src/core/run-fixers.ts +10 -6
- package/src/core/wire.ts +11 -0
- package/src/fleet/scan-fleet.ts +2 -1
- package/src/home/activity-shape.ts +135 -0
- package/src/home/activity.ts +249 -0
- package/src/home/global-units.ts +3 -2
- package/src/home/home-routes.ts +32 -5
- package/src/home/pm.ts +37 -4
- package/src/home/process-rows.ts +205 -0
- package/src/home/resources.ts +35 -8
- package/src/home/run-daemon.ts +6 -6
- package/src/home/run-pm.ts +1 -0
- 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/hooks/run-hooks.ts +20 -3
- package/src/ledger/db.ts +1 -1
- package/src/ledger/gate-checks.ts +17 -3
- 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 +19 -2
- package/src/ledger/world-cache.ts +51 -4
- 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 +11 -3
- 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/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 +49 -12
- package/src/serve/hub-app/home-strip.css +65 -22
- package/src/serve/hub-app/home-strip.tsx +228 -97
- package/src/serve/hub-app/home.html +4 -0
- package/src/serve/hub-app/hub-app.tsx +96 -160
- package/src/serve/hub-app/hub.css +77 -37
- package/src/serve/hub-app/icons.tsx +23 -3
- package/src/serve/hub-app/index.html +4 -0
- package/src/serve/hub-app/keys.ts +42 -7
- package/src/serve/hub-app/live-svc.ts +24 -64
- package/src/serve/hub-app/machine-st.ts +64 -1
- 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 +11 -7
- 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 +25 -6
- 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 +20 -3
- 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 +33 -11
- 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/shipkit-files.ts +20 -4
- package/src/checks/SK023-stores-under-cap.check.ts +0 -49
- package/src/checks/contracts.check.ts +0 -20
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// sites that write history refuse on the disk fact either way (core/nesting.ts),
|
|
4
4
|
// so this rule is about the declaration telling the truth to the next reader.
|
|
5
5
|
import { nestedIn } from '../core/nesting.ts'
|
|
6
|
+
import { CONFIG_FILE } from '../core/check-pass.ts'
|
|
6
7
|
import { check } from '../check.ts'
|
|
7
8
|
|
|
8
9
|
export const nestedRepo = check({
|
|
@@ -11,7 +12,10 @@ export const nestedRepo = check({
|
|
|
11
12
|
title: 'nested repo',
|
|
12
13
|
description: 'a config inside a larger work tree declares `nested: true`, and only there',
|
|
13
14
|
owner: 'build/shipkit/config-keys.md',
|
|
14
|
-
|
|
15
|
+
// the declaration, and where the git work tree root sits, which no tree
|
|
16
|
+
// id describes, so its path is the rest of the key
|
|
17
|
+
inputs: [CONFIG_FILE],
|
|
18
|
+
cacheKey: (env) => `nested=${env.config.nested === true}|top=${nestedIn(env.root) ?? 'root'}`,
|
|
15
19
|
run: (c) => {
|
|
16
20
|
const top = nestedIn(c.root)
|
|
17
21
|
const declared = c.config.nested === true
|
|
@@ -11,6 +11,7 @@ import { basename } from 'path'
|
|
|
11
11
|
import { check } from '../check.ts'
|
|
12
12
|
import type { CheckCtx } from '../core/check-ctx.ts'
|
|
13
13
|
import { checkFloorReader, resolveFloor } from '../core/floor-resolve.ts'
|
|
14
|
+
import { rootPublishesNpm } from '../core/repo-facts.ts'
|
|
14
15
|
import { whenOfTask } from '../core/when.ts'
|
|
15
16
|
import { hubProblemMatcher } from '../problems/problem-line.ts'
|
|
16
17
|
import { indentAt, parseSpans, plain, removalSpan, type Node } from '../scaffold/jsonc-spans.ts'
|
|
@@ -165,6 +166,10 @@ function ownTasks(c: CheckCtx): { text: string } | 'none' | 'linked' {
|
|
|
165
166
|
return text == null ? 'none' : { text }
|
|
166
167
|
}
|
|
167
168
|
|
|
169
|
+
/** a package published to npm is worked on by strangers, whose editor
|
|
170
|
+
* runs these tasks */
|
|
171
|
+
const PUBLIC_SKIP = `the root unit publishes to npm: ${TASKS} belongs to every contributor, so it is never rewritten`
|
|
172
|
+
|
|
168
173
|
export const vscodeHubTask = check({
|
|
169
174
|
id: 'SK017',
|
|
170
175
|
// a stale editor file is behind, not broken: it warns, and the fix pass
|
|
@@ -174,6 +179,7 @@ export const vscodeHubTask = check({
|
|
|
174
179
|
description: 'the vscode hub task carries the problem matcher and no extra tsc watcher runs',
|
|
175
180
|
owner: 'build/vscode-tasks.md',
|
|
176
181
|
run: (c) => {
|
|
182
|
+
if (rootPublishesNpm(c.config)) return c.ok(PUBLIC_SKIP)
|
|
177
183
|
const own = ownTasks(c)
|
|
178
184
|
if (own === 'none') return c.ok(`no ${TASKS}, so there is no hub task to wire`)
|
|
179
185
|
if (own === 'linked') return c.ok(`.vscode/ is a link to a shared dir, left alone`)
|
|
@@ -185,6 +191,7 @@ export const vscodeHubTask = check({
|
|
|
185
191
|
for (const ch of r.changes) c.fail(`${TASKS}: ${ch.replace(/^removed/, 'should remove').replace(/^dropped/, 'should drop').replace(/^wrote/, 'should write').replace(/^folded/, 'should fold')}`)
|
|
186
192
|
},
|
|
187
193
|
fix: (c) => {
|
|
194
|
+
if (rootPublishesNpm(c.config)) return { changed: false, summary: PUBLIC_SKIP }
|
|
188
195
|
const own = ownTasks(c)
|
|
189
196
|
if (typeof own === 'string') return { changed: false, summary: `no ${TASKS} to repair` }
|
|
190
197
|
const r = repairTasks(own.text, { typecheckOn: typecheckOn(c) })
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// rvlib-shipkit pin is a minimum. It warns when the repo asks for a version
|
|
4
4
|
// newer than the one this process runs: a rule or an api that pin relies on
|
|
5
5
|
// is absent here until the house copy catches up.
|
|
6
|
+
import { join } from 'path'
|
|
6
7
|
import { LIB_VERSION } from '../core/lib-version.ts'
|
|
8
|
+
import { fileStamp } from '../core/file-stamp.ts'
|
|
7
9
|
import { check } from '../check.ts'
|
|
8
10
|
|
|
9
11
|
type Pkg = { version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
|
|
@@ -23,8 +25,10 @@ export const machineRunsThePin = check({
|
|
|
23
25
|
title: 'machine runs the pin',
|
|
24
26
|
description: 'the shipkit this machine runs is at least the version this repo pins',
|
|
25
27
|
owner: 'build/shipkit.md',
|
|
26
|
-
// the
|
|
27
|
-
|
|
28
|
+
// the pin, the install (gitignored) and the version this machine runs,
|
|
29
|
+
// which is no file of the repo
|
|
30
|
+
inputs: ['package.json'],
|
|
31
|
+
cacheKey: (env) => `${LIB_VERSION}|installed=${fileStamp(join(env.root, 'node_modules', NAME, 'package.json'))}`,
|
|
28
32
|
run: (c) => {
|
|
29
33
|
const pkg = c.json<Pkg>('package.json')
|
|
30
34
|
const spec = pkg?.devDependencies?.[NAME] ?? pkg?.dependencies?.[NAME]
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// surface that shows the repo (the home page header, its rail tile) draws it
|
|
4
4
|
// in its own colour. The fix derives them from the repo icon, or from a hue
|
|
5
5
|
// hashed from the name, and writes that one key, every other byte kept.
|
|
6
|
+
import { join } from 'path'
|
|
6
7
|
import { check } from '../check.ts'
|
|
8
|
+
import { fileStamp } from '../core/file-stamp.ts'
|
|
7
9
|
import { configName, CONFIG_FILE, deriveIdentity, identityRefusal, INSTALLED_CONFIG_SRC } from '../core/identity.ts'
|
|
8
10
|
import { declaresIdentity, identityOfText, identityProblem, setIdentityText } from '../core/identity-color.ts'
|
|
9
11
|
|
|
@@ -16,6 +18,9 @@ export const identityColors = check({
|
|
|
16
18
|
title: 'identity colors',
|
|
17
19
|
description: 'repo.config.ts declares identity colours: colorLight and colorDark, two hex',
|
|
18
20
|
owner: 'build/shipkit/built-in-checks.md',
|
|
21
|
+
// the config, and the installed lib that says whether it knows the key
|
|
22
|
+
inputs: [CONFIG_FILE],
|
|
23
|
+
cacheKey: (env) => `installed=${fileStamp(join(env.root, INSTALLED_CONFIG_SRC))}`,
|
|
19
24
|
run: (c) => {
|
|
20
25
|
const text = c.read(CONFIG_FILE)
|
|
21
26
|
if (text == null) return c.note(`no ${CONFIG_FILE}`)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// SK025 release drift (build/shipkit/built-in-checks.md): every unit this repo
|
|
2
|
+
// releases matches what npm serves and what its push remote holds, the same
|
|
3
|
+
// survey `shipkit releases` prints. It replaces a drift copy per page. A repo
|
|
4
|
+
// that releases nothing (a private root with no release config and no units)
|
|
5
|
+
// skips it. A world probe: the registry and the remote move on a publish this
|
|
6
|
+
// tree cannot see, so a clean answer keeps for a day, keyed on every unit's
|
|
7
|
+
// version so a local bump asks again at once.
|
|
8
|
+
import { check, type CheckCtx } from '../check.ts'
|
|
9
|
+
import { parseWireOutput } from '../core/wire.ts'
|
|
10
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
11
|
+
import { attentionLines, collectReports, type UnitReport } from '../release/run-releases.ts'
|
|
12
|
+
import { resolveUnits, type ReleaseUnit } from '../release/units.ts'
|
|
13
|
+
|
|
14
|
+
/** the units this repo releases: every declared one, and the root only when
|
|
15
|
+
* it publishes to npm or the repo declares a release config */
|
|
16
|
+
export function releasedUnits(root: string, config: RepoConfig): ReleaseUnit[] {
|
|
17
|
+
return resolveUnits(root, config).units.filter((u) => u.id !== 'root' || u.targets.npm || config.release != null)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** the survey as check lines: one ok per unit in step, else its attention lines */
|
|
21
|
+
export function reportDrift(c: CheckCtx, reports: UnitReport[]): void {
|
|
22
|
+
for (const r of reports) {
|
|
23
|
+
const lines = attentionLines(r)
|
|
24
|
+
if (lines.length === 0) {
|
|
25
|
+
// "in sync with npm" only for a unit that HAS an npm target and whose
|
|
26
|
+
// registry read succeeded (attentionLines warns when it did not)
|
|
27
|
+
c.ok(r.npmTarget ? `${r.name}@${r.version} in sync with npm` : `${r.name}@${r.version}, no npm target, tag state only`)
|
|
28
|
+
continue
|
|
29
|
+
}
|
|
30
|
+
for (const l of parseWireOutput(lines.join('\n'))) {
|
|
31
|
+
if (l.kind === 'warn' || l.kind === 'fail') c.warn(l.text)
|
|
32
|
+
else if (l.kind === 'ok') c.ok(l.text)
|
|
33
|
+
else c.note(l.text)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const releaseDrift = check({
|
|
39
|
+
id: 'SK025',
|
|
40
|
+
blocking: false,
|
|
41
|
+
title: 'release drift',
|
|
42
|
+
description: 'every unit this repo releases matches npm and its push remote',
|
|
43
|
+
owner: 'build/shipkit/built-in-checks.md',
|
|
44
|
+
when: 'health',
|
|
45
|
+
ttl: '20h',
|
|
46
|
+
every: (last) => (last?.status === 'pass' ? ['20h', '28h'] : '4h'),
|
|
47
|
+
planOff: (env) => (releasedUnits(env.root, env.config).length === 0 ? 'this repo releases nothing' : null),
|
|
48
|
+
cacheKey: (env) => releasedUnits(env.root, env.config).map((u) => `${u.id}@${u.version}`).join(','),
|
|
49
|
+
run: async (c) => {
|
|
50
|
+
const ids = new Set(releasedUnits(c.root, c.config).map((u) => u.id))
|
|
51
|
+
if (ids.size === 0) return void c.lines.push({ kind: 'skip', text: 'this repo releases nothing: no npm root, no release config, no units' })
|
|
52
|
+
const { reports, errors } = await collectReports(c.root, c.config)
|
|
53
|
+
for (const e of errors) c.warn(e)
|
|
54
|
+
reportDrift(c, reports.filter((r) => ids.has(r.id)))
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
export default releaseDrift
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// SK026 asks auto close (build/shipkit/built-in-checks.md): probe every open
|
|
2
|
+
// ask's own `check:` and archive the ones that pass, so an act done in the
|
|
3
|
+
// world leaves the queue without anyone editing a file. An open queue is no
|
|
4
|
+
// warning: the hub and `shipkit needs` show it. A repo with no queue skips.
|
|
5
|
+
import { existsSync } from 'fs'
|
|
6
|
+
import { join } from 'path'
|
|
7
|
+
import { checkAsks, listAsks } from '../asks/asks-queue.ts'
|
|
8
|
+
import { check } from '../check.ts'
|
|
9
|
+
import { LAYOUT } from '../core/layout.ts'
|
|
10
|
+
|
|
11
|
+
export const asksAutoclose = check({
|
|
12
|
+
id: 'SK026',
|
|
13
|
+
blocking: false,
|
|
14
|
+
title: 'asks auto close',
|
|
15
|
+
description: 'no open ask whose own probe passes stays in the queue',
|
|
16
|
+
owner: 'build/shipkit/built-in-checks.md',
|
|
17
|
+
when: 'health',
|
|
18
|
+
never: "runs each open ask's own probe, a shell command that reads the world",
|
|
19
|
+
every: ['20h', '28h'],
|
|
20
|
+
// it only probes an ask that carries one, and archives one marked done
|
|
21
|
+
planOff: (env) => {
|
|
22
|
+
const dir = join(env.root, env.config.asks?.dir ?? LAYOUT.asks)
|
|
23
|
+
if (!existsSync(dir)) return 'no asks queue'
|
|
24
|
+
return listAsks(dir).some((a) => a.check != null || a.status === 'done') ? null : 'no open ask carries a probe'
|
|
25
|
+
},
|
|
26
|
+
run: async (c) => {
|
|
27
|
+
const dir = join(c.root, c.config.asks?.dir ?? LAYOUT.asks)
|
|
28
|
+
if (!existsSync(dir)) return void c.lines.push({ kind: 'skip', text: 'no asks queue in this repo' })
|
|
29
|
+
// archiving RENAMES files, and the release path runs the checks between
|
|
30
|
+
// its clean tree guard and `npm publish`: frozen means report only
|
|
31
|
+
if (c.frozen) return void c.lines.push({ kind: 'skip', text: 'asks not probed: a frozen run archives nothing' })
|
|
32
|
+
const events: string[] = []
|
|
33
|
+
await checkAsks(c.root, dir, (l) => events.push(l))
|
|
34
|
+
// `closed + archived <file>` / `archived <file>` are the queue's frozen stdout contract
|
|
35
|
+
for (const l of events) {
|
|
36
|
+
if (l.startsWith('closed') || l.startsWith('archived')) c.ok(`ask ${l}`)
|
|
37
|
+
// the queue could not run (another agent holds the write lease)
|
|
38
|
+
else if (l.startsWith('WARN')) c.warn(l.replace(/^WARN\s*/, ''))
|
|
39
|
+
}
|
|
40
|
+
if (!c.lines.some((l) => l.kind === 'ok')) c.ok('no open ask has a probe that passes')
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
export default asksAutoclose
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// SK027 agent wiki links (build/shipkit/built-in-checks.md): a repo's agent/
|
|
2
|
+
// IS an md wiki (entry index.md, the CLAUDE.md include), so an orphan page or
|
|
3
|
+
// a dangling ref there fails. A cross wiki pointer (into a house wiki) is
|
|
4
|
+
// backticked, never a bare → or @, which reads as an agent local link and
|
|
5
|
+
// dangles. A repo without agent/index.md skips.
|
|
6
|
+
import { existsSync } from 'fs'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
import { check } from '../check.ts'
|
|
9
|
+
import { lintMd } from '../wiki/lint-md.ts'
|
|
10
|
+
|
|
11
|
+
export const agentWikiLinks = check({
|
|
12
|
+
id: 'SK027',
|
|
13
|
+
blocking: true,
|
|
14
|
+
title: 'agent wiki links',
|
|
15
|
+
description: 'every agent/ page is linked from somewhere, and every agent/ link resolves',
|
|
16
|
+
owner: 'meta/agent-folder.md',
|
|
17
|
+
// the md lint walks agent/ on disk rather than through `c`: agent/ is all it reads
|
|
18
|
+
inputs: ['agent'],
|
|
19
|
+
run: (c) => {
|
|
20
|
+
if (!existsSync(join(c.root, 'agent/index.md'))) return void c.lines.push({ kind: 'skip', text: 'no agent/ wiki in this repo' })
|
|
21
|
+
const lint = lintMd({ root: join(c.root, 'agent'), index: 'index.md' })
|
|
22
|
+
for (const f of lint.orphans) c.fail(`agent/${f}: orphan, unreachable from agent/index.md: link it from a page or delete it`)
|
|
23
|
+
for (const d of lint.dangling) c.fail(`agent/${d.from}: dangling ref → ${d.token}, target not found (backtick a cross wiki ref; fix or delete the pointer)`)
|
|
24
|
+
if (!c.failed) c.ok('agent/ has no orphans and no dangling refs')
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
export default agentWikiLinks
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// SK028 readme (build/shipkit/built-in-checks.md): the README.md a repo's hub
|
|
2
|
+
// page shows exists and carries what makes the repo recognizable at a glance.
|
|
3
|
+
// A list of named requirements, each reporting its own line, so a new
|
|
4
|
+
// mandatory item is one more entry. Read through `c` only, so the answer is
|
|
5
|
+
// keyed on README.md and the images it points to, never on a clock.
|
|
6
|
+
import { posix } from 'path'
|
|
7
|
+
import { check } from '../check.ts'
|
|
8
|
+
import type { CheckCtx } from '../core/check-ctx.ts'
|
|
9
|
+
|
|
10
|
+
const README = 'README.md'
|
|
11
|
+
|
|
12
|
+
/** every image source the README shows: markdown `` and html
|
|
13
|
+
* `<img src>`, code fences and inline code left out, since a sample there
|
|
14
|
+
* shows nothing */
|
|
15
|
+
export const imageRefs = (md: string): string[] => {
|
|
16
|
+
const prose = md.replace(/^(```|~~~)[^\n]*\n[\s\S]*?^\1[^\n]*$/gm, '').replace(/`[^`\n]*`/g, '')
|
|
17
|
+
const refs: { at: number; src: string }[] = []
|
|
18
|
+
for (const m of prose.matchAll(/!\[[^\]]*\]\(\s*<?([^\s)>]+)>?(?:\s+["'(][^)]*)?\)/g)) refs.push({ at: m.index, src: m[1]! })
|
|
19
|
+
for (const m of prose.matchAll(/<img\b[^>]*?\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi)) refs.push({ at: m.index, src: (m[1] ?? m[2] ?? m[3])! })
|
|
20
|
+
return refs.sort((a, b) => a.at - b.at).map((r) => r.src)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** a status badge (ci, version, license) shows the repo's state, never what it looks like */
|
|
24
|
+
const isBadge = (src: string): boolean => /(^|[/.])(shields\.io|badgen\.net)\//i.test(src) || /badge[^/]*\.svg([?#]|$)/i.test(src)
|
|
25
|
+
|
|
26
|
+
/** where an image source lands: an https url, a repo file, or nowhere with why */
|
|
27
|
+
const resolveImage = (c: CheckCtx, src: string): { ok: string } | { bad: string } => {
|
|
28
|
+
if (isBadge(src)) return { bad: `${src.slice(0, 60)} is a badge` }
|
|
29
|
+
if (/^https:\/\//i.test(src)) return { ok: src }
|
|
30
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith('//')) return { bad: `${src.slice(0, 60)} is neither an https url nor a repo file` }
|
|
31
|
+
let rel: string
|
|
32
|
+
try {
|
|
33
|
+
rel = posix.normalize(decodeURI(src.replace(/[?#].*$/, '')).replace(/^\/+/, ''))
|
|
34
|
+
} catch {
|
|
35
|
+
return { bad: `${src} is not a readable path` }
|
|
36
|
+
}
|
|
37
|
+
if (rel === '.' || rel.startsWith('../')) return { bad: `${src} points out of the repo` }
|
|
38
|
+
return c.exists(rel, 'file') ? { ok: rel } : { bad: `${rel} is missing` }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type Finding = { ok: string } | { warn: string; fix: string }
|
|
42
|
+
type Requirement = { name: string; test: (c: CheckCtx, md: string) => Finding }
|
|
43
|
+
|
|
44
|
+
/** what every README carries, in order. Add an entry to make one more item mandatory */
|
|
45
|
+
const REQUIREMENTS: Requirement[] = [
|
|
46
|
+
{
|
|
47
|
+
name: 'screenshot',
|
|
48
|
+
test: (c, md) => {
|
|
49
|
+
const found = imageRefs(md).map((src) => resolveImage(c, src))
|
|
50
|
+
const shown = found.find((f): f is { ok: string } => 'ok' in f)
|
|
51
|
+
if (shown != null) return { ok: `${README} shows a screenshot: ${shown.ok}` }
|
|
52
|
+
const why = found.map((f) => (f as { bad: string }).bad)
|
|
53
|
+
return {
|
|
54
|
+
warn: `${README}: screenshot: ${why.length === 0 ? 'no image at all' : `no image resolves (${why.join(', ')})`}`,
|
|
55
|
+
fix: 'add a screenshot to README.md: , a file in the repo, or an https url',
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
export const readme = check({
|
|
62
|
+
id: 'SK028',
|
|
63
|
+
blocking: false,
|
|
64
|
+
title: 'readme',
|
|
65
|
+
description: 'README.md exists and shows at least one screenshot',
|
|
66
|
+
owner: 'build/shipkit/built-in-checks.md',
|
|
67
|
+
run: (c) => {
|
|
68
|
+
const md = c.read(README)
|
|
69
|
+
if (md == null) return c.warn(`${README}: exists: no ${README} at the repo root`, 'write README.md: what the repo is, and a screenshot of it')
|
|
70
|
+
c.ok(`${README} exists`)
|
|
71
|
+
for (const r of REQUIREMENTS) {
|
|
72
|
+
const f = r.test(c, md)
|
|
73
|
+
if ('ok' in f) c.ok(f.ok)
|
|
74
|
+
else c.warn(f.warn, f.fix)
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
export default readme
|
package/src/cli/cmd_Assets.ts
CHANGED
|
@@ -99,7 +99,7 @@ export class AssetsCheckCommand extends Command {
|
|
|
99
99
|
|
|
100
100
|
static override usage = Command.Usage({
|
|
101
101
|
category: 'Assets',
|
|
102
|
-
description: 'kit loads, kinds valid, cflow workflows load, drafts present,
|
|
102
|
+
description: 'kit loads, kinds valid, cflow workflows load, drafts present, identity outputs present',
|
|
103
103
|
examples: [['health-check the asset pipeline', 'shipkit assets check']],
|
|
104
104
|
})
|
|
105
105
|
|
package/src/cli/cmd_Ci.ts
CHANGED
|
@@ -24,6 +24,7 @@ export class CiCommand extends Command {
|
|
|
24
24
|
['run everything, take nothing from the cache', 'shipkit ci --fresh'],
|
|
25
25
|
['include an opt in check', 'shipkit ci --with up:itch'],
|
|
26
26
|
['in the background, read it back with shipkit status', 'shipkit ci --detach'],
|
|
27
|
+
['every line, the ones nobody acts on included', 'shipkit ci --verbose'],
|
|
27
28
|
],
|
|
28
29
|
})
|
|
29
30
|
|
|
@@ -38,6 +39,8 @@ export class CiCommand extends Command {
|
|
|
38
39
|
fresh = Option.Boolean('--fresh', false, { description: 'run every check: no answer comes from the cache, and what this run finds is stored fresh' })
|
|
39
40
|
full = Option.Boolean('--full', false, { description: `run the FULL test tier: every suite the repo declares, the slow ones it skips while you work included. What the release path always runs. Same as SHIPKIT_TEST_FULL=1` })
|
|
40
41
|
|
|
42
|
+
verbose = Option.Boolean('--verbose', false, { description: 'print every line, the ones nobody acts on included: a clean fix pass, the machine pool, the test tier, the cache declarations, the checks not in this run, the cache counts' })
|
|
43
|
+
|
|
41
44
|
detach = Option.Boolean('--detach', false, { description: 'start this gate in the background and return at once with a run id. `shipkit status <id>` prints it. The gate is an ordinary one: same cache, same run record' })
|
|
42
45
|
job = Option.String('--job', { hidden: true })
|
|
43
46
|
|
|
@@ -72,6 +75,7 @@ export class CiCommand extends Command {
|
|
|
72
75
|
...this.only.flatMap((v) => ['--only', v]),
|
|
73
76
|
...(this.fresh ? ['--fresh'] : []),
|
|
74
77
|
...(this.full ? ['--full'] : []),
|
|
78
|
+
...(this.verbose ? ['--verbose'] : []),
|
|
75
79
|
]
|
|
76
80
|
}
|
|
77
81
|
|
|
@@ -92,6 +96,7 @@ export class CiCommand extends Command {
|
|
|
92
96
|
onlyIds: this.only,
|
|
93
97
|
fresh: this.fresh,
|
|
94
98
|
...(this.full ? { tests: 'full' as const } : {}),
|
|
99
|
+
...(this.verbose ? { verbose: true } : {}),
|
|
95
100
|
}
|
|
96
101
|
const print = (l: string): void => { this.context.stdout.write(`${l}\n`) }
|
|
97
102
|
// on a machine with the home, the repo's hub runs it (home/via-hub.ts)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// shipkit cross-repo-activity: the activity page in the console. The rows come
|
|
2
|
+
// from activityOf, the one computation the daemon's route serves the page, so
|
|
3
|
+
// the two can never disagree; this file only renders them as text. It reads
|
|
4
|
+
// local git and each repo's run record directly and needs no daemon: when one
|
|
5
|
+
// answers, its plan says which checks each repo declares today, as the page's
|
|
6
|
+
// status dot counts them.
|
|
7
|
+
import { homedir } from 'os'
|
|
8
|
+
import { Command, Option } from 'clipanion'
|
|
9
|
+
import { activityOf, type Declared } from '../home/activity.ts'
|
|
10
|
+
import { ageShort, clampCap, DEFAULT_CAP, DEFAULT_MINUTES, dirtWords, idleRow, upstreamWords, type ActivityAnswer, type ActivityCommit, type ActivityRow } from '../home/activity-shape.ts'
|
|
11
|
+
import { railRepos } from '../serve/project-gutter-route.ts'
|
|
12
|
+
|
|
13
|
+
/** a span as typed: `10m`, `1h`, `3d`, `90` (minutes); null when it is none */
|
|
14
|
+
export function spanMinutes(v: string): number | null {
|
|
15
|
+
const m = /^(\d+(?:\.\d+)?)\s*(m|min|h|d)?$/.exec(v.trim())
|
|
16
|
+
if (m == null) return null
|
|
17
|
+
const n = Number(m[1]) * (m[2] === 'h' ? 60 : m[2] === 'd' ? 1440 : 1)
|
|
18
|
+
return n > 0 ? Math.round(n) : null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** single dash spellings of the long options, as typed: `-max-commit 3` */
|
|
22
|
+
export function aliasArgs(argv: string[]): string[] {
|
|
23
|
+
return argv.map((a) => (/^-max-(commit|age)(=|$)/.test(a) ? `-${a}` : a))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** the declared check ids from a running daemon's plan; null when none answers */
|
|
27
|
+
export async function daemonDeclared(port = Number(process.env.SHIPKIT_DAEMON_PORT ?? 4798)): Promise<Declared | null> {
|
|
28
|
+
try {
|
|
29
|
+
const r = await fetch(`http://127.0.0.1:${port}/api/home/planned.json`, { signal: AbortSignal.timeout(1_500) })
|
|
30
|
+
if (!r.ok) return null
|
|
31
|
+
const j = (await r.json()) as { rows: { repo: string; check: string }[] }
|
|
32
|
+
const by = new Map<string, Set<string>>()
|
|
33
|
+
for (const row of j.rows) {
|
|
34
|
+
if (!by.has(row.repo)) by.set(row.repo, new Set())
|
|
35
|
+
by.get(row.repo)!.add(row.check)
|
|
36
|
+
}
|
|
37
|
+
return (name) => by.get(name) ?? null
|
|
38
|
+
} catch {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const dot = (r: ActivityRow): string => {
|
|
44
|
+
const c = r.status
|
|
45
|
+
if (c == null) return '·'
|
|
46
|
+
if (c.state === 'red') return `● ${c.reds.length} red`
|
|
47
|
+
if (c.state === 'orange') return `● ${c.warns.length} warn`
|
|
48
|
+
return '● green'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const commitLine = (c: ActivityCommit, now: number, old: boolean): string =>
|
|
52
|
+
` ${ageShort(c.at, now).padStart(3)} ${c.sha} ${c.pushed ? '✓' : '↑'} ${c.branch == null ? '' : `[${c.branch}] `}${c.title}${old ? ' (last commit, before the window)' : ''}`
|
|
53
|
+
|
|
54
|
+
/** the page's rows as text: busy repos first, then the quiet ones, each with
|
|
55
|
+
* its status, branch, upstream, uncommitted line and commits */
|
|
56
|
+
export function renderActivity(a: ActivityAnswer, cap: number): string[] {
|
|
57
|
+
const out: string[] = []
|
|
58
|
+
const busy = a.rows.filter((r) => !idleRow(r))
|
|
59
|
+
const quiet = a.rows.filter(idleRow)
|
|
60
|
+
for (const r of [...busy, ...quiet]) {
|
|
61
|
+
const branch = r.branch ?? (r.detached == null ? '' : `detached ${r.detached}`)
|
|
62
|
+
out.push(`${r.name} ${dot(r)} ${branch} (${upstreamWords(r)})`)
|
|
63
|
+
if (r.status != null && r.status.reds.length > 0) out.push(` failing: ${r.status.reds.map((id) => (r.status!.since[id] == null ? id : `${id} (${ageShort(r.status!.since[id]!, a.now)})`)).join(', ')}`)
|
|
64
|
+
if (r.error != null) out.push(` git failed: ${r.error}`)
|
|
65
|
+
const dirt = r.error == null ? dirtWords(r) : ''
|
|
66
|
+
if (dirt !== '') {
|
|
67
|
+
out.push(` uncommitted: ${dirt}`)
|
|
68
|
+
const mark = { staged: 'S', modified: 'M', untracked: '?' } as const
|
|
69
|
+
for (const f of r.files) out.push(` ${mark[f.kind]} ${f.path}`)
|
|
70
|
+
}
|
|
71
|
+
for (const c of r.commits.slice(0, cap)) out.push(commitLine(c, a.now, false))
|
|
72
|
+
if (r.commits.length > cap) out.push(` … ${r.commits.length - cap} more`)
|
|
73
|
+
if (r.last != null) out.push(commitLine(r.last, a.now, true))
|
|
74
|
+
if (r.commits.length === 0 && r.last == null && r.error == null) out.push(' no commit yet')
|
|
75
|
+
}
|
|
76
|
+
out.push(`${busy.length} busy, ${quiet.length} quiet, read in ${a.ms}ms`)
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class CrossRepoActivityCommand extends Command {
|
|
81
|
+
static override paths = [['cross-repo-activity'], ['activity']]
|
|
82
|
+
|
|
83
|
+
static override usage = Command.Usage({
|
|
84
|
+
category: 'Home',
|
|
85
|
+
description: 'the activity page in the console: every house repo (archived ones left out), its checks, branch, upstream, uncommitted work and commits in a window. The same computation the page reads, local git only, no daemon needed',
|
|
86
|
+
examples: [
|
|
87
|
+
['the last 30 minutes, 3 commits per repo, as the page opens', 'shipkit cross-repo-activity'],
|
|
88
|
+
['the same, by the page name', 'shipkit activity'],
|
|
89
|
+
['the last 3 hours, up to 10 commits per repo', 'shipkit cross-repo-activity --max-age 3h --max-commit 10'],
|
|
90
|
+
['the route\'s exact payload', 'shipkit cross-repo-activity --max-age 30m --json'],
|
|
91
|
+
],
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
maxAge = Option.String('--max-age', { required: false, description: `how far back: 10m, 1h, 3d, or minutes (default ${DEFAULT_MINUTES}m)` })
|
|
95
|
+
maxCommit = Option.String('--max-commit', { required: false, description: `commits shown per repo, 1 to 15 (default ${DEFAULT_CAP}). -max-commit works too` })
|
|
96
|
+
json = Option.Boolean('--json', false, { description: 'the daemon route\'s exact payload: { now, minutes, rows, ms }' })
|
|
97
|
+
|
|
98
|
+
async execute(): Promise<number> {
|
|
99
|
+
const minutes = this.maxAge == null ? DEFAULT_MINUTES : spanMinutes(this.maxAge)
|
|
100
|
+
if (minutes == null) {
|
|
101
|
+
this.context.stderr.write(`--max-age must be a span like 10m, 1h or 3d, got ${JSON.stringify(this.maxAge)}\n`)
|
|
102
|
+
return 2
|
|
103
|
+
}
|
|
104
|
+
const cap = clampCap(this.maxCommit ?? DEFAULT_CAP)
|
|
105
|
+
const declared = await daemonDeclared()
|
|
106
|
+
// HOME as the shell has it now: the house include names `~`
|
|
107
|
+
const a = await activityOf(railRepos(process.cwd(), process.env.HOME || homedir()), minutes, Date.now(), declared)
|
|
108
|
+
if (this.json) {
|
|
109
|
+
this.context.stdout.write(`${JSON.stringify(a, null, 2)}\n`)
|
|
110
|
+
return 0
|
|
111
|
+
}
|
|
112
|
+
for (const l of renderActivity(a, cap)) this.context.stdout.write(`${l}\n`)
|
|
113
|
+
if (declared == null) this.context.stdout.write('note: no daemon answered, so every check a run record holds counts, declared today or not\n')
|
|
114
|
+
return 0
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// shipkit global-processes: the overview's processes table in the console,
|
|
2
|
+
// shipkit's own (pm, the daemon, the web) first. The rows come from pm's own
|
|
3
|
+
// answer through process-rows.ts, the one model the page draws, so the two can
|
|
4
|
+
// never disagree; this file only asks pm and the daemon and renders them as
|
|
5
|
+
// text. With pm down it reads the declared units the way pm does
|
|
6
|
+
// (pm-config.ts) and marks each one unknown.
|
|
7
|
+
import { homedir } from 'os'
|
|
8
|
+
import { Command, Option } from 'clipanion'
|
|
9
|
+
import { daemonPort, daemonUrl, homePort, pmPort, pmUrl } from '../home/home-paths.ts'
|
|
10
|
+
import { houseRootOf } from '../home/home-start.ts'
|
|
11
|
+
import type { PmAnswer } from '../home/pm.ts'
|
|
12
|
+
import { coreGroup, declaredRow, groupRows, processGroups, stateWords, type ProcessGroup } from '../home/process-rows.ts'
|
|
13
|
+
import type { PmConfig } from '../home/pm-config.ts'
|
|
14
|
+
import { readPmConfig } from '../home/run-pm.ts'
|
|
15
|
+
import { railRepos } from '../serve/project-gutter-route.ts'
|
|
16
|
+
|
|
17
|
+
export type GlobalProcessesAnswer = {
|
|
18
|
+
/** pm as it answered, null when it did not */
|
|
19
|
+
pm: { pid: number; version: string } | null
|
|
20
|
+
now: number
|
|
21
|
+
groups: ProcessGroup[]
|
|
22
|
+
/** the unit files that would not load */
|
|
23
|
+
errors: string[]
|
|
24
|
+
/** with pm down: whether a house was found to read the declared units from */
|
|
25
|
+
house: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type GlobalProcessesDeps = {
|
|
29
|
+
pm: () => Promise<PmAnswer | null>
|
|
30
|
+
/** the daemon's identity, for its version; null when it does not answer */
|
|
31
|
+
daemon?: () => Promise<{ version: string } | null>
|
|
32
|
+
/** the declared units, read like pm reads them; null when no house is found */
|
|
33
|
+
declared: () => Promise<Pick<PmConfig, 'processes' | 'errors'> | null>
|
|
34
|
+
/** the project list's order */
|
|
35
|
+
order: string[]
|
|
36
|
+
now: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** the ports this shell would find pm and its children on */
|
|
40
|
+
const localPorts = () => ({ pm: pmPort(), daemon: daemonPort(), web: homePort() })
|
|
41
|
+
|
|
42
|
+
/** pm's answer, shipkit's own rows first; the declared units as unknown when
|
|
43
|
+
* pm does not answer */
|
|
44
|
+
export async function globalProcessesAnswer(d: GlobalProcessesDeps): Promise<GlobalProcessesAnswer> {
|
|
45
|
+
const [pm, daemon] = await Promise.all([d.pm(), d.daemon?.() ?? null])
|
|
46
|
+
const core = { daemonVersion: daemon?.version ?? null, ports: localPorts() }
|
|
47
|
+
if (pm != null) return { pm: { pid: pm.pid, version: pm.version }, now: d.now, groups: processGroups(pm, d.order, core), errors: pm.unitErrors, house: true }
|
|
48
|
+
const c = await d.declared()
|
|
49
|
+
return { pm: null, now: d.now, groups: [coreGroup(null, core), ...groupRows((c?.processes ?? []).map(declaredRow), d.order)], errors: c?.errors ?? [], house: c != null }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** the daemon's identity at /hub.json, or null */
|
|
53
|
+
export async function askDaemon(url = daemonUrl()): Promise<{ version: string } | null> {
|
|
54
|
+
try {
|
|
55
|
+
const r = await fetch(`${url}/hub.json`, { signal: AbortSignal.timeout(3_000) })
|
|
56
|
+
const j = r.ok ? ((await r.json()) as { hub?: string; version?: string }) : null
|
|
57
|
+
return j?.hub === 'shipkit-home' && typeof j.version === 'string' ? { version: j.version } : null
|
|
58
|
+
} catch {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** pm's status answer, or null when it does not answer */
|
|
64
|
+
export async function askPm(url = pmUrl()): Promise<PmAnswer | null> {
|
|
65
|
+
try {
|
|
66
|
+
const r = await fetch(`${url}/api/pm/status.json`, { signal: AbortSignal.timeout(1_500) })
|
|
67
|
+
if (!r.ok) return null
|
|
68
|
+
const j = (await r.json()) as PmAnswer
|
|
69
|
+
return j.pm === 'shipkit-pm' ? j : null
|
|
70
|
+
} catch {
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** the project list's order and the declared units, read from disk the way
|
|
76
|
+
* pm reads them: what the table falls back on while pm does not answer */
|
|
77
|
+
export function localProcessDeps(cwd: string): Pick<GlobalProcessesDeps, 'declared' | 'order'> {
|
|
78
|
+
// HOME as the shell has it now: the house include names `~`
|
|
79
|
+
const home = process.env.HOME || homedir()
|
|
80
|
+
let order: string[] = []
|
|
81
|
+
try {
|
|
82
|
+
order = railRepos(cwd, home).map((r) => r.name)
|
|
83
|
+
} catch {
|
|
84
|
+
// no rail from here: the groups sort by name
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
order,
|
|
88
|
+
declared: async () => {
|
|
89
|
+
const root = houseRootOf(cwd)
|
|
90
|
+
if (root == null) return null
|
|
91
|
+
try {
|
|
92
|
+
return await readPmConfig(root)
|
|
93
|
+
} catch (e) {
|
|
94
|
+
return { processes: [], errors: [(e as Error).message] }
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const pad = (s: string, n: number): string => (s.length >= n ? s : s + ' '.repeat(n - s.length))
|
|
101
|
+
|
|
102
|
+
/** the table as text: shipkit's own, then per repo, one line per process,
|
|
103
|
+
* its note and last log line under it */
|
|
104
|
+
export function renderGlobalProcesses(a: GlobalProcessesAnswer): string[] {
|
|
105
|
+
const out: string[] = []
|
|
106
|
+
const n = a.groups.reduce((k, g) => k + (g.core ? 0 : g.rows.length), 0)
|
|
107
|
+
if (a.pm != null) out.push(`pm pid ${a.pm.pid}, shipkit ${a.pm.version}: ${n} global process${n === 1 ? '' : 'es'}`)
|
|
108
|
+
else if (!a.house) out.push(`pm does not answer on ${pmUrl()}, and no house wiki is found from ${process.cwd()}, so no declared process is known`)
|
|
109
|
+
else out.push(`pm does not answer on ${pmUrl()}: the ${n} declared process${n === 1 ? '' : 'es'} below, each state unknown`)
|
|
110
|
+
const rows = a.groups.flatMap((g) => g.rows)
|
|
111
|
+
const w = {
|
|
112
|
+
state: Math.max(0, ...rows.map((r) => stateWords(r, a.now).length)),
|
|
113
|
+
label: Math.max(0, ...rows.map((r) => r.label.length)),
|
|
114
|
+
host: Math.max(0, ...rows.map((r) => (r.host ?? '').length)),
|
|
115
|
+
}
|
|
116
|
+
for (const g of a.groups) {
|
|
117
|
+
out.push(g.repo)
|
|
118
|
+
for (const r of g.rows) {
|
|
119
|
+
const cells = [` ● ${pad(stateWords(r, a.now), w.state)}`, pad(r.label, w.label), pad(r.host ?? '', w.host), pad(r.pid == null ? '' : `pid ${r.pid}`, 10), r.version == null ? '' : `shipkit ${r.version}`]
|
|
120
|
+
out.push(cells.join(' ').trimEnd())
|
|
121
|
+
if (r.note != null) out.push(` ${r.note}`)
|
|
122
|
+
// shipkit's own logs only matter while one is not up
|
|
123
|
+
if (r.lastLog != null && !(r.core && r.status === 'up')) out.push(` log: ${r.lastLog}`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (a.pm != null && n === 0) out.push('none declared: a repo declares one in a <name>.shipkit.ts file, as export default globalProcess({...})')
|
|
127
|
+
for (const e of a.errors) out.push(`WARN ${e}`)
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export class GlobalProcessesCommand extends Command {
|
|
132
|
+
static override paths = [['global-processes'], ['gp']]
|
|
133
|
+
|
|
134
|
+
static override usage = Command.Usage({
|
|
135
|
+
category: 'Home',
|
|
136
|
+
description: 'every process on the machine and its state, as the overview shows them: shipkit\'s own (pm, the daemon, the web, with their version) first, then per repo, one line per global process with its state, url, pid and last log line. When pm is down, the declared ones with their state unknown. Always exits 0',
|
|
137
|
+
examples: [
|
|
138
|
+
['the table', 'shipkit global-processes'],
|
|
139
|
+
['the same, short', 'shipkit gp'],
|
|
140
|
+
['the rows the overview draws, as json', 'shipkit global-processes --json'],
|
|
141
|
+
],
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
json = Option.Boolean('--json', false, { description: 'the rows as json: { pm, now, groups, errors, house }, groups exactly as the overview draws them' })
|
|
145
|
+
|
|
146
|
+
async execute(): Promise<number> {
|
|
147
|
+
const a = await globalProcessesAnswer({ ...localProcessDeps(process.cwd()), pm: () => askPm(), daemon: () => askDaemon(), now: Date.now() })
|
|
148
|
+
if (this.json) {
|
|
149
|
+
this.context.stdout.write(`${JSON.stringify(a, null, 2)}\n`)
|
|
150
|
+
return 0
|
|
151
|
+
}
|
|
152
|
+
for (const l of renderGlobalProcesses(a)) this.context.stdout.write(`${l}\n`)
|
|
153
|
+
return 0
|
|
154
|
+
}
|
|
155
|
+
}
|