rvlib-shipkit 2.7.0 → 2.8.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,9 @@
1
+ ## 2.8.0
2
+
3
+ - **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.
4
+ - **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.
5
+ - **`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`.
6
+
1
7
  ## 2.7.0
2
8
 
3
9
  - **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.8.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
 
@@ -12,6 +12,7 @@ export function comfyModule(comfy: string | undefined, name: string): string {
12
12
  const COMFY_TIMEOUT_S = 15 * 60
13
13
 
14
14
  export async function comfyRun(module: string, vars: Record<string, string | number | boolean | undefined>, out: string): Promise<void> {
15
+ if (Bun.which('comfy-ts') == null) throw new Error(`comfy-ts is not on PATH, and ${module} is a comfy-ts module. Install it: bun add -g comfy-ts`)
15
16
  const args = ['comfy-ts', 'run', module]
16
17
  for (const [k, v] of Object.entries(vars)) if (v !== undefined) args.push(`--${k}`, String(v))
17
18
  args.push('--out', out, '--json')