rvlib-shipkit 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +2 -2
- package/src/cli/cmd_Commands.ts +26 -5
- package/src/cli/cmd_FleetPush.ts +47 -0
- package/src/cli/cmd_GlobalProcesses.ts +5 -5
- package/src/cli/cmd_Review.ts +16 -1
- package/src/cli/cmd_Trailer.ts +4 -1
- package/src/cli/family-pages.ts +49 -0
- package/src/cli/kit/builtins.ts +1 -0
- package/src/cli/registry.ts +137 -0
- package/src/cli.ts +47 -135
- package/src/core/last-good.ts +217 -0
- package/src/core/load-probe.ts +71 -0
- package/src/core/reexec.ts +5 -3
- package/src/fleet/push-fleet.ts +80 -0
- package/src/home/home-routes.ts +12 -2
- package/src/home/lib-watch.ts +110 -20
- package/src/home/process-rows.ts +4 -2
- package/src/home/run-daemon.ts +1 -1
- package/src/review/model.ts +19 -0
- package/src/review/paste.ts +30 -0
- package/src/review/routes.ts +10 -3
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/project-scaffold.ts +2 -2
- package/src/serve/hub-app/activity-st.ts +31 -0
- package/src/serve/hub-app/activity-view.tsx +9 -0
- package/src/serve/hub-app/activity.css +6 -0
- package/src/serve/hub-app/home-strip.tsx +3 -1
- package/src/serve/hub-app/machine-st.ts +1 -1
- package/src/serve/hub-app/review/review-answer.tsx +16 -37
- package/src/serve/hub-app/review/review-page.tsx +8 -1
- package/src/serve/hub-app/review/review-st.ts +51 -3
- package/src/serve/hub-app/review/review-views.tsx +1 -1
- package/src/serve/hub-app/review.css +4 -4
- package/src/serve/run-serve.ts +25 -0
- package/src/serve/serve-hub.ts +8 -1
- package/src/serve/stale-restart.ts +19 -2
- package/src/trailer/build.ts +16 -7
- package/src/trailer/ffmpeg.ts +21 -1
- package/src/trailer/grammar.ts +56 -11
- package/src/trailer/look.ts +31 -16
- package/src/trailer/prune.ts +59 -12
- package/src/trailer/score/dsp.ts +87 -27
- package/src/trailer/score/pattern.ts +1 -1
- package/src/trailer/score/synth.ts +26 -5
- package/src/trailer/titles.ts +63 -34
- package/src/trailer/vocab.ts +65 -6
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// the last good copy of the toolkit: what `sk` and the home run while the
|
|
2
|
+
// checkout fails to load. The daemon clones the source after a load that
|
|
3
|
+
// works (home/lib-watch.ts); the boot (cli.ts) runs the copy when the tree
|
|
4
|
+
// fails to load, with one line that names the file.
|
|
5
|
+
//
|
|
6
|
+
// the one constraint a reader breaks first: cli.ts imports this file
|
|
7
|
+
// statically, so it imports node builtins only. A lib import here would let
|
|
8
|
+
// a broken file stop the boot before it can fall back.
|
|
9
|
+
import { constants, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'fs'
|
|
10
|
+
import { homedir } from 'os'
|
|
11
|
+
import { join, relative } from 'path'
|
|
12
|
+
|
|
13
|
+
/** set on a process that runs the copy: the Held record, as json */
|
|
14
|
+
export const LAST_GOOD_ENV = 'SHIPKIT_LAST_GOOD'
|
|
15
|
+
|
|
16
|
+
/** why a process runs code older than its tree */
|
|
17
|
+
export type Held = {
|
|
18
|
+
/** epoch ms the tree was first seen failing */
|
|
19
|
+
since: number
|
|
20
|
+
/** the file that fails, relative to the lib (`src/trailer/graph.ts:12`) */
|
|
21
|
+
file: string
|
|
22
|
+
error: string
|
|
23
|
+
/** the lib root whose tree fails */
|
|
24
|
+
from: string
|
|
25
|
+
/** epoch ms the copy was taken, null when this process runs its own boot image */
|
|
26
|
+
copyAt: number | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** how long a replaced copy stays, from when it stopped being current: a
|
|
30
|
+
* process that runs it may still import a module from it lazily */
|
|
31
|
+
export const OLD_COPY_MS = 10 * 60_000
|
|
32
|
+
|
|
33
|
+
/** this lib's root (its package.json beside src) */
|
|
34
|
+
export const OWN_LIB = join(import.meta.dir, '../..')
|
|
35
|
+
|
|
36
|
+
const real = (p: string): string => {
|
|
37
|
+
try {
|
|
38
|
+
return realpathSync(p)
|
|
39
|
+
} catch {
|
|
40
|
+
return p
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const lastGoodHome = (env: Record<string, string | undefined> = process.env): string =>
|
|
45
|
+
env.SHIPKIT_LAST_GOOD_HOME != null && env.SHIPKIT_LAST_GOOD_HOME !== '' ? env.SHIPKIT_LAST_GOOD_HOME : join(homedir(), '.cache/shipkit/last-good')
|
|
46
|
+
|
|
47
|
+
/** one folder per lib checkout, so a worktree never runs the main checkout's copy */
|
|
48
|
+
export const lastGoodDir = (lib: string, home = lastGoodHome()): string => join(home, Bun.hash(real(lib)).toString(16).padStart(16, '0'))
|
|
49
|
+
|
|
50
|
+
const STAMP = 'stamp.json'
|
|
51
|
+
/** in a copy that is no longer current: when it stopped being */
|
|
52
|
+
const RETIRED = 'retired'
|
|
53
|
+
|
|
54
|
+
/** a copy carries a stamp: the lib it was taken from and when */
|
|
55
|
+
export function copyStamp(root: string): { at: number; from: string } | null {
|
|
56
|
+
try {
|
|
57
|
+
const j = JSON.parse(readFileSync(join(root, STAMP), 'utf8')) as { at?: number; from?: string }
|
|
58
|
+
return typeof j.at === 'number' && typeof j.from === 'string' ? { at: j.at, from: j.from } : null
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** the copy's cli and when it was taken, null when the lib has none */
|
|
65
|
+
export function lastGood(lib: string, home = lastGoodHome()): { cli: string; at: number } | null {
|
|
66
|
+
const cur = join(lastGoodDir(lib, home), 'current')
|
|
67
|
+
const s = copyStamp(cur)
|
|
68
|
+
const cli = join(cur, 'src/cli.ts')
|
|
69
|
+
return s == null || !existsSync(cli) ? null : { cli, at: s.at }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** clone the lib's source as the last good copy (APFS clone: about 0.2 s for
|
|
73
|
+
* the whole source), its node_modules linked, probe it in its own folder,
|
|
74
|
+
* then point `current` at it. Throws when the clone does not load */
|
|
75
|
+
export async function takeLastGood(lib: string, o: { home?: string; now?: number } = {}): Promise<{ dir: string; ms: number }> {
|
|
76
|
+
const t0 = performance.now()
|
|
77
|
+
const now = o.now ?? Date.now()
|
|
78
|
+
const base = lastGoodDir(lib, o.home)
|
|
79
|
+
const name = `v${now}`
|
|
80
|
+
const dir = join(base, name)
|
|
81
|
+
mkdirSync(dir, { recursive: true })
|
|
82
|
+
const cp = Bun.spawn(['cp', '-c', '-R', join(lib, 'src'), join(dir, 'src')], { stdout: 'ignore', stderr: 'pipe' })
|
|
83
|
+
if ((await cp.exited) !== 0) {
|
|
84
|
+
const err = (await new Response(cp.stderr).text()).trim()
|
|
85
|
+
rmSync(dir, { recursive: true, force: true })
|
|
86
|
+
throw new Error(`could not clone ${lib}/src: ${err}`)
|
|
87
|
+
}
|
|
88
|
+
for (const f of ['package.json', 'tsconfig.json']) if (existsSync(join(lib, f))) copyFileSync(join(lib, f), join(dir, f), constants.COPYFILE_FICLONE)
|
|
89
|
+
symlinkSync(join(real(lib), 'node_modules'), join(dir, 'node_modules'))
|
|
90
|
+
writeFileSync(join(dir, STAMP), `${JSON.stringify({ at: now, from: real(lib) })}\n`)
|
|
91
|
+
// a write that landed during the clone is in it: the copy loads in its own folder, or never becomes current
|
|
92
|
+
const { probeLoad, bootEntries } = await import('./load-probe.ts')
|
|
93
|
+
const a = await probeLoad(dir, bootEntries(dir, 'all'))
|
|
94
|
+
if (!a.ok) {
|
|
95
|
+
rmSync(dir, { recursive: true, force: true })
|
|
96
|
+
throw new Error(`the clone fails to load (${a.file}: ${a.error}), so the copy before it stays current`)
|
|
97
|
+
}
|
|
98
|
+
// a link renamed over the old one: a reader sees the old copy or the new, never none
|
|
99
|
+
const link = join(base, `.current-${process.pid}`)
|
|
100
|
+
rmSync(link, { force: true })
|
|
101
|
+
symlinkSync(name, link)
|
|
102
|
+
let was: string | null = null
|
|
103
|
+
try { was = readlinkSync(join(base, 'current')) } catch { /* the first copy */ }
|
|
104
|
+
renameSync(link, join(base, 'current'))
|
|
105
|
+
// a copy ages from when it stopped being current: a process that runs it keeps its modules
|
|
106
|
+
if (was != null && was !== name && existsSync(join(base, was))) writeFileSync(join(base, was, RETIRED), `${now}\n`)
|
|
107
|
+
for (const e of readdirSync(base)) {
|
|
108
|
+
const at = /^v(\d+)$/.exec(e)
|
|
109
|
+
if (at == null || e === name) continue
|
|
110
|
+
let since = Number(at[1])
|
|
111
|
+
try { since = Number(readFileSync(join(base, e, RETIRED), 'utf8')) } catch { /* never current: a clone that failed or was cut */ }
|
|
112
|
+
if (now - since > OLD_COPY_MS) rmSync(join(base, e), { recursive: true, force: true })
|
|
113
|
+
}
|
|
114
|
+
return { dir, ms: performance.now() - t0 }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** the lib file that imports `name` from `module`, read only on a failure */
|
|
118
|
+
function importerOf(lib: string, name: string, module: string): string | null {
|
|
119
|
+
const want = real(module).replace(/\.[cm]?[jt]sx?$/, '')
|
|
120
|
+
const re = new RegExp(`(?:import|export)\\s*(?:type\\s*)?\\{[^}]*\\b${name.replace(/[^\w$]/g, '')}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`, 'g')
|
|
121
|
+
for (const f of new Bun.Glob('src/**/*.{ts,tsx}').scanSync(lib)) {
|
|
122
|
+
const full = join(lib, f)
|
|
123
|
+
let text: string
|
|
124
|
+
try { text = readFileSync(full, 'utf8') } catch { continue }
|
|
125
|
+
if (!text.includes(name)) continue
|
|
126
|
+
for (const m of text.matchAll(re)) {
|
|
127
|
+
if (!m[1]!.startsWith('.')) continue
|
|
128
|
+
if (real(join(full, '..', m[1]!)).replace(/\.[cm]?[jt]sx?$/, '') === want) return full
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** the file a load error names, relative to `lib`, and its first line */
|
|
135
|
+
export function loadFailure(e: unknown, lib: string): { file: string; error: string } {
|
|
136
|
+
const x = (e ?? {}) as { message?: string; position?: { file?: string; line?: number } | null; referrer?: string; stack?: string }
|
|
137
|
+
const rel = (p: string): string => {
|
|
138
|
+
const r = relative(real(lib), real(p))
|
|
139
|
+
return r.startsWith('..') ? p : r
|
|
140
|
+
}
|
|
141
|
+
const error = String(x.message ?? e).split('\n')[0]!.slice(0, 200)
|
|
142
|
+
// bun names the module a missing export was asked of, never the file that asked
|
|
143
|
+
const miss = /Export named '([^']+)' not found in module '([^']+)'/.exec(error)
|
|
144
|
+
if (miss != null) {
|
|
145
|
+
const importer = importerOf(lib, miss[1]!, miss[2]!)
|
|
146
|
+
return { file: rel(importer ?? miss[2]!), error: `imports '${miss[1]}', which ${rel(miss[2]!)} does not export` }
|
|
147
|
+
}
|
|
148
|
+
if (x.position?.file != null) return { file: `${rel(x.position.file)}${x.position.line != null ? `:${x.position.line}` : ''}`, error }
|
|
149
|
+
if (typeof x.referrer === 'string' && x.referrer !== '') return { file: rel(x.referrer), error }
|
|
150
|
+
const at = /\(?((?:\/[^\s():]+)+\.[cm]?[jt]sx?):(\d+)/.exec(x.stack ?? '')
|
|
151
|
+
return { file: at == null ? '?' : `${rel(at[1]!)}:${at[2]}`, error }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const hhmm = (t: number): string => new Date(t).toTimeString().slice(0, 5)
|
|
155
|
+
|
|
156
|
+
/** when a copy was taken: HH:MM within the last hour of today, else its
|
|
157
|
+
* date, time and age, so an old copy is never mistaken for a fresh one */
|
|
158
|
+
export function copyWhen(at: number, now = Date.now()): string {
|
|
159
|
+
const d = new Date(at)
|
|
160
|
+
if (d.toDateString() === new Date(now).toDateString() && now - at < 3_600_000) return hhmm(at)
|
|
161
|
+
const age = now - at
|
|
162
|
+
const old = age < 48 * 3_600_000 ? `${Math.round(age / 3_600_000)} h old` : `${Math.floor(age / 86_400_000)} d old`
|
|
163
|
+
const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
164
|
+
return `${day} ${hhmm(at)}, ${old}`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** the one line every surface shows (the hub, the overview, a log) */
|
|
168
|
+
export const heldText = (h: Held): string =>
|
|
169
|
+
`running last good code since ${hhmm(h.since)} because ${h.file} fails to load: ${h.error}${h.copyAt == null ? '' : ` (the copy from ${copyWhen(h.copyAt)})`}`
|
|
170
|
+
|
|
171
|
+
let held: Held | null = null
|
|
172
|
+
|
|
173
|
+
/** this process's own state: a refused restart set it, or the boot that ran
|
|
174
|
+
* the copy left it in the env */
|
|
175
|
+
export function heldNow(): Held | null {
|
|
176
|
+
if (held != null) return held
|
|
177
|
+
const raw = process.env[LAST_GOOD_ENV]
|
|
178
|
+
if (raw == null || raw === '' || copyStamp(OWN_LIB) == null) return null
|
|
179
|
+
try {
|
|
180
|
+
return JSON.parse(raw) as Held
|
|
181
|
+
} catch {
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function setHeld(h: Held | null): void {
|
|
187
|
+
held = h
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** the tree this process should go back to: its own, or the one its copy stands in for */
|
|
191
|
+
export const homeLib = (): string => (copyStamp(OWN_LIB)?.from ?? OWN_LIB)
|
|
192
|
+
|
|
193
|
+
/** the boot of a tree that loads: an env a copy left is not this process's */
|
|
194
|
+
export function bootLoaded(): void {
|
|
195
|
+
if (copyStamp(OWN_LIB) == null) delete process.env[LAST_GOOD_ENV]
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** the boot of a tree that failed to load: runs the copy (never returns), or
|
|
199
|
+
* returns when there is none so the caller throws the error itself */
|
|
200
|
+
export function runLastGood(e: unknown, argv: string[]): void {
|
|
201
|
+
const f = loadFailure(e, OWN_LIB)
|
|
202
|
+
// the copy itself failing is the error to show, never a loop
|
|
203
|
+
if (copyStamp(OWN_LIB) != null) return
|
|
204
|
+
const copy = lastGood(OWN_LIB)
|
|
205
|
+
if (copy == null) {
|
|
206
|
+
process.stderr.write(`FAIL shipkit: ${f.file} fails to load (${f.error}), and this checkout has no last good copy: the home's daemon keeps one\n`)
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
const h: Held = { since: Date.now(), file: f.file, error: f.error, from: real(OWN_LIB), copyAt: copy.at }
|
|
210
|
+
process.stderr.write(`WARN shipkit: ${f.file} fails to load (${f.error}), so this runs the last good copy from ${copyWhen(copy.at)}\n`)
|
|
211
|
+
const env = Object.fromEntries(Object.entries({ ...process.env, [LAST_GOOD_ENV]: JSON.stringify(h) }).filter((x): x is [string, string] => x[1] != null))
|
|
212
|
+
const args = [process.execPath, ...process.execArgv, copy.cli, ...argv]
|
|
213
|
+
const execve = (process as { execve?: (file: string, args: string[], env: Record<string, string>) => never }).execve
|
|
214
|
+
if (typeof execve === 'function') execve.call(process, process.execPath, args, env)
|
|
215
|
+
const child = Bun.spawnSync(args, { cwd: process.cwd(), stdout: 'inherit', stderr: 'inherit', stdin: 'inherit', env })
|
|
216
|
+
process.exit(child.exitCode ?? 1)
|
|
217
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// does a tree load: a child bun imports its entries and says which file
|
|
2
|
+
// fails. Asked before a machine process restarts onto new code, and before
|
|
3
|
+
// the daemon keeps a copy as the last good one. Run as a child so a module
|
|
4
|
+
// that fails, or one whose top level runs, never touches the asking process.
|
|
5
|
+
import { join } from 'path'
|
|
6
|
+
import { loadFailure } from './last-good.ts'
|
|
7
|
+
import type { LoadVerdict } from '../serve/stale-restart.ts'
|
|
8
|
+
|
|
9
|
+
export type LoadAnswer = { ok: true; ms: number } | { ok: false; ms: number; file: string; error: string }
|
|
10
|
+
|
|
11
|
+
export type Role = 'daemon' | 'pm' | 'web' | 'serve'
|
|
12
|
+
|
|
13
|
+
const RUNNER: Record<Role, string> = { daemon: 'src/home/run-daemon.ts', pm: 'src/home/run-pm.ts', web: 'src/home/run-web.ts', serve: 'src/serve/run-serve.ts' }
|
|
14
|
+
|
|
15
|
+
/** the modules a restart through the cli loads: the boot's (cli.ts
|
|
16
|
+
* `loadBoot`: the registry and what runs before it) and the role's runner;
|
|
17
|
+
* `all`: every role's, what a copy every process may run must load */
|
|
18
|
+
export const bootEntries = (lib: string, role?: Role | 'all'): string[] => [
|
|
19
|
+
join(lib, 'src/cli.ts'),
|
|
20
|
+
...(role == null ? [] : (role === 'all' ? Object.values(RUNNER) : [RUNNER[role]]).map((r) => join(lib, r))),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
/** import `entries` in a child bun from `lib`'s own probe file */
|
|
24
|
+
export async function probeLoad(lib: string, entries: string[]): Promise<LoadAnswer> {
|
|
25
|
+
const t0 = performance.now()
|
|
26
|
+
const p = Bun.spawn([process.execPath, join(lib, 'src/core/load-probe.ts'), ...entries], { cwd: lib, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, SHIPKIT_NO_HOME: '1' } })
|
|
27
|
+
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
|
|
28
|
+
const ms = performance.now() - t0
|
|
29
|
+
if (code === 0) return { ok: true, ms }
|
|
30
|
+
try {
|
|
31
|
+
const j = JSON.parse(out.trim().split('\n').at(-1) ?? '') as { file: string; error: string }
|
|
32
|
+
return { ok: false, ms, file: j.file, error: j.error }
|
|
33
|
+
} catch {
|
|
34
|
+
// the probe file itself did not load
|
|
35
|
+
const line = err.split('\n').find((l) => /error/i.test(l)) ?? err.trim().split('\n')[0] ?? `exit ${code}`
|
|
36
|
+
return { ok: false, ms, file: 'src/core/load-probe.ts', error: line.trim().slice(0, 200) }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** one probe per state of the tree: asked again only once the key moves */
|
|
41
|
+
export class TreeProbe {
|
|
42
|
+
private key: string | null = null
|
|
43
|
+
private verdict: LoadVerdict = 'pending'
|
|
44
|
+
|
|
45
|
+
constructor(private readonly run: () => Promise<LoadAnswer>, private readonly keyOf: () => string) {}
|
|
46
|
+
|
|
47
|
+
loads(): LoadVerdict {
|
|
48
|
+
const k = this.keyOf()
|
|
49
|
+
if (k !== this.key) {
|
|
50
|
+
this.key = k
|
|
51
|
+
this.verdict = 'pending'
|
|
52
|
+
const land = (v: LoadVerdict): void => { if (this.key === k) this.verdict = v }
|
|
53
|
+
void this.run().then((a) => land(a.ok ? 'ok' : { file: a.file, error: a.error }), (e: Error) => land({ file: '?', error: e.message }))
|
|
54
|
+
}
|
|
55
|
+
return this.verdict
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (import.meta.main) {
|
|
60
|
+
const lib = join(import.meta.dir, '../..')
|
|
61
|
+
for (const e of process.argv.slice(2)) {
|
|
62
|
+
try {
|
|
63
|
+
const m = (await import(e)) as { loadBoot?: () => Promise<unknown> }
|
|
64
|
+
if (typeof m.loadBoot === 'function') await m.loadBoot()
|
|
65
|
+
} catch (x) {
|
|
66
|
+
console.log(JSON.stringify(loadFailure(x, lib)))
|
|
67
|
+
process.exit(3)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
process.exit(0)
|
|
71
|
+
}
|
package/src/core/reexec.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// code, which keeps the terminal but leaves this process waiting under it.
|
|
6
6
|
import { existsSync } from 'fs'
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/** `entry` in place of the running script: a process on the last good copy
|
|
9
|
+
* goes back to the tree's own cli (core/last-good.ts) */
|
|
10
|
+
const argvOf = (entry?: string): string[] => [process.execPath, ...process.execArgv, ...(entry == null ? process.argv.slice(1) : [entry, ...process.argv.slice(2)])]
|
|
9
11
|
|
|
10
12
|
/** why this invocation cannot run again, null when it can. Asked BEFORE a
|
|
11
13
|
* caller tears anything down, so a refusal leaves the process serving */
|
|
@@ -16,8 +18,8 @@ export function reexecBlocker(): string | null {
|
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/** never returns. `env` defaults to this process's own */
|
|
19
|
-
export function reexecSelf(env: Record<string, string | undefined> = process.env): never {
|
|
20
|
-
const argv = argvOf()
|
|
21
|
+
export function reexecSelf(env: Record<string, string | undefined> = process.env, entry?: string): never {
|
|
22
|
+
const argv = argvOf(entry)
|
|
21
23
|
const clean = Object.fromEntries(Object.entries(env).filter((e): e is [string, string] => e[1] != null))
|
|
22
24
|
const execve = (process as { execve?: (file: string, args: string[], env: Record<string, string>) => never }).execve
|
|
23
25
|
if (typeof execve === 'function') execve.call(process, process.execPath, argv, clean)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// push every house repo that holds commits its remote does not: `sk fleet
|
|
2
|
+
// push` and the activity page's push button run this one path. A plain
|
|
3
|
+
// `git push`, never a force: a branch that also fell behind its remote waits
|
|
4
|
+
// for a pull, a branch with no upstream goes to its one remote with -u, and
|
|
5
|
+
// git itself refuses anything that is not a fast-forward. Local git only, no
|
|
6
|
+
// fetch first: "behind" is as of the last fetch, and the push is what finds
|
|
7
|
+
// out for sure.
|
|
8
|
+
import { parseStatus } from '../home/activity.ts'
|
|
9
|
+
|
|
10
|
+
export type PushAction = 'push' | 'push-upstream' | 'none' | 'skip'
|
|
11
|
+
export type PushPlan = { name: string; path: string; branch: string | null; remote: string | null; ahead: number; action: PushAction; why: string }
|
|
12
|
+
export type PushResult = PushPlan & { ok: boolean; line: string }
|
|
13
|
+
|
|
14
|
+
/** at most this many pushes at once */
|
|
15
|
+
const PARALLEL = 4
|
|
16
|
+
const PUSH_MS = 60_000
|
|
17
|
+
|
|
18
|
+
async function git(cwd: string, args: string[], ms = 15_000): Promise<{ code: number; out: string; err: string }> {
|
|
19
|
+
// a push that would ask for a password fails instead of hanging the page
|
|
20
|
+
const p = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
|
|
21
|
+
const timer = setTimeout(() => p.kill(), ms)
|
|
22
|
+
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
|
|
23
|
+
clearTimeout(timer)
|
|
24
|
+
return { code, out, err }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** what to do with one repo, from `git status --porcelain=v2 --branch`, its
|
|
28
|
+
* remotes and its commit count. Pure: the tests feed it text */
|
|
29
|
+
export function planOf(repo: { name: string; path: string }, status: string, remotes: string[], commits: number): PushPlan {
|
|
30
|
+
const s = parseStatus(status)
|
|
31
|
+
const base = { name: repo.name, path: repo.path, branch: s.branch, remote: null as string | null, ahead: s.ahead ?? 0 }
|
|
32
|
+
if (s.branch == null) return { ...base, action: 'skip', why: s.detached != null ? `detached at ${s.detached}: no branch to push` : 'no commit yet' }
|
|
33
|
+
if (s.upstream == null) {
|
|
34
|
+
if (commits === 0) return { ...base, action: 'none', why: 'no commit yet' }
|
|
35
|
+
const remote = remotes.length === 1 ? remotes[0]! : remotes.includes('origin') ? 'origin' : null
|
|
36
|
+
if (remote == null) return { ...base, action: 'skip', why: remotes.length === 0 ? 'no remote to push to' : `no upstream, and ${remotes.length} remotes: set one with git push -u <remote> ${s.branch}` }
|
|
37
|
+
return { ...base, remote, ahead: commits, action: 'push-upstream', why: `no upstream: push -u ${remote} ${s.branch}` }
|
|
38
|
+
}
|
|
39
|
+
if (s.gone) return { ...base, action: 'skip', why: `its upstream ${s.upstream} is gone` }
|
|
40
|
+
const ahead = s.ahead ?? 0
|
|
41
|
+
const behind = s.behind ?? 0
|
|
42
|
+
if (ahead === 0) return { ...base, action: 'none', why: 'pushed' }
|
|
43
|
+
if (behind > 0) return { ...base, action: 'skip', why: `${ahead} ahead and ${behind} behind ${s.upstream}: pull first, never a force` }
|
|
44
|
+
return { ...base, remote: s.upstream.split('/')[0] ?? null, action: 'push', why: `${ahead} unpushed to ${s.upstream}` }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function pushPlan(repo: { name: string; path: string }): Promise<PushPlan> {
|
|
48
|
+
const [status, remotes, count] = await Promise.all([
|
|
49
|
+
git(repo.path, ['status', '--porcelain=v2', '--branch']),
|
|
50
|
+
git(repo.path, ['remote']),
|
|
51
|
+
git(repo.path, ['rev-list', '--count', 'HEAD']),
|
|
52
|
+
])
|
|
53
|
+
if (status.code !== 0) return { name: repo.name, path: repo.path, branch: null, remote: null, ahead: 0, action: 'skip', why: `git status failed: ${status.err.trim().split('\n')[0]}` }
|
|
54
|
+
return planOf(repo, status.out, remotes.out.split('\n').filter((r) => r !== ''), Number(count.out.trim()) || 0)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function pushOne(p: PushPlan): Promise<PushResult> {
|
|
58
|
+
if (p.action !== 'push' && p.action !== 'push-upstream') return { ...p, ok: p.action === 'none', line: `${p.action === 'none' ? 'ok' : 'skip'}: ${p.name}: ${p.why}` }
|
|
59
|
+
const args = p.action === 'push' ? ['push'] : ['push', '-u', p.remote!, p.branch!]
|
|
60
|
+
const r = await git(p.path, args, PUSH_MS)
|
|
61
|
+
if (r.code === 0) return { ...p, ok: true, line: `ok: ${p.name}: pushed ${p.ahead} commit${p.ahead === 1 ? '' : 's'} (${p.branch})` }
|
|
62
|
+
const why = r.err.split('\n').map((l) => l.trim()).filter((l) => l !== '' && !l.startsWith('hint:')).slice(-1)[0] ?? `git push exited ${r.code}`
|
|
63
|
+
return { ...p, ok: false, line: `FAIL: ${p.name}: git ${args.join(' ')}: ${why}` }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** every repo planned, the ones that need it pushed, a few at a time; `dry`
|
|
67
|
+
* plans only. Results come back in the repos' order */
|
|
68
|
+
export async function pushFleet(repos: { name: string; path: string }[], o: { dry?: boolean } = {}): Promise<PushResult[]> {
|
|
69
|
+
const plans = await Promise.all(repos.map(pushPlan))
|
|
70
|
+
if (o.dry === true) return plans.map((p) => ({ ...p, ok: p.action !== 'skip', line: `${p.action === 'skip' ? 'skip' : p.action === 'none' ? 'ok' : 'would push'}: ${p.name}: ${p.why}` }))
|
|
71
|
+
const out: PushResult[] = new Array(plans.length)
|
|
72
|
+
let next = 0
|
|
73
|
+
await Promise.all(Array.from({ length: PARALLEL }, async () => {
|
|
74
|
+
while (next < plans.length) {
|
|
75
|
+
const i = next++
|
|
76
|
+
out[i] = await pushOne(plans[i]!)
|
|
77
|
+
}
|
|
78
|
+
}))
|
|
79
|
+
return out
|
|
80
|
+
}
|
package/src/home/home-routes.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { existsSync } from 'fs'
|
|
|
7
7
|
import { dirname, join } from 'path'
|
|
8
8
|
import { fileURLToPath } from 'url'
|
|
9
9
|
import { installedVersion, isBehind } from '../fleet/scan-fleet.ts'
|
|
10
|
+
import { heldNow, heldText } from '../core/last-good.ts'
|
|
10
11
|
import { LIB_VERSION } from '../core/lib-version.ts'
|
|
11
12
|
import { projectGutter, type ProjectRow } from '../serve/project-gutter-route.ts'
|
|
12
13
|
import type { WiredRepo } from '../serve/project-gutter.ts'
|
|
@@ -20,6 +21,7 @@ import { ActivityCache, repoActivity, windowMinutes } from './activity.ts'
|
|
|
20
21
|
import { devRepos, InsightsCache, type Scope } from './insights.ts'
|
|
21
22
|
import { GLOBAL_CONTEXT_EVERY_MS, houseContext, writeIfChanged } from './global-context.ts'
|
|
22
23
|
import { HouseRuns } from '../serve/house-runs.ts'
|
|
24
|
+
import { pushFleet } from '../fleet/push-fleet.ts'
|
|
23
25
|
|
|
24
26
|
/** how far back a repo's git page lists its commits */
|
|
25
27
|
export const GIT_PAGE_MS = 7 * 24 * 3600_000
|
|
@@ -38,6 +40,8 @@ export type HomeAnswer = {
|
|
|
38
40
|
/** repo → its installed shipkit, for every repo behind this home's */
|
|
39
41
|
behind: Record<string, string>
|
|
40
42
|
modernize: Record<string, ModernizeView>
|
|
43
|
+
/** the daemon runs older code than its tree, and why (core/last-good.ts) */
|
|
44
|
+
held?: string | null
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
export type HomeDeps = {
|
|
@@ -156,7 +160,8 @@ export function homeRoutes(d: HomeDeps) {
|
|
|
156
160
|
}
|
|
157
161
|
const modernize: Record<string, ModernizeView> = {}
|
|
158
162
|
for (const [k, m] of modernizing) modernize[k] = { status: m.status, code: m.code, tail: tailLines(m.log, 40) }
|
|
159
|
-
|
|
163
|
+
const h = heldNow()
|
|
164
|
+
return { version: LIB_VERSION, workers: d.hubs.views(repos), active: d.state.data.active, behind, modernize, held: h == null ? null : heldText(h) }
|
|
160
165
|
}
|
|
161
166
|
|
|
162
167
|
const find = (name: unknown): WiredRepo | null => repos.find((r) => r.name === name) ?? null
|
|
@@ -171,9 +176,14 @@ export function homeRoutes(d: HomeDeps) {
|
|
|
171
176
|
}
|
|
172
177
|
if (path === '/hub.json')
|
|
173
178
|
// the identity a client probes (homeUp), through the web or here
|
|
174
|
-
return Response.json({ hub: 'shipkit-home', name: d.name, root: d.root, version: LIB_VERSION }, { headers: { 'access-control-allow-origin': '*' } })
|
|
179
|
+
return Response.json({ hub: 'shipkit-home', name: d.name, root: d.root, version: LIB_VERSION, held: heldNow() == null ? null : heldText(heldNow()!) }, { headers: { 'access-control-allow-origin': '*' } })
|
|
175
180
|
if (!sameOrigin(req)) return new Response('refused: a request that changes something must come from the home page', { status: 403 })
|
|
176
181
|
if (path.startsWith('/api/projects')) return d.gutter.handle(req, path)
|
|
182
|
+
// the activity page's push button: every house repo with unpushed commits, never a force (fleet/push-fleet.ts)
|
|
183
|
+
if (path === '/api/home/push-all' && req.method === 'POST') {
|
|
184
|
+
if (repos.length === 0) await refresh()
|
|
185
|
+
return Response.json(await pushFleet(repos.map((r) => ({ name: r.name, path: r.path }))))
|
|
186
|
+
}
|
|
177
187
|
// the web asks which page routes to serve: one per house repo
|
|
178
188
|
if (path === '/api/home/repos.json') {
|
|
179
189
|
if (repos.length === 0) await refresh()
|
package/src/home/lib-watch.ts
CHANGED
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
// started through cli.ts holds every command (one edit would restart all
|
|
6
6
|
// three). This watch covers the lib source, filtered to the files the role's
|
|
7
7
|
// own entry module reaches, and says a change once the burst of writes settles.
|
|
8
|
-
|
|
8
|
+
// A restart lands only on code that loads (core/load-probe.ts): while a file
|
|
9
|
+
// of the tree fails, the role keeps its running image and says why once. The
|
|
10
|
+
// daemon also keeps the last good copy of the tree (core/last-good.ts).
|
|
11
|
+
import { existsSync, readFileSync, realpathSync, statSync, watch, type FSWatcher } from 'fs'
|
|
9
12
|
import { dirname, extname, join, relative, resolve } from 'path'
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
13
|
+
import { copyStamp, heldNow, homeLib, LAST_GOOD_ENV, lastGood, OWN_LIB, setHeld, takeLastGood } from '../core/last-good.ts'
|
|
14
|
+
import { LIB_VERSION } from '../core/lib-version.ts'
|
|
15
|
+
import { bootEntries, probeLoad, TreeProbe } from '../core/load-probe.ts'
|
|
12
16
|
import { reexecBlocker, reexecSelf } from '../core/reexec.ts'
|
|
13
17
|
import { StaleRestart } from '../serve/stale-restart.ts'
|
|
14
18
|
|
|
@@ -56,6 +60,9 @@ export function libGraph(entry: string, o: { bundles?: boolean } = {}): Set<stri
|
|
|
56
60
|
export class LibWatch {
|
|
57
61
|
private w: FSWatcher | null = null
|
|
58
62
|
private last: { file: string; at: number } | null = null
|
|
63
|
+
private any: { file: string; at: number } | null = null
|
|
64
|
+
/** every code write under src, own or not */
|
|
65
|
+
seq = 0
|
|
59
66
|
readonly error: string | null = null
|
|
60
67
|
private now: () => number
|
|
61
68
|
private settleMs: number
|
|
@@ -64,11 +71,25 @@ export class LibWatch {
|
|
|
64
71
|
constructor(private root: string, o: { settleMs?: number; now?: () => number; only?: Set<string> } = {}) {
|
|
65
72
|
this.now = o.now ?? Date.now
|
|
66
73
|
this.settleMs = o.settleMs ?? 1_000
|
|
74
|
+
// an APFS clone of the tree (the last good copy, a scratch checkout)
|
|
75
|
+
// fires a change on every source file it reads: only a moved mtime,
|
|
76
|
+
// or a file gone, is a write
|
|
77
|
+
const mtimes = new Map<string, number>()
|
|
78
|
+
const mtimeOf = (f: string): number | null => {
|
|
79
|
+
try { return statSync(f).mtimeMs } catch { return null }
|
|
80
|
+
}
|
|
81
|
+
for (const f of new Bun.Glob('**/*').scanSync(join(root, 'src'))) if (CODE.test(f)) mtimes.set(join(root, 'src', f), mtimeOf(join(root, 'src', f)) ?? -1)
|
|
67
82
|
try {
|
|
68
83
|
this.w = watch(join(root, 'src'), { recursive: true }, (_ev, file) => {
|
|
69
84
|
if (file == null || !CODE.test(String(file))) return
|
|
85
|
+
const full = join(root, 'src', String(file))
|
|
86
|
+
const m = mtimeOf(full)
|
|
87
|
+
if (m != null && m === mtimes.get(full)) return
|
|
88
|
+
mtimes.set(full, m ?? -1)
|
|
89
|
+
this.seq++
|
|
90
|
+
this.any = { file: String(file), at: this.now() }
|
|
70
91
|
if (o.only != null && !o.only.has(join(root, 'src', String(file)))) return
|
|
71
|
-
this.last =
|
|
92
|
+
this.last = this.any
|
|
72
93
|
})
|
|
73
94
|
} catch (e) {
|
|
74
95
|
this.error = (e as Error).message
|
|
@@ -86,41 +107,92 @@ export class LibWatch {
|
|
|
86
107
|
return `the shipkit source moved (src/${this.last.file})`
|
|
87
108
|
}
|
|
88
109
|
|
|
110
|
+
/** no write anywhere under src for settleMs */
|
|
111
|
+
settled(): boolean {
|
|
112
|
+
return this.any == null || this.now() - this.any.at >= this.settleMs
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** one line on the last write anywhere under src, once settled, else null */
|
|
116
|
+
whyAny(): string | null {
|
|
117
|
+
return this.any == null || !this.settled() ? null : `the shipkit source moved (src/${this.any.file})`
|
|
118
|
+
}
|
|
119
|
+
|
|
89
120
|
close(): void {
|
|
90
121
|
this.w?.close()
|
|
91
122
|
this.w = null
|
|
92
123
|
}
|
|
93
124
|
}
|
|
94
125
|
|
|
126
|
+
/** how often the daemon may probe the tree to keep its last good copy */
|
|
127
|
+
export const KEEP_EVERY_MS = 60_000
|
|
128
|
+
|
|
129
|
+
const realOf = (p: string): string => {
|
|
130
|
+
try { return realpathSync(p) } catch { return p }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const versionAt = (root: string): string => {
|
|
134
|
+
try { return (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version?: string }).version ?? '?' } catch { return '?' }
|
|
135
|
+
}
|
|
136
|
+
|
|
95
137
|
/** a machine process restarts itself in place, same pid, once its own code
|
|
96
|
-
* moved
|
|
97
|
-
* outlive the image (its server); what it started
|
|
138
|
+
* moved, nothing it runs is in flight and the new code loads. `before`
|
|
139
|
+
* tears down what must not outlive the image (its server); what it started
|
|
140
|
+
* detached keeps running. On the last good copy it watches the tree and
|
|
141
|
+
* goes back to it once it loads. `keep`: this process keeps the copy */
|
|
98
142
|
export function reloadOnOwnCode(o: {
|
|
99
|
-
role:
|
|
143
|
+
role: 'pm' | 'daemon' | 'web'
|
|
100
144
|
/** the role's runner module: its graph is what the role reloads on */
|
|
101
145
|
entry: string
|
|
102
146
|
bundles?: boolean
|
|
103
147
|
busy?: () => boolean
|
|
104
148
|
before: () => void
|
|
149
|
+
keep?: boolean
|
|
105
150
|
io: { print: (l: string) => void; printErr: (l: string) => void }
|
|
106
151
|
}): { stop: () => void; files: number } {
|
|
107
|
-
const
|
|
108
|
-
const
|
|
152
|
+
const onCopy = copyStamp(OWN_LIB) != null
|
|
153
|
+
const tree = homeLib()
|
|
154
|
+
const graph = libGraph(onCopy ? join(tree, relative(OWN_LIB, o.entry)) : o.entry, { bundles: o.bundles })
|
|
155
|
+
const lib = new LibWatch(tree, { only: onCopy ? undefined : graph })
|
|
109
156
|
if (lib.error != null) o.io.printErr(`WARN no watch on the shipkit source (${lib.error}), so an edit of the ${o.role} needs a restart of it`)
|
|
157
|
+
const probe = new TreeProbe(() => probeLoad(tree, bootEntries(tree, o.role)), () => `${lib.seq}|${versionAt(tree)}`)
|
|
158
|
+
let since: number | null = null
|
|
159
|
+
let said = ''
|
|
160
|
+
const hold = (v: { file: string; error: string } | null): void => {
|
|
161
|
+
if (v == null) {
|
|
162
|
+
if (since != null) o.io.print(`ok: the shipkit source loads again`)
|
|
163
|
+
since = null
|
|
164
|
+
said = ''
|
|
165
|
+
if (!onCopy) setHeld(null)
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
since ??= heldNow()?.since ?? Date.now()
|
|
169
|
+
const text = `${v.file} fails to load (${v.error})`
|
|
170
|
+
if (text !== said) {
|
|
171
|
+
said = text
|
|
172
|
+
o.io.printErr(`FAIL: ${text}: sk runs the last good copy, and the ${o.role} keeps its running code until the tree loads again`)
|
|
173
|
+
}
|
|
174
|
+
setHeld({ since, file: v.file, error: v.error, from: tree, copyAt: lastGood(tree)?.at ?? null })
|
|
175
|
+
}
|
|
110
176
|
const stale = new StaleRestart({
|
|
111
177
|
boot: LIB_VERSION,
|
|
112
|
-
disk:
|
|
113
|
-
|
|
178
|
+
disk: () => versionAt(tree),
|
|
179
|
+
// on the copy, any write may heal the tree, and the first look is at once
|
|
180
|
+
moved: () => (onCopy ? (lib.whyAny() ?? (lib.seq === 0 ? 'this process runs the last good copy' : null)) : lib.why()),
|
|
114
181
|
busy: o.busy ?? (() => false),
|
|
182
|
+
loads: () => probe.loads(),
|
|
183
|
+
hold,
|
|
115
184
|
restart: (why) => {
|
|
116
185
|
const blocked = reexecBlocker()
|
|
117
186
|
if (blocked != null) throw new Error(blocked)
|
|
118
|
-
o.io.print(`ok: ${why}: the ${o.role} restarts in place`)
|
|
119
|
-
|
|
120
|
-
lib.close()
|
|
187
|
+
o.io.print(`ok: ${why}: the ${o.role} restarts in place${onCopy ? ' on the tree' : ''}`)
|
|
188
|
+
stop()
|
|
121
189
|
o.before()
|
|
122
190
|
try {
|
|
123
|
-
reexecSelf()
|
|
191
|
+
if (!onCopy) reexecSelf()
|
|
192
|
+
const env = { ...process.env }
|
|
193
|
+
delete env[LAST_GOOD_ENV]
|
|
194
|
+
// the same script in the tree: cli.ts for most roles, run-pm.ts for pm
|
|
195
|
+
reexecSelf(env, join(tree, relative(realOf(OWN_LIB), realOf(process.argv[1] ?? join(OWN_LIB, 'src/cli.ts')))))
|
|
124
196
|
} catch (e) {
|
|
125
197
|
o.io.printErr(`FAIL: the ${o.role} stopped to restart and could not: ${(e as Error).message}`)
|
|
126
198
|
process.exit(1)
|
|
@@ -128,12 +200,30 @@ export function reloadOnOwnCode(o: {
|
|
|
128
200
|
},
|
|
129
201
|
say: (text) => { if (text != null) o.io.printErr(`WARN ${text}`) },
|
|
130
202
|
})
|
|
203
|
+
// the daemon keeps the copy: after a settled burst that loads, at most every KEEP_EVERY_MS
|
|
204
|
+
// once a minute counts from the last copy's own stamp (a restart keeps it), or from a try that failed
|
|
205
|
+
let kept = -1
|
|
206
|
+
let triedAt = 0
|
|
207
|
+
const keeper = o.keep === true && !onCopy
|
|
208
|
+
? setInterval(() => {
|
|
209
|
+
if (lib.seq === kept || !lib.settled() || Date.now() - Math.max(lastGood(tree)?.at ?? 0, triedAt) < KEEP_EVERY_MS) return
|
|
210
|
+
const v = probe.loads()
|
|
211
|
+
if (v === 'pending') return
|
|
212
|
+
if (v !== 'ok') return hold(v)
|
|
213
|
+
hold(null)
|
|
214
|
+
kept = lib.seq
|
|
215
|
+
triedAt = Date.now()
|
|
216
|
+
void takeLastGood(tree).catch((e: Error) => o.io.printErr(`WARN the last good copy of the shipkit source could not be kept: ${e.message}`))
|
|
217
|
+
}, 2_000)
|
|
218
|
+
: null
|
|
219
|
+
function stop(): void {
|
|
220
|
+
stale.stop()
|
|
221
|
+
lib.close()
|
|
222
|
+
if (keeper != null) clearInterval(keeper)
|
|
223
|
+
}
|
|
131
224
|
stale.start()
|
|
132
225
|
return {
|
|
133
|
-
files: [...graph].filter((f) => !relative(
|
|
134
|
-
stop
|
|
135
|
-
stale.stop()
|
|
136
|
-
lib.close()
|
|
137
|
-
},
|
|
226
|
+
files: [...graph].filter((f) => !relative(tree, f).startsWith('..')).length,
|
|
227
|
+
stop,
|
|
138
228
|
}
|
|
139
229
|
}
|