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 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.7.0",
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
- import { ComfyTS, type AnyVar, type ComfyExecution, type DefinedWorkflow, type MediaImage } from 'comfy-ts'
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: DefinedWorkflow }
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>): DefinedWorkflow | null {
26
- const isDefined = (x: unknown): x is DefinedWorkflow =>
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 DefinedWorkflow).run === 'function' &&
29
- typeof (x as DefinedWorkflow).entries === 'function'
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<MediaImage, 'ComfyNode'>): boolean =>
62
+ export const isRollImage = (img: Pick<CflowImage, 'ComfyNode'>): boolean =>
41
63
  !/preview/i.test(img.ComfyNode?.class_type ?? '')
42
64
 
43
- /** the ComfyTS class of the comfy-ts a bare import from `dir` reaches,
44
- * shipkit's own when the repo carries none */
45
- async function comfyTsOf(dir: string): Promise<typeof ComfyTS> {
46
- let path: string
47
- try {
48
- path = Bun.resolveSync('comfy-ts', dir)
49
- } catch {
50
- return ComfyTS
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
- const mod = (await import(path)) as { ComfyTS?: typeof ComfyTS }
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)) as Record<string, unknown>
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 = varsOf(lw.wf)
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: DefinedWorkflow, vars: Record<string, unknown>, source: string): void {
109
- const defs = varsOf(wf)
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 = varsOf(lw.wf)
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: ComfyExecution
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), two ways to get the mark:
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
- // favicon = logo master resized to 512² (browsers derive the rest)
114
- await write('favicon.png', await sharp(logoPng).resize(FAVICON_SIZE, FAVICON_SIZE).png().toBuffer())
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
- await write('og.png', og.toBuffer('image/png'))
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 — shipkit never commits.
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 drift = stale.map((s) => `${s.name}${s.linked ? ' pin' : ''} ${s.installed} → ${s.latest}`).join(', ')
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: ${drift}, not updating: ${pm.refused}`)
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: ${drift} (frozen — apply with: ${cmd.join(' ')})`)
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
- c.warn(`newer rvlib live: ${drift} (published to npm, never updated by a check — apply with: ${cmd.join(' ')})`)
171
- return
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 stale) if (s.linked) await c.exec(['bun', 'link', s.name], { cwd: c.root, timeoutMs: 60_000 })
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 stale) {
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}`)
@@ -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, through its own gate', 'sk fleet --update'],
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
+ }
@@ -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
- async function git(cwd: string, args: string[], ms = 15_000): Promise<{ code: number; out: string; err: string }> {
19
- // a push that would ask for a password fails instead of hanging the page
20
- const p = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
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[]> {