rvlib-shipkit 1.1.1 → 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 +130 -0
- package/README.md +1 -1
- package/package.json +3 -1
- package/src/assets/run-assets.ts +3 -8
- package/src/check.ts +40 -2
- 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 +47 -0
- package/src/checks/SK024-identity-colors.check.ts +54 -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_Check.ts +26 -1
- package/src/cli/cmd_Ci.ts +23 -5
- package/src/cli/cmd_CrossRepoActivity.ts +116 -0
- package/src/cli/cmd_GlobalProcesses.ts +155 -0
- package/src/cli/cmd_Home.ts +170 -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 +30 -2
- package/src/collections/checks.ts +3 -2
- package/src/collections/providers.ts +1 -1
- package/src/collections/test-time.ts +10 -1
- package/src/comfy/comfy-panel.ts +1 -16
- package/src/core/agent-spawn.ts +46 -0
- package/src/core/archived.ts +82 -0
- package/src/core/attach.ts +14 -9
- package/src/core/canonical-scripts.ts +12 -3
- package/src/core/check-file-names.ts +8 -0
- package/src/core/check-runner.ts +80 -30
- package/src/core/discover-checks.ts +38 -18
- 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 +203 -0
- package/src/core/identity.ts +91 -0
- package/src/core/job-follow.ts +102 -0
- package/src/core/job-run.ts +27 -0
- package/src/core/jobs.ts +87 -27
- package/src/core/legacy-shapes.ts +1 -14
- package/src/core/machine-db.ts +83 -0
- package/src/core/one-lib.ts +85 -0
- package/src/core/port-holders.ts +43 -0
- package/src/core/repo-facts.ts +14 -0
- package/src/core/retention.ts +222 -0
- package/src/core/retired-checks.ts +7 -0
- package/src/core/run-check.ts +12 -4
- package/src/core/run-ci.ts +46 -12
- package/src/core/run-fix.ts +7 -2
- package/src/core/run-fixers.ts +10 -6
- package/src/core/slots.ts +5 -0
- package/src/core/statement-end.ts +17 -0
- 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/attach.ts +95 -0
- package/src/home/autostart.ts +108 -0
- package/src/home/global-units.ts +87 -0
- package/src/home/gui-app/ShipkitGui.swift +159 -0
- package/src/home/gui-app.ts +99 -0
- package/src/home/handoff.ts +51 -0
- package/src/home/home-launchd.ts +104 -0
- package/src/home/home-paths.ts +88 -0
- package/src/home/home-routes.ts +235 -0
- package/src/home/home-start.ts +105 -0
- package/src/home/home-state.ts +43 -0
- package/src/home/home-words.ts +12 -0
- package/src/home/hubs.ts +286 -0
- package/src/home/lib-watch.ts +139 -0
- package/src/home/machine-stores.ts +115 -0
- package/src/home/open-app.ts +16 -0
- package/src/home/pm-config.ts +37 -0
- package/src/home/pm.ts +174 -0
- package/src/home/process-rows.ts +205 -0
- package/src/home/resources.ts +365 -0
- package/src/home/run-daemon.ts +137 -0
- package/src/home/run-pm.ts +150 -0
- package/src/home/run-unit-action.ts +25 -0
- package/src/home/run-web.ts +37 -0
- package/src/home/serve-daemon.ts +31 -0
- package/src/home/serve-web.ts +175 -0
- package/src/home/supervise.ts +361 -0
- package/src/home/via-hub.ts +142 -0
- package/src/home/widget-text.ts +119 -0
- package/src/home/widgets.ts +102 -0
- package/src/hooks/run-hooks.ts +20 -3
- package/src/ledger/check-stats.ts +19 -45
- package/src/ledger/db.ts +69 -17
- package/src/ledger/gate-checks.ts +22 -3
- package/src/ledger/git-scan.ts +31 -7
- package/src/ledger/house-runs.ts +43 -0
- package/src/ledger/ledger.ts +40 -14
- package/src/ledger/live-timeline.ts +4 -19
- package/src/ledger/run-log.ts +92 -28
- package/src/ledger/store.ts +245 -103
- package/src/ledger/world-cache.ts +64 -15
- package/src/plan/cadence.ts +253 -0
- package/src/plan/catch-up.ts +37 -0
- package/src/plan/plan-timeline.ts +99 -0
- package/src/plan/planner.ts +428 -0
- package/src/plan/repo-source.ts +212 -0
- package/src/problems/problem-line.ts +1 -1
- package/src/release/run-release.ts +9 -2
- package/src/release/unit-resolve.ts +8 -12
- package/src/repo-config.ts +61 -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-view.tsx +9 -9
- package/src/serve/hub-app/check-words.ts +3 -1
- package/src/serve/hub-app/checks-svc.ts +12 -8
- package/src/serve/hub-app/col-card.tsx +4 -4
- package/src/serve/hub-app/cols-svc.ts +92 -13
- package/src/serve/hub-app/comfy-svc.ts +12 -6
- package/src/serve/hub-app/comfy-view.tsx +3 -3
- 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/floating.tsx +49 -0
- package/src/serve/hub-app/geometry-view.tsx +4 -4
- package/src/serve/hub-app/hash-route.ts +8 -2
- package/src/serve/hub-app/header-bar.tsx +102 -95
- package/src/serve/hub-app/health-svc.ts +3 -3
- package/src/serve/hub-app/help.tsx +10 -7
- package/src/serve/hub-app/home-st.ts +278 -0
- package/src/serve/hub-app/home-strip.css +113 -0
- package/src/serve/hub-app/home-strip.tsx +376 -0
- package/src/serve/hub-app/home.html +21 -0
- package/src/serve/hub-app/hub-app.tsx +221 -177
- package/src/serve/hub-app/hub-types.ts +0 -3
- package/src/serve/hub-app/hub.css +112 -52
- package/src/serve/hub-app/icons.tsx +34 -0
- package/src/serve/hub-app/index.html +7 -1
- package/src/serve/hub-app/keys.ts +60 -0
- package/src/serve/hub-app/layout-st.ts +55 -0
- package/src/serve/hub-app/live-svc.ts +35 -72
- package/src/serve/hub-app/machine-st.ts +333 -0
- package/src/serve/hub-app/md.ts +38 -5
- package/src/serve/hub-app/media-svc.ts +63 -44
- package/src/serve/hub-app/media-view.tsx +20 -20
- package/src/serve/hub-app/next-run.ts +24 -30
- package/src/serve/hub-app/palette-st.ts +92 -0
- 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 +107 -0
- package/src/serve/hub-app/planned-view.tsx +114 -0
- package/src/serve/hub-app/planned.css +42 -0
- package/src/serve/hub-app/processes-svc.ts +63 -0
- package/src/serve/hub-app/project-list.css +119 -0
- package/src/serve/hub-app/project-list.tsx +266 -0
- package/src/serve/hub-app/projects-svc.ts +253 -23
- package/src/serve/hub-app/prose-hits-st.ts +3 -3
- package/src/serve/hub-app/prose-hits-view.tsx +6 -6
- 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 +340 -0
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/screen-cache.ts +74 -0
- package/src/serve/hub-app/sidebar.tsx +84 -0
- package/src/serve/hub-app/test-view.tsx +4 -4
- package/src/serve/hub-app/tests-svc.ts +3 -3
- package/src/serve/hub-app/tests-view.tsx +41 -14
- package/src/serve/hub-app/timeline-chart.tsx +55 -39
- package/src/serve/hub-app/timeline-geometry.ts +40 -5
- package/src/serve/hub-app/timeline-svc.ts +7 -6
- package/src/serve/hub-app/timeline-view.tsx +33 -16
- package/src/serve/hub-app/tree-row.tsx +2 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-app/wikis-view.tsx +10 -6
- package/src/serve/hub-runtime.ts +550 -0
- package/src/serve/other-repos.ts +8 -45
- package/src/serve/project-gutter-route.ts +121 -20
- package/src/serve/project-gutter.ts +33 -11
- package/src/serve/readme-route.ts +57 -0
- package/src/serve/repo-processes.ts +89 -0
- package/src/serve/run-serve.ts +110 -402
- package/src/serve/serve-hub.ts +155 -52
- package/src/serve/stale-restart.ts +5 -0
- package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
- package/src/serve/warm-cell.ts +95 -0
- package/src/shipkit-files.ts +130 -0
- package/src/shipkit.ts +2 -1
- package/src/suite/kept-timings.ts +1 -1
- package/src/wiki/graph-positions.ts +1 -1
- package/src/checks/contracts.check.ts +0 -20
- package/src/serve/health-schedule.ts +0 -102
- package/src/serve/hub-app/hub-st.ts +0 -250
- package/src/serve/hub-app/project-gutter.css +0 -65
- package/src/serve/hub-app/project-gutter.tsx +0 -128
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// the activity page's data: for every house repo, its branch, how far it is
|
|
2
|
+
// from its upstream, what it holds uncommitted and what it committed in a
|
|
3
|
+
// window. Plain local git, never a fetch, so "ahead" is against the upstream
|
|
4
|
+
// as this disk last saw it.
|
|
5
|
+
//
|
|
6
|
+
// the one constraint a reader breaks first: this runs only when the page asks
|
|
7
|
+
// (no tick). A pass reads ACTIVITY_CONCURRENCY repos at once, and an answer is
|
|
8
|
+
// shared for ACTIVITY_TTL_MS per window, never served past it, so a page left
|
|
9
|
+
// open costs about one pass a minute however many tabs show it, and a closed
|
|
10
|
+
// one costs nothing.
|
|
11
|
+
import { gitBin } from '../core/git-bin.ts'
|
|
12
|
+
import { readLastStatusReadonly } from '../ledger/run-log.ts'
|
|
13
|
+
import { checkStateOf, recordOf } from '../serve/project-gutter.ts'
|
|
14
|
+
import type { Line } from '../core/wire.ts'
|
|
15
|
+
import { MAX_COMMITS, MAX_FILES, type ActivityAnswer, type ActivityCommit, type ActivityRepo, type ActivityRow, type FileKind, type StatusCheck } from './activity-shape.ts'
|
|
16
|
+
|
|
17
|
+
export * from './activity-shape.ts'
|
|
18
|
+
|
|
19
|
+
/** how long one window's answer is shared between readers: half the page's
|
|
20
|
+
* 60s cadence, so tabs a few seconds apart share a pass and none is shown
|
|
21
|
+
* an answer older than this */
|
|
22
|
+
export const ACTIVITY_TTL_MS = 30_000
|
|
23
|
+
|
|
24
|
+
export type ParsedStatus = Pick<ActivityRow, 'branch' | 'detached' | 'upstream' | 'gone' | 'ahead' | 'behind' | 'counts' | 'files'>
|
|
25
|
+
|
|
26
|
+
/** `git status --porcelain=v2 --branch`, read */
|
|
27
|
+
export function parseStatus(text: string): ParsedStatus {
|
|
28
|
+
const out: ParsedStatus = { branch: null, detached: null, upstream: null, gone: false, ahead: null, behind: null, counts: { staged: 0, modified: 0, untracked: 0 }, files: [] }
|
|
29
|
+
let oid = ''
|
|
30
|
+
const add = (kind: FileKind, path: string): void => {
|
|
31
|
+
if (out.files.length < MAX_FILES) out.files.push({ kind, path })
|
|
32
|
+
}
|
|
33
|
+
for (const line of text.split('\n')) {
|
|
34
|
+
if (line.startsWith('# branch.oid ')) oid = line.slice(13)
|
|
35
|
+
else if (line.startsWith('# branch.head ')) {
|
|
36
|
+
const h = line.slice(14)
|
|
37
|
+
if (h !== '(detached)') out.branch = h
|
|
38
|
+
} else if (line.startsWith('# branch.upstream ')) out.upstream = line.slice(18)
|
|
39
|
+
else if (line.startsWith('# branch.ab ')) {
|
|
40
|
+
const m = /^\+(\d+) -(\d+)$/.exec(line.slice(12))
|
|
41
|
+
if (m != null) {
|
|
42
|
+
out.ahead = Number(m[1])
|
|
43
|
+
out.behind = Number(m[2])
|
|
44
|
+
}
|
|
45
|
+
} else if (line.startsWith('1 ') || line.startsWith('2 ')) {
|
|
46
|
+
// `1 XY sub mH mI mW hH hI path`, `2 … Xscore path<TAB>orig`
|
|
47
|
+
const f = line.split(' ')
|
|
48
|
+
const xy = f[1] ?? '..'
|
|
49
|
+
const path = f.slice(line.startsWith('1 ') ? 8 : 9).join(' ').split('\t')[0]!
|
|
50
|
+
if (xy[0] !== '.') out.counts.staged++
|
|
51
|
+
if (xy[1] !== '.') out.counts.modified++
|
|
52
|
+
add(xy[1] !== '.' ? 'modified' : 'staged', path)
|
|
53
|
+
} else if (line.startsWith('u ')) {
|
|
54
|
+
out.counts.modified++
|
|
55
|
+
add('modified', line.split(' ').slice(10).join(' '))
|
|
56
|
+
} else if (line.startsWith('? ')) {
|
|
57
|
+
out.counts.untracked++
|
|
58
|
+
add('untracked', line.slice(2))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (out.branch == null && oid !== '' && oid !== '(initial)') out.detached = oid.slice(0, 7)
|
|
62
|
+
if (out.upstream != null && out.ahead == null) out.gone = true
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const SEP = '\x1f'
|
|
67
|
+
const END = '\x1e'
|
|
68
|
+
const FORMAT = `--format=%h${SEP}%ct${SEP}%an${SEP}%S${SEP}%s${SEP}%H${END}`
|
|
69
|
+
|
|
70
|
+
/** `git log` in FORMAT, read; `unpushed` holds the full shas no remote has */
|
|
71
|
+
export function parseLog(text: string, onHead: boolean, unpushed: (full: string) => boolean = () => false): ActivityCommit[] {
|
|
72
|
+
const out: ActivityCommit[] = []
|
|
73
|
+
for (const rec of text.split(END)) {
|
|
74
|
+
const f = rec.replace(/^\n/, '').split(SEP)
|
|
75
|
+
if (f.length < 6) continue
|
|
76
|
+
const src = f[3]!.replace(/^refs\/heads\//, '')
|
|
77
|
+
out.push({ sha: f[0]!, at: Number(f[1]) * 1000, author: f[2]!, branch: onHead || src === '' ? null : src, title: f[4]!, pushed: !unpushed(f[5]!) })
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function git(cwd: string, args: string[]): Promise<{ code: number; out: string; err: string }> {
|
|
83
|
+
// no optional locks: a look never takes the index lock an agent's commit needs
|
|
84
|
+
const p = Bun.spawn([gitBin(), ...args], { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } })
|
|
85
|
+
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
|
|
86
|
+
return { code, out, err: err.trim() }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** which check ids a repo declares today: the daemon's plan; null = not
|
|
90
|
+
* known, every recorded check counts */
|
|
91
|
+
export type Declared = (name: string) => ReadonlySet<string> | null
|
|
92
|
+
|
|
93
|
+
/** each declared check's title by id; null = not known, the id stands in */
|
|
94
|
+
export type Titles = (name: string) => ReadonlyMap<string, string> | null
|
|
95
|
+
|
|
96
|
+
/** the line a check printed about its problem: its first line of the same
|
|
97
|
+
* weight (a fail for a failure, a warning for a warning), else its first
|
|
98
|
+
* line that is not a pass, without the id its line leads with */
|
|
99
|
+
export function findingOf(lines: string, status: 'fail' | 'warn', id: string): string {
|
|
100
|
+
let parsed: Line[]
|
|
101
|
+
try {
|
|
102
|
+
parsed = JSON.parse(lines) as Line[]
|
|
103
|
+
} catch {
|
|
104
|
+
return ''
|
|
105
|
+
}
|
|
106
|
+
if (!Array.isArray(parsed)) return ''
|
|
107
|
+
const kinds = status === 'fail' ? ['fail', 'fix-fail'] : ['warn', 'fix-warn']
|
|
108
|
+
const hit = parsed.find((l) => kinds.includes(l.kind)) ?? parsed.find((l) => l.kind !== 'ok' && l.kind !== 'skip')
|
|
109
|
+
if (hit == null) return ''
|
|
110
|
+
const t = hit.text.trim()
|
|
111
|
+
// `[ID] text`, and a runner line's `ID — text`: the list names the id already
|
|
112
|
+
for (const lead of [`[${id}]`, `${id} —`]) if (t.startsWith(lead)) return t.slice(lead.length).trim()
|
|
113
|
+
return t
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** a repo's checks as its run record last saw them, counted over what it
|
|
117
|
+
* declares, and the failing and warning ones with what each said */
|
|
118
|
+
export function statusOf(repo: ActivityRepo, declared: Declared | null = null, titles: Titles | null = null): Pick<ActivityRow, 'status' | 'failing'> {
|
|
119
|
+
const record = recordOf(repo.path)
|
|
120
|
+
if (record == null) return { status: null, failing: [] }
|
|
121
|
+
const rows = readLastStatusReadonly(record, { lines: true })
|
|
122
|
+
const status = checkStateOf(rows, declared?.(repo.name) ?? null)
|
|
123
|
+
if (status == null) return { status, failing: [] }
|
|
124
|
+
const byId = new Map(rows.map((r) => [r.id, r]))
|
|
125
|
+
const named = titles?.(repo.name) ?? null
|
|
126
|
+
const one = (id: string, st: 'fail' | 'warn'): StatusCheck => {
|
|
127
|
+
const r = byId.get(id)
|
|
128
|
+
return { id, title: named?.get(id) ?? id, status: st, at: status.since[id] ?? null, line: r == null ? '' : findingOf(r.lines, st, id) }
|
|
129
|
+
}
|
|
130
|
+
return { status, failing: [...status.reds.map((id) => one(id, 'fail')), ...status.warns.map((id) => one(id, 'warn'))] }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** one repo, from `since` (epoch ms) to now */
|
|
134
|
+
export async function repoActivity(repo: ActivityRepo, since: number, declared: Declared | null = null, titles: Titles | null = null): Promise<ActivityRow> {
|
|
135
|
+
const empty = parseStatus('')
|
|
136
|
+
const row: ActivityRow = { name: repo.name, ...empty, commits: [], last: null, ...statusOf(repo, declared, titles), error: null }
|
|
137
|
+
try {
|
|
138
|
+
const iso = new Date(since).toISOString()
|
|
139
|
+
const range = [`--since=${iso}`, `--max-count=${MAX_COMMITS}`, '--no-show-signature', FORMAT]
|
|
140
|
+
// every ask that needs no other answer goes at once: git calls are
|
|
141
|
+
// ~5ms each but queue behind a busy daemon, so waiting for them one
|
|
142
|
+
// by one multiplies the wait. The upstream diff and the last commit
|
|
143
|
+
// are cheap enough to ask even when the answer goes unused
|
|
144
|
+
const [st, up, head, refs, one] = await Promise.all([
|
|
145
|
+
git(repo.path, ['status', '--porcelain=v2', '--branch']),
|
|
146
|
+
git(repo.path, ['rev-list', `--max-count=${MAX_COMMITS}`, '@{upstream}..HEAD']),
|
|
147
|
+
// a repo with no commit yet has no HEAD to walk: nothing committed
|
|
148
|
+
git(repo.path, ['log', ...range, 'HEAD']),
|
|
149
|
+
// the other local branches (an agent's worktree) whose tip moved in
|
|
150
|
+
// the window, minus what HEAD has: a repo with hundreds of branches
|
|
151
|
+
// walks only the few that moved
|
|
152
|
+
git(repo.path, ['for-each-ref', '--format=%(refname:short)%09%(committerdate:unix)%09%(upstream:short)%09%(upstream:track)', 'refs/heads']),
|
|
153
|
+
git(repo.path, ['log', '-1', '--no-show-signature', FORMAT, 'HEAD']),
|
|
154
|
+
])
|
|
155
|
+
if (st.code !== 0) return { ...row, error: st.err || `git status exited ${st.code}` }
|
|
156
|
+
Object.assign(row, parseStatus(st.out))
|
|
157
|
+
// HEAD's unpushed commits: all of them without an upstream, else the
|
|
158
|
+
// `ahead` ones
|
|
159
|
+
const headAway = row.upstream == null || row.gone
|
|
160
|
+
const away = !headAway && (row.ahead ?? 0) > 0 && up.code === 0 ? new Set(up.out.split('\n').filter((l) => l !== '')) : new Set<string>()
|
|
161
|
+
const own = head.code === 0 ? parseLog(head.out, true, (h) => headAway || away.has(h)) : []
|
|
162
|
+
const moved = (refs.code === 0 ? refs.out.split('\n') : [])
|
|
163
|
+
.map((l) => l.split('\t'))
|
|
164
|
+
.filter((f) => f.length >= 2 && f[0] !== row.branch && Number(f[1]) * 1000 >= since)
|
|
165
|
+
// an upstream that is gone holds nothing
|
|
166
|
+
.map((f) => (f[3] === '[gone]' ? [f[0]!, f[1]!, ''] : f))
|
|
167
|
+
let other: ActivityCommit[] = []
|
|
168
|
+
if (moved.length > 0) {
|
|
169
|
+
const side = await git(repo.path, ['log', ...range, '--source', ...moved.map((f) => `refs/heads/${f[0]}`), '--not', 'HEAD', '--'])
|
|
170
|
+
// a side branch's commit is pushed once its own upstream holds it;
|
|
171
|
+
// on a failed ask every one reads unpushed, never pushed
|
|
172
|
+
const tracked = moved.filter((f) => (f[2] ?? '') !== '')
|
|
173
|
+
let sideAway = new Set<string>()
|
|
174
|
+
if (tracked.length > 0) {
|
|
175
|
+
const r = await git(repo.path, ['rev-list', `--since=${iso}`, ...tracked.map((f) => `refs/heads/${f[0]}`), '--not', ...tracked.map((f) => f[2]!), '--'])
|
|
176
|
+
if (r.code === 0) sideAway = new Set(r.out.split('\n').filter((l) => l !== ''))
|
|
177
|
+
else sideAway = new Set(['*'])
|
|
178
|
+
}
|
|
179
|
+
const untracked = new Set(moved.filter((f) => (f[2] ?? '') === '').map((f) => f[0]!))
|
|
180
|
+
other = side.code === 0 ? parseLog(side.out, false, (h) => sideAway.has('*') || sideAway.has(h)).map((c) => (c.branch != null && untracked.has(c.branch) ? { ...c, pushed: false } : c)) : []
|
|
181
|
+
}
|
|
182
|
+
row.commits = [...own, ...other].sort((a, b) => b.at - a.at).slice(0, MAX_COMMITS)
|
|
183
|
+
// an empty window still says what came last on HEAD
|
|
184
|
+
if (row.commits.length === 0) {
|
|
185
|
+
row.last = one.code === 0 ? parseLog(one.out, true, (h) => headAway || away.has(h))[0] ?? null : null
|
|
186
|
+
}
|
|
187
|
+
return row
|
|
188
|
+
} catch (e) {
|
|
189
|
+
return { ...row, error: (e as Error).message }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** how many repos a pass reads at once: five git calls each, so a pass
|
|
194
|
+
* never has more than 30 git processes alive */
|
|
195
|
+
export const ACTIVITY_CONCURRENCY = 6
|
|
196
|
+
|
|
197
|
+
/** every repo, ACTIVITY_CONCURRENCY at a time, rows in the order given.
|
|
198
|
+
* THE computation: the daemon's route and `shipkit cross-repo-activity`
|
|
199
|
+
* both call it, and only render it differently */
|
|
200
|
+
export async function activityOf(repos: ActivityRepo[], minutes: number, now = Date.now(), declared: Declared | null = null, titles: Titles | null = null): Promise<ActivityAnswer> {
|
|
201
|
+
const t0 = performance.now()
|
|
202
|
+
const rows: ActivityRow[] = new Array(repos.length)
|
|
203
|
+
let next = 0
|
|
204
|
+
const worker = async (): Promise<void> => {
|
|
205
|
+
while (next < repos.length) {
|
|
206
|
+
const i = next++
|
|
207
|
+
rows[i] = await repoActivity(repos[i]!, now - minutes * 60_000, declared, titles)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
await Promise.all(Array.from({ length: Math.min(ACTIVITY_CONCURRENCY, repos.length) }, worker))
|
|
211
|
+
return { now, minutes, rows, ms: Math.round(performance.now() - t0) }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** one answer per window, shared for ttlMs. Past it a read waits for a new
|
|
215
|
+
* pass: an old answer is never served as current. Callers during a pass
|
|
216
|
+
* share it. */
|
|
217
|
+
export class ActivityCache {
|
|
218
|
+
private kept = new Map<number, ActivityAnswer>()
|
|
219
|
+
private running = new Map<number, Promise<ActivityAnswer>>()
|
|
220
|
+
private ttlMs: number
|
|
221
|
+
private now: () => number
|
|
222
|
+
private declared: Declared | null
|
|
223
|
+
private titles: Titles | null
|
|
224
|
+
|
|
225
|
+
constructor(o: { ttlMs?: number; now?: () => number; declared?: Declared; titles?: Titles } = {}) {
|
|
226
|
+
this.ttlMs = o.ttlMs ?? ACTIVITY_TTL_MS
|
|
227
|
+
this.now = o.now ?? Date.now
|
|
228
|
+
this.declared = o.declared ?? null
|
|
229
|
+
this.titles = o.titles ?? null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private pass(repos: ActivityRepo[], minutes: number): Promise<ActivityAnswer> {
|
|
233
|
+
const going = this.running.get(minutes)
|
|
234
|
+
if (going != null) return going
|
|
235
|
+
const p = activityOf(repos, minutes, this.now(), this.declared, this.titles)
|
|
236
|
+
.then((a) => { this.kept.set(minutes, a); return a })
|
|
237
|
+
.finally(() => this.running.delete(minutes))
|
|
238
|
+
this.running.set(minutes, p)
|
|
239
|
+
return p
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** the kept answer while it is younger than the ttl, else a new pass.
|
|
243
|
+
* `fresh` always waits for a pass: the page's refresh button */
|
|
244
|
+
get(repos: ActivityRepo[], minutes: number, o: { fresh?: boolean } = {}): Promise<ActivityAnswer> {
|
|
245
|
+
const k = this.kept.get(minutes)
|
|
246
|
+
if (k == null || o.fresh === true || this.now() - k.now >= this.ttlMs) return this.pass(repos, minutes)
|
|
247
|
+
return Promise.resolve(k)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// `shipkit serve` while a home runs: the hub itself runs in the home, and
|
|
2
|
+
// this process only streams its lines. So the VS Code folder-open task keeps its
|
|
3
|
+
// problem matcher and its clickable url, with no change to any repo's
|
|
4
|
+
// tasks.json, and closing VS Code no longer stops the hub.
|
|
5
|
+
//
|
|
6
|
+
// the log is what the home prints for that hub, so the frames the matcher
|
|
7
|
+
// reads arrive as the hub prints them. On attach, the last complete frame is replayed first,
|
|
8
|
+
// so the problems pane fills at once instead of at the next run.
|
|
9
|
+
import { closeSync, existsSync, openSync, readSync, statSync } from 'fs'
|
|
10
|
+
import { FRAME_BEGIN, FRAME_END_HEAD } from '../problems/problem-line.ts'
|
|
11
|
+
import { daemonUrl, homeDir, homeUrl, hubBase, hubLog } from './home-paths.ts'
|
|
12
|
+
|
|
13
|
+
/** the last complete problem frame in a log's text, begin to end line, or [] */
|
|
14
|
+
export function lastFrame(text: string): string[] {
|
|
15
|
+
const lines = text.split('\n')
|
|
16
|
+
for (let end = lines.length - 1; end >= 0; end--) {
|
|
17
|
+
if (!lines[end]!.startsWith(FRAME_END_HEAD)) continue
|
|
18
|
+
for (let begin = end - 1; begin >= 0; begin--) {
|
|
19
|
+
if (lines[begin] === FRAME_BEGIN) return lines.slice(begin, end + 1)
|
|
20
|
+
if (lines[begin]!.startsWith(FRAME_END_HEAD)) break
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return []
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** how often the log is looked at for new bytes */
|
|
27
|
+
export const FOLLOW_MS = 300
|
|
28
|
+
/** the home is asked again this often, so a stream says when it went away */
|
|
29
|
+
export const ENSURE_MS = 60_000
|
|
30
|
+
|
|
31
|
+
type EnsureAnswer = { status?: string; url?: string; blocked?: string | null; note?: string | null }
|
|
32
|
+
|
|
33
|
+
async function ensure(name: string): Promise<EnsureAnswer | string> {
|
|
34
|
+
try {
|
|
35
|
+
const r = await fetch(`${daemonUrl()}/api/home/hub`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, act: 'ensure' }), signal: AbortSignal.timeout(3_000) })
|
|
36
|
+
if (!r.ok) return `${r.status} ${await r.text()}`
|
|
37
|
+
return (await r.json()) as EnsureAnswer
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return (e as Error).message
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** hand the hub to the home and stream its log until killed. 'serve' when
|
|
44
|
+
* the home will not run it (not one of its repos): the caller serves itself */
|
|
45
|
+
export async function attachToHome(name: string, io: { print: (l: string) => void; printErr: (l: string) => void }): Promise<number | 'serve'> {
|
|
46
|
+
const first = await ensure(name)
|
|
47
|
+
if (typeof first === 'string' || first.blocked != null) {
|
|
48
|
+
io.print(` the home did not take ${name}'s hub (${typeof first === 'string' ? first : first.blocked}), so this process serves it`)
|
|
49
|
+
return 'serve'
|
|
50
|
+
}
|
|
51
|
+
io.print(`${name} hub → ${homeUrl().replace('127.0.0.1', 'localhost')}${first.url ?? `${hubBase(name)}/`} (run by the home)`)
|
|
52
|
+
const log = hubLog(homeDir(), name)
|
|
53
|
+
let pos = 0
|
|
54
|
+
if (existsSync(log)) {
|
|
55
|
+
const text = await Bun.file(log).text()
|
|
56
|
+
for (const l of lastFrame(text)) io.print(l)
|
|
57
|
+
pos = Buffer.byteLength(text)
|
|
58
|
+
}
|
|
59
|
+
let partial = ''
|
|
60
|
+
const follow = (): void => {
|
|
61
|
+
let size: number
|
|
62
|
+
try {
|
|
63
|
+
size = statSync(log).size
|
|
64
|
+
} catch {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
// the home cut the log (capLog) or restarted the hub on a new one
|
|
68
|
+
if (size < pos) pos = 0
|
|
69
|
+
if (size === pos) return
|
|
70
|
+
const fd = openSync(log, 'r')
|
|
71
|
+
try {
|
|
72
|
+
const buf = Buffer.alloc(size - pos)
|
|
73
|
+
readSync(fd, buf, 0, buf.length, pos)
|
|
74
|
+
pos = size
|
|
75
|
+
const text = partial + buf.toString('utf8')
|
|
76
|
+
const lines = text.split('\n')
|
|
77
|
+
partial = lines.pop() ?? ''
|
|
78
|
+
for (const l of lines) io.print(l)
|
|
79
|
+
} finally {
|
|
80
|
+
closeSync(fd)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
setInterval(follow, FOLLOW_MS)
|
|
84
|
+
let warned = false
|
|
85
|
+
setInterval(() => {
|
|
86
|
+
void ensure(name).then((r) => {
|
|
87
|
+
if (typeof r === 'string') {
|
|
88
|
+
if (!warned) io.printErr(`WARN the home stopped answering (${r}), so this repo's hub is down with it until launchd brings the home back`)
|
|
89
|
+
warned = true
|
|
90
|
+
} else warned = false
|
|
91
|
+
})
|
|
92
|
+
}, ENSURE_MS)
|
|
93
|
+
await new Promise(() => {})
|
|
94
|
+
return 0
|
|
95
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// any shipkit command on a machine with a house starts pm first when it does
|
|
2
|
+
// not answer: one localhost probe, then a launchd kickstart, and the agent is
|
|
3
|
+
// installed from the house repo the first time. The command itself never waits
|
|
4
|
+
// for pm: a client that needs the daemon (ci, check, serve) waits on its own.
|
|
5
|
+
//
|
|
6
|
+
// the one constraint a reader breaks first: a `shipkit pm uninstall` is a
|
|
7
|
+
// machine saying no. It leaves an off marker, and nothing here installs pm
|
|
8
|
+
// again until `shipkit pm install` lifts it.
|
|
9
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
|
10
|
+
import { join } from 'path'
|
|
11
|
+
import { fileURLToPath } from 'url'
|
|
12
|
+
import { SLOT_HELD_ENV } from '../core/slots.ts'
|
|
13
|
+
import { homeDir, pmUp, WORKER_ENV } from './home-paths.ts'
|
|
14
|
+
import { installHome, LABEL } from './home-launchd.ts'
|
|
15
|
+
import { homeInstalled, houseCli, houseRootOf } from './home-start.ts'
|
|
16
|
+
|
|
17
|
+
type Env = Record<string, string | undefined>
|
|
18
|
+
const set = (v: string | undefined): boolean => v != null && v !== ''
|
|
19
|
+
|
|
20
|
+
/** the marker `pm uninstall` leaves: pm stays down until `pm install` */
|
|
21
|
+
export const offMarker = (dir = homeDir()): string => join(dir, 'autostart-off')
|
|
22
|
+
|
|
23
|
+
export function markAutostart(on: boolean, dir = homeDir()): void {
|
|
24
|
+
if (on) rmSync(offMarker(dir), { force: true })
|
|
25
|
+
else {
|
|
26
|
+
mkdirSync(dir, { recursive: true })
|
|
27
|
+
writeFileSync(offMarker(dir), 'shipkit pm uninstall stopped pm here. `shipkit pm install` starts it again\n')
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** the commands that ARE the home, or talk to launchd themselves */
|
|
32
|
+
const OWN = new Set(['pm', 'home', 'daemon', 'web', 'gui'])
|
|
33
|
+
|
|
34
|
+
/** why this command does not start pm, or null when it should */
|
|
35
|
+
export function autostartSkip(argv: string[], env: Env, o: { platform?: string; off?: () => boolean } = {}): string | null {
|
|
36
|
+
if (OWN.has(argv[0] ?? '')) return 'a home command'
|
|
37
|
+
if (set(env.CI)) return 'CI'
|
|
38
|
+
if (set(env.SHIPKIT_NO_HOME)) return 'SHIPKIT_NO_HOME'
|
|
39
|
+
if (set(env[WORKER_ENV])) return 'inside the home'
|
|
40
|
+
if (set(env[SLOT_HELD_ENV])) return 'inside a check'
|
|
41
|
+
if ((o.platform ?? process.platform) !== 'darwin') return 'no launchd'
|
|
42
|
+
if ((o.off ?? (() => existsSync(offMarker())))()) return 'pm uninstalled'
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type AutostartDeps = {
|
|
47
|
+
up: () => Promise<boolean>
|
|
48
|
+
installed: () => boolean
|
|
49
|
+
/** the repo that hosts the house wiki, or null on a machine with none */
|
|
50
|
+
house: () => string | null
|
|
51
|
+
install: (root: string) => Promise<number>
|
|
52
|
+
kickstart: () => Promise<number>
|
|
53
|
+
printErr: (l: string) => void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type AutostartResult = 'up' | 'kicked' | 'installed' | 'no house' | 'failed'
|
|
57
|
+
|
|
58
|
+
/** pm answers, or is on its way. Never throws: a command runs even when pm
|
|
59
|
+
* cannot start, and says so once */
|
|
60
|
+
export async function ensurePm(d: AutostartDeps): Promise<AutostartResult> {
|
|
61
|
+
if (await d.up()) return 'up'
|
|
62
|
+
try {
|
|
63
|
+
if (!d.installed()) {
|
|
64
|
+
const root = d.house()
|
|
65
|
+
if (root == null) return 'no house'
|
|
66
|
+
if ((await d.install(root)) !== 0) {
|
|
67
|
+
d.printErr(`WARN pm is down and its LaunchAgent would not install from ${root}: \`shipkit pm install\` there says why`)
|
|
68
|
+
return 'failed'
|
|
69
|
+
}
|
|
70
|
+
return 'installed'
|
|
71
|
+
}
|
|
72
|
+
const code = await d.kickstart()
|
|
73
|
+
if (code !== 0) {
|
|
74
|
+
d.printErr(`WARN pm is down and launchctl kickstart ${LABEL} failed (exit ${code}): \`shipkit pm install\` from the house repo reinstalls it`)
|
|
75
|
+
return 'failed'
|
|
76
|
+
}
|
|
77
|
+
return 'kicked'
|
|
78
|
+
} catch (e) {
|
|
79
|
+
d.printErr(`WARN pm is down and could not be started: ${(e as Error).message}`)
|
|
80
|
+
return 'failed'
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const OWN_CLI = fileURLToPath(new URL('../cli.ts', import.meta.url))
|
|
85
|
+
const LIB_DIR = fileURLToPath(new URL('../..', import.meta.url))
|
|
86
|
+
|
|
87
|
+
/** the real machine's wiring: cwd first, else the house this lib lives in */
|
|
88
|
+
export function machineAutostart(cwd: string): AutostartDeps {
|
|
89
|
+
const quiet = { print: (l: string) => { if (l.startsWith('FAIL')) process.stderr.write(`${l}\n`) } }
|
|
90
|
+
return {
|
|
91
|
+
up: () => pmUp(undefined, 300),
|
|
92
|
+
installed: homeInstalled,
|
|
93
|
+
house: () => houseRootOf(cwd) ?? houseRootOf(LIB_DIR),
|
|
94
|
+
install: (root) => installHome({ root, cli: houseCli(root, OWN_CLI), ...quiet }),
|
|
95
|
+
kickstart: async () => {
|
|
96
|
+
const p = Bun.spawn(['launchctl', 'kickstart', `gui/${process.getuid?.() ?? 501}/${LABEL}`], { stdout: 'ignore', stderr: 'ignore' })
|
|
97
|
+
return p.exited
|
|
98
|
+
},
|
|
99
|
+
printErr: (l) => process.stderr.write(`${l}\n`),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** the cli's first step: start pm when this command should */
|
|
104
|
+
export async function autostart(argv: string[], env: Env = process.env, cwd = process.cwd()): Promise<AutostartResult | string> {
|
|
105
|
+
const skip = autostartSkip(argv, env)
|
|
106
|
+
if (skip != null) return skip
|
|
107
|
+
return ensurePm(machineAutostart(cwd))
|
|
108
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// the `.shipkit.ts` units of every house repo that belong to the machine, not
|
|
2
|
+
// to one repo: its global processes (pm's) and its widgets (the daemon's).
|
|
3
|
+
// Read by importing each file, so the reader must have registered the one
|
|
4
|
+
// copy of the lib first (core/one-lib.ts): a unit file imports it by name.
|
|
5
|
+
import { relative, resolve } from 'path'
|
|
6
|
+
import { importFresh, repoUnitFiles } from '../core/discover-checks.ts'
|
|
7
|
+
import { unitKind, type GlobalProcessUnit, type WidgetUnit } from '../shipkit-files.ts'
|
|
8
|
+
|
|
9
|
+
/** one button of a global process, as pm runs it */
|
|
10
|
+
export type ActionDecl = { id: string; label: string; cmd: string[] | null }
|
|
11
|
+
|
|
12
|
+
/** a global process as pm holds it: plain data, paths absolute */
|
|
13
|
+
export type GlobalProcessDecl = {
|
|
14
|
+
id: string
|
|
15
|
+
label: string
|
|
16
|
+
description: string | null
|
|
17
|
+
where: string | null
|
|
18
|
+
cmd: string[]
|
|
19
|
+
down: string[] | null
|
|
20
|
+
/** absolute: where cmd, down and every action run */
|
|
21
|
+
cwd: string
|
|
22
|
+
url: string | null
|
|
23
|
+
probe: string | null
|
|
24
|
+
/** as declared: an emoji, or an image path relative to root */
|
|
25
|
+
icon: string | null
|
|
26
|
+
actions: ActionDecl[]
|
|
27
|
+
/** the repo that declares it, and the file, repo-relative */
|
|
28
|
+
repo: string
|
|
29
|
+
root: string
|
|
30
|
+
file: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type RepoRef = { name: string; path: string }
|
|
34
|
+
|
|
35
|
+
export type LoadedWidget = { repo: string; file: string; def: WidgetUnit }
|
|
36
|
+
|
|
37
|
+
export type GlobalUnits = {
|
|
38
|
+
processes: GlobalProcessDecl[]
|
|
39
|
+
widgets: LoadedWidget[]
|
|
40
|
+
/** a file that would not load, or a process id declared twice */
|
|
41
|
+
errors: string[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function toDecl(u: GlobalProcessUnit, repo: RepoRef, file: string): GlobalProcessDecl {
|
|
45
|
+
return {
|
|
46
|
+
id: u.id,
|
|
47
|
+
label: u.label ?? u.id,
|
|
48
|
+
description: u.description ?? null,
|
|
49
|
+
where: u.where ?? null,
|
|
50
|
+
cmd: u.cmd,
|
|
51
|
+
down: u.down ?? null,
|
|
52
|
+
cwd: resolve(repo.path, u.cwd ?? '.'),
|
|
53
|
+
url: u.url ?? null,
|
|
54
|
+
probe: u.probe ?? u.url ?? null,
|
|
55
|
+
icon: u.icon ?? null,
|
|
56
|
+
actions: Object.entries(u.actions ?? {}).map(([id, a]) => ({ id, label: a.label, cmd: a.cmd ?? null })),
|
|
57
|
+
repo: repo.name,
|
|
58
|
+
root: repo.path,
|
|
59
|
+
file: relative(repo.path, file).replace(/\\/g, '/'),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** every global process and widget the repos declare. A process id is the
|
|
64
|
+
* machine's: a second declaration of one is an error, the first one wins */
|
|
65
|
+
export async function scanGlobalUnits(repos: RepoRef[], o: { files?: (root: string) => string[] } = {}): Promise<GlobalUnits> {
|
|
66
|
+
const out: GlobalUnits = { processes: [], widgets: [], errors: [] }
|
|
67
|
+
for (const repo of repos) {
|
|
68
|
+
for (const f of (o.files ?? repoUnitFiles)(repo.path)) {
|
|
69
|
+
const rel = relative(repo.path, f).replace(/\\/g, '/')
|
|
70
|
+
let d: unknown
|
|
71
|
+
try {
|
|
72
|
+
d = await importFresh(f)
|
|
73
|
+
} catch (e) {
|
|
74
|
+
out.errors.push(`${repo.name}/${rel}: ${(e as Error).message}`)
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
const kind = unitKind(d)
|
|
78
|
+
if (kind === 'globalProcess') {
|
|
79
|
+
const u = d as GlobalProcessUnit
|
|
80
|
+
const first = out.processes.find((p) => p.id === u.id)
|
|
81
|
+
if (first != null) out.errors.push(`${repo.name}/${rel}: global process '${u.id}' is already declared by ${first.repo}/${first.file}`)
|
|
82
|
+
else out.processes.push(toDecl(u, repo, f))
|
|
83
|
+
} else if (kind === 'widget') out.widgets.push({ repo: repo.name, file: rel, def: d as WidgetUnit })
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out
|
|
87
|
+
}
|