rvlib-shipkit 2.1.1 → 2.2.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 -1
- 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 +158 -43
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Trailer.ts +108 -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 +157 -0
- package/src/cli.ts +13 -2
- package/src/core/layout.ts +10 -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 +5 -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/shipkit-files.ts +8 -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 +23 -0
- package/src/trailer/build.ts +344 -0
- package/src/trailer/ffmpeg.ts +50 -0
- package/src/trailer/footage.ts +104 -0
- package/src/trailer/grammar.ts +915 -0
- package/src/trailer/graph.ts +323 -0
- package/src/trailer/load-unit.ts +14 -0
- package/src/trailer/look.ts +110 -0
- package/src/trailer/plan.ts +133 -0
- package/src/trailer/stitch.ts +150 -0
- package/src/trailer/titles.ts +274 -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 +40 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 2.2.0
|
|
2
|
+
|
|
3
|
+
- **The project CLI kit, `rvlib-shipkit/cli-kit`, and a project CLI from `sk start`.** A repo's own CLI is `runProjectCli({ name, root, families })`: the first word loads only its family, the bare listing prints one line per command (0.027 s on a fresh repo), and every run is logged to `.shipkit/logs/<cli>-calls.jsonl`. A command over code that exists is a row, `verbs([{ path, category, description, examples, run: { bun } | { cmd } | { tool, cold } }])`, never a second implementation. A row marked `essential` lands on the everyday page, and a row marked `film: 'still' | 'clip'` takes `--out` (resolved against the caller's cwd), a clip also `--seconds`, and fails when it exits 0 without writing `--out`. The kit adds `<cli> commands` (`--out` and `--essentials` write the agent pages, `--check` fails on a stale page or a row with no category, a long description or no example starting with its words, `--json` is the registry for other tools) and `<cli> calls`. `sk start` writes the skeleton on day one: `src/cli.ts`, `src/cli/cmd_Dev.ts` with a dev row and a commented shot row, both agent pages generated, the agent index loading the essentials page, package.json `cli` and a `bin` of the repo name. `sk start <name> --cli` writes it into a repo with history, and refuses one that already has a CLI. The scaffold tsconfig names `types: ["bun"]`, which TypeScript 7 needs for Bun's globals.
|
|
4
|
+
- **`sk calls`: every sk run logged machine wide** (`~/.shipkit/logs/sk-calls.jsonl`, `SK_LOG=off` to stop), read back per command with the slowest runs, `--here` for one repo. It adds no measurable time to `sk --help`, and `sk start` now loads its scaffold only when it runs.
|
|
5
|
+
- **`sk trailer`: a `.trailer` file in any repo becomes an mp4 and its contact strip.** The trailer language, its core words: a header (`trailer`, `size`, `fps`, `music`, `duck`, `fade-in`), shots with one film line each, titles, cuts, `from`, `push`, `frame`, `lift`, `shake`, `flash`, sections in bars with their tempo and meter, `mark`, `starts on`, and music, sfx and voice from files. Every error is at its file and line, all at once, and a file with one error never builds. A film line is `clip <video>`, `image <picture>`, `card`, `run "<command {out}>"`, or a film row of the repo's own CLI: `film <words>` runs `<cli> <words> --out <file>`, with `--seconds <n>` for a clip, the rows read from `<cli> commands --json` only when a line needs them. A take is filmed once into `.shipkit/cache/trailer/footage/<key>`, keyed on what films (the key formula of pkm-multi's trailer, verbatim), and stamped with the repo's world: a stale take is reported and filmed again only on `--refilm`. The stitch renders one piece per section, cached on its ffmpeg arguments and its inputs, so a title edit renders one section and a build with nothing changed takes about 0.6 s. Five title styles (`logo`, `big`, `caption`, `corner`, `line`) and the card wear the repo's brand: the palette of `.shipkit/identity/brand.ts`, its new optional `trailer` section for the face, the logo and each colour, and a house look for a repo with no brand. A `trailer({ sound })` unit in any `<name>.shipkit.ts` resolves sound ids that are not files. Verbs `sk trailer [file]`, `sk trailer check`, `sk trailer shot <name>` (its nine frames on one png).
|
|
6
|
+
- **`sk trailer`: the vertical short from brand data, and `film url <route>`.** The header word `vertical [mark left|right] [outro card|none|<video>]` makes the cut a 9:16 short (1080x1920 unless `size` says otherwise, upright only) finished with the brand's `trailer.vertical` look: a lockup that pops in over every frame under the platforms' top tabs (a wordmark image, else the logo and the name, and a tag pill), a watermark in a bottom corner, an optional outro clip, and an end card whose image hops in, its lines with `*word*` in the accent. Each part takes its colours and face from the brand's look, each is `false` to leave it out, and a repo with no brand gets its logo and its folder name. The finish is cached on the pieces and the mix it reads: an unchanged build stays about 1 s, a title edit renders one section and the finish. `film url <route> [viewport WxH] [wait <time>] [still]` films a page of the repo's dev url (the `url` of its global process, `base <id>` when it has several, started through pm when down) through `shotUrl` and `clipUrl`, a portrait cut at a phone's width. Lifted from pkm-multi's `chi site vertical`; the footage key formula is unchanged.
|
|
7
|
+
- **`sk shot <url>`: a png of any url through one warm browser for the whole machine, `--video <s>` a clip.** The `shot` global process (declared in the lib, `src/shot/shot.shipkit.ts`) starts on the first `sk shot` and never at login. It holds one headless chromium and one booted page, one request at a time. A still of the url the page already shows is a ready check plus chromium's fast capture: 33 to 52 ms in the service, 0.11 to 0.23 s for the whole command, measured on a running vite app. A page with `<meta name="shot-ready" content="wait">` is shot once it sets `content="ready"`, any other page `--wait` ms (300) after its load. `--video` films the page's largest canvas through its own WebCodecs H.264 encoder on its animation frames and muxes the stream into an mp4 with no second encode. It stays out of the way of other work: the browser runs at nice 10, the page is frozen 10 s after a request and loaded again before the next still, and the browser closes after 60 s idle. Code calls `shotUrl` and `clipUrl` from `src/shot/client.ts`. Playwright resolves from the repo that runs the service through `loadPlaywright`, now generic over the slice its caller types.
|
|
8
|
+
- **`sk shot --video` films any web page, canvas or not.** A page that a canvas fills (half the viewport or more) still films that canvas through its WebCodecs encoder. Any other page, or any page with `--dom`, films as chromium paints it: CDP `Page.startScreencast` takes every painted frame, a frame holds on the clip's fixed grid until the next one paints (a still page fills its seconds from one frame), and ffmpeg encodes the jpegs to H.264 as they arrive. Measured on a 3 s animated page against Playwright's `recordVideo`: 90 of 90 distinct frames against 65 of 89, 3.29 s against 3.43 s from the start of filming to the closed file, and 1.8 s of CPU above an idle page against 2.7 s. A 3 s DOM clip takes 3.7 s for the whole command, the canvas path 3.6 s as before. A DOM clip is in css pixels whatever `--dpr`. `clipUrl` takes an optional `dom`, and a clip's request cap now adds its own seconds, so a `--long` clip past about 110 s no longer fails at the 120 s cap.
|
|
9
|
+
- **Plans get a tracked home, `.shipkit/plans/`.** SK004 re-includes `!.shipkit/plans/` in a private repo's managed block, so a dated plan, an arc's board and its lane files survive a cleanup of the scratch, and a lost machine once committed. A repo declared `visibility: 'public'` gets no re-include, through the same filter that keeps its comfy drafts local. The next `sk ci` fix pass rewrites the block, and nothing else changes for anyone. The old root layout migration files the dated plans at the top of `tmp/` (`YYYYMMDD-HHMMSS-<slug>.md`) under `.shipkit/plans/` and moves the rest of the scratch under `.shipkit/tmp/` as before. A plan already in `.shipkit/tmp/` is not moved: the scratch holds other files, and only a human or an agent knows which one is a plan.
|
|
10
|
+
- **`sk reflect` keeps only what the human typed.** A sub-agent hand-back (`<agent-message`) and a message from another Claude session (`<cross-session-message`, `Another Claude session sent a message`) are model output the harness parks in the queue, and the corpus counted them as queued prompts. They are dropped now, typed or queued. A real prompt that mentions `<agent-message` mid text stays.
|
|
11
|
+
- **A red test check names the file that threw outside a test.** bun can count a throw inside a describe callback as `0 fail` beside `1 error`, and the check said `FAIL: 0 test(s) failed`, a red line that read green. It now says `FAIL: 1 error(s) outside any test (tests/light/galerie.test.ts threw error: unknown room: galerie), Ran 1 tests`, and adds the failed test count only when there is one. The record behind the hub keeps that error too: the error inside bun's banner block, a `ReferenceError:` or any named error class included.
|
|
12
|
+
|
|
1
13
|
## 2.1.1
|
|
2
14
|
|
|
3
15
|
- **`--only a,b` takes a comma list** in `sk ci`, `sk cache-audit` and `sk fleet`, the same as repeating the flag.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"funding": "https://github.com/sponsors/rvion",
|
|
5
5
|
"description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
},
|
|
17
17
|
"exports": {
|
|
18
18
|
".": "./src/shipkit.ts",
|
|
19
|
+
"./cli-kit": "./src/cli/kit/index.ts",
|
|
19
20
|
"./contracts": "./src/contracts/contract-types.ts",
|
|
20
21
|
"./vite": "./src/assets/vite.ts",
|
|
21
22
|
"./src/*": "./src/*"
|
|
@@ -15,6 +15,7 @@ import { mkdirSync } from 'fs'
|
|
|
15
15
|
import { dirname, join } from 'path'
|
|
16
16
|
import sharp from 'sharp'
|
|
17
17
|
import { defineKind, type CanvasGen, type ComfyGen, type Kind } from '../kit-config.ts'
|
|
18
|
+
import { HOUSE_FAMILIES, type TrailerLookInput } from '../../trailer/look.ts'
|
|
18
19
|
|
|
19
20
|
export type Brand = {
|
|
20
21
|
name: string
|
|
@@ -29,6 +30,9 @@ export type Brand = {
|
|
|
29
30
|
/** og layout hook — replaces the WHOLE default card (bg + mark left +
|
|
30
31
|
* name/tagline right); the hook owns compositing the mark if it wants one */
|
|
31
32
|
drawOg?: (ctx: SKRSContext2D, brand: Brand, size: { w: number; h: number }) => void
|
|
33
|
+
/** the look every trailer of the repo wears (titles, card, face, logo);
|
|
34
|
+
* each value left out is derived from the palette (trailer/look.ts) */
|
|
35
|
+
trailer?: TrailerLookInput
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
export const FAVICON_SIZE = 512
|
|
@@ -52,9 +56,9 @@ export function identityKinds(brand: Brand): Kind[] {
|
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
// named faces first: the generic sans-serif can resolve to a face with no
|
|
55
|
-
// Latin-1 accents (macOS). Each family
|
|
56
|
-
//
|
|
57
|
-
const OG_FAMILIES =
|
|
59
|
+
// Latin-1 accents (macOS). Each family carries them, and an absent one is
|
|
60
|
+
// skipped (macOS, Windows, then the common Linux faces)
|
|
61
|
+
const OG_FAMILIES = HOUSE_FAMILIES
|
|
58
62
|
export const ogFont = (px: number, bold = false): string => `${bold ? 'bold ' : ''}${px}px ${OG_FAMILIES}`
|
|
59
63
|
|
|
60
64
|
// shrink px until text fits maxW (og cards must never clip the tagline)
|
package/src/assets/run-assets.ts
CHANGED
|
@@ -122,14 +122,28 @@ async function repoWebRoot(root: string): Promise<string> {
|
|
|
122
122
|
|
|
123
123
|
export type IdentityRender = { written: string[] } | { blocked: string }
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
/** the repo's brand, `.shipkit/identity/brand.ts`: null when the file is
|
|
126
|
+
* absent, a string saying why when it does not default-export one */
|
|
127
|
+
export async function loadBrand(root: string): Promise<Brand | null | string> {
|
|
126
128
|
const brandPath = join(root, '.shipkit/identity/brand.ts')
|
|
127
|
-
if (!existsSync(brandPath)) return
|
|
129
|
+
if (!existsSync(brandPath)) return null
|
|
128
130
|
// cache-bust: bun caches dynamic imports for the process lifetime — a
|
|
129
131
|
// long-running hub must re-render the EDITED brand, not the cached one
|
|
130
132
|
const mod = (await import(`${brandPath}?v=${await brandHash(root)}`)) as { default?: Brand }
|
|
131
|
-
|
|
132
|
-
|
|
133
|
+
return mod.default ?? '.shipkit/identity/brand.ts must default-export the Brand'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** the rendered logo of the identity set, repo relative, when it exists */
|
|
137
|
+
export async function identityLogo(root: string): Promise<string | null> {
|
|
138
|
+
const web = await repoWebRoot(root)
|
|
139
|
+
const file = web === '.' ? 'logo.png' : `${web}/logo.png`
|
|
140
|
+
return existsSync(join(root, file)) ? file : null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function renderIdentityNow(root: string): Promise<IdentityRender> {
|
|
144
|
+
const brand = await loadBrand(root)
|
|
145
|
+
if (brand == null) return { blocked: 'no .shipkit/identity/brand.ts — create one exporting a Brand (name, palette, mark)' }
|
|
146
|
+
if (typeof brand === 'string') return { blocked: brand }
|
|
133
147
|
let markFile: string | undefined
|
|
134
148
|
if (brand.mark.backend === 'comfy') {
|
|
135
149
|
// comfy marks render from the PICKED identity-logo roll — the kit
|
|
@@ -32,6 +32,10 @@ const CONTRIB = '# repo-contributed lines (repo.config.ts gitignore:)'
|
|
|
32
32
|
|
|
33
33
|
const DRAFTS_BACK = '!.comfy-ts/drafts/'
|
|
34
34
|
|
|
35
|
+
/** re-includes a private repo carries and a declared public one drops: a
|
|
36
|
+
* draft names private loras, a plan or a board quotes the owner */
|
|
37
|
+
const PRIVATE_ONLY: ReadonlySet<string> = new Set([DRAFTS_BACK, `!${LAYOUT.plans}/`])
|
|
38
|
+
|
|
35
39
|
export const POLICY_LINES = [
|
|
36
40
|
// everything shipkit keeps lives under .shipkit/ (core/layout.ts). Local
|
|
37
41
|
// by default: the private notes, drafts, venue listings and caches, and the
|
|
@@ -45,12 +49,13 @@ export const POLICY_LINES = [
|
|
|
45
49
|
// asks git about it: a negation below the block that exposed the keyword
|
|
46
50
|
// list would otherwise pass as a venue override
|
|
47
51
|
`${LAYOUT.private}/`,
|
|
48
|
-
// the scratch and the review files, spelled out for the same reason:
|
|
49
|
-
//
|
|
52
|
+
// the scratch and the review files, spelled out for the same reason:
|
|
53
|
+
// nothing durable lives in the scratch, and a review narrates agent process
|
|
50
54
|
`${LAYOUT.tmp}/`,
|
|
51
55
|
`${LAYOUT.reviews}/`,
|
|
52
56
|
// a root scratch stays out of git too: the fix pass moves it under
|
|
53
|
-
// .shipkit/,
|
|
57
|
+
// .shipkit/, its dated plans into .shipkit/plans/, and until it runs
|
|
58
|
+
// nothing there is exposed
|
|
54
59
|
`${LEGACY_TMP}/`,
|
|
55
60
|
'CLAUDE.local.md',
|
|
56
61
|
// atomicWrite lands every file via `.<name>.tmp-<pid>` next to its target,
|
|
@@ -74,9 +79,9 @@ export const POLICY_LINES = [
|
|
|
74
79
|
]
|
|
75
80
|
|
|
76
81
|
/** the policy one repo carries. A declared public tree ships to strangers,
|
|
77
|
-
* so
|
|
78
|
-
*
|
|
79
|
-
const policyLines = (config: RepoConfig): string[] => declaredPublic(config) ? POLICY_LINES.filter((l) => l
|
|
82
|
+
* so its drafts and plans stay ignored there: authored state in a private
|
|
83
|
+
* repo, private words in a public one */
|
|
84
|
+
const policyLines = (config: RepoConfig): string[] => declaredPublic(config) ? POLICY_LINES.filter((l) => !PRIVATE_ONLY.has(l)) : POLICY_LINES
|
|
80
85
|
|
|
81
86
|
const LEGACY = '# the old root layout, ignored until the fix pass moves it under .shipkit/'
|
|
82
87
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// scaffold, so a policy written there would bind exactly one checkout.
|
|
15
15
|
import { isAbsolute, join } from 'path'
|
|
16
16
|
import { inferBin } from '../core/canonical-scripts.ts'
|
|
17
|
-
import { denyRules,
|
|
17
|
+
import { denyRules, GIT_STASH_RULES, LEGACY_STASH_RULE, RAW_RUNNERS } from '../core/one-way-in-policy.ts'
|
|
18
18
|
import { rootPublishesNpm } from '../core/repo-facts.ts'
|
|
19
19
|
import type { RepoConfig } from '../repo-config.ts'
|
|
20
20
|
import { repoOff } from '../core/when.ts'
|
|
@@ -139,9 +139,12 @@ export const oneWayInPolicy = check({
|
|
|
139
139
|
return
|
|
140
140
|
}
|
|
141
141
|
const have = new Set(s.permissions?.deny ?? [])
|
|
142
|
-
const missing = want.filter((r) => !have.has(r) && r
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
const missing = want.filter((r) => !have.has(r) && !GIT_STASH_RULES.includes(r))
|
|
143
|
+
const stashMissing = GIT_STASH_RULES.filter((r) => !have.has(r))
|
|
144
|
+
if (stashMissing.length > 0)
|
|
145
|
+
c.fail(`${SETTINGS_PATH}: ${stashMissing.length} stash deny row(s) missing (${stashMissing.slice(0, 2).join(', ')}${stashMissing.length > 2 ? ', …' : ''}), so an agent can stash every other agent's uncommitted work in a shared tree`)
|
|
146
|
+
if (have.has(LEGACY_STASH_RULE))
|
|
147
|
+
c.fail(`${SETTINGS_PATH}: the blanket "${LEGACY_STASH_RULE}" row also refuses the read only \`git stash list\` and \`git stash show\``)
|
|
145
148
|
if (missing.length > 0)
|
|
146
149
|
c.fail(`${SETTINGS_PATH}: ${missing.length} raw runner(s) still permitted (${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ', …' : ''}) — those calls never reach shipkit, so nothing is cached and no run can be attached to`)
|
|
147
150
|
const guard = guardHookCommand(s)
|
|
@@ -172,7 +175,7 @@ export const oneWayInPolicy = check({
|
|
|
172
175
|
// replacement is named while the guard cannot run is the green that
|
|
173
176
|
// measured nothing, one line under the WARN that says otherwise
|
|
174
177
|
if (!c.failed)
|
|
175
|
-
c.ok(`${SETTINGS_PATH} closes ${want.length -
|
|
178
|
+
c.ok(`${SETTINGS_PATH} closes ${want.length - GIT_STASH_RULES.length}/${RAW_RUNNERS.length} raw runner(s) and refuses git stash${guardRuns ? ', with the replacement named' : ' (the guard that names the replacement is declared, not yet runnable)'}`)
|
|
176
179
|
},
|
|
177
180
|
// MERGES, never rewrites: this file also carries the attribution silencer,
|
|
178
181
|
// a repo's own allow list and its own hooks, and clobbering those to fix a
|
|
@@ -185,11 +188,13 @@ export const oneWayInPolicy = check({
|
|
|
185
188
|
if (existing == null && c.exists(SETTINGS_PATH)) return { changed: false, summary: `${SETTINGS_PATH} is unparseable — repair it by hand first, a merge would destroy it` }
|
|
186
189
|
const s: Settings = existing ?? {}
|
|
187
190
|
const changes: string[] = []
|
|
188
|
-
const
|
|
191
|
+
const old = s.permissions?.deny ?? []
|
|
192
|
+
const deny = old.filter((r) => r !== LEGACY_STASH_RULE)
|
|
193
|
+
if (deny.length < old.length) changes.push(`the blanket ${LEGACY_STASH_RULE} row replaced by one row per stash write`)
|
|
189
194
|
const added = want.filter((r) => !deny.includes(r))
|
|
190
|
-
if (added.length > 0) {
|
|
195
|
+
if (added.length > 0 || deny.length < old.length) {
|
|
191
196
|
s.permissions = { ...s.permissions, deny: [...deny, ...added] }
|
|
192
|
-
changes.push(`${added.length} deny rule(s) added`)
|
|
197
|
+
if (added.length > 0) changes.push(`${added.length} deny rule(s) added`)
|
|
193
198
|
}
|
|
194
199
|
const wantGuard = await guardCommandFor(d)
|
|
195
200
|
if (!hasGuardHook(s)) {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// `<cli> calls`: the call log (cli/kit/calls.ts) read back. shipkit logs every
|
|
2
|
+
// run machine wide, because one sk serves every repo; a project CLI logs under
|
|
3
|
+
// its own root. One command class for both: only the log file differs.
|
|
4
|
+
import { Command, Option } from 'clipanion'
|
|
5
|
+
import { homedir } from 'os'
|
|
6
|
+
import { callsFile, callsReport, envPrefix, renderCallsReport, sinceOf } from './kit/calls.ts'
|
|
7
|
+
|
|
8
|
+
export class CallsCommand extends Command {
|
|
9
|
+
static override paths = [['calls']]
|
|
10
|
+
|
|
11
|
+
static override usage = Command.Usage({
|
|
12
|
+
category: 'Health',
|
|
13
|
+
description: 'every sk run logged with its time: per command how often, how long, and the slowest runs',
|
|
14
|
+
details: 'reads ~/.shipkit/logs/sk-calls.jsonl, where every sk run writes a start line and an end line (time, cpu, memory, exit code). A run with no end line was killed or still runs. Runs another sk spawned are left out unless --nested. SK_LOG=off writes nothing; SK_AGENT names who ran it.',
|
|
15
|
+
examples: [
|
|
16
|
+
['what agents waited on today', 'sk calls --since 1d'],
|
|
17
|
+
['one command family', 'sk calls --cmd ci'],
|
|
18
|
+
['only the runs started in this repo', 'sk calls --here'],
|
|
19
|
+
['as json', 'sk calls --json'],
|
|
20
|
+
],
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
since = Option.String('--since', { description: '30m, 2h, 3d or a date: only the runs that started after' })
|
|
24
|
+
cmd = Option.String('--cmd', { description: 'a command path prefix' })
|
|
25
|
+
top = Option.String('--top', '10', { description: 'how many slowest runs to list' })
|
|
26
|
+
nested = Option.Boolean('--nested', false, { description: 'keep the runs another run spawned' })
|
|
27
|
+
here = Option.Boolean('--here', false, { description: 'only the runs started inside the current folder' })
|
|
28
|
+
json = Option.Boolean('--json', false, { description: 'the report as json' })
|
|
29
|
+
|
|
30
|
+
/** the log this command reads */
|
|
31
|
+
logFile(): string {
|
|
32
|
+
return callsFile(homedir(), 'sk')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async execute(): Promise<number> {
|
|
36
|
+
const bin = this.cli.binaryName
|
|
37
|
+
const file = this.logFile()
|
|
38
|
+
const since = this.since == null ? undefined : sinceOf(this.since, Date.now())
|
|
39
|
+
if (since === null) { this.context.stderr.write(`FAIL: --since ${this.since}: 30m, 2h, 3d or a date\n`); return 2 }
|
|
40
|
+
const { existsSync, readFileSync } = await import('fs')
|
|
41
|
+
if (!existsSync(file)) { this.context.stdout.write(`no call yet: ${file}\n`); return 0 }
|
|
42
|
+
const r = callsReport(readFileSync(file, 'utf8'), {
|
|
43
|
+
since,
|
|
44
|
+
cmd: this.cmd,
|
|
45
|
+
top: Number(this.top),
|
|
46
|
+
nested: this.nested,
|
|
47
|
+
under: this.here ? process.cwd() : undefined,
|
|
48
|
+
self: process.env[`${envPrefix(bin)}_CALL`],
|
|
49
|
+
})
|
|
50
|
+
this.context.stdout.write(this.json ? `${JSON.stringify({ file, ...r })}\n` : renderCallsReport(bin, r, file))
|
|
51
|
+
return 0
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/cli/cmd_Commands.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// the binary describing itself, in markdown. a hand-copied command list
|
|
2
2
|
// drifts the moment a flag changes, and then teaches commands that do not
|
|
3
|
-
// exist. rendering the live registry makes the doc a build artifact.
|
|
3
|
+
// exist. rendering the live registry makes the doc a build artifact. One
|
|
4
|
+
// renderer for every binary: shipkit's own page and each project CLI's pages
|
|
5
|
+
// (the kit in cli/kit/) come out of these functions.
|
|
4
6
|
import { Command, Option } from 'clipanion'
|
|
5
7
|
import { spliceMarkedBlock } from '../core/doc-block.ts'
|
|
8
|
+
import { type Essential, type Film, marksOf, ROW_OF, runText } from './kit/verb.ts'
|
|
6
9
|
|
|
7
10
|
export type CommandOption = { definition: string; description: string }
|
|
8
11
|
export type CommandRow = {
|
|
@@ -12,6 +15,12 @@ export type CommandRow = {
|
|
|
12
15
|
usage: string
|
|
13
16
|
options: CommandOption[]
|
|
14
17
|
examples: [string, string][]
|
|
18
|
+
/** a filmable command (cli/kit/verb.ts): takes --out, a clip also --seconds */
|
|
19
|
+
film?: Film
|
|
20
|
+
/** listed on the essentials page */
|
|
21
|
+
essential?: Essential
|
|
22
|
+
/** what a kit row runs */
|
|
23
|
+
runs?: string
|
|
15
24
|
}
|
|
16
25
|
|
|
17
26
|
export type Definition = {
|
|
@@ -30,68 +39,146 @@ export const DESCRIPTION_MAX = 100
|
|
|
30
39
|
// clipanion renders a value placeholder as `#0`, which nobody can type
|
|
31
40
|
const clean = (s: string | null | undefined): string => (s ?? '').trim().replace(/\s+/g, ' ').replace(/ #\d+\b/g, ' <value>')
|
|
32
41
|
|
|
42
|
+
/** the names a binary answers to: shipkit is also `sk` */
|
|
43
|
+
const namesOf = (bin: string): string[] => (bin === 'sk' || bin === 'shipkit' ? ['shipkit', 'sk'] : [bin])
|
|
44
|
+
|
|
45
|
+
const stripName = (bin: string, path: string): string => {
|
|
46
|
+
for (const n of namesOf(bin)) if (path.startsWith(`${n} `)) return path.slice(n.length + 1)
|
|
47
|
+
return path
|
|
48
|
+
}
|
|
49
|
+
|
|
33
50
|
/** rows from the live registry, builtins dropped, sorted by category then path.
|
|
34
51
|
* clipanion splits what we need across two calls: the plain form carries the
|
|
35
52
|
* examples, the detailed form carries the flags. Merge by path, or the page
|
|
36
53
|
* ends up showing a command with no arguments, which is the state that sends
|
|
37
|
-
* an agent to `--help` before it can act.
|
|
38
|
-
|
|
54
|
+
* an agent to `--help` before it can act. `classes` (the registered command
|
|
55
|
+
* classes) add each command's marks: film, essential, what a row runs */
|
|
56
|
+
export function commandRows(plain: Definition[], detailed: Definition[] = [], bin = 'sk', classes: readonly { paths?: string[][] }[] = []): CommandRow[] {
|
|
39
57
|
const flagsByPath = new Map(detailed.map((d) => [d.path, d.options ?? []]))
|
|
58
|
+
const classByPath = new Map<string, object>()
|
|
59
|
+
for (const c of classes) for (const p of c.paths ?? []) classByPath.set(p.join(' '), c)
|
|
40
60
|
return plain
|
|
41
|
-
.filter((d) => !/^(
|
|
42
|
-
.map((d) =>
|
|
43
|
-
path
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
61
|
+
.filter((d) => !/^(--help|-h|--version|-v)$/.test(stripName(bin, d.path)))
|
|
62
|
+
.map((d) => {
|
|
63
|
+
const path = stripName(bin, d.path)
|
|
64
|
+
const c = classByPath.get(path)
|
|
65
|
+
const row: CommandRow = {
|
|
66
|
+
path,
|
|
67
|
+
category: clean(d.category) || 'Other',
|
|
68
|
+
// verbatim from the CLI so the doc cannot say something it does not
|
|
69
|
+
description: clean(d.description),
|
|
70
|
+
usage: clean(d.usage),
|
|
71
|
+
options: (flagsByPath.get(d.path) ?? [])
|
|
72
|
+
.map((o) => ({ definition: clean(o.definition), description: clean(o.description) }))
|
|
73
|
+
.filter((o) => o.definition !== ''),
|
|
74
|
+
examples: (d.examples ?? []).map(([label, cmd]) => [clean(label), clean(cmd)] as [string, string]),
|
|
75
|
+
}
|
|
76
|
+
if (c != null) {
|
|
77
|
+
const m = marksOf(c)
|
|
78
|
+
if (m.film != null) row.film = m.film
|
|
79
|
+
if (m.essential != null) row.essential = m.essential
|
|
80
|
+
const v = ROW_OF.get(c)
|
|
81
|
+
if (v != null) row.runs = runText(v.run)
|
|
82
|
+
}
|
|
83
|
+
return row
|
|
84
|
+
})
|
|
53
85
|
.sort((a, b) => a.category.localeCompare(b.category) || a.path.localeCompare(b.path))
|
|
54
86
|
}
|
|
55
87
|
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
88
|
+
/** a command's invocation as an agent types it */
|
|
89
|
+
const signature = (r: CommandRow, bin: string): string => r.usage.replace(/^\$0/, bin).trim() || `${bin} ${r.path}`
|
|
90
|
+
|
|
91
|
+
/** the markdown block: per command its invocation with every flag, one line
|
|
92
|
+
* of description, what a row runs, then its examples. What a flag does and
|
|
93
|
+
* the long text are `<bin> <command> --help`. */
|
|
94
|
+
export function renderMarkdown(rows: CommandRow[], bin = 'sk'): string {
|
|
60
95
|
const out: string[] = []
|
|
61
96
|
let cat = ''
|
|
62
97
|
for (const r of rows) {
|
|
63
98
|
if (r.category !== cat) { cat = r.category; out.push(`\n### ${cat}\n`) }
|
|
64
|
-
const sig = r.usage.replace(/^\$0/, 'sk').trim() || `sk ${r.path}`
|
|
65
99
|
const flags = r.options.map((o) => `[${o.definition}]`).join(' ')
|
|
100
|
+
const sig = signature(r, bin)
|
|
66
101
|
// one line per bullet: a description on its own indented line reads as
|
|
67
102
|
// hard-wrapped prose to the house lint
|
|
68
|
-
out.push(`- \`${sig}${flags === '' ? '' : ` ${flags}`}\` — ${r.description}`)
|
|
103
|
+
out.push(`- \`${sig}${flags === '' ? '' : ` ${flags}`}\` — ${r.description}${r.runs != null ? ` → \`${r.runs}\`` : ''}${r.film != null ? ` (films a ${r.film})` : ''}`)
|
|
69
104
|
for (const [label, cmd] of r.examples) out.push(` - ${label}: \`${cmd}\``)
|
|
70
105
|
}
|
|
71
106
|
return out.join('\n').trim()
|
|
72
107
|
}
|
|
73
108
|
|
|
74
109
|
/** the eager form: one line per command, its positional arguments and its
|
|
75
|
-
* description. Flags and examples are
|
|
110
|
+
* description. Flags and examples are `<bin> <command> --help`, so a page every
|
|
76
111
|
* session loads costs a line per command, not a screen */
|
|
77
|
-
export function renderCompact(rows: CommandRow[]): string {
|
|
112
|
+
export function renderCompact(rows: CommandRow[], bin = 'sk'): string {
|
|
78
113
|
const out: string[] = []
|
|
79
114
|
let cat = ''
|
|
80
115
|
for (const r of rows) {
|
|
81
116
|
if (r.category !== cat) { cat = r.category; out.push(`\n### ${cat}\n`) }
|
|
82
|
-
const sig = r.usage.replace(/^\$0/,
|
|
117
|
+
const sig = r.usage.replace(/^\$0/, bin).replace(/\s+\[(?:-|<\w+>)[^\]]*\]/g, '').trim() || `${bin} ${r.path}`
|
|
83
118
|
out.push(`- \`${sig}\` — ${r.description}`)
|
|
84
119
|
}
|
|
85
120
|
return out.join('\n').trim()
|
|
86
121
|
}
|
|
87
122
|
|
|
88
|
-
|
|
89
|
-
|
|
123
|
+
/** the page every session loads: per category its everyday commands, a few
|
|
124
|
+
* examples, and how to list the rest. Generated whole */
|
|
125
|
+
export function renderEssentials(rows: CommandRow[], bin: string, command: string): string {
|
|
126
|
+
const out = [`# ${bin}: the everyday commands`, '', `Generated by \`${command}\` from the commands marked \`essential\`. Every command: \`${bin}\`, \`${bin} <family>\`, \`${bin} <command> --help\`. \`${bin} <words>\` runs as \`bun run cli <words>\` from the repo root, and as \`${bin}\` anywhere once \`bun link\` ran there.`]
|
|
127
|
+
const first = (r: CommandRow): string => r.path.split(' ')[0]!
|
|
128
|
+
const quiet: string[] = []
|
|
129
|
+
for (const category of [...new Set(rows.map((r) => r.category))]) {
|
|
130
|
+
const all = rows.filter((r) => r.category === category)
|
|
131
|
+
const rest = [...new Set(all.map(first))]
|
|
132
|
+
.map((w) => [w, all.filter((r) => first(r) === w && r.essential == null).length] as const)
|
|
133
|
+
.filter(([, n]) => n > 0)
|
|
134
|
+
.map(([w, n]) => `\`${bin} ${w}${all.some((r) => r.path === w) ? ' --help' : ''}\` (${n} command${n === 1 ? '' : 's'})`)
|
|
135
|
+
const mine = all.filter((r) => r.essential != null)
|
|
136
|
+
if (mine.length === 0) { quiet.push(...rest); continue }
|
|
137
|
+
out.push('', `## ${category}${rest.length > 0 ? ` (more: ${rest.join(', ')})` : ''}`, '')
|
|
138
|
+
for (const r of mine) {
|
|
139
|
+
const e = r.essential!
|
|
140
|
+
out.push(`- \`${bin} ${r.path}${e.args != null ? ` ${e.args}` : ''}\`: ${r.description}`)
|
|
141
|
+
for (const [label, cmd] of r.examples.slice(0, e.examples ?? 0)) out.push(` - \`${cmd}\`: ${label}`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (quiet.length > 0) out.push('', `## The other families: ${quiet.join(', ')}`)
|
|
145
|
+
return `${out.join('\n')}\n`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** what a project CLI's registry must hold for its pages and its listing to
|
|
149
|
+
* teach right: a category, a description under the cap, and an example that
|
|
150
|
+
* starts with the command's own words. One line per breach */
|
|
151
|
+
export function shapeProblems(rows: CommandRow[], bin: string): string[] {
|
|
152
|
+
const out: string[] = []
|
|
153
|
+
for (const r of rows) {
|
|
154
|
+
if (r.category === 'Other') out.push(`${bin} ${r.path}: no category`)
|
|
155
|
+
if (r.description === '') out.push(`${bin} ${r.path}: no description`)
|
|
156
|
+
else if (r.description.length > DESCRIPTION_MAX) out.push(`${bin} ${r.path}: description over ${DESCRIPTION_MAX} characters (the rest goes in details)`)
|
|
157
|
+
if (!r.examples.some(([, cmd]) => cmd === `${bin} ${r.path}` || cmd.startsWith(`${bin} ${r.path} `))) out.push(`${bin} ${r.path}: no example starting with \`${bin} ${r.path}\``)
|
|
158
|
+
}
|
|
159
|
+
return out
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** the markers a page carries around the generated block: shipkit keeps its
|
|
163
|
+
* historical name, a project CLI uses its own */
|
|
164
|
+
export const markersFor = (bin: string): [string, string] => {
|
|
165
|
+
const n = namesOf(bin).includes('shipkit') ? 'shipkit' : bin
|
|
166
|
+
return [`<!-- ${n}:commands -->`, `<!-- /${n}:commands -->`]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const [BEGIN, END] = markersFor('sk')
|
|
90
170
|
|
|
91
171
|
/** replace the marked block in a host file. Returns null when the markers are
|
|
92
172
|
* missing, so a renamed or hand-mangled host fails loud instead of silently
|
|
93
173
|
* writing nothing. */
|
|
94
|
-
export const spliceBlock = (host: string, block: string): string | null => spliceMarkedBlock(host, block,
|
|
174
|
+
export const spliceBlock = (host: string, block: string, bin = 'sk'): string | null => spliceMarkedBlock(host, block, ...markersFor(bin))
|
|
175
|
+
|
|
176
|
+
/** the page a project CLI's `--out` creates when it is missing: a title, how
|
|
177
|
+
* it is made, and the markers the block goes between */
|
|
178
|
+
export const newCommandsPage = (bin: string, path: string): string => {
|
|
179
|
+
const [begin, end] = markersFor(bin)
|
|
180
|
+
return `# ${bin}: every command\n\nGenerated between the markers by \`${bin} commands --out ${path}\` from the registry. \`${bin}\` lists every command, \`${bin} <command> --help\` prints one.\n\n${begin}\n${end}\n`
|
|
181
|
+
}
|
|
95
182
|
|
|
96
183
|
export class CommandsCommand extends Command {
|
|
97
184
|
static override paths = [['commands']]
|
|
@@ -109,33 +196,61 @@ export class CommandsCommand extends Command {
|
|
|
109
196
|
],
|
|
110
197
|
})
|
|
111
198
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
199
|
+
/** a project CLI holds its rows to shapeProblems (--out and --check fail on
|
|
200
|
+
* a breach), and its --out creates a missing page */
|
|
201
|
+
static shapeRule = false
|
|
202
|
+
|
|
203
|
+
json = Option.Boolean('--json', false, { description: 'machine-readable rows, with film and essential where a command carries them' })
|
|
204
|
+
out = Option.String('--out', { description: 'host file carrying the <bin>:commands markers. The block between them is rewritten' })
|
|
205
|
+
check = Option.Boolean('--check', false, { description: 'with --out or --essentials: exit 1 if a page is stale, write nothing' })
|
|
206
|
+
compact = Option.Boolean('--compact', false, { description: 'one line per command: no flags, no examples (they are `<bin> <command> --help`)' })
|
|
207
|
+
essentials = Option.String('--essentials', { description: 'the everyday commands page, rewritten whole from the commands marked essential' })
|
|
116
208
|
|
|
117
209
|
async execute(): Promise<number> {
|
|
210
|
+
const bin = this.cli.binaryName
|
|
211
|
+
const classes = (this.context as { classes?: { paths?: string[][] }[] }).classes ?? []
|
|
118
212
|
const rows = commandRows(
|
|
119
213
|
this.cli.definitions() as Definition[],
|
|
120
214
|
(this.cli.definitions as (o?: { detailed?: boolean }) => Definition[])({ detailed: true }),
|
|
215
|
+
bin,
|
|
216
|
+
classes,
|
|
121
217
|
)
|
|
122
|
-
|
|
123
|
-
|
|
218
|
+
const write = (s: string): void => void this.context.stdout.write(s)
|
|
219
|
+
if (this.out == null && this.essentials == null) {
|
|
220
|
+
write(this.json ? `${JSON.stringify(rows)}\n` : `${(this.compact ? renderCompact : renderMarkdown)(rows, bin)}\n`)
|
|
124
221
|
return 0
|
|
125
222
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
223
|
+
if ((this.constructor as typeof CommandsCommand).shapeRule) {
|
|
224
|
+
const bad = shapeProblems(rows, bin)
|
|
225
|
+
for (const b of bad) write(`FAIL: ${b}\n`)
|
|
226
|
+
if (bad.length > 0) { write(' fix: give the row a category, a short description and an example that starts with its words\n'); return 1 }
|
|
227
|
+
}
|
|
228
|
+
const { existsSync, mkdirSync, readFileSync, writeFileSync: writeRaw } = await import('fs')
|
|
229
|
+
const { dirname } = await import('path')
|
|
230
|
+
const writeFileSync = (path: string, text: string): void => { mkdirSync(dirname(path), { recursive: true }); writeRaw(path, text) }
|
|
231
|
+
let code = 0
|
|
232
|
+
if (this.essentials != null) {
|
|
233
|
+
const cmd = `${bin} commands${this.out != null ? ` --out ${this.out}` : ''} --essentials ${this.essentials}`
|
|
234
|
+
const text = renderEssentials(rows, bin, cmd)
|
|
235
|
+
if (existsSync(this.essentials) && readFileSync(this.essentials, 'utf8') === text) write(`ok: ${this.essentials} current\n`)
|
|
236
|
+
else if (this.check) { write(`FAIL: ${this.essentials} is STALE — the essential commands moved\n fix: ${cmd}\n`); code = 1 }
|
|
237
|
+
else { writeFileSync(this.essentials, text); write(`ok: ${this.essentials} rewritten\n`) }
|
|
238
|
+
}
|
|
239
|
+
if (this.out == null) return code
|
|
240
|
+
const [begin, end] = markersFor(bin)
|
|
241
|
+
const project = (this.constructor as typeof CommandsCommand).shapeRule
|
|
242
|
+
if (!existsSync(this.out) && (!project || this.check)) { write(`FAIL: ${this.out} does not exist\n`); return 1 }
|
|
243
|
+
const host = existsSync(this.out) ? readFileSync(this.out, 'utf8') : newCommandsPage(bin, this.out)
|
|
244
|
+
const next = spliceBlock(host, (this.compact ? renderCompact : renderMarkdown)(rows, bin), bin)
|
|
245
|
+
if (next == null) { write(`FAIL: ${this.out} has no ${begin} / ${end} markers\n`); return 1 }
|
|
246
|
+
if (next === host) { write(`ok: ${this.out} command index current (${rows.length} commands)\n`); return code }
|
|
132
247
|
if (this.check) {
|
|
133
|
-
|
|
134
|
-
|
|
248
|
+
write(`FAIL: ${this.out} command index is STALE — the binary registers commands the page does not list\n`)
|
|
249
|
+
write(` fix: ${bin === 'sk' ? 'shipkit' : bin} commands${this.compact ? ' --compact' : ''} --out ${this.out}\n`)
|
|
135
250
|
return 1
|
|
136
251
|
}
|
|
137
252
|
writeFileSync(this.out, next)
|
|
138
|
-
|
|
139
|
-
return
|
|
253
|
+
write(`ok: ${this.out} command index rewritten (${rows.length} commands)\n`)
|
|
254
|
+
return code
|
|
140
255
|
}
|
|
141
256
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// sk shot: a png of any url, or a clip with --video, through the machine's
|
|
2
|
+
// one warm browser (shot/client.ts). A repo's own CLI wraps it with its setup
|
|
3
|
+
// flags; the trailer's `url` film calls the same functions.
|
|
4
|
+
import { relative } from 'path'
|
|
5
|
+
import { Command, Option } from 'clipanion'
|
|
6
|
+
import { clipUrl, shotUrl, type ShotResult } from '../shot/client.ts'
|
|
7
|
+
import { askOf, isClip } from '../shot/shot-ask.ts'
|
|
8
|
+
|
|
9
|
+
export const renderShot = (r: ShotResult, cwd: string): string[] => {
|
|
10
|
+
const phases = Object.entries(r.phases)
|
|
11
|
+
.map(([k, v]) => `${k} ${v}`)
|
|
12
|
+
.join(', ')
|
|
13
|
+
const out = relative(cwd, r.out)
|
|
14
|
+
const lines = [`ok ${out.startsWith('..') ? r.out : out} in ${r.ms} ms (${phases}; ${r.via}) ${r.note}`]
|
|
15
|
+
for (const e of r.errors.slice(0, 10)) lines.push(`WARN page ${e}`)
|
|
16
|
+
if (r.errors.length > 10) lines.push(`WARN page ${r.errors.length - 10} more errors`)
|
|
17
|
+
return lines
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class ShotCommand extends Command {
|
|
21
|
+
static override paths = [['shot']]
|
|
22
|
+
|
|
23
|
+
static override usage = Command.Usage({
|
|
24
|
+
category: 'Serve',
|
|
25
|
+
description: 'a png of any url through the warm browser, or an mp4 clip with --video',
|
|
26
|
+
details:
|
|
27
|
+
'one warm headless chromium for the whole machine (the `shot` global process, started when down). A still reuses the warm page when it already shows the url, so a warm shot answers in about 0.1 s. A page with `<meta name="shot-ready" content="wait">` is shot once it sets content="ready"; any other page --wait ms after its load. --video films a page that is a canvas through its WebCodecs encoder (H.264 muxed with no second encode), and any other page as chromium paints it (its screencast, encoded by ffmpeg as the frames arrive); --dom films the page even when a canvas fills it',
|
|
28
|
+
examples: [
|
|
29
|
+
['a png of a running dev server', 'sk shot localhost:5173/'],
|
|
30
|
+
['a phone screen, at the pixel ratio of a phone', 'sk shot :5173/play --viewport 390,844 --dpr 3 --out shots/phone.png'],
|
|
31
|
+
['a 3 s clip', 'sk shot :4808/ --video 3 --out clips/walk.mp4'],
|
|
32
|
+
['a clip of a whole web app page, canvas or not', 'sk shot :5173/settings --video 3 --dom'],
|
|
33
|
+
],
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
url = Option.String({ required: true, name: 'url' })
|
|
37
|
+
viewport = Option.String('--viewport', { description: 'w,h in css pixels (default 1280,720)' })
|
|
38
|
+
dpr = Option.String('--dpr', { description: 'device pixel ratio (default 1)' })
|
|
39
|
+
out = Option.String('--out', { description: 'the file (default .shipkit/tmp/shots/<url>.png, or .mp4 with --video)' })
|
|
40
|
+
reload = Option.Boolean('--reload', false, { description: 'load the page again even when the warm page shows this url' })
|
|
41
|
+
wait = Option.String('--wait', { description: 'ms after load for a page with no ready mark (default 300)' })
|
|
42
|
+
video = Option.String('--video', { description: 'seconds of clip: an mp4 of the page (its canvas when a canvas fills it)' })
|
|
43
|
+
fps = Option.String('--fps', { description: 'clip frames per second (default 30)' })
|
|
44
|
+
long = Option.Boolean('--long', false, { description: 'allow a clip past 30 s' })
|
|
45
|
+
dom = Option.Boolean('--dom', false, { description: 'clip the page itself even when a canvas fills it' })
|
|
46
|
+
json = Option.Boolean('--json', false, { description: 'one json line: out, ms, phases, errors, note, via' })
|
|
47
|
+
|
|
48
|
+
async execute(): Promise<number> {
|
|
49
|
+
const cwd = process.cwd()
|
|
50
|
+
const err = (l: string) => this.context.stderr.write(`${l}\n`)
|
|
51
|
+
try {
|
|
52
|
+
const a = askOf({ url: this.url, viewport: this.viewport, dpr: this.dpr, out: this.out, reload: this.reload, wait: this.wait, video: this.video, fps: this.fps, long: this.long, dom: this.dom }, cwd)
|
|
53
|
+
const r = isClip(a) ? await clipUrl(a, err) : await shotUrl(a, err)
|
|
54
|
+
if (this.json) this.context.stdout.write(`${JSON.stringify(r)}\n`)
|
|
55
|
+
else for (const l of renderShot(r, cwd)) this.context.stdout.write(`${l}\n`)
|
|
56
|
+
return 0
|
|
57
|
+
} catch (e) {
|
|
58
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
59
|
+
err(msg.startsWith('FAIL') ? msg : `FAIL shot: ${msg}`)
|
|
60
|
+
return 1
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|