rvlib-shipkit 2.7.0 → 2.9.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 +11 -3
- package/src/assets/gen/comfyts-gen.ts +56 -25
- package/src/assets/kits/identity-kit.ts +23 -4
- package/src/checks/SK005-rvlib-versions.check.ts +17 -11
- package/src/cli/cmd_Fleet.ts +8 -5
- package/src/core/update-policy.ts +6 -0
- package/src/fleet/commit-bump.ts +123 -0
- package/src/fleet/push-fleet.ts +9 -3
- package/src/fleet/update-fleet.ts +38 -9
- package/src/serve/hub-app/auto-text.tsx +52 -0
- package/src/serve/hub-app/hub.css +2 -0
- package/src/serve/hub-app/media-view.tsx +3 -11
- package/src/serve/hub-app/review/review-answer.tsx +8 -7
- package/src/serve/hub-app/review/review-page.tsx +28 -12
- package/src/serve/hub-app/review/review-st.ts +39 -8
- package/src/serve/hub-app/review/review-views.tsx +104 -35
- package/src/serve/hub-app/review/trailer-page.tsx +5 -4
- package/src/serve/hub-app/review.css +20 -2
- package/src/trailer/sound/comfy.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 2.9.0
|
|
2
|
+
|
|
3
|
+
- **`sk fleet --update` commits and pushes each bump.** Every repo the update moved gets one commit of package.json and its lock alone, by pathspec, through the repo's own hooks: `chore(deps): rvlib-shipkit <range>`, then the plain push of `sk fleet push`. A repo whose manifest held changes before the update, whose package.json moved more than the bump, mid-merge, mid-rebase, on a detached HEAD or nested in another repo is skipped, its bump left in the tree and the command to run printed. A refused hook, a failed push or a branch behind its upstream is a FAIL line, and the command exits 1. `--no-commit` leaves every bump in its tree, as before.
|
|
4
|
+
- **`sk fleet --update` moves a published package's devDependencies.** A repo that declares `release.npm` was only told its bump (« published to npm, never updated by a check »), so comfy-ts stayed behind on every release. Under the fleet, SK005 now moves its devDependencies; a `dependencies` entry stays named, and a plain gate still never writes either.
|
|
5
|
+
- **The review card: a rectangle's note opens beside it, every image starts full, every text box grows.** A rectangle drawn on an image opens its note box right there, under it (over it when it sits low), numbered and focused, to type in at once, Enter or Escape closes it, a click on the rectangle opens it again, ✕ deletes it; the answer column still lists every note. Each image, pair and clip of a choice or a group shows full under its row from the start (⤢ folds one); a sound stays a row. Every text box of the page (the overall word, a view's or a tile's word, a note, the reply, a trailer note) grows with its text, no inner scrollbar and no drag handle: `AutoText` (`hub-app/auto-text.tsx`), which the atelier's prompt now uses too. On a phone the card pane holds the answer under the views (no third « answer » pane), a field's text is 16 px so the page no longer zooms into it, and the views' labels take the whole width. Another card opens at its top. A draft card's history cycles in place over its live frame again (a slider), and its files carry the frame, so a new frame shows. A clip's sound button and volume sit on one line with its time (a sound view's class had taken the same name).
|
|
6
|
+
|
|
7
|
+
## 2.8.0
|
|
8
|
+
|
|
9
|
+
- **A brand's `icon` draws the favicon family at each size.** `icon: canvas(draw)` in `.shipkit/identity/brand.ts` draws `favicon.png` (512), `favicon-32.png`, `favicon-16.png` and `apple-touch-icon.png` (180, flattened on `palette.bg`), each at its own size, `asset.id` naming the file, so a 16 px tab reads a shape made for it, never a shrunk logo. Without one the favicon is the logo master resized, as before.
|
|
10
|
+
- **comfy-ts is an optional peer, no longer a dependency.** A repo that never declares comfy no longer installs it (npm and bun both skip an optional peer). The toolkit names comfy-ts in no import, a type import included, so importing it, `sk ci`, `sk serve`, the hub and `sk assets identity` never load it. It is loaded at the first workflow load, from the repo's copy. A repo with comfy kinds or `.cflow.ts` modules installs it itself: `bun add -d comfy-ts`. Without it, a workflow load fails loud with that line, and `sk sound` fails loud when no `comfy-ts` is on PATH. `LoadedWorkflow.wf` is typed by the toolkit's own `CflowWorkflow`, the slice of comfy-ts it drives, held to comfy-ts's types by a test.
|
|
11
|
+
- **`og.png` is written small.** The identity writer encodes the og card as a 256 colour palette png at maximum compression: invisible at preview size, and a photo card drops about 3x (pkm-multi's banner card: 970 KB → 280 KB), under the size where some chat apps stop showing a preview. The file name and format stay `og.png`.
|
|
12
|
+
|
|
1
13
|
## 2.7.0
|
|
2
14
|
|
|
3
15
|
- **What waits on the owner is a pill on every repo's row of the project list.** Beside the open asks badge, a pill in the review hue counts the cards that wait on the owner's verdict or answer; zero draws none, and folded it rides the icon's top left corner. A click opens that repo's review page on its first waiting card, the list on the owner's move. One count, `waitingCards` (`collections/review.ts`), read by the tree's review row, `sk needs`, the review page and the list; `/api/projects.json` reads it on every request, so a new card shows within one poll and a verdict at once. Below 768 px the list is always the rail, so the pills show on a phone.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.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",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"@types/bun": "^1.3.14",
|
|
26
26
|
"@types/react": "^19.2.17",
|
|
27
27
|
"@types/react-dom": "^19.2.3",
|
|
28
|
-
"@types/sanitize-html": "^2.16.1"
|
|
28
|
+
"@types/sanitize-html": "^2.16.1",
|
|
29
|
+
"comfy-ts": "^2.9.0"
|
|
29
30
|
},
|
|
30
31
|
"overrides": {
|
|
31
32
|
"mobx": "^7.0.0",
|
|
@@ -38,7 +39,6 @@
|
|
|
38
39
|
"@floating-ui/react": "^0.27.20",
|
|
39
40
|
"@napi-rs/canvas": "^1.1.0",
|
|
40
41
|
"clipanion": "^4.0.0-rc.4",
|
|
41
|
-
"comfy-ts": "^2.9.0",
|
|
42
42
|
"ink": "^7.1.1",
|
|
43
43
|
"marked": "^18.0.7",
|
|
44
44
|
"mobx": "^7.0.0",
|
|
@@ -48,5 +48,13 @@
|
|
|
48
48
|
"react-resizable-panels": "^4.13.3",
|
|
49
49
|
"sanitize-html": "^2.17.6",
|
|
50
50
|
"sharp": "^0.35.3"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"comfy-ts": "^2.9.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"comfy-ts": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
51
59
|
}
|
|
52
60
|
}
|
|
@@ -4,16 +4,40 @@
|
|
|
4
4
|
// must disconnectAll() in a finally, or the host websocket keeps bun alive.
|
|
5
5
|
// Workflows are discriminated by duck-typing, never instanceof: a consumer's
|
|
6
6
|
// .cflow.ts may hold a second copy of comfy-ts in its module graph.
|
|
7
|
+
// comfy-ts is an optional peer: this file is the one place that loads it, at
|
|
8
|
+
// the first workflow load, and never names it in an import, so a repo without
|
|
9
|
+
// it imports and typechecks the toolkit (tests/comfy-optional.test.ts).
|
|
7
10
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
|
|
8
11
|
import { basename, join } from 'path'
|
|
9
12
|
import { pathToFileURL } from 'url'
|
|
10
|
-
|
|
13
|
+
|
|
14
|
+
// the slice of comfy-ts the toolkit drives, held to comfy-ts's own types by
|
|
15
|
+
// tests/comfy-optional.test.ts
|
|
16
|
+
export type CflowVar = { readonly kind: string; parse(raw: string): boolean; loadJSON(value: unknown): unknown; reset(): unknown }
|
|
17
|
+
export type CflowImage = { readonly ComfyNode: { class_type?: string } | null | undefined; readonly metadata: { type?: string }; readonly buffer: Uint8Array }
|
|
18
|
+
export type CflowExecution = { readonly status: string | null | undefined; data: { error?: unknown }; images: CflowImage[]; imageErrors: unknown[] }
|
|
19
|
+
export type CflowHost = {
|
|
20
|
+
data: { id: string }
|
|
21
|
+
schema: { readonly size: number }
|
|
22
|
+
getServerHostHTTP(): string
|
|
23
|
+
disconnect(): void
|
|
24
|
+
interrupt(): Promise<void>
|
|
25
|
+
}
|
|
26
|
+
export type CflowWorkflow = {
|
|
27
|
+
spec: { id?: string }
|
|
28
|
+
readonly vars: { [key: string]: CflowVar }
|
|
29
|
+
host: CflowHost
|
|
30
|
+
entries(): [string, CflowVar][]
|
|
31
|
+
build(): Promise<{ problems: { title: string }[]; readonly size: number }>
|
|
32
|
+
run(settings?: { onProgress?: (p: { percent: number; nodeName: string | null }) => void }): Promise<CflowExecution>
|
|
33
|
+
}
|
|
34
|
+
type ComfyTsModule = { ComfyTS?: { create(o: { rootPath: string }): unknown } }
|
|
11
35
|
|
|
12
36
|
export type GenProgress = { value: number; max: number; node: string | null }
|
|
13
37
|
export type OnProgress = (p: GenProgress) => void
|
|
14
38
|
|
|
15
39
|
/** root: the repo the workflow belongs to, which holds its drafts */
|
|
16
|
-
export type LoadedWorkflow = { id: string; root: string; file: string; wf:
|
|
40
|
+
export type LoadedWorkflow = { id: string; root: string; file: string; wf: CflowWorkflow }
|
|
17
41
|
export type ComfyTsRuntime = { workflows: Map<string, LoadedWorkflow>; heal?: string }
|
|
18
42
|
|
|
19
43
|
// function declaration, not const — TS only narrows on never-calls to
|
|
@@ -22,35 +46,39 @@ function fail(msg: string): never { throw new Error(msg) }
|
|
|
22
46
|
|
|
23
47
|
// same duck predicate the comfy-ts TUI and serve bind to: a module valid
|
|
24
48
|
// for shipkit is valid for `bunx comfy-ts tui` and vice versa
|
|
25
|
-
function findDefinedWorkflow(mod: Record<string, unknown>):
|
|
26
|
-
const isDefined = (x: unknown): x is
|
|
49
|
+
function findDefinedWorkflow(mod: Record<string, unknown>): CflowWorkflow | null {
|
|
50
|
+
const isDefined = (x: unknown): x is CflowWorkflow =>
|
|
27
51
|
typeof x === 'object' && x != null && 'spec' in x &&
|
|
28
|
-
typeof (x as
|
|
29
|
-
typeof (x as
|
|
52
|
+
typeof (x as CflowWorkflow).run === 'function' &&
|
|
53
|
+
typeof (x as CflowWorkflow).entries === 'function'
|
|
30
54
|
return [mod.default, ...Object.values(mod)].find(isDefined) ?? null
|
|
31
55
|
}
|
|
32
56
|
|
|
33
|
-
const varsOf = (wf: DefinedWorkflow): Record<string, AnyVar> => wf.vars as Record<string, AnyVar>
|
|
34
|
-
|
|
35
57
|
/** true when the image belongs in a roll. Preview-family nodes (PreviewImage,
|
|
36
58
|
* PreviewBridge, …) emit raw temp images that comfy-ts downloads like any
|
|
37
59
|
* output, in completion order, and MediaImage drops the server-side
|
|
38
60
|
* 'temp' marker, so the producing node's class_type is the only signal left.
|
|
39
61
|
* no node json (SaveImageWebsocket frames) = deliberate output, kept. */
|
|
40
|
-
export const isRollImage = (img: Pick<
|
|
62
|
+
export const isRollImage = (img: Pick<CflowImage, 'ComfyNode'>): boolean =>
|
|
41
63
|
!/preview/i.test(img.ComfyNode?.class_type ?? '')
|
|
42
64
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
65
|
+
const COMFY_TS = 'comfy-ts'
|
|
66
|
+
const INSTALL_COMFY_TS = 'Install it in this repo: bun add -d comfy-ts'
|
|
67
|
+
|
|
68
|
+
/** the ComfyTS class of the comfy-ts a bare import from `dir` reaches, else
|
|
69
|
+
* the toolkit's own; loud when neither is installed */
|
|
70
|
+
async function comfyTsOf(dir: string): Promise<NonNullable<ComfyTsModule['ComfyTS']>> {
|
|
71
|
+
for (const from of [dir, import.meta.dir]) {
|
|
72
|
+
let path: string
|
|
73
|
+
try {
|
|
74
|
+
path = Bun.resolveSync(COMFY_TS, from)
|
|
75
|
+
} catch {
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
const mod = (await import(path)) as ComfyTsModule
|
|
79
|
+
if (typeof mod.ComfyTS?.create === 'function') return mod.ComfyTS
|
|
51
80
|
}
|
|
52
|
-
|
|
53
|
-
return typeof mod.ComfyTS?.create === 'function' ? mod.ComfyTS : ComfyTS
|
|
81
|
+
fail(`comfy-ts is not installed, and ${dir}/ holds comfy-ts workflow modules. ${INSTALL_COMFY_TS}`)
|
|
54
82
|
}
|
|
55
83
|
|
|
56
84
|
/** dir = the flat folder holding the <id>.cflow.ts workflow modules.
|
|
@@ -74,7 +102,10 @@ export async function loadCflowWorkflows(root: string, dir: string): Promise<Map
|
|
|
74
102
|
for (const f of readdirSync(dir).filter((x) => x.endsWith('.cflow.ts')).sort()) {
|
|
75
103
|
const file = join(dir, f)
|
|
76
104
|
const id = basename(f, '.cflow.ts')
|
|
77
|
-
const mod = (await import(pathToFileURL(file).href))
|
|
105
|
+
const mod = (await import(pathToFileURL(file).href).catch((e: Error) => {
|
|
106
|
+
if (/Cannot find (package|module) 'comfy-ts'/.test(e.message)) fail(`${file} imports comfy-ts, which this repo does not install. ${INSTALL_COMFY_TS}`)
|
|
107
|
+
throw e
|
|
108
|
+
})) as Record<string, unknown>
|
|
78
109
|
const wf = findDefinedWorkflow(mod)
|
|
79
110
|
if (wf == null) fail(`${file}: exports no DefinedWorkflow — export default host.defineWorkflow({...})`)
|
|
80
111
|
// the file name IS the id: it is also the comfy-ts draft folder key,
|
|
@@ -96,7 +127,7 @@ export function workflowFor(workflows: Map<string, LoadedWorkflow>, ref: { workf
|
|
|
96
127
|
: workflows.size === 1
|
|
97
128
|
? [...workflows.values()][0]!
|
|
98
129
|
: fail(`asset '${ref.assetId ?? '?'}' names no workflow and ${workflows.size} are loaded (${[...workflows.keys()].join(', ')}) — set workflow on the kind`)
|
|
99
|
-
const vars =
|
|
130
|
+
const vars = lw.wf.vars
|
|
100
131
|
if (vars.prompt?.kind !== 'prompt' || vars.seed?.kind !== 'seed')
|
|
101
132
|
fail(`kind '${ref.kind ?? '?'}' drives workflow '${lw.id}', which must declare vars.prompt (v.prompt) and vars.seed (v.seed) — the atelier writes both`)
|
|
102
133
|
return lw
|
|
@@ -105,8 +136,8 @@ export function workflowFor(workflows: Map<string, LoadedWorkflow>, ref: { workf
|
|
|
105
136
|
/** Apply a name→value record onto a workflow's vars: strings go through
|
|
106
137
|
* parse (validated), everything else through loadJSON (the drafts setter).
|
|
107
138
|
* Unknown names throw listing what the workflow has. */
|
|
108
|
-
export function applyVarValues(wf:
|
|
109
|
-
const defs =
|
|
139
|
+
export function applyVarValues(wf: CflowWorkflow, vars: Record<string, unknown>, source: string): void {
|
|
140
|
+
const defs = wf.vars
|
|
110
141
|
for (const [name, value] of Object.entries(vars)) {
|
|
111
142
|
const varDef = defs[name]
|
|
112
143
|
if (varDef == null) fail(`${source}: unknown var '${name}' on workflow '${wf.spec.id ?? '?'}' (has: ${wf.entries().map(([n, d]) => `${n}:${d.kind}`).join(', ')})`)
|
|
@@ -167,7 +198,7 @@ export async function generateComfyTs(
|
|
|
167
198
|
// names no kind. It is what the tune button edits
|
|
168
199
|
const draftId = args.draftId ?? args.assetId
|
|
169
200
|
ensureAssetDraft(lw, draftId, args.prompt)
|
|
170
|
-
const defs =
|
|
201
|
+
const defs = lw.wf.vars
|
|
171
202
|
// modules are import-cached: a previous asset's values must not leak
|
|
172
203
|
for (const [, varDef] of lw.wf.entries()) varDef.reset()
|
|
173
204
|
if (args.vars != null) applyVarValues(lw.wf, args.vars, `asset '${args.assetId}' kit vars`)
|
|
@@ -192,7 +223,7 @@ export async function generateComfyTs(
|
|
|
192
223
|
defs.seed!.parse(`= ${args.seed}`)
|
|
193
224
|
mkdirSync(args.destDir, { recursive: true })
|
|
194
225
|
const healHint = rt.heal != null ? ` — is the backend up? (${rt.heal})` : ''
|
|
195
|
-
let execution:
|
|
226
|
+
let execution: CflowExecution
|
|
196
227
|
try {
|
|
197
228
|
execution = await lw.wf.run({
|
|
198
229
|
onProgress: args.onProgress == null ? undefined : (p) => args.onProgress!({ value: Math.round(p.percent), max: 100, node: p.nodeName }),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// kits/identity — favicon / logo / OG as asset kinds. One render path
|
|
2
2
|
// (renderIdentityFromMark: mark png → logo master 1024², favicon 512², og
|
|
3
|
-
// card
|
|
3
|
+
// card; a brand's `icon` draws the favicon family at each size instead), two
|
|
4
|
+
// ways to get the mark:
|
|
4
5
|
// - canvas mark: deterministic, instant — `shipkit assets identity` draws it
|
|
5
6
|
// headlessly and renders the set.
|
|
6
7
|
// - comfy mark: roll-and-pick in the studio; the kind carries a BUILT-IN ship
|
|
@@ -28,6 +29,12 @@ export type Brand = {
|
|
|
28
29
|
/** comfy marks: the FULL generation prompt for identity-logo rolls
|
|
29
30
|
* (style included — the studio shows/edits exactly this) */
|
|
30
31
|
markPrompt?: string
|
|
32
|
+
/** the small mark, when the logo does not read at 16 px: favicon.png,
|
|
33
|
+
* favicon-32.png, favicon-16.png and apple-touch-icon.png (180², flattened
|
|
34
|
+
* on palette.bg) are each drawn at their own size; `asset.id` names the
|
|
35
|
+
* file (apple-touch-icon fills its square: iOS rounds it). Absent, the
|
|
36
|
+
* favicon is the logo master resized */
|
|
37
|
+
icon?: CanvasGen
|
|
31
38
|
/** og layout hook — replaces the WHOLE default card (bg + mark left +
|
|
32
39
|
* name/tagline right); the hook owns compositing the mark if it wants one */
|
|
33
40
|
drawOg?: (ctx: SKRSContext2D, brand: Brand, size: { w: number; h: number }) => void
|
|
@@ -42,6 +49,8 @@ export const FAVICON_SIZE = 512
|
|
|
42
49
|
export const OG_W = 1200
|
|
43
50
|
export const OG_H = 630
|
|
44
51
|
export const LOGO_SIZE = 1024
|
|
52
|
+
/** what a brand's `icon` draws, each at its own size */
|
|
53
|
+
export const ICON_FILES = [['favicon', FAVICON_SIZE], ['favicon-32', 32], ['favicon-16', 16], ['apple-touch-icon', 180]] as const
|
|
45
54
|
|
|
46
55
|
/** the one next step when a comfy mark has no picked roll yet */
|
|
47
56
|
export const NO_PICK_FIX = 'roll + pick in the studio: bun run hub → /assets/#identity-logo'
|
|
@@ -110,13 +119,23 @@ export async function renderIdentityFromMark(brand: Brand, root: string, markPng
|
|
|
110
119
|
.resize(LOGO_SIZE, LOGO_SIZE, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
111
120
|
.png().toBuffer()
|
|
112
121
|
await write('logo.png', logoPng)
|
|
113
|
-
|
|
114
|
-
|
|
122
|
+
if (brand.icon != null) {
|
|
123
|
+
for (const [id, px] of ICON_FILES) {
|
|
124
|
+
const c = createCanvas(px, px)
|
|
125
|
+
await brand.icon.draw(c.getContext('2d'), { id, prompt: '' })
|
|
126
|
+
const png = sharp(c.toBuffer('image/png'))
|
|
127
|
+
await write(`${id}.png`, await (id === 'apple-touch-icon' ? png.flatten({ background: brand.palette.bg }) : png).png().toBuffer())
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
// favicon = logo master resized to 512² (browsers derive the rest)
|
|
131
|
+
await write('favicon.png', await sharp(logoPng).resize(FAVICON_SIZE, FAVICON_SIZE).png().toBuffer())
|
|
132
|
+
}
|
|
115
133
|
const og = createCanvas(OG_W, OG_H)
|
|
116
134
|
const ctx = og.getContext('2d')
|
|
117
135
|
if (brand.drawOg != null) brand.drawOg(ctx, brand, { w: OG_W, h: OG_H })
|
|
118
136
|
else defaultOg(ctx, brand, { w: OG_W, h: OG_H }, await loadImage(logoPng))
|
|
119
|
-
|
|
137
|
+
// a link preview is small: a 256 colour palette is invisible there and cuts a photo card about 3x (some chat apps drop a preview past ~300 KB)
|
|
138
|
+
await write('og.png', await sharp(og.toBuffer('image/png')).png({ palette: true, quality: 90, effort: 10, compressionLevel: 9 }).toBuffer())
|
|
120
139
|
return out
|
|
121
140
|
}
|
|
122
141
|
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
// exists for: package.json moves, package-lock.json does not, and
|
|
41
41
|
// the `npm ci` a deploy runs refuses that pair
|
|
42
42
|
// registry unreachable → loud skip, never red (offline is not a defect)
|
|
43
|
-
// Committing the bump stays with the working agent
|
|
43
|
+
// Committing the bump stays with the working agent: a check never commits
|
|
44
|
+
// (`sk fleet --update` commits the bump it asked for, fleet/commit-bump.ts).
|
|
44
45
|
// Clean passes cache 24h (rule ttl), so the registry is fetched at most
|
|
45
46
|
// daily per repo; skip/WARN/FAIL runs are never cached. The key carries the
|
|
46
47
|
// INSTALLED versions, so a manual `bun add -d rvlib-x@N` busts it: keyed on
|
|
@@ -51,7 +52,7 @@
|
|
|
51
52
|
import { existsSync, readFileSync } from 'fs'
|
|
52
53
|
import { basename, join } from 'path'
|
|
53
54
|
import type { CheckCtx } from '../core/check-ctx.ts'
|
|
54
|
-
import { allowMajor, breakingAdvice, isBreaking, isLinkedInstall, pinFloor } from '../core/update-policy.ts'
|
|
55
|
+
import { allowMajor, breakingAdvice, fleetAsked, isBreaking, isLinkedInstall, pinFloor } from '../core/update-policy.ts'
|
|
55
56
|
import { isRunningCopy, rememberLatest } from '../core/self-update.ts'
|
|
56
57
|
import { rootPublishesNpm } from '../core/repo-facts.ts'
|
|
57
58
|
import { mutateLease, withMutateLease } from '../core/locked-write.ts'
|
|
@@ -153,23 +154,28 @@ export const rvlibVersions = check({
|
|
|
153
154
|
}
|
|
154
155
|
if (stale.length === 0) return
|
|
155
156
|
|
|
156
|
-
const
|
|
157
|
-
const names = stale.map((s) => s.name)
|
|
157
|
+
const driftOf = (xs: typeof stale): string => xs.map((s) => `${s.name}${s.linked ? ' pin' : ''} ${s.installed} → ${s.latest}`).join(', ')
|
|
158
158
|
if (isRefusal(pm)) {
|
|
159
|
-
c.warn(`newer rvlib live: ${
|
|
159
|
+
c.warn(`newer rvlib live: ${driftOf(stale)}, not updating: ${pm.refused}`)
|
|
160
160
|
return
|
|
161
161
|
}
|
|
162
|
-
const cmd = pm.update(names)
|
|
163
162
|
if (process.env['SHIPKIT_FROZEN'] === '1') {
|
|
164
|
-
c.warn(`newer rvlib live: ${
|
|
163
|
+
c.warn(`newer rvlib live: ${driftOf(stale)} (frozen — apply with: ${pm.update(stale.map((s) => s.name)).join(' ')})`)
|
|
165
164
|
return
|
|
166
165
|
}
|
|
167
166
|
// a package published to npm: its package.json is its public
|
|
168
167
|
// contract, so the bump is a release decision, named and never made
|
|
168
|
+
// by a check. `sk fleet --update` is the operator naming it, and a
|
|
169
|
+
// devDependency is read by no one who installs the package: it moves
|
|
170
|
+
let moving = stale
|
|
169
171
|
if (rootPublishesNpm(c.config)) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
+
const held = fleetAsked() ? stale.filter((s) => pkg.dependencies?.[s.name] != null) : stale
|
|
173
|
+
if (held.length > 0) c.warn(`newer rvlib live: ${driftOf(held)} (published to npm, never updated by a check${fleetAsked() ? ' nor the fleet: a dependency its installers get' : ''} — apply with: ${pm.update(held.map((s) => s.name)).join(' ')})`)
|
|
174
|
+
moving = stale.filter((s) => !held.includes(s))
|
|
175
|
+
if (moving.length === 0) return
|
|
172
176
|
}
|
|
177
|
+
const drift = driftOf(moving)
|
|
178
|
+
const cmd = pm.update(moving.map((s) => s.name))
|
|
173
179
|
const dirty = await c.exec(['git', 'status', '--porcelain', '--', ...manifest], { cwd: c.root })
|
|
174
180
|
if (dirty.code !== 0 || dirty.out.trim() !== '') {
|
|
175
181
|
c.warn(`newer rvlib live: ${drift} — self-update deferred (${dirty.code !== 0 ? `git says: ${dirty.out.trim().split('\n')[0] ?? `exit ${dirty.code}`}` : 'package.json/lockfile dirty'}); commit first, then re-run check`)
|
|
@@ -188,7 +194,7 @@ export const rvlibVersions = check({
|
|
|
188
194
|
const up = await c.exec(cmd, { cwd: c.root, timeoutMs: 120_000 })
|
|
189
195
|
// the update may swap a link for the tarball: put every link back,
|
|
190
196
|
// whatever the update did, inside the same lease
|
|
191
|
-
for (const s of
|
|
197
|
+
for (const s of moving) if (s.linked) await c.exec(['bun', 'link', s.name], { cwd: c.root, timeoutMs: 60_000 })
|
|
192
198
|
return up
|
|
193
199
|
})
|
|
194
200
|
if (!leased.ok) {
|
|
@@ -212,7 +218,7 @@ export const rvlibVersions = check({
|
|
|
212
218
|
else c.warn(`self-update failed (${why}) and nothing was written — the registry or the network, not this repo. Retry with: ${cmd.join(' ')}: ${tail}`)
|
|
213
219
|
return
|
|
214
220
|
}
|
|
215
|
-
for (const s of
|
|
221
|
+
for (const s of moving) {
|
|
216
222
|
if (s.linked) {
|
|
217
223
|
const now = pinFloor(readPin(c.root, s.name) ?? '')
|
|
218
224
|
if (!isLinkedInstall(c.root, s.name)) c.fail(`${s.name} pin ${s.installed} → ${now ?? 'unreadable'} but node_modules/${s.name} is no longer linked to its source — run: bun link ${s.name}`)
|
package/src/cli/cmd_Fleet.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// shipkit fleet — every repo on this disk that installs the toolkit, and how
|
|
2
2
|
// far behind each one is. Reports by default. `--update` hands the work to
|
|
3
3
|
// each repo's OWN shipkit, so rule SK005 performs the update with the lease,
|
|
4
|
-
// the dirty-tree guard and the frozen guard it already owns
|
|
4
|
+
// the dirty-tree guard and the frozen guard it already owns, then commits and
|
|
5
|
+
// pushes each repo's bump unless `--no-commit`.
|
|
5
6
|
import { Command, Option } from 'clipanion'
|
|
6
7
|
import { join } from 'path'
|
|
7
8
|
import { npmLatest, resolveLatest, scanFleet, type FleetRepo } from '../fleet/scan-fleet.ts'
|
|
@@ -17,7 +18,8 @@ export class FleetCommand extends Command {
|
|
|
17
18
|
examples: [
|
|
18
19
|
['what is stale out there', 'sk fleet'],
|
|
19
20
|
['filesystem only, no registry', 'sk fleet --offline'],
|
|
20
|
-
['bring every behind repo up
|
|
21
|
+
['bring every behind repo up through its own gate, commit and push each bump', 'sk fleet --update'],
|
|
22
|
+
['update, leave the bumps in the trees', 'sk fleet --update --no-commit'],
|
|
21
23
|
['one repo only', 'sk fleet --update --only my-app'],
|
|
22
24
|
['machine-readable', 'sk fleet --json'],
|
|
23
25
|
],
|
|
@@ -25,6 +27,7 @@ export class FleetCommand extends Command {
|
|
|
25
27
|
|
|
26
28
|
root = Option.String('--root', { required: false, description: 'directory holding the repos (default: the parent of this repo)' })
|
|
27
29
|
update = Option.Boolean('--update', false, { description: "run each behind repo's own `shipkit check`, which is where rule SK005 performs the guarded update" })
|
|
30
|
+
noCommit = Option.Boolean('--no-commit', false, { description: 'with --update: leave each bump in its tree. By default each moved repo commits package.json and its lock alone, by pathspec, as `chore(deps): <dep> <range>`, then pushes; a repo whose manifest was dirty before, whose package.json moved more than the bump, mid-merge, mid-rebase, on a detached HEAD or nested in another repo is skipped with the command to run' })
|
|
28
31
|
only = Option.Array('--only', { description: 'limit to these repo directory names (repeatable)' })
|
|
29
32
|
offline = Option.Boolean('--offline', false, { description: 'skip the registry: report what is installed, never what is current' })
|
|
30
33
|
major = Option.Boolean('--major', false, { description: 'let --update cross a breaking boundary too. Default off: a major (or a 0.x minor, as npm caret reads it) is a porting job, not a bump, and no automatic update path takes one' })
|
|
@@ -58,11 +61,11 @@ export class FleetCommand extends Command {
|
|
|
58
61
|
return 0
|
|
59
62
|
}
|
|
60
63
|
if (!this.update) {
|
|
61
|
-
w(`WARN ${behind.length}/${repos.length} repo(s) behind — rerun with --update, which runs each repo's own gate so SK005 does the write`)
|
|
64
|
+
w(`WARN ${behind.length}/${repos.length} repo(s) behind — rerun with --update, which runs each repo's own gate so SK005 does the write, then commits and pushes each bump`)
|
|
62
65
|
return 0
|
|
63
66
|
}
|
|
64
|
-
await updateFleet(behind, { major: this.major, print: w })
|
|
65
|
-
return 0
|
|
67
|
+
const res = await updateFleet(behind, { major: this.major, commit: !this.noCommit, print: w })
|
|
68
|
+
return res.failed > 0 ? 1 : 0
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
private row(r: FleetRepo): string {
|
|
@@ -10,6 +10,12 @@ export const ALLOW_MAJOR_ENV = 'SHIPKIT_ALLOW_MAJOR'
|
|
|
10
10
|
|
|
11
11
|
export const allowMajor = (): boolean => process.env[ALLOW_MAJOR_ENV] === '1'
|
|
12
12
|
|
|
13
|
+
/** set by `shipkit fleet --update` on every repo run it spawns: the operator
|
|
14
|
+
* named the bump, so a published package's devDependencies may move */
|
|
15
|
+
export const FLEET_UPDATE_ENV = 'SHIPKIT_FLEET_UPDATE'
|
|
16
|
+
|
|
17
|
+
export const fleetAsked = (): boolean => process.env[FLEET_UPDATE_ENV] === '1'
|
|
18
|
+
|
|
13
19
|
/** a jump across the boundary npm's caret draws: a changed major, or on a
|
|
14
20
|
* `0.x` package a changed minor (`^0.2.0` means `<0.3.0`). A porting job
|
|
15
21
|
* rather than a bump, so no automatic path takes it. */
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// the commit `fleet --update` makes once a repo moved: package.json and its lock
|
|
2
|
+
// only, by pathspec (never a private index, never `add -A`, never a stash or a
|
|
3
|
+
// branch switch), then the plain push of `sk fleet push`. It commits nothing
|
|
4
|
+
// that is not the bump: a manifest dirty before the update, a package.json diff
|
|
5
|
+
// beyond the moved deps, a merge or rebase under way and a detached HEAD each
|
|
6
|
+
// skip, the files left updated and the line saying what to run.
|
|
7
|
+
import { existsSync, readFileSync } from 'fs'
|
|
8
|
+
import { isAbsolute, join } from 'path'
|
|
9
|
+
import { detectPackageManager, isRefusal, LOCK_FILES } from '../core/package-manager.ts'
|
|
10
|
+
import { git, pushRepo } from './push-fleet.ts'
|
|
11
|
+
|
|
12
|
+
export type BumpResult = { status: 'pushed' | 'skip' | 'fail' | 'none'; line: string }
|
|
13
|
+
|
|
14
|
+
type Change = { path: string; tracked: boolean }
|
|
15
|
+
|
|
16
|
+
/** what the repo held before the update: its manifest (package.json and the
|
|
17
|
+
* locks of the manager it installs with) and which of those files were
|
|
18
|
+
* already changed, someone else's work. Null outside a git repo */
|
|
19
|
+
export type Baseline = { manifest: string[]; dirty: Change[] } | null
|
|
20
|
+
|
|
21
|
+
async function changes(dir: string, manifest: string[]): Promise<Change[] | null> {
|
|
22
|
+
const r = await git(dir, ['status', '--porcelain', '--', ...manifest])
|
|
23
|
+
if (r.code !== 0) return null
|
|
24
|
+
return r.out.split('\n').filter((l) => l !== '').map((l) => ({ path: l.slice(3), tracked: !l.startsWith('??') }))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function bumpBaseline(dir: string): Promise<Baseline> {
|
|
28
|
+
const pm = detectPackageManager(dir)
|
|
29
|
+
const manifest = ['package.json', ...(isRefusal(pm) ? LOCK_FILES : pm.locks)]
|
|
30
|
+
const dirty = await changes(dir, manifest)
|
|
31
|
+
return dirty == null ? null : { manifest, dirty }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const OPERATIONS: Array<[string, string]> = [['MERGE_HEAD', 'a merge'], ['rebase-merge', 'a rebase'], ['rebase-apply', 'a rebase'], ['CHERRY_PICK_HEAD', 'a cherry-pick'], ['REVERT_HEAD', 'a revert']]
|
|
35
|
+
|
|
36
|
+
/** why this repo takes no commit of ours right now, or null. One git call:
|
|
37
|
+
* the dir's place in its work tree (a nested repo is a folder of another
|
|
38
|
+
* repo's history, never committed on), the ref HEAD names (`HEAD` itself
|
|
39
|
+
* when detached), then where each operation in progress leaves its state */
|
|
40
|
+
async function headRefusal(dir: string): Promise<string | null> {
|
|
41
|
+
const r = await git(dir, ['rev-parse', '--show-prefix', '--symbolic-full-name', 'HEAD', ...OPERATIONS.flatMap(([p]) => ['--git-path', p])])
|
|
42
|
+
if (r.code !== 0) return `git rev-parse failed: ${r.err.trim().split('\n')[0]}`
|
|
43
|
+
const [prefix, ref, ...paths] = r.out.split('\n').map((l) => l.trim())
|
|
44
|
+
if (prefix != null && prefix !== '') return `it is the folder ${prefix} of a larger repo, whose history is not its own`
|
|
45
|
+
for (const [i, [, label]] of OPERATIONS.entries()) {
|
|
46
|
+
const p = paths[i]
|
|
47
|
+
if (p != null && p !== '' && existsSync(isAbsolute(p) ? p : join(dir, p))) return `${label} is under way`
|
|
48
|
+
}
|
|
49
|
+
return ref === 'HEAD' ? 'HEAD is detached' : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** a value with its object keys sorted, so key order is never a change */
|
|
53
|
+
const canonical = (v: unknown): unknown =>
|
|
54
|
+
Array.isArray(v) ? v.map(canonical) : v != null && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical((v as Record<string, unknown>)[k])])) : v
|
|
55
|
+
|
|
56
|
+
const DEP_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']
|
|
57
|
+
|
|
58
|
+
/** every field of package.json the update changed besides the moved deps,
|
|
59
|
+
* as `devDependencies.left-pad` or `scripts` */
|
|
60
|
+
export function extraChanges(before: Record<string, unknown>, after: Record<string, unknown>, moved: string[]): string[] {
|
|
61
|
+
const out: string[] = []
|
|
62
|
+
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
63
|
+
const [b, a] = [before[key], after[key]]
|
|
64
|
+
if (DEP_FIELDS.includes(key) && (b == null || typeof b === 'object') && (a == null || typeof a === 'object')) {
|
|
65
|
+
const [bs, as] = [(b ?? {}) as Record<string, unknown>, (a ?? {}) as Record<string, unknown>]
|
|
66
|
+
for (const dep of new Set([...Object.keys(bs), ...Object.keys(as)])) {
|
|
67
|
+
if (moved.includes(dep) && (key === 'dependencies' || key === 'devDependencies')) continue
|
|
68
|
+
if (bs[dep] !== as[dep]) out.push(`${key}.${dep}`)
|
|
69
|
+
}
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (JSON.stringify(canonical(b)) !== JSON.stringify(canonical(a))) out.push(key)
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const lastLine = (text: string): string | null => text.split('\n').map((l) => l.trim()).filter((l) => l !== '' && !l.startsWith('hint:')).at(-1) ?? null
|
|
78
|
+
|
|
79
|
+
/** commit and push the bump of `moved` in `dir`, against the baseline read
|
|
80
|
+
* before the update ran */
|
|
81
|
+
export async function commitBump(repo: { name: string; dir: string }, moved: string[], base: Baseline): Promise<BumpResult> {
|
|
82
|
+
const { name, dir } = repo
|
|
83
|
+
const skip = (why: string, then: string): BumpResult => ({ status: 'skip', line: `skip: ${name}: ${why}, so the bump is not committed. It stays in the tree: cd ${dir} && ${then}` })
|
|
84
|
+
if (base == null) return skip('git status failed before the update', 'git status')
|
|
85
|
+
if (base.dirty.length > 0) {
|
|
86
|
+
const held = base.dirty.map((c) => c.path)
|
|
87
|
+
return skip(`${held.join(', ')} held changes before the update`, `git diff -- ${held.join(' ')}, then commit the bump with them by hand`)
|
|
88
|
+
}
|
|
89
|
+
const refused = await headRefusal(dir)
|
|
90
|
+
if (refused != null) return skip(refused, 'commit the bump by hand once it can take one')
|
|
91
|
+
const after = await changes(dir, base.manifest)
|
|
92
|
+
if (after == null) return { status: 'fail', line: `FAIL: ${name}: git status failed after the update: cd ${dir} && git status` }
|
|
93
|
+
const paths = after.filter((c) => c.tracked).map((c) => c.path).sort((a, b) => Number(b === 'package.json') - Number(a === 'package.json'))
|
|
94
|
+
if (paths.length === 0) return { status: 'none', line: `ok: ${name}: package.json and its lock unchanged, nothing to commit` }
|
|
95
|
+
|
|
96
|
+
const head = await git(dir, ['show', 'HEAD:./package.json'])
|
|
97
|
+
let was: Record<string, unknown>
|
|
98
|
+
let now: Record<string, unknown>
|
|
99
|
+
try {
|
|
100
|
+
was = JSON.parse(head.out) as Record<string, unknown>
|
|
101
|
+
now = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as Record<string, unknown>
|
|
102
|
+
} catch {
|
|
103
|
+
return skip('package.json is unreadable in HEAD or on disk', 'git diff -- package.json')
|
|
104
|
+
}
|
|
105
|
+
const extra = extraChanges(was, now, moved)
|
|
106
|
+
if (extra.length > 0) return skip(`the update changed ${extra.join(', ')} in package.json too`, 'git diff -- package.json')
|
|
107
|
+
|
|
108
|
+
const deps = now as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
|
|
109
|
+
const message = `chore(deps): ${[...moved].sort().map((d) => `${d} ${deps.dependencies?.[d] ?? deps.devDependencies?.[d] ?? '?'}`).join(', ')}`
|
|
110
|
+
const by = `git commit -m '${message}' -- ${paths.join(' ')}`
|
|
111
|
+
|
|
112
|
+
// its first line names the commit: `[main 1a2b3c4] chore(deps): …`
|
|
113
|
+
const done = await git(dir, ['commit', '-m', message, '--', ...paths], 120_000)
|
|
114
|
+
if (done.code !== 0) return { status: 'fail', line: `FAIL: ${name}: the commit was refused: ${lastLine(`${done.out}\n${done.err}`) ?? `git commit exited ${done.code}`}. The bump stays in the tree: cd ${dir} && ${by}` }
|
|
115
|
+
const sha = done.out.match(/^\[\S+ ([0-9a-f]+)\]/m)?.[1] ?? 'HEAD'
|
|
116
|
+
const push = await pushRepo({ name, path: dir })
|
|
117
|
+
const committed = `committed ${sha} ${message}`
|
|
118
|
+
if (push.ok && push.action !== 'none') return { status: 'pushed', line: `ok: ${name}: ${committed}, pushed (${push.branch})` }
|
|
119
|
+
// never a force: a branch behind its upstream waits for a pull, and the
|
|
120
|
+
// operator is told as loudly as a failed push
|
|
121
|
+
if (push.action === 'skip') return { status: 'fail', line: `FAIL: ${name}: ${committed}, not pushed: ${push.why}` }
|
|
122
|
+
return { status: 'fail', line: `FAIL: ${name}: ${committed}, the push failed: ${push.line.replace(/^FAIL: [^:]+: /, '')}. Run: cd ${dir} && git push` }
|
|
123
|
+
}
|
package/src/fleet/push-fleet.ts
CHANGED
|
@@ -15,9 +15,12 @@ export type PushResult = PushPlan & { ok: boolean; line: string }
|
|
|
15
15
|
const PARALLEL = 4
|
|
16
16
|
const PUSH_MS = 60_000
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
/** git in another repo: a push that would ask for a password fails instead of
|
|
19
|
+
* hanging, and a GIT_DIR or GIT_INDEX_FILE this process inherited (a hook) is
|
|
20
|
+
* never carried into it */
|
|
21
|
+
export async function git(cwd: string, args: string[], ms = 15_000): Promise<{ code: number; out: string; err: string }> {
|
|
22
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== 'GIT_DIR' && k !== 'GIT_INDEX_FILE' && k !== 'GIT_WORK_TREE'))
|
|
23
|
+
const p = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...env, GIT_TERMINAL_PROMPT: '0' } })
|
|
21
24
|
const timer = setTimeout(() => p.kill(), ms)
|
|
22
25
|
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
|
|
23
26
|
clearTimeout(timer)
|
|
@@ -63,6 +66,9 @@ async function pushOne(p: PushPlan): Promise<PushResult> {
|
|
|
63
66
|
return { ...p, ok: false, line: `FAIL: ${p.name}: git ${args.join(' ')}: ${why}` }
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
/** one repo planned and pushed: what `fleet --update` runs after its commit */
|
|
70
|
+
export const pushRepo = async (repo: { name: string; path: string }): Promise<PushResult> => pushOne(await pushPlan(repo))
|
|
71
|
+
|
|
66
72
|
/** every repo planned, the ones that need it pushed, a few at a time; `dry`
|
|
67
73
|
* plans only. Results come back in the repos' order */
|
|
68
74
|
export async function pushFleet(repos: { name: string; path: string }[], o: { dry?: boolean } = {}): Promise<PushResult[]> {
|