rvlib-shipkit 2.1.1 → 2.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 +50 -0
- package/package.json +2 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/assets/kits/identity-kit.ts +7 -3
- package/src/assets/run-assets.ts +18 -4
- package/src/checks/SK004-gitignore-policy.check.ts +11 -6
- package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
- package/src/cli/cmd_Calls.ts +53 -0
- package/src/cli/cmd_Commands.ts +163 -43
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +361 -0
- package/src/cli/kit/builtins.ts +40 -0
- package/src/cli/kit/calls.ts +177 -0
- package/src/cli/kit/families.ts +67 -0
- package/src/cli/kit/index.ts +8 -0
- package/src/cli/kit/pages-check.ts +36 -0
- package/src/cli/kit/project-cli.ts +73 -0
- package/src/cli/kit/verb.ts +167 -0
- package/src/cli.ts +15 -2
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/layout.ts +12 -2
- package/src/core/migrate-layout.ts +31 -8
- package/src/core/one-way-in-policy.ts +12 -4
- package/src/reflect/prompt-corpus.ts +15 -114
- package/src/reflect/transcript.ts +254 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +150 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +854 -0
- package/src/review/project.ts +110 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +228 -0
- package/src/review/store.ts +1002 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +165 -0
- package/src/review/watchers.ts +54 -0
- package/src/scaffold/cli-skeleton.ts +149 -0
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/files/tsconfig.json +3 -0
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/scaffold/project-scaffold.ts +90 -12
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +28 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +10 -2
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +45 -13
- package/src/serve/hub-app/icons.tsx +9 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +22 -4
- package/src/serve/hub-app/project-list.tsx +41 -8
- package/src/serve/hub-app/projects-svc.ts +4 -0
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +45 -3
- package/src/serve/hub-app/review/clip-st.ts +208 -0
- package/src/serve/hub-app/review/kind-view.tsx +171 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +239 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +101 -0
- package/src/serve/hub-app/review/review-st.ts +779 -0
- package/src/serve/hub-app/review/review-views.tsx +503 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +329 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-runtime.ts +5 -3
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +8 -0
- package/src/shipkit-files.ts +12 -4
- package/src/shot/client.ts +67 -0
- package/src/shot/clip-encoder.ts +146 -0
- package/src/shot/dom-clip.ts +124 -0
- package/src/shot/serve-shot.ts +54 -0
- package/src/shot/shooter.ts +343 -0
- package/src/shot/shot-ask.ts +189 -0
- package/src/shot/shot.shipkit.ts +14 -0
- package/src/smoke/run-smoke.ts +2 -2
- package/src/start-repo.ts +29 -14
- package/src/suite/judge-suites.ts +56 -10
- package/src/suite/select.ts +2 -5
- package/src/suite/suite-cli.ts +3 -2
- package/src/trailer/adapter.ts +78 -0
- package/src/trailer/build.ts +603 -0
- package/src/trailer/ffmpeg.ts +72 -0
- package/src/trailer/footage.ts +173 -0
- package/src/trailer/grammar.ts +1290 -0
- package/src/trailer/graph.ts +508 -0
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +31 -0
- package/src/trailer/look.ts +124 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +192 -0
- package/src/trailer/prune.ts +193 -0
- package/src/trailer/score/compose.ts +240 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +91 -0
- package/src/trailer/score/score.ts +214 -0
- package/src/trailer/score/sound.ts +112 -0
- package/src/trailer/score/synth.ts +361 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +233 -0
- package/src/trailer/titles.ts +379 -0
- package/src/trailer/url.ts +108 -0
- package/src/trailer/vertical-draw.ts +286 -0
- package/src/trailer/vertical.ts +148 -0
- package/src/trailer/vocab.ts +59 -0
- package/src/trailer/words.ts +254 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// the browser half of the shots: ONE headless chromium, ONE booted page kept
|
|
2
|
+
// warm, one request at a time on it (`Turns`). A still reuses the page when
|
|
3
|
+
// it already shows the url (its own HMR keeps it current), so a warm shot is
|
|
4
|
+
// a ready check plus chromium's fast capture. A clip films on a page of its
|
|
5
|
+
// own in the same browser, so it never disturbs the warm page: a page that IS
|
|
6
|
+
// a canvas through the page's WebCodecs (clip-encoder.ts), any other page
|
|
7
|
+
// through chromium's screencast and ffmpeg (dom-clip.ts).
|
|
8
|
+
//
|
|
9
|
+
// it never slows the machine's other work: the browser runs niced, the page
|
|
10
|
+
// stays live only LIVE_MS after a request and is then frozen (no frame, no
|
|
11
|
+
// timer, no cpu), a frozen page is loaded again before its next still (an
|
|
12
|
+
// edit it missed while frozen never reaches a shot), and the browser closes
|
|
13
|
+
// after BROWSER_IDLE_MS.
|
|
14
|
+
//
|
|
15
|
+
// the constraint a reader breaks first: Playwright is NOT a dependency of the
|
|
16
|
+
// lib (a toolkit every repo installs carries no browser). It is resolved from
|
|
17
|
+
// the repo that runs this, through the one resolver `loadPlaywright`.
|
|
18
|
+
import { mkdirSync } from 'fs'
|
|
19
|
+
import { setPriority } from 'os'
|
|
20
|
+
import { dirname } from 'path'
|
|
21
|
+
import { loadPlaywright } from '../smoke/run-smoke.ts'
|
|
22
|
+
import { CLIP_INIT, CLIP_QUANTIZER, clipDone, installClip, KEY_EVERY, muxArgs, type PageClipAsk, type PageClipDone, TAKE, takeClip } from './clip-encoder.ts'
|
|
23
|
+
import { type ClipPath, clipPathOf, domEncodeArgs, FrameTimeline, largestCanvas, screencastParams } from './dom-clip.ts'
|
|
24
|
+
import { capMsOf, type ClipAsk, pageMove, readMark, type ShotAsk, slotKey, Turns, waitReady } from './shot-ask.ts'
|
|
25
|
+
|
|
26
|
+
/** the slice of playwright the shots touch, so the lib types nothing it does not ship */
|
|
27
|
+
type Cdp = {
|
|
28
|
+
send(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>
|
|
29
|
+
on(event: string, cb: (e: Record<string, unknown>) => void): unknown
|
|
30
|
+
off(event: string, cb: (e: Record<string, unknown>) => void): unknown
|
|
31
|
+
}
|
|
32
|
+
type Page = {
|
|
33
|
+
goto(url: string, o?: { waitUntil?: string; timeout?: number }): Promise<unknown>
|
|
34
|
+
reload(o?: { waitUntil?: string; timeout?: number }): Promise<unknown>
|
|
35
|
+
evaluate<R, A>(fn: (a: A) => R | Promise<R>, arg?: A): Promise<R>
|
|
36
|
+
waitForTimeout(ms: number): Promise<void>
|
|
37
|
+
on(event: string, cb: (x: { message?: string; text?(): string; type?(): string }) => void): void
|
|
38
|
+
close(): Promise<void>
|
|
39
|
+
isClosed(): boolean
|
|
40
|
+
}
|
|
41
|
+
type Context = { newPage(): Promise<Page>; newCDPSession(p: Page): Promise<Cdp>; addInitScript(s: { content: string }): Promise<void>; close(): Promise<void> }
|
|
42
|
+
type Browser = {
|
|
43
|
+
newContext(o: { viewport: { width: number; height: number }; deviceScaleFactor: number; locale?: string }): Promise<Context>
|
|
44
|
+
close(): Promise<void>
|
|
45
|
+
isConnected(): boolean
|
|
46
|
+
on(event: 'disconnected', cb: () => void): void
|
|
47
|
+
}
|
|
48
|
+
type Playwright = { chromium: { launch(o: { headless: boolean; args: string[] }): Promise<Browser> } }
|
|
49
|
+
|
|
50
|
+
/** WebGL on the GPU over metal on a mac: in software a 3D page crawls. Audio plays with no gesture */
|
|
51
|
+
export const BROWSER_ARGS = [...(process.platform === 'darwin' ? ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist'] : []), '--autoplay-policy=no-user-gesture-required']
|
|
52
|
+
|
|
53
|
+
/** the page keeps running this long after a request (an agent's burst of shots), then it is frozen */
|
|
54
|
+
export const LIVE_MS = 10_000
|
|
55
|
+
/** the browser closes after this long with no request; the next shot pays its launch again (about 1 s) */
|
|
56
|
+
export const BROWSER_IDLE_MS = 60_000
|
|
57
|
+
/** the niceness of the process that launches the browser: chromium inherits it */
|
|
58
|
+
export const SHOT_NICE = 10
|
|
59
|
+
/** a page that paints nothing once the screencast starts is filmed from one still taken after this */
|
|
60
|
+
const FIRST_FRAME_MS = 1000
|
|
61
|
+
|
|
62
|
+
export type ShotDone = { out: string; ms: number; phases: Record<string, number>; errors: string[]; note: string }
|
|
63
|
+
|
|
64
|
+
type Slot = { key: string; url: string; ctx: Context; page: Page; cdp: Cdp; errors: string[]; frozen: boolean }
|
|
65
|
+
|
|
66
|
+
/** the roots tried for playwright, in order: the repo that runs the shots, then wherever this lib sits */
|
|
67
|
+
export async function resolvePlaywright(roots: string[]): Promise<Playwright> {
|
|
68
|
+
let last: unknown = null
|
|
69
|
+
for (const r of roots) {
|
|
70
|
+
try {
|
|
71
|
+
return await loadPlaywright<Playwright>(r)
|
|
72
|
+
} catch (e) {
|
|
73
|
+
last = e
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
throw last instanceof Error ? last : new Error(String(last))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ms = (t0: number): number => Math.round(performance.now() - t0)
|
|
80
|
+
|
|
81
|
+
const withCap = <T>(what: string, capMs: number, p: Promise<T>): Promise<T> =>
|
|
82
|
+
Promise.race([p, new Promise<T>((_, fail) => setTimeout(() => fail(new Error(`${what} took past ${capMs / 1000} s`)), capMs).unref?.())])
|
|
83
|
+
|
|
84
|
+
export class Shooter {
|
|
85
|
+
readonly #roots: string[]
|
|
86
|
+
readonly #turns = new Turns()
|
|
87
|
+
#browser: Browser | null = null
|
|
88
|
+
#launching: Promise<Browser> | null = null
|
|
89
|
+
#slot: Slot | null = null
|
|
90
|
+
#idle: ReturnType<typeof setTimeout> | null = null
|
|
91
|
+
#live: ReturnType<typeof setTimeout> | null = null
|
|
92
|
+
|
|
93
|
+
constructor(roots: string[]) {
|
|
94
|
+
this.#roots = roots
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get warm(): { browser: boolean; url: string | null; waiting: number } {
|
|
98
|
+
return { browser: this.#browser?.isConnected() === true, url: this.#slot?.url ?? null, waiting: this.#turns.waiting }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** a png of the url, written at `a.out` */
|
|
102
|
+
shot(a: ShotAsk): Promise<ShotDone> {
|
|
103
|
+
return this.#turns.run(() => withCap(`the shot of ${a.url}`, capMsOf(a), this.#shot(a)).finally(() => this.#touch()))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** an mp4 of `a.seconds` of the url, written at `a.out`: its canvas when the canvas is the page, else the page */
|
|
107
|
+
clip(a: ClipAsk): Promise<ShotDone> {
|
|
108
|
+
return this.#turns.run(() => withCap(`the clip of ${a.url}`, capMsOf(a), this.#clip(a)).finally(() => this.#touch()))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async close(): Promise<void> {
|
|
112
|
+
if (this.#idle) clearTimeout(this.#idle)
|
|
113
|
+
if (this.#live) clearTimeout(this.#live)
|
|
114
|
+
this.#slot = null
|
|
115
|
+
const b = this.#browser
|
|
116
|
+
this.#browser = null
|
|
117
|
+
await b?.close().catch(() => undefined)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#touch(): void {
|
|
121
|
+
if (this.#idle) clearTimeout(this.#idle)
|
|
122
|
+
this.#idle = setTimeout(() => void this.close(), BROWSER_IDLE_MS)
|
|
123
|
+
this.#idle.unref?.()
|
|
124
|
+
if (this.#live) clearTimeout(this.#live)
|
|
125
|
+
this.#live = setTimeout(() => void this.#turns.run(() => this.#freeze()), LIVE_MS)
|
|
126
|
+
this.#live.unref?.()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async #freeze(): Promise<void> {
|
|
130
|
+
const s = this.#slot
|
|
131
|
+
if (s == null || s.frozen || s.page.isClosed()) return
|
|
132
|
+
await s.cdp.send('Page.setWebLifecycleState', { state: 'frozen' }).catch(() => undefined)
|
|
133
|
+
s.frozen = true
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#ensureBrowser(): Promise<Browser> {
|
|
137
|
+
if (this.#browser?.isConnected()) return Promise.resolve(this.#browser)
|
|
138
|
+
this.#launching ??= (async () => {
|
|
139
|
+
const pw = await resolvePlaywright(this.#roots)
|
|
140
|
+
try {
|
|
141
|
+
setPriority(SHOT_NICE)
|
|
142
|
+
} catch {}
|
|
143
|
+
const b = await pw.chromium.launch({ headless: true, args: BROWSER_ARGS })
|
|
144
|
+
b.on('disconnected', () => {
|
|
145
|
+
if (this.#browser === b) {
|
|
146
|
+
this.#browser = null
|
|
147
|
+
this.#slot = null
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
this.#browser = b
|
|
151
|
+
return b
|
|
152
|
+
})().finally(() => (this.#launching = null))
|
|
153
|
+
return this.#launching
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async #context(a: ShotAsk): Promise<Context> {
|
|
157
|
+
const b = await this.#ensureBrowser()
|
|
158
|
+
return b.newContext({ viewport: a.viewport, deviceScaleFactor: a.dpr, locale: 'en-US' })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
static #listen(page: Page, errors: string[]): void {
|
|
162
|
+
page.on('pageerror', (e) => errors.push(`pageerror: ${e.message ?? ''}`))
|
|
163
|
+
page.on('console', (m) => {
|
|
164
|
+
if (m.type?.() === 'error') errors.push(`console: ${m.text?.() ?? ''}`)
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async #shot(a: ShotAsk): Promise<ShotDone> {
|
|
169
|
+
const phases: Record<string, number> = {}
|
|
170
|
+
const t0 = performance.now()
|
|
171
|
+
let move = pageMove(this.#slot == null || this.#slot.page.isClosed() ? null : { url: this.#slot.url, key: this.#slot.key }, a)
|
|
172
|
+
if (move === 'boot') {
|
|
173
|
+
const old = this.#slot
|
|
174
|
+
this.#slot = null
|
|
175
|
+
await old?.ctx.close().catch(() => undefined)
|
|
176
|
+
const t = performance.now()
|
|
177
|
+
const ctx = await this.#context(a)
|
|
178
|
+
const page = await ctx.newPage()
|
|
179
|
+
const errors: string[] = []
|
|
180
|
+
Shooter.#listen(page, errors)
|
|
181
|
+
this.#slot = { key: slotKey(a), url: a.url, ctx, page, cdp: await ctx.newCDPSession(page), errors, frozen: false }
|
|
182
|
+
phases['browser'] = ms(t)
|
|
183
|
+
}
|
|
184
|
+
const slot = this.#slot!
|
|
185
|
+
slot.errors.length = 0
|
|
186
|
+
const thawed = slot.frozen
|
|
187
|
+
if (slot.frozen) {
|
|
188
|
+
await slot.cdp.send('Page.setWebLifecycleState', { state: 'active' })
|
|
189
|
+
slot.frozen = false
|
|
190
|
+
}
|
|
191
|
+
// a frozen page may have missed an edit: it loads again
|
|
192
|
+
if (move === 'none' && thawed) move = 'reload'
|
|
193
|
+
if (move !== 'none') {
|
|
194
|
+
const t = performance.now()
|
|
195
|
+
slot.url = a.url
|
|
196
|
+
if (move === 'reload') await slot.page.reload({ waitUntil: 'load', timeout: 60_000 })
|
|
197
|
+
else await slot.page.goto(a.url, { waitUntil: 'load', timeout: 60_000 })
|
|
198
|
+
phases['load'] = ms(t)
|
|
199
|
+
}
|
|
200
|
+
let t = performance.now()
|
|
201
|
+
const how = await waitReady(() => slot.page.evaluate(readMark), move === 'none' ? 0 : a.waitMs, { now: () => performance.now(), sleep: (n) => slot.page.waitForTimeout(n) })
|
|
202
|
+
phases['ready'] = ms(t)
|
|
203
|
+
t = performance.now()
|
|
204
|
+
const r = await slot.cdp.send('Page.captureScreenshot', { format: 'png', optimizeForSpeed: true })
|
|
205
|
+
phases['capture'] = ms(t)
|
|
206
|
+
mkdirSync(dirname(a.out), { recursive: true })
|
|
207
|
+
await Bun.write(a.out, Buffer.from(String(r['data']), 'base64'))
|
|
208
|
+
const note = `${move === 'none' ? 'warm page' : thawed ? 'frozen page reloaded' : move}, ${how === 'ready' ? 'ready mark' : move === 'none' ? 'no wait' : `${a.waitMs} ms after load`}`
|
|
209
|
+
return { out: a.out, ms: ms(t0), phases, errors: [...slot.errors], note }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async #clip(a: ClipAsk): Promise<ShotDone> {
|
|
213
|
+
const phases: Record<string, number> = {}
|
|
214
|
+
const t0 = performance.now()
|
|
215
|
+
const ctx = await this.#context(a)
|
|
216
|
+
const errors: string[] = []
|
|
217
|
+
try {
|
|
218
|
+
await ctx.addInitScript({ content: CLIP_INIT })
|
|
219
|
+
const page = await ctx.newPage()
|
|
220
|
+
Shooter.#listen(page, errors)
|
|
221
|
+
let t = performance.now()
|
|
222
|
+
await page.goto(a.url, { waitUntil: 'load', timeout: 60_000 })
|
|
223
|
+
await waitReady(() => page.evaluate(readMark), a.waitMs, { now: () => performance.now(), sleep: (n) => page.waitForTimeout(n) })
|
|
224
|
+
phases['load'] = ms(t)
|
|
225
|
+
t = performance.now()
|
|
226
|
+
const path: ClipPath = clipPathOf({ dom: a.dom === true, canvas: await page.evaluate(largestCanvas), viewport: a.viewport })
|
|
227
|
+
const why = path === 'canvas' ? await page.evaluate(installClip, { seconds: a.seconds, fps: a.fps, quantizer: CLIP_QUANTIZER, keyEvery: KEY_EVERY } satisfies PageClipAsk) : null
|
|
228
|
+
if (path === 'dom' || why != null) {
|
|
229
|
+
const note = await filmPage(await ctx.newCDPSession(page), page, a)
|
|
230
|
+
phases['film'] = ms(t)
|
|
231
|
+
return { out: a.out, ms: ms(t0), phases, errors, note: why == null ? note : `${note}; the canvas path refused: ${why}` }
|
|
232
|
+
}
|
|
233
|
+
const parts: Buffer[] = []
|
|
234
|
+
let done: PageClipDone | null = null
|
|
235
|
+
let failed: unknown = null
|
|
236
|
+
void page.evaluate(clipDone).then(
|
|
237
|
+
(d) => (done = d),
|
|
238
|
+
(e) => (failed = e),
|
|
239
|
+
)
|
|
240
|
+
const drain = async (): Promise<void> => {
|
|
241
|
+
for (;;) {
|
|
242
|
+
const data = await page.evaluate(takeClip, TAKE)
|
|
243
|
+
if (data === '') return
|
|
244
|
+
parts.push(Buffer.from(data, 'base64'))
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
while (done == null && failed == null) {
|
|
248
|
+
await Bun.sleep(250)
|
|
249
|
+
await drain()
|
|
250
|
+
}
|
|
251
|
+
if (failed != null) throw failed instanceof Error ? failed : new Error(String(failed))
|
|
252
|
+
await drain()
|
|
253
|
+
phases['film'] = ms(t)
|
|
254
|
+
t = performance.now()
|
|
255
|
+
const stream = Buffer.concat(parts)
|
|
256
|
+
await mux(stream, a.fps, a.out)
|
|
257
|
+
phases['mux'] = ms(t)
|
|
258
|
+
const d = done as unknown as PageClipDone
|
|
259
|
+
const mb = (stream.length / 2 ** 20).toFixed(1)
|
|
260
|
+
const note = `${d.frames} frames ${d.width}x${d.height} at ${a.fps} fps (${d.repeated} repeated for a slow page), canvas ${d.canvasOfViewport}, ${mb} MB H.264 from the page's WebCodecs, muxed`
|
|
261
|
+
return { out: a.out, ms: ms(t0), phases, errors, note }
|
|
262
|
+
} finally {
|
|
263
|
+
await ctx.close().catch(() => undefined)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const ffmpegBin = (): string => {
|
|
269
|
+
const ffmpeg = Bun.which('ffmpeg') ?? (process.platform === 'darwin' ? '/opt/homebrew/bin/ffmpeg' : null)
|
|
270
|
+
if (ffmpeg == null) throw new Error('ffmpeg is not installed: brew install ffmpeg')
|
|
271
|
+
return ffmpeg
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const ffmpegFailed = (what: string, err: string, code: number | null): Error => new Error(`ffmpeg ${what}: ${err.trim().split('\n').slice(-6).join('\n') || `exit ${code}`}`)
|
|
275
|
+
|
|
276
|
+
/** the page's H.264 stream into an mp4 by ffmpeg, no second encode; loud with ffmpeg's own words */
|
|
277
|
+
export async function mux(stream: Buffer, fps: number, out: string): Promise<void> {
|
|
278
|
+
const ffmpeg = ffmpegBin()
|
|
279
|
+
mkdirSync(dirname(out), { recursive: true })
|
|
280
|
+
const p = Bun.spawn([ffmpeg, ...muxArgs(fps, out)], { stdin: new Blob([new Uint8Array(stream)]), stdout: 'ignore', stderr: 'pipe' })
|
|
281
|
+
const err = await new Response(p.stderr).text()
|
|
282
|
+
if ((await p.exited) !== 0) throw ffmpegFailed(`mux → ${out}`, err, p.exitCode)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** `a.seconds` of the page as chromium paints it, encoded by ffmpeg as the frames arrive; the note says what it filmed */
|
|
286
|
+
async function filmPage(cdp: Cdp, page: Page, a: ClipAsk): Promise<string> {
|
|
287
|
+
const ffmpeg = ffmpegBin()
|
|
288
|
+
mkdirSync(dirname(a.out), { recursive: true })
|
|
289
|
+
const tl = new FrameTimeline(a.fps, a.seconds)
|
|
290
|
+
const p = Bun.spawn([ffmpeg, ...domEncodeArgs(a.fps, tl.total, a.out)], { stdin: 'pipe', stdout: 'ignore', stderr: 'pipe' })
|
|
291
|
+
const stderr = new Response(p.stderr).text()
|
|
292
|
+
let last: Uint8Array | null = null
|
|
293
|
+
let writing: Promise<unknown> = Promise.resolve()
|
|
294
|
+
const put = (copies: number, frame: Uint8Array | null): void => {
|
|
295
|
+
if (copies <= 0 || frame == null) return
|
|
296
|
+
writing = writing.then(() => {
|
|
297
|
+
for (let i = 0; i < copies; i++) p.stdin.write(frame)
|
|
298
|
+
return p.stdin.flush()
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
let stop!: () => void
|
|
302
|
+
const stopped = new Promise<void>((ok) => (stop = ok))
|
|
303
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
304
|
+
const frame = (t: number, data: Uint8Array): void => {
|
|
305
|
+
timer ??= setTimeout(stop, a.seconds * 1000)
|
|
306
|
+
const { previous, past } = tl.arrive(t)
|
|
307
|
+
put(previous, last)
|
|
308
|
+
if (past) stop()
|
|
309
|
+
else last = data
|
|
310
|
+
}
|
|
311
|
+
let px = `${a.viewport.width}x${a.viewport.height}`
|
|
312
|
+
const onFrame = (e: Record<string, unknown>): void => {
|
|
313
|
+
void cdp.send('Page.screencastFrameAck', { sessionId: e['sessionId'] }).catch(() => undefined)
|
|
314
|
+
const m = e['metadata'] as { timestamp?: number; deviceWidth?: number; deviceHeight?: number } | undefined
|
|
315
|
+
if (timer == null && m?.deviceWidth != null && m.deviceHeight != null) px = `${Math.round(m.deviceWidth)}x${Math.round(m.deviceHeight)}`
|
|
316
|
+
frame(m?.timestamp ?? Date.now() / 1000, Buffer.from(String(e['data']), 'base64'))
|
|
317
|
+
}
|
|
318
|
+
cdp.on('Page.screencastFrame', onFrame)
|
|
319
|
+
try {
|
|
320
|
+
await cdp.send('Page.startScreencast', screencastParams(a.viewport))
|
|
321
|
+
await page.waitForTimeout(FIRST_FRAME_MS)
|
|
322
|
+
if (timer == null) {
|
|
323
|
+
const still = await cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 90 })
|
|
324
|
+
if (timer == null) frame(Date.now() / 1000, Buffer.from(String(still['data']), 'base64'))
|
|
325
|
+
}
|
|
326
|
+
await stopped
|
|
327
|
+
} finally {
|
|
328
|
+
cdp.off('Page.screencastFrame', onFrame)
|
|
329
|
+
if (timer != null) clearTimeout(timer)
|
|
330
|
+
await cdp.send('Page.stopScreencast').catch(() => undefined)
|
|
331
|
+
}
|
|
332
|
+
put(tl.end(), last)
|
|
333
|
+
const broke = await writing.then(
|
|
334
|
+
() => null,
|
|
335
|
+
(e: unknown) => e,
|
|
336
|
+
)
|
|
337
|
+
await Promise.resolve(p.stdin.end()).catch(() => undefined)
|
|
338
|
+
const err = await stderr
|
|
339
|
+
if ((await p.exited) !== 0) throw ffmpegFailed(`encode → ${a.out}`, err, p.exitCode)
|
|
340
|
+
if (broke != null) throw broke instanceof Error ? broke : new Error(String(broke))
|
|
341
|
+
const dpr = a.dpr === 1 ? '' : ` (css pixels: the screencast ignores --dpr ${a.dpr})`
|
|
342
|
+
return `${tl.total} frames ${px}${dpr} at ${a.fps} fps from ${tl.shown} the page painted (${tl.realFps.toFixed(1)} fps), filmed by chromium's screencast, H.264 by ffmpeg`
|
|
343
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// what a shot or a clip asks the warm shot service, and every pure rule about
|
|
2
|
+
// it: the flags mapped to an ask, the ready mark read, the default file. No
|
|
3
|
+
// browser, no disk, no process here: the suite tests all of it.
|
|
4
|
+
import { join, resolve } from 'path'
|
|
5
|
+
import { LAYOUT } from '../core/layout.ts'
|
|
6
|
+
|
|
7
|
+
export type Viewport = { width: number; height: number }
|
|
8
|
+
|
|
9
|
+
export type ShotAsk = {
|
|
10
|
+
url: string
|
|
11
|
+
viewport: Viewport
|
|
12
|
+
/** device pixel ratio: a png holds viewport × dpr pixels */
|
|
13
|
+
dpr: number
|
|
14
|
+
/** absolute: the service writes the file here */
|
|
15
|
+
out: string
|
|
16
|
+
/** load the page again even when the warm page already shows this url */
|
|
17
|
+
reload: boolean
|
|
18
|
+
/** a page with no ready mark is shot this long after its load (a warm page at once) */
|
|
19
|
+
waitMs: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ClipAsk = ShotAsk & {
|
|
23
|
+
seconds: number
|
|
24
|
+
fps: number
|
|
25
|
+
/** film the page itself even when a canvas fills it (dom-clip.ts); a page with no big canvas is filmed so anyway */
|
|
26
|
+
dom?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_SHOT_PORT = 4796
|
|
30
|
+
|
|
31
|
+
export const shotPort = (): number => {
|
|
32
|
+
const env = Number(process.env.SHIPKIT_SHOT_PORT)
|
|
33
|
+
return Number.isInteger(env) && env > 0 ? env : DEFAULT_SHOT_PORT
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const shotUrlBase = (port = shotPort()): string => `http://127.0.0.1:${port}`
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_VIEWPORT: Viewport = { width: 1280, height: 720 }
|
|
39
|
+
export const DEFAULT_WAIT_MS = 300
|
|
40
|
+
export const DEFAULT_FPS = 30
|
|
41
|
+
/** a clip past this length needs `--long`: a long clip is a long wait and a big file */
|
|
42
|
+
export const MAX_CLIP_SECONDS = 30
|
|
43
|
+
|
|
44
|
+
/** `1280,720` or `1280x720` */
|
|
45
|
+
export function parseViewport(s: string): Viewport {
|
|
46
|
+
const m = /^\s*(\d+)\s*[,x]\s*(\d+)\s*$/.exec(s)
|
|
47
|
+
const width = Number(m?.[1])
|
|
48
|
+
const height = Number(m?.[2])
|
|
49
|
+
if (!m || width < 16 || height < 16 || width > 7680 || height > 4320) throw new Error(`--viewport '${s}' is not w,h in pixels (16 to 7680 by 4320), like 1280,720`)
|
|
50
|
+
return { width, height }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** a bare `localhost:5173/x` or `/x` against nothing reads as http */
|
|
54
|
+
export function normalizeUrl(s: string): string {
|
|
55
|
+
const t = s.trim()
|
|
56
|
+
if (/^https?:\/\//.test(t)) return t
|
|
57
|
+
if (/^(localhost|127\.0\.0\.1|\[::1\]|[\w.-]+\.\w+)(:\d+)?(\/|$)/.test(t)) return `http://${t}`
|
|
58
|
+
if (/^:\d+(\/|$)/.test(t)) return `http://localhost${t}`
|
|
59
|
+
throw new Error(`'${s}' is not a url: give http://host:port/path, localhost:5173/path or :5173/path`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** the file a shot lands in when no `--out` is given: under the repo's local scratch, named after the url */
|
|
63
|
+
export function defaultOut(url: string, ext: 'png' | 'mp4'): string {
|
|
64
|
+
const u = new URL(url)
|
|
65
|
+
const slug = `${u.hostname}-${u.port || (u.protocol === 'https:' ? '443' : '80')}${u.pathname}${u.search}${u.hash}`
|
|
66
|
+
.replace(/[^\w.-]+/g, '-')
|
|
67
|
+
.replace(/-+/g, '-')
|
|
68
|
+
.replace(/^-|-$/g, '')
|
|
69
|
+
.slice(0, 80)
|
|
70
|
+
return join(LAYOUT.tmp, 'shots', `${slug}.${ext}`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type ShotFlags = {
|
|
74
|
+
url: string
|
|
75
|
+
viewport?: string
|
|
76
|
+
dpr?: string
|
|
77
|
+
out?: string
|
|
78
|
+
reload?: boolean
|
|
79
|
+
wait?: string
|
|
80
|
+
video?: string
|
|
81
|
+
fps?: string
|
|
82
|
+
long?: boolean
|
|
83
|
+
dom?: boolean
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const positive = (flag: string, s: string, max: number): number => {
|
|
87
|
+
const n = Number(s)
|
|
88
|
+
if (!Number.isFinite(n) || n <= 0 || n > max) throw new Error(`${flag} '${s}' must be a number above 0 and at most ${max}`)
|
|
89
|
+
return n
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** the command line mapped to the ask the service runs: every default here, every refusal loud */
|
|
93
|
+
export function askOf(f: ShotFlags, cwd: string): ShotAsk | ClipAsk {
|
|
94
|
+
const url = normalizeUrl(f.url)
|
|
95
|
+
const seconds = f.video == null ? null : positive('--video', f.video, 600)
|
|
96
|
+
if (seconds != null && seconds > MAX_CLIP_SECONDS && f.long !== true) throw new Error(`--video ${seconds}: a clip past ${MAX_CLIP_SECONDS} s needs --long (a long clip is a long wait and a big file)`)
|
|
97
|
+
if (seconds == null && f.fps != null) throw new Error('--fps only applies to a clip: add --video <seconds>')
|
|
98
|
+
if (seconds == null && f.dom === true) throw new Error('--dom only applies to a clip: add --video <seconds>')
|
|
99
|
+
const base: ShotAsk = {
|
|
100
|
+
url,
|
|
101
|
+
viewport: f.viewport == null ? DEFAULT_VIEWPORT : parseViewport(f.viewport),
|
|
102
|
+
dpr: f.dpr == null ? 1 : positive('--dpr', f.dpr, 4),
|
|
103
|
+
out: resolve(cwd, f.out ?? defaultOut(url, seconds == null ? 'png' : 'mp4')),
|
|
104
|
+
reload: f.reload === true,
|
|
105
|
+
waitMs: f.wait == null ? DEFAULT_WAIT_MS : f.wait === '0' ? 0 : positive('--wait', f.wait, 60_000),
|
|
106
|
+
}
|
|
107
|
+
if (seconds == null) return base
|
|
108
|
+
if (!base.out.endsWith('.mp4')) throw new Error(`--out '${f.out}': a clip is an mp4 (H.264)`)
|
|
109
|
+
return { ...base, seconds, fps: f.fps == null ? DEFAULT_FPS : positive('--fps', f.fps, 60), dom: f.dom === true }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const isClip = (a: ShotAsk | ClipAsk): a is ClipAsk => 'seconds' in a
|
|
113
|
+
|
|
114
|
+
/** a request held past this (a clip: past its own seconds) fails loud and frees the turn */
|
|
115
|
+
export const REQUEST_MAX_MS = 120_000
|
|
116
|
+
export const capMsOf = (a: ShotAsk | ClipAsk): number => REQUEST_MAX_MS + (isClip(a) ? a.seconds * 1000 : 0)
|
|
117
|
+
|
|
118
|
+
// ── the ready mark ──────────────────────────────────────────────────────────
|
|
119
|
+
// A page that knows when it is drawn says so: `<meta name="shot-ready"
|
|
120
|
+
// content="wait">` in its head while it settles, `content="ready"` once it is
|
|
121
|
+
// drawn. A page with the meta is shot the moment it says ready, and fails
|
|
122
|
+
// loud at the cap when it never does. A page without it is shot `waitMs`
|
|
123
|
+
// after its load.
|
|
124
|
+
|
|
125
|
+
export type ReadyState = 'ready' | 'pending' | 'silent'
|
|
126
|
+
|
|
127
|
+
/** what the page's meta says: null content means the page has no meta */
|
|
128
|
+
export const readyOf = (content: string | null): ReadyState => (content == null ? 'silent' : content.trim().toLowerCase() === 'ready' ? 'ready' : 'pending')
|
|
129
|
+
|
|
130
|
+
/** run IN the page: its source goes to the browser, so it reads nothing but the document */
|
|
131
|
+
export const readMark = (): string | null => document.querySelector('meta[name="shot-ready"]')?.getAttribute('content') ?? (document.querySelector('meta[name="shot-ready"]') ? '' : null)
|
|
132
|
+
|
|
133
|
+
export const READY_CAP_MS = 30_000
|
|
134
|
+
const POLL_MS = 16
|
|
135
|
+
|
|
136
|
+
export interface Clock {
|
|
137
|
+
now(): number
|
|
138
|
+
sleep(ms: number): Promise<void>
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** polls the page until it is ready to shoot: 'ready' once its mark says so, 'fallback' once a page with no mark had
|
|
142
|
+
* its wait. A page whose mark never says ready fails loud at the cap */
|
|
143
|
+
export async function waitReady(probe: () => Promise<string | null>, fallbackMs: number, clock: Clock, capMs = READY_CAP_MS): Promise<'ready' | 'fallback'> {
|
|
144
|
+
const t0 = clock.now()
|
|
145
|
+
for (;;) {
|
|
146
|
+
const s = readyOf(await probe())
|
|
147
|
+
if (s === 'ready') return 'ready'
|
|
148
|
+
const spent = clock.now() - t0
|
|
149
|
+
if (s === 'silent' && spent >= fallbackMs) return 'fallback'
|
|
150
|
+
if (s === 'pending' && spent >= capMs) throw new Error(`the page has <meta name="shot-ready"> and never set content="ready" in ${capMs / 1000} s`)
|
|
151
|
+
await clock.sleep(Math.min(POLL_MS, Math.max(1, (s === 'silent' ? fallbackMs : capMs) - spent)))
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── the turn ────────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
/** one request at a time on the one warm page: a job runs once every job asked before it settled, and a job that
|
|
158
|
+
* throws frees the turn like one that returns */
|
|
159
|
+
export class Turns {
|
|
160
|
+
#tail: Promise<unknown> = Promise.resolve()
|
|
161
|
+
#waiting = 0
|
|
162
|
+
|
|
163
|
+
get waiting(): number {
|
|
164
|
+
return this.#waiting
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
run<T>(job: () => Promise<T>): Promise<T> {
|
|
168
|
+
this.#waiting++
|
|
169
|
+
const mine = this.#tail.then(
|
|
170
|
+
() => job(),
|
|
171
|
+
() => job(),
|
|
172
|
+
)
|
|
173
|
+
this.#tail = mine.then(
|
|
174
|
+
() => (this.#waiting--, undefined),
|
|
175
|
+
() => (this.#waiting--, undefined),
|
|
176
|
+
)
|
|
177
|
+
return mine
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** what the slot of the one warm page is keyed by: a request of another screen boots its own page */
|
|
182
|
+
export const slotKey = (a: Pick<ShotAsk, 'viewport' | 'dpr'>): string => `${a.viewport.width}x${a.viewport.height}@${a.dpr}`
|
|
183
|
+
|
|
184
|
+
/** what the warm page must do before a still: nothing, navigate, or reload */
|
|
185
|
+
export function pageMove(current: { url: string; key: string } | null, a: ShotAsk): 'boot' | 'goto' | 'reload' | 'none' {
|
|
186
|
+
if (current == null || current.key !== slotKey(a)) return 'boot'
|
|
187
|
+
if (current.url !== a.url) return 'goto'
|
|
188
|
+
return a.reload ? 'reload' : 'none'
|
|
189
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// the warm shot service as a machine process: pm keeps it up, and `sk shot`
|
|
2
|
+
// starts it when it is down. One browser for every repo of the machine.
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
import { globalProcess } from '../shipkit-files.ts'
|
|
5
|
+
import { shotUrlBase } from './shot-ask.ts'
|
|
6
|
+
|
|
7
|
+
export default globalProcess({
|
|
8
|
+
id: 'shot',
|
|
9
|
+
label: 'shots',
|
|
10
|
+
description: 'warm headless browser: sk shot <url> gives a png, --video a clip, for every repo',
|
|
11
|
+
cmd: ['bun', join(import.meta.dir, 'serve-shot.ts')],
|
|
12
|
+
probe: `${shotUrlBase()}/health`,
|
|
13
|
+
icon: '📸',
|
|
14
|
+
})
|
package/src/smoke/run-smoke.ts
CHANGED
|
@@ -262,14 +262,14 @@ export const PLAYWRIGHT_HINT = 'FAIL playwright is not installed in this repo
|
|
|
262
262
|
|
|
263
263
|
/** resolved from the REPO, never from this package: the browser is the
|
|
264
264
|
* consumer's dependency, and the message names the two commands that fix it */
|
|
265
|
-
export async function loadPlaywright(root: string): Promise<
|
|
265
|
+
export async function loadPlaywright<T = Playwright>(root: string): Promise<T> {
|
|
266
266
|
let resolved: string
|
|
267
267
|
try {
|
|
268
268
|
resolved = Bun.resolveSync('playwright', root)
|
|
269
269
|
} catch {
|
|
270
270
|
throw new Error(PLAYWRIGHT_HINT)
|
|
271
271
|
}
|
|
272
|
-
return (await import(resolved)) as
|
|
272
|
+
return (await import(resolved)) as T
|
|
273
273
|
}
|
|
274
274
|
|
|
275
275
|
const isSelector = (spec: string): boolean => /^[#.[]/.test(spec) || /^[a-z][\w-]*(\[|:|$)/.test(spec) || spec.includes(' > ')
|
package/src/start-repo.ts
CHANGED
|
@@ -13,7 +13,8 @@ import { join } from 'path'
|
|
|
13
13
|
import { ensureGitRepo, ensureIgnoreBaseline, firstGhLine, isScratch, publishRepo, runner, type Run } from './git-bootstrap.ts'
|
|
14
14
|
import { initRepo } from './init-repo.ts'
|
|
15
15
|
import { AGENT_LINE } from './checks/SK003-claude-md-shape.check.ts'
|
|
16
|
-
import { adoptHouse, scaffoldProject, TOOLCHAIN } from './scaffold/project-scaffold.ts'
|
|
16
|
+
import { adoptCli, adoptHouse, installedLib, scaffoldProject, TOOLCHAIN } from './scaffold/project-scaffold.ts'
|
|
17
|
+
import { kitInstalled } from './scaffold/cli-skeleton.ts'
|
|
17
18
|
import { defaultHubPort } from './serve/hub-port.ts'
|
|
18
19
|
|
|
19
20
|
/** the one template: `shipkit start <name>` is the only thing anyone types,
|
|
@@ -53,6 +54,8 @@ export type StartOpts = {
|
|
|
53
54
|
github?: boolean
|
|
54
55
|
/** how `gh` runs — injected by tests, which never touch a real account */
|
|
55
56
|
gh?: Run | null
|
|
57
|
+
/** a repo with history gets the project CLI skeleton too (a started repo always does) */
|
|
58
|
+
cli?: boolean
|
|
56
59
|
/** how `bun` runs for the dep install — injected by tests, which never
|
|
57
60
|
* touch the network. An explicit null means no bun on this machine */
|
|
58
61
|
bun?: Run | null
|
|
@@ -121,18 +124,6 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
|
|
|
121
124
|
// all (a downloaded template, one stray line) otherwise skipped
|
|
122
125
|
// the baseline and refused the first commit over 7000 dep files
|
|
123
126
|
ensureIgnoreBaseline(dir)
|
|
124
|
-
// the day-one shape: tsconfig, vscode tasks, the agent wiki, the
|
|
125
|
-
// verify skill. Create-only, so a dir that already holds any of
|
|
126
|
-
// them keeps its own. The .claude/ policy files ride on the
|
|
127
|
-
// include lines: a caller that declares a house gets the house
|
|
128
|
-
// defaults, a bare `shipkit start` never has its agent
|
|
129
|
-
// permissions widened by a scaffold
|
|
130
|
-
// the port comes from the name being WIRED, which is the one on the
|
|
131
|
-
// command line: init takes the caller's name over the manifest's,
|
|
132
|
-
// and a dir found holding a forked package.json carries a name
|
|
133
|
-
// nothing will listen on
|
|
134
|
-
try { scaffoldProject(dir, { name, port: defaultHubPort(name), print, claudePolicy: house.length > 0 }) }
|
|
135
|
-
catch (e) { console.error(`WARN scaffold incomplete in ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
|
|
136
127
|
// the wired scripts call node_modules binaries, so a scaffold
|
|
137
128
|
// whose scripts exit 127 is not a started repo. Skips mirror the
|
|
138
129
|
// github half's guards: an injected runner wins, the env switch
|
|
@@ -150,12 +141,36 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
|
|
|
150
141
|
if (r.code === 0) console.error(`ok: installed ${deps.join(', ')}`)
|
|
151
142
|
else console.error(`WARN dependency install failed: ${firstGhLine(r.out)}`)
|
|
152
143
|
}
|
|
144
|
+
// the day-one shape: tsconfig, vscode tasks, the agent wiki, the
|
|
145
|
+
// verify skill. Create-only, so a dir that already holds any of
|
|
146
|
+
// them keeps its own. The .claude/ policy files ride on the
|
|
147
|
+
// include lines: a caller that declares a house gets the house
|
|
148
|
+
// defaults, a bare `shipkit start` never has its agent
|
|
149
|
+
// permissions widened by a scaffold
|
|
150
|
+
// the port comes from the name being WIRED, which is the one on the
|
|
151
|
+
// command line: init takes the caller's name over the manifest's,
|
|
152
|
+
// and a dir found holding a forked package.json carries a name
|
|
153
|
+
// nothing will listen on
|
|
154
|
+
// The install runs first, so the project CLI is written only when the
|
|
155
|
+
// installed lib carries the kit its entry imports
|
|
156
|
+
const kit = kitInstalled(installedLib(dir))
|
|
157
|
+
if (kit === false) console.error(`skip: project CLI — the installed rvlib-shipkit ${installedLib(dir)?.version ?? ''} predates the CLI kit: \`sk start ${name} --cli\` once a release carries it`)
|
|
158
|
+
try { scaffoldProject(dir, { name, port: defaultHubPort(name), print, claudePolicy: house.length > 0, projectCli: kit !== false }) }
|
|
159
|
+
catch (e) { console.error(`WARN scaffold incomplete in ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
|
|
153
160
|
}
|
|
154
161
|
// a repo with history that a house adopts: its own files win, and the
|
|
155
162
|
// house half of the day-one shape lands beside them, so the include
|
|
156
163
|
// lines below route at an agent wiki that exists
|
|
164
|
+
// the project CLI on an adopted repo is asked for, never implied: its
|
|
165
|
+
// src/cli.ts may be the product's own bin, or its CLI may live
|
|
166
|
+
// elsewhere. Before the house files, so a fresh agent index loads it
|
|
167
|
+
let cliWritten = false
|
|
168
|
+
if (wired && !startable && opts.cli === true) {
|
|
169
|
+
try { cliWritten = adoptCli(dir, { name, print }) != null }
|
|
170
|
+
catch (e) { console.error(`WARN could not write the project CLI in ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
|
|
171
|
+
}
|
|
157
172
|
if (wired && !startable && house.length > 0) {
|
|
158
|
-
try { adoptHouse(dir, { name, print }) }
|
|
173
|
+
try { adoptHouse(dir, { name, print, cli: cliWritten }) }
|
|
159
174
|
catch (e) { console.error(`WARN could not seed the house files in ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
|
|
160
175
|
}
|
|
161
176
|
let unscaffolded = false
|