rvlib-shipkit 0.6.0 → 0.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 +52 -1
- package/package.json +3 -2
- package/src/assets/gen/derived.ts +83 -0
- package/src/assets/gen/gemini-gen.ts +77 -0
- package/src/assets/gen/generate-roll.ts +12 -1
- package/src/assets/kit-config.ts +25 -2
- package/src/assets/run-assets.ts +69 -28
- package/src/assets/server/studio-page.ts +3 -2
- package/src/assets/server/studio-server.ts +31 -6
- package/src/cli/cmd_Mv.ts +34 -0
- package/src/cli.ts +2 -0
- package/src/contracts/manifests/discord.contract.ts +1 -1
- package/src/contracts/manifests/identity.contract.ts +1 -1
- package/src/contracts/manifests/social.contract.ts +1 -1
- package/src/core/package-json-check.ts +1 -0
- package/src/init-repo.ts +1 -1
- package/src/mv/mv-md.ts +90 -0
- package/src/serve/hub-app/hub-app.tsx +156 -0
- package/src/serve/hub-app/hub.css +41 -0
- package/src/serve/hub-app/index.html +13 -0
- package/src/serve/hub-pages.ts +39 -78
- package/src/serve/run-serve.ts +97 -4
- package/src/serve/serve-hub.ts +30 -9
- package/src/shipkit.ts +6 -3
- package/src/tui/run-tui.ts +1 -1
- package/src/tui/tui-store.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0 — 2026-07-22
|
|
4
|
+
|
|
5
|
+
New:
|
|
6
|
+
|
|
7
|
+
- `shipkit mv <old.md> <new.md>` in every repo: rename an md page + its
|
|
8
|
+
sibling asset dir + `<name>.check.ts`, sweeping every tracked file for
|
|
9
|
+
refs (full path, docs-root id, check filename, boundary-guarded so
|
|
10
|
+
lookalikes survive). Dry by default, `--write` applies, `--commit` adds
|
|
11
|
+
one conventional commit behind the repo's pre-commit gate. A repo
|
|
12
|
+
`mv:after` script (NOT `postmv`, bun npm-compat auto-runs post* names)
|
|
13
|
+
runs after the moves for repo-specific re-stamps. `movePage` exported.
|
|
14
|
+
`init` scaffolds the `mv` script; the package.json probe requires it.
|
|
15
|
+
Upgraders: re-run `shipkit init` (idempotent, only adds the gap).
|
|
16
|
+
- the hub detects its own staleness: lib version read at boot vs on disk
|
|
17
|
+
per attention load. A mismatched running process shows a red line telling
|
|
18
|
+
you to restart the task (field bug: a morning process served old studio
|
|
19
|
+
code all day and a pick looked broken).
|
|
20
|
+
|
|
21
|
+
## 0.7.0 — 2026-07-22
|
|
22
|
+
|
|
23
|
+
New (the hub becomes THE dashboard; autonomy over nagging):
|
|
24
|
+
|
|
25
|
+
- hub UI rebuilt as a **Bun-native React app** (Bun.serve + HTML import, no
|
|
26
|
+
vite, no build step; MobX store). The old template-string landing +
|
|
27
|
+
status pages are DELETED.
|
|
28
|
+
- `/` IS the **attention dashboard**: failing checks (STATUS.md), open
|
|
29
|
+
asks, missing/unpicked/stale assets, contract gaps, planned posts — every
|
|
30
|
+
line links to its owning surface. Data via `/api/attention.json` +
|
|
31
|
+
`/api/status.json`; sources are failure-isolated (a broken collector
|
|
32
|
+
reports itself, never blanks the page).
|
|
33
|
+
- a thin left **rail** (⚡🕸🤖🖼📊, active-tab marked, red dot when
|
|
34
|
+
anything red) on EVERY hub page — React pages natively, legacy pages
|
|
35
|
+
(graph pages, studio SPA) via server-side injection until they migrate.
|
|
36
|
+
- **derived assets**: `from: ['parentId']` on an AssetSpec + the
|
|
37
|
+
`gemini({ model? })` backend (plain fetch, key from env GEMINI_API_KEY —
|
|
38
|
+
unset dies loud). The asset's prompt is the EDIT instruction; parents'
|
|
39
|
+
PICKED rolls feed generation as context images. Unknown refs, cycles and
|
|
40
|
+
non-gemini `from` die at defineKit. Each roll records its parent picks in
|
|
41
|
+
its meta sidecar.
|
|
42
|
+
- **autonomy contract — machine-clearable red clears itself**: re-picking a
|
|
43
|
+
parent AUTO-enqueues every now-stale child (no button, no nag; spend
|
|
44
|
+
gated on the intentful pick). Identity self-heals the same way: brand.ts
|
|
45
|
+
content hash in `.rv-identity/brand.hash`; `assets check` and the
|
|
46
|
+
attention collector detect drift and RE-RENDER public/* themselves —
|
|
47
|
+
only a missing pick still reaches a human. The old
|
|
48
|
+
"WARN outputs missing — run identity" line is gone.
|
|
49
|
+
- fill/add walk parents-first (topo) so derived children never generate
|
|
50
|
+
before their parents; a blocked derived asset FAILs naming the parent.
|
|
51
|
+
- studio: stale = orange sidebar dot; `/api/state` gains
|
|
52
|
+
`derived`/`stale` per asset.
|
|
53
|
+
|
|
3
54
|
## 0.6.0 — 2026-07-22
|
|
4
55
|
|
|
5
56
|
New (wave 3 of the shipkit expansion — media + manual assets):
|
|
@@ -70,7 +121,7 @@ New (wave 1 of the shipkit expansion — uniform repo setup):
|
|
|
70
121
|
|
|
71
122
|
- fix: `react-dom` was missing from dependencies AND the CLI entry loaded
|
|
72
123
|
the react/ink chain eagerly — every command crashed in a fresh consumer
|
|
73
|
-
repo without react-dom (found by
|
|
124
|
+
repo without react-dom (found by a consumer-repo swap in the field,
|
|
74
125
|
confirmed against the 0.3.0 tarball). tui now lazy-loads its body; deps
|
|
75
126
|
carry react-dom. Proven by a fresh-repo install test (init → ci green in
|
|
76
127
|
a react-less repo).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"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",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/bun": "^1.3.14",
|
|
23
|
-
"@types/react": "^19.2.17"
|
|
23
|
+
"@types/react": "^19.2.17",
|
|
24
|
+
"@types/react-dom": "^19.2.3"
|
|
24
25
|
},
|
|
25
26
|
"dependencies": {
|
|
26
27
|
"@napi-rs/canvas": "^1.0.2",
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Derived assets — `from: ['parentId']` on an AssetSpec: the parents'
|
|
2
|
+
// PICKED rolls feed generation as context images; each generated roll
|
|
3
|
+
// records the parent picks in its meta sidecar; a parent re-pick flips
|
|
4
|
+
// children to STALE. Shared by the studio queue and the fill/gen CLI.
|
|
5
|
+
import { join } from 'path'
|
|
6
|
+
import { findAsset, type FoundAsset, type KitConfig } from '../kit-config.ts'
|
|
7
|
+
import { assetDir, listRolls, metaPath, readPick } from '../roll-workspace.ts'
|
|
8
|
+
import { readJson, updateJson } from '../json-store.ts'
|
|
9
|
+
import type { ContextImage } from './gemini-gen.ts'
|
|
10
|
+
|
|
11
|
+
const MIME: Record<string, string> = { png: 'image/png', webp: 'image/webp' }
|
|
12
|
+
|
|
13
|
+
export type ParentContext = { images: ContextImage[]; parents: Record<string, string> }
|
|
14
|
+
|
|
15
|
+
export const parentIdsOf = (f: FoundAsset): string[] => f.asset.from ?? []
|
|
16
|
+
|
|
17
|
+
/** Parents' picked rolls as base64 context — or the list of blockers
|
|
18
|
+
* (unpicked/unknown parents), never a silent partial. */
|
|
19
|
+
export async function resolveParentContext(kit: KitConfig, workspace: string, f: FoundAsset): Promise<ParentContext | { missing: string[] }> {
|
|
20
|
+
const missing: string[] = []
|
|
21
|
+
const images: ContextImage[] = []
|
|
22
|
+
const parents: Record<string, string> = {}
|
|
23
|
+
for (const id of parentIdsOf(f)) {
|
|
24
|
+
const parent = findAsset(kit, id)
|
|
25
|
+
if (parent == null) { missing.push(`${id} (not in kit)`); continue }
|
|
26
|
+
const dir = assetDir(workspace, id)
|
|
27
|
+
const pick = (await readPick(dir)).pick
|
|
28
|
+
if (pick == null) { missing.push(`${id} (no pick)`); continue }
|
|
29
|
+
const ext = pick.split('.').at(-1)!
|
|
30
|
+
const file = Bun.file(join(dir, pick))
|
|
31
|
+
if (!(await file.exists())) { missing.push(`${id} (picked roll ${pick} vanished)`); continue }
|
|
32
|
+
images.push({ mime: MIME[ext] ?? 'image/png', base64: Buffer.from(await file.arrayBuffer()).toString('base64') })
|
|
33
|
+
parents[id] = pick
|
|
34
|
+
}
|
|
35
|
+
if (missing.length > 0) return { missing }
|
|
36
|
+
return { images, parents }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Stamp the parent picks onto every roll of this seed (v<seed>.*) — called
|
|
40
|
+
* after a successful derived generation. */
|
|
41
|
+
export async function recordParents(destDir: string, seed: number, parents: Record<string, string>): Promise<void> {
|
|
42
|
+
if (Object.keys(parents).length === 0) return
|
|
43
|
+
for (const roll of listRolls(destDir).filter((r) => r.startsWith(`v${seed}.`) || r.startsWith(`v${seed}-`)))
|
|
44
|
+
await updateJson<{ parents?: Record<string, string> }>(metaPath(destDir, roll), {}, (m) => { m.parents = parents })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A derived asset is STALE when its PICKED roll's recorded parent picks
|
|
48
|
+
* differ from the parents' CURRENT picks (or were never recorded). An
|
|
49
|
+
* unpicked asset is never stale — it's just unpicked. */
|
|
50
|
+
export async function isStale(kit: KitConfig, workspace: string, f: FoundAsset): Promise<boolean> {
|
|
51
|
+
const parentIds = parentIdsOf(f)
|
|
52
|
+
if (parentIds.length === 0) return false
|
|
53
|
+
const dir = assetDir(workspace, f.asset.id)
|
|
54
|
+
const pick = (await readPick(dir)).pick
|
|
55
|
+
if (pick == null) return false
|
|
56
|
+
const meta = await readJson<{ parents?: Record<string, string> }>(metaPath(dir, pick), {})
|
|
57
|
+
for (const id of parentIds) {
|
|
58
|
+
const current = (await readPick(assetDir(workspace, id))).pick
|
|
59
|
+
if (current == null) continue // parent lost its pick — child can't regen anyway; blocker surfaces at generate
|
|
60
|
+
if (meta.parents?.[id] !== current) return true
|
|
61
|
+
}
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Parents-first order for batch generation (fill/add, regen-stale).
|
|
66
|
+
* Cycles are rejected at defineKit; a stray one here throws loud. */
|
|
67
|
+
export function topoSort(assets: FoundAsset[]): FoundAsset[] {
|
|
68
|
+
const byId = new Map(assets.map((f) => [f.asset.id, f]))
|
|
69
|
+
const done = new Set<string>()
|
|
70
|
+
const visiting = new Set<string>()
|
|
71
|
+
const out: FoundAsset[] = []
|
|
72
|
+
const visit = (f: FoundAsset): void => {
|
|
73
|
+
if (done.has(f.asset.id)) return
|
|
74
|
+
if (visiting.has(f.asset.id)) throw new Error(`derived-asset cycle through '${f.asset.id}'`)
|
|
75
|
+
visiting.add(f.asset.id)
|
|
76
|
+
for (const id of parentIdsOf(f)) { const p = byId.get(id); if (p != null) visit(p) }
|
|
77
|
+
visiting.delete(f.asset.id)
|
|
78
|
+
done.add(f.asset.id)
|
|
79
|
+
out.push(f)
|
|
80
|
+
}
|
|
81
|
+
for (const f of assets) visit(f)
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Gemini image backend — plain fetch, no SDK. THE backend for
|
|
2
|
+
// edit-from-parent flows (context images = parents' picked rolls); comfy
|
|
3
|
+
// stays for from-scratch art. Key from env (default GEMINI_API_KEY) — a
|
|
4
|
+
// missing key dies loud at generation, never silently.
|
|
5
|
+
import { mkdirSync } from 'fs'
|
|
6
|
+
import { join } from 'path'
|
|
7
|
+
|
|
8
|
+
export type GeminiModel = 'flash' | 'pro'
|
|
9
|
+
export const GEMINI_MODELS: Record<GeminiModel, string> = {
|
|
10
|
+
flash: 'gemini-2.5-flash-image',
|
|
11
|
+
pro: 'gemini-3-pro-image-preview',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type GeminiOpts = {
|
|
15
|
+
/** env var carrying the API key (default GEMINI_API_KEY) */
|
|
16
|
+
apiKeyEnv?: string
|
|
17
|
+
/** test seam / self-host — default the real Google endpoint */
|
|
18
|
+
host?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ContextImage = { mime: string; base64: string }
|
|
22
|
+
|
|
23
|
+
const HOST = 'https://generativelanguage.googleapis.com'
|
|
24
|
+
|
|
25
|
+
export function geminiKey(o: GeminiOpts): string {
|
|
26
|
+
const env = o.apiKeyEnv ?? 'GEMINI_API_KEY'
|
|
27
|
+
const key = process.env[env]
|
|
28
|
+
if (key == null || key === '')
|
|
29
|
+
throw new Error(`env ${env} unset — the gemini() backend needs it (mint: aistudio.google.com/apikey, export it in the repo's scripts)`)
|
|
30
|
+
return key
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type GenerateContentResponse = {
|
|
34
|
+
candidates?: { content?: { parts?: { inlineData?: { mimeType?: string; data?: string }; text?: string }[] } }[]
|
|
35
|
+
error?: { message?: string }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One generation: prompt + context images → image roll(s) saved as
|
|
39
|
+
* <destDir>/<name>.png (suffix -2… on multi-image). Returns saved paths. */
|
|
40
|
+
export async function generateGemini(
|
|
41
|
+
o: GeminiOpts,
|
|
42
|
+
args: { prompt: string; model?: GeminiModel; destDir: string; name: string; context: ContextImage[] },
|
|
43
|
+
): Promise<string[]> {
|
|
44
|
+
const key = geminiKey(o)
|
|
45
|
+
const model = GEMINI_MODELS[args.model ?? 'flash']
|
|
46
|
+
const res = await fetch(`${o.host ?? HOST}/v1beta/models/${model}:generateContent`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': key },
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
contents: [{ parts: [{ text: args.prompt }, ...args.context.map((c) => ({ inlineData: { mimeType: c.mime, data: c.base64 } }))] }],
|
|
51
|
+
}),
|
|
52
|
+
})
|
|
53
|
+
if (!res.ok) throw new Error(`gemini ${model} → ${res.status}: ${(await res.text()).slice(0, 200)}`)
|
|
54
|
+
const body = (await res.json()) as GenerateContentResponse
|
|
55
|
+
const parts = body.candidates?.[0]?.content?.parts ?? []
|
|
56
|
+
mkdirSync(args.destDir, { recursive: true })
|
|
57
|
+
const saved: string[] = []
|
|
58
|
+
for (const p of parts) {
|
|
59
|
+
if (p.inlineData?.data == null) continue
|
|
60
|
+
// roll names must satisfy the workspace regex (v<seed>.png|webp) or
|
|
61
|
+
// the studio never lists them — an invisible paid roll is the worst
|
|
62
|
+
// failure mode. Unexpected mimes die loud; extra images get their
|
|
63
|
+
// own seed-valid names instead of an unlistable -2 suffix.
|
|
64
|
+
const mime = p.inlineData.mimeType ?? 'image/png'
|
|
65
|
+
const ext = mime === 'image/webp' ? 'webp' : mime === 'image/png' ? 'png' : null
|
|
66
|
+
if (ext == null) throw new Error(`gemini returned mime '${mime}' — only png/webp land as rolls (got ${parts.length} part(s))`)
|
|
67
|
+
const name = saved.length === 0 ? args.name : `v${Math.floor(Math.random() * 2_000_000_000) + 1}`
|
|
68
|
+
const dest = join(args.destDir, `${name}.${ext}`)
|
|
69
|
+
await Bun.write(dest, Buffer.from(p.inlineData.data, 'base64'))
|
|
70
|
+
saved.push(dest)
|
|
71
|
+
}
|
|
72
|
+
if (saved.length === 0) {
|
|
73
|
+
const text = parts.find((p) => p.text != null)?.text?.slice(0, 160)
|
|
74
|
+
throw new Error(`gemini ${model} returned no image${text != null ? ` — model said: ${text}` : ''} (blocked prompt? wrong model?)`)
|
|
75
|
+
}
|
|
76
|
+
return saved
|
|
77
|
+
}
|
|
@@ -8,6 +8,8 @@ import { mkdirSync } from 'fs'
|
|
|
8
8
|
import { join } from 'path'
|
|
9
9
|
import type { FoundAsset } from '../kit-config.ts'
|
|
10
10
|
import { generate, type OnProgress } from './comfy-gen.ts'
|
|
11
|
+
import { recordParents, type ParentContext } from './derived.ts'
|
|
12
|
+
import { generateGemini } from './gemini-gen.ts'
|
|
11
13
|
import { loadWorkflows, workflowFor, type Workflow } from './workflow-loader.ts'
|
|
12
14
|
|
|
13
15
|
/** roll canvas master size (square) — favicon/logo/sprites derive from it */
|
|
@@ -20,12 +22,21 @@ export type ComfyResolver = () => Promise<ComfyRuntime>
|
|
|
20
22
|
* v<seed>.<ext> (comfy). `resolveComfy` is only awaited for comfy backends. */
|
|
21
23
|
export async function generateRoll(
|
|
22
24
|
found: FoundAsset,
|
|
23
|
-
args: { prompt: string; seed: number; destDir: string; onProgress?: OnProgress },
|
|
25
|
+
args: { prompt: string; seed: number; destDir: string; onProgress?: OnProgress; context?: ParentContext },
|
|
24
26
|
resolveComfy: ComfyResolver,
|
|
25
27
|
): Promise<void> {
|
|
26
28
|
const gen = found.kind.generate
|
|
27
29
|
if (gen.backend === 'manual')
|
|
28
30
|
throw new Error(`'${found.asset.id}' is a manual asset — upload a file in the studio (button or drag-drop), nothing generates it`)
|
|
31
|
+
if (gen.backend === 'gemini') {
|
|
32
|
+
// derived assets NEED their parents' picked rolls — the caller
|
|
33
|
+
// resolves them (queue/CLI) so a blocker names the parent, loud
|
|
34
|
+
if ((found.asset.from?.length ?? 0) > 0 && args.context == null)
|
|
35
|
+
throw new Error(`'${found.asset.id}' derives from ${found.asset.from!.join(', ')} — parent context missing (are the parents picked?)`)
|
|
36
|
+
await generateGemini({}, { prompt: args.prompt, model: gen.model, destDir: args.destDir, name: `v${args.seed}`, context: args.context?.images ?? [] })
|
|
37
|
+
if (args.context != null) await recordParents(args.destDir, args.seed, args.context.parents)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
29
40
|
if (gen.backend === 'canvas') {
|
|
30
41
|
const { createCanvas } = await import('@napi-rs/canvas')
|
|
31
42
|
const c = createCanvas(CANVAS_SIZE, CANVAS_SIZE)
|
package/src/assets/kit-config.ts
CHANGED
|
@@ -10,9 +10,13 @@ import type { GroundRect } from './panels/geometry-rects.ts'
|
|
|
10
10
|
|
|
11
11
|
export type AssetSpec = {
|
|
12
12
|
id: string
|
|
13
|
-
/** FULL prompt text incl. style — the studio shows/edits exactly what
|
|
13
|
+
/** FULL prompt text incl. style — the studio shows/edits exactly what
|
|
14
|
+
* generates. On a DERIVED asset it is the EDIT instruction. */
|
|
14
15
|
prompt: string
|
|
15
16
|
label?: string
|
|
17
|
+
/** derived asset: parent asset ids whose PICKED rolls feed generation as
|
|
18
|
+
* context images (gemini kinds only). Parent re-pick auto-regens. */
|
|
19
|
+
from?: string[]
|
|
16
20
|
/** freeform data passed through to the ship hook */
|
|
17
21
|
ship?: unknown
|
|
18
22
|
}
|
|
@@ -23,10 +27,13 @@ export type CanvasGen = { backend: 'canvas'; draw: (ctx: unknown, asset: AssetSp
|
|
|
23
27
|
* manual asset with no rolls is RED + FAILs `shipkit assets check`. The
|
|
24
28
|
* asset's `prompt` is the curation brief. */
|
|
25
29
|
export type ManualGen = { backend: 'manual' }
|
|
26
|
-
|
|
30
|
+
/** Gemini image backend (edit-from-parent flows; comfy stays for from-scratch). */
|
|
31
|
+
export type GeminiGen = { backend: 'gemini'; model?: 'flash' | 'pro' }
|
|
32
|
+
export type GeneratorSpec = ComfyGen | CanvasGen | ManualGen | GeminiGen
|
|
27
33
|
export const comfy = (p?: { workflow?: string; bg?: string }): ComfyGen => ({ backend: 'comfy', workflow: p?.workflow, bg: p?.bg })
|
|
28
34
|
export const canvas = (draw: CanvasGen['draw']): CanvasGen => ({ backend: 'canvas', draw })
|
|
29
35
|
export const manual = (): ManualGen => ({ backend: 'manual' })
|
|
36
|
+
export const gemini = (p?: { model?: 'flash' | 'pro' }): GeminiGen => ({ backend: 'gemini', model: p?.model })
|
|
30
37
|
|
|
31
38
|
export type Media = 'image' | 'audio'
|
|
32
39
|
export const mediaOf = (kind: Kind): Media => kind.media ?? 'image'
|
|
@@ -130,6 +137,9 @@ export function validateKit(k: KitConfig): string[] {
|
|
|
130
137
|
errs.push(`kind '${kind.id}': media 'audio' supports only the manual() backend today (generation backends are image-only)`)
|
|
131
138
|
if (kind.media === 'audio' && (kind.meta?.panels ?? []).some((p) => p.panel === 'geometry'))
|
|
132
139
|
errs.push(`kind '${kind.id}': audio kinds take no geometry panel`)
|
|
140
|
+
for (const a of kind.assets ?? [])
|
|
141
|
+
if ((a.from?.length ?? 0) > 0 && kind.generate?.backend !== 'gemini')
|
|
142
|
+
errs.push(`kind '${kind.id}': asset '${a.id}' derives (from:) but the backend is '${kind.generate?.backend}' — only gemini() takes context images`)
|
|
133
143
|
if (kind.ship?.fn != null && kind.ship?.via != null) errs.push(`kind '${kind.id}': ship.fn and ship.via are two hooks for one pick — keep one`)
|
|
134
144
|
if (!Array.isArray(kind.assets) || kind.assets.length === 0) errs.push(`kind '${kind.id}' has no assets — a silent no-op kind is a config mistake`)
|
|
135
145
|
for (const a of kind.assets ?? []) {
|
|
@@ -148,6 +158,19 @@ export function validateKit(k: KitConfig): string[] {
|
|
|
148
158
|
errs.push(`kind '${kind.id}' panel '${p.id}': script must be repo-root-relative without '..' (got '${p.script}')`)
|
|
149
159
|
}
|
|
150
160
|
}
|
|
161
|
+
// derived-asset graph: every from-ref must exist, and the graph is a DAG
|
|
162
|
+
const fromOf = new Map<string, string[]>()
|
|
163
|
+
for (const kind of k.kinds ?? []) for (const a of kind.assets ?? []) fromOf.set(a.id, a.from ?? [])
|
|
164
|
+
for (const [id, refs] of fromOf) for (const r of refs) if (!fromOf.has(r)) errs.push(`asset '${id}' derives from unknown asset '${r}'`)
|
|
165
|
+
const state = new Map<string, 'visiting' | 'done'>()
|
|
166
|
+
const visit = (id: string, path: string[]): void => {
|
|
167
|
+
if (state.get(id) === 'done') return
|
|
168
|
+
if (state.get(id) === 'visiting') { errs.push(`derived-asset cycle: ${[...path, id].join(' → ')}`); return }
|
|
169
|
+
state.set(id, 'visiting')
|
|
170
|
+
for (const r of fromOf.get(id) ?? []) if (fromOf.has(r)) visit(r, [...path, id])
|
|
171
|
+
state.set(id, 'done')
|
|
172
|
+
}
|
|
173
|
+
for (const id of fromOf.keys()) visit(id, [])
|
|
151
174
|
return errs
|
|
152
175
|
}
|
|
153
176
|
|
package/src/assets/run-assets.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { existsSync } from 'fs'
|
|
|
5
5
|
import { join, relative } from 'path'
|
|
6
6
|
import { randomSeed } from './gen/comfy-gen.ts'
|
|
7
7
|
import { comfyResolver, generateRoll, type ComfyResolver } from './gen/generate-roll.ts'
|
|
8
|
+
import { parentIdsOf, resolveParentContext, topoSort, type ParentContext } from './gen/derived.ts'
|
|
8
9
|
import { loadWorkflows } from './gen/workflow-loader.ts'
|
|
9
10
|
import { allAssets, findAsset, loadKit, type ResolvedKit } from './kit-config.ts'
|
|
10
11
|
import { NO_PICK_FIX, renderIdentity, type Brand } from './kits/identity-kit.ts'
|
|
@@ -28,11 +29,19 @@ async function rollOnce(root: string, io: AssetsIo, kit: ResolvedKit, resolveCom
|
|
|
28
29
|
if (f == null) die(`unknown asset '${assetId}'`)
|
|
29
30
|
const dir = assetDir(join(root, kit.workspace), assetId)
|
|
30
31
|
const prompt = (await readPromptOverlay(dir)).prompt ?? f!.asset.prompt
|
|
32
|
+
// derived assets need their parents' picks as context — a blocker names
|
|
33
|
+
// the parent and fails the whole asset, never a partial
|
|
34
|
+
let context: ParentContext | undefined
|
|
35
|
+
if (parentIdsOf(f!).length > 0) {
|
|
36
|
+
const r = await resolveParentContext(kit, join(root, kit.workspace), f!)
|
|
37
|
+
if ('missing' in r) { io.printErr(`FAIL: ${assetId} blocked — parents not ready: ${r.missing.join(', ')}`); return count }
|
|
38
|
+
context = r
|
|
39
|
+
}
|
|
31
40
|
let failed = 0
|
|
32
41
|
for (let i = 0; i < count; i++) {
|
|
33
42
|
const seed = randomSeed()
|
|
34
43
|
try {
|
|
35
|
-
await generateRoll(f!, { prompt, seed, destDir: dir }, resolveComfy)
|
|
44
|
+
await generateRoll(f!, { prompt, seed, destDir: dir, context }, resolveComfy)
|
|
36
45
|
io.print(`ok: ${assetId} v${seed}`)
|
|
37
46
|
} catch (e) {
|
|
38
47
|
failed++
|
|
@@ -70,12 +79,12 @@ export const runAssetsTopUp = (root: string, io: AssetsIo, o: { mode: 'fill' | '
|
|
|
70
79
|
const inScope = allAssets(kit).filter((f) => o.categories.length === 0 || o.categories.includes(f.kind.category))
|
|
71
80
|
const manualCount = inScope.filter((f) => f.kind.generate.backend === 'manual').length
|
|
72
81
|
if (manualCount > 0) io.print(`skip: ${manualCount} manual asset(s) — upload in the studio`)
|
|
73
|
-
|
|
74
|
-
|
|
82
|
+
// topo (parents-first) replaced sparsest-first: derived children need
|
|
83
|
+
// their parents' rolls to exist before they can ever generate
|
|
84
|
+
const targets = topoSort(inScope.filter((f) => f.kind.generate.backend !== 'manual'))
|
|
75
85
|
.map((f) => ({ f, have: listRolls(assetDir(join(root, kit.workspace), f.asset.id)).length }))
|
|
76
86
|
.map((t) => ({ ...t, want: o.mode === 'add' ? o.n : Math.max(0, o.n - t.have) }))
|
|
77
87
|
.filter((t) => t.want > 0)
|
|
78
|
-
.sort((a, b) => a.have - b.have) // sparsest first
|
|
79
88
|
let failed = 0
|
|
80
89
|
for (const t of targets) failed += await rollOnce(root, io, kit, resolveComfy, t.f.asset.id, t.want)
|
|
81
90
|
io.print(`${o.mode}: ${targets.length} asset(s) topped up${failed > 0 ? ` — ${failed} roll(s) FAILED` : ''}`)
|
|
@@ -89,26 +98,57 @@ export const runAssetsGen = (root: string, io: AssetsIo, o: { assetId: string; n
|
|
|
89
98
|
return failed > 0 ? 1 : 0
|
|
90
99
|
})
|
|
91
100
|
|
|
101
|
+
// ── identity: ONE render path + a brand.ts content hash so drift is
|
|
102
|
+
// machine-detectable — and machine-CLEARED (auto re-render), never a nag.
|
|
103
|
+
// Only a missing PICK still asks a human (taste).
|
|
104
|
+
const brandHashPath = (root: string): string => join(root, '.rv-identity/brand.hash')
|
|
105
|
+
const brandHash = async (root: string): Promise<string> =>
|
|
106
|
+
new Bun.CryptoHasher('sha256').update(await Bun.file(join(root, '.rv-identity/brand.ts')).text()).digest('hex').slice(0, 16)
|
|
107
|
+
|
|
108
|
+
export type IdentityRender = { written: string[] } | { blocked: string }
|
|
109
|
+
|
|
110
|
+
export async function renderIdentityNow(root: string): Promise<IdentityRender> {
|
|
111
|
+
const brandPath = join(root, '.rv-identity/brand.ts')
|
|
112
|
+
if (!existsSync(brandPath)) return { blocked: 'no .rv-identity/brand.ts — create one exporting a Brand (name, palette, mark)' }
|
|
113
|
+
// cache-bust: bun caches dynamic imports for the process lifetime — a
|
|
114
|
+
// long-running hub must re-render the EDITED brand, not the cached one
|
|
115
|
+
const mod = (await import(`${brandPath}?v=${await brandHash(root)}`)) as { default?: Brand }
|
|
116
|
+
if (mod.default == null) return { blocked: '.rv-identity/brand.ts must default-export the Brand' }
|
|
117
|
+
const brand = mod.default
|
|
118
|
+
let markFile: string | undefined
|
|
119
|
+
if (brand.mark.backend === 'comfy') {
|
|
120
|
+
// comfy marks render from the PICKED identity-logo roll — the kit
|
|
121
|
+
// owns the workspace where picks live
|
|
122
|
+
let kit: ResolvedKit
|
|
123
|
+
try { kit = await loadKit(root) } catch (e) { return { blocked: `comfy mark needs the kit's roll workspace — ${(e as Error).message}` } }
|
|
124
|
+
const dir = assetDir(join(root, kit.workspace), 'identity-logo')
|
|
125
|
+
const pick = await readPick(dir)
|
|
126
|
+
if (pick.pick == null) return { blocked: `brand '${brand.name}' uses a comfy mark and none is picked — ${NO_PICK_FIX}` }
|
|
127
|
+
markFile = join(dir, pick.pick)
|
|
128
|
+
if (!existsSync(markFile)) return { blocked: `picked roll ${pick.pick} vanished from ${dir} — re-pick in the studio` }
|
|
129
|
+
}
|
|
130
|
+
const written = await renderIdentity(brand, root, { markFile })
|
|
131
|
+
await Bun.write(brandHashPath(root), (await brandHash(root)) + '\n')
|
|
132
|
+
return { written }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** brand.ts drifted since the last render → re-render NOW (autonomous).
|
|
136
|
+
* Returns what happened; 'blocked' = only a human pick can clear it. */
|
|
137
|
+
export async function healIdentity(root: string): Promise<{ state: 'fresh' | 'rendered' | 'blocked' | 'absent'; detail?: string }> {
|
|
138
|
+
if (!existsSync(join(root, '.rv-identity/brand.ts'))) return { state: 'absent' }
|
|
139
|
+
const outputsMissing = ['public/favicon.png', 'public/og.png', 'public/logo.png'].some((p) => !existsSync(join(root, p)))
|
|
140
|
+
const stored = existsSync(brandHashPath(root)) ? (await Bun.file(brandHashPath(root)).text()).trim() : null
|
|
141
|
+
if (!outputsMissing && stored === (await brandHash(root))) return { state: 'fresh' }
|
|
142
|
+
const r = await renderIdentityNow(root)
|
|
143
|
+
if ('blocked' in r) return { state: 'blocked', detail: r.blocked }
|
|
144
|
+
return { state: 'rendered', detail: r.written.join(', ') }
|
|
145
|
+
}
|
|
146
|
+
|
|
92
147
|
export const runAssetsIdentity = (root: string, io: AssetsIo): Promise<number> =>
|
|
93
148
|
guarded(io, async () => {
|
|
94
|
-
const
|
|
95
|
-
if (
|
|
96
|
-
const
|
|
97
|
-
if (mod.default == null) die('.rv-identity/brand.ts must default-export the Brand')
|
|
98
|
-
const brand = mod.default!
|
|
99
|
-
let markFile: string | undefined
|
|
100
|
-
if (brand.mark.backend === 'comfy') {
|
|
101
|
-
// comfy marks render from the PICKED identity-logo roll — the kit
|
|
102
|
-
// owns the workspace where picks live
|
|
103
|
-
const kit = await loadKit(root).catch((e: Error) => die(`comfy mark needs the kit's roll workspace — ${e.message}`))
|
|
104
|
-
const dir = assetDir(join(root, kit.workspace), 'identity-logo')
|
|
105
|
-
const pick = await readPick(dir)
|
|
106
|
-
if (pick.pick == null) die(`brand '${brand.name}' uses a comfy mark and none is picked — ${NO_PICK_FIX}`)
|
|
107
|
-
markFile = join(dir, pick.pick!)
|
|
108
|
-
if (!existsSync(markFile)) die(`picked roll ${pick.pick} vanished from ${dir} — re-pick in the studio`)
|
|
109
|
-
}
|
|
110
|
-
const written = await renderIdentity(brand, root, { markFile })
|
|
111
|
-
for (const w of written) io.print(`ok: ${w}`)
|
|
149
|
+
const r = await renderIdentityNow(root)
|
|
150
|
+
if ('blocked' in r) die(r.blocked)
|
|
151
|
+
for (const w of (r as { written: string[] }).written) io.print(`ok: ${w}`)
|
|
112
152
|
return 0
|
|
113
153
|
})
|
|
114
154
|
|
|
@@ -137,13 +177,14 @@ export const runAssetsCheck = (root: string, io: AssetsIo): Promise<number> =>
|
|
|
137
177
|
const up = await fetch(`${kit.comfy.host}/system_stats`, { signal: AbortSignal.timeout(3000) }).then((r) => r.ok).catch(() => false)
|
|
138
178
|
io.print(up ? `ok: comfy backend reachable (${kit.comfy.host})` : `WARN comfy backend unreachable (${kit.comfy.host})${kit.comfy.heal != null ? ` — ${kit.comfy.heal}` : ''}`)
|
|
139
179
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
180
|
+
// machine-clearable red clears itself: drifted/missing identity
|
|
181
|
+
// outputs re-render HERE; only a missing pick (taste) reaches a human
|
|
182
|
+
const idn = await healIdentity(root)
|
|
183
|
+
if (idn.state === 'fresh') io.print('ok: identity outputs present + brand.ts unchanged')
|
|
184
|
+
else if (idn.state === 'rendered') io.print(`ok: identity re-rendered (brand.ts changed) — ${idn.detail}`)
|
|
185
|
+
else if (idn.state === 'blocked') { io.print(`FAIL: identity blocked — ${idn.detail}`); bad++ }
|
|
145
186
|
// manual assets: no rolls = nothing to ship = RED (the missing list
|
|
146
|
-
// is the shopping list — build/audio doctrine); rolls-but-no-pick = WARN
|
|
187
|
+
// is the shopping list — build/game-audio doctrine); rolls-but-no-pick = WARN
|
|
147
188
|
const manuals = allAssets(kit).filter((f) => f.kind.generate.backend === 'manual')
|
|
148
189
|
let manualBad = 0
|
|
149
190
|
for (const f of manuals) {
|
|
@@ -49,6 +49,7 @@ select,input[type=number]{background:var(--bg);color:var(--ink);border:1px solid
|
|
|
49
49
|
.vig.gen{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--dim);font-size:11px}
|
|
50
50
|
.card .th .spin{position:absolute;top:8px;right:8px}
|
|
51
51
|
.dot.miss{background:#e5484d;border-color:#e5484d}
|
|
52
|
+
.dot.stale{background:#f5a524;border-color:#f5a524}
|
|
52
53
|
.vig.aud{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;font-size:20px}
|
|
53
54
|
.vig.aud audio{width:100%;height:24px}
|
|
54
55
|
.stageaud{display:flex;flex-direction:column;align-items:center;gap:14px;font-size:48px;padding:40px}
|
|
@@ -225,7 +226,7 @@ window.addEventListener('hashchange',restoreFromHash)
|
|
|
225
226
|
function toggleFold(c){openCats.has(c)?openCats.delete(c):openCats.add(c);lastSideSig=null;renderSide(assets)}
|
|
226
227
|
function renderSide(list){
|
|
227
228
|
assets=list;$('#count').textContent=list.length
|
|
228
|
-
const sig=JSON.stringify(list.map(a=>[a.id,a.rolls,a.picked]))+'|'+cur+'|'+catMode+'|'+[...openCats].join(',')+'|'+pending.join(',')
|
|
229
|
+
const sig=JSON.stringify(list.map(a=>[a.id,a.rolls,a.picked,a.stale]))+'|'+cur+'|'+catMode+'|'+[...openCats].join(',')+'|'+pending.join(',')
|
|
229
230
|
if(sig===lastSideSig&&$('#list').innerHTML!=='')return; lastSideSig=sig
|
|
230
231
|
const cats=[...new Set(list.map(a=>a.cat))]
|
|
231
232
|
$('#list').innerHTML=cats.map(c=>{
|
|
@@ -236,7 +237,7 @@ function renderSide(list){
|
|
|
236
237
|
if(!open)return head
|
|
237
238
|
return head+list.filter(a=>a.cat===c).map(a=>
|
|
238
239
|
'<div class="arow'+(a.id===cur?' sel':'')+'" data-id="'+esc(a.id)+'" onclick="selectAsset(\\''+jsq(a.id)+'\\')"><div class=lb><b>'+esc(a.label)+
|
|
239
|
-
'</b><span>'+a.rolls+' rolls</span></div>'+(pending.includes(a.id)?'<div class=spin></div>':'<div class="dot '+(a.picked?'done':(a.rolls?'some':(a.manual?'miss':'')))+'"'+(a.manual&&!a.rolls?' title="manual asset — nothing uploaded yet"':'')+'></div>')+'</div>').join('')
|
|
240
|
+
'</b><span>'+a.rolls+' rolls</span></div>'+(pending.includes(a.id)?'<div class=spin></div>':'<div class="dot '+(a.stale?'stale':(a.picked?'done':(a.rolls?'some':(a.manual?'miss':''))))+'"'+(a.manual&&!a.rolls?' title="manual asset — nothing uploaded yet"':'')+'></div>')+'</div>').join('')
|
|
240
241
|
}).join('')
|
|
241
242
|
}
|
|
242
243
|
// ── per-asset info surface (W2-2): backend, workflow provenance, ship target,
|
|
@@ -8,6 +8,7 @@ import { existsSync, mkdirSync, unlinkSync } from 'fs'
|
|
|
8
8
|
import { join, relative } from 'path'
|
|
9
9
|
import { findAsset, allAssets, mediaOf, missingPanelScripts, MEDIA_EXTS, type CustomPanelSpec, type FoundAsset, type GeometryPanelSpec, type ResolvedKit, type ShipFn } from '../kit-config.ts'
|
|
10
10
|
import { interrupt, randomSeed, type OnProgress } from '../gen/comfy-gen.ts'
|
|
11
|
+
import { isStale, parentIdsOf, resolveParentContext, type ParentContext } from '../gen/derived.ts'
|
|
11
12
|
import { generateRoll, type ComfyRuntime } from '../gen/generate-roll.ts'
|
|
12
13
|
import { loadWorkflows, type Workflow } from '../gen/workflow-loader.ts'
|
|
13
14
|
import { alphaHeight, toPng } from '../image-ops.ts'
|
|
@@ -20,15 +21,16 @@ export type StudioDeps = {
|
|
|
20
21
|
kit: ResolvedKit
|
|
21
22
|
root: string
|
|
22
23
|
page: () => string
|
|
23
|
-
/** test seam — defaults to the
|
|
24
|
-
|
|
24
|
+
/** test seam — defaults to the real engines. `context` = a derived
|
|
25
|
+
* asset's resolved parent picks (queue resolves it at run time) */
|
|
26
|
+
generateOne?: (found: FoundAsset, prompt: string, seed: number, destDir: string, onProgress?: OnProgress, context?: ParentContext) => Promise<void>
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export type Studio = { server: ReturnType<typeof Bun.serve>; queue: StudioQueue; stop: () => void }
|
|
28
30
|
|
|
29
31
|
// what the studio detail view shows about an asset's plumbing (W2-2)
|
|
30
32
|
export type AssetInfo = {
|
|
31
|
-
backend: 'canvas' | 'comfy' | 'manual'
|
|
33
|
+
backend: 'canvas' | 'comfy' | 'manual' | 'gemini'
|
|
32
34
|
/** canvas rolls have no seed input — every roll is identical */
|
|
33
35
|
deterministic: boolean
|
|
34
36
|
/** comfy only: resolved workflow id + its .rv-comfy path (note = why unresolved) */
|
|
@@ -100,15 +102,36 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
|
|
|
100
102
|
if (kit.comfy == null || workflows == null) throw new Error('kit has no comfy config — only canvas kinds can generate')
|
|
101
103
|
return { workflows, host: kit.comfy.host, heal: kit.comfy.heal }
|
|
102
104
|
}
|
|
103
|
-
const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir, onProgress) =>
|
|
104
|
-
generateRoll(found, { prompt, seed, destDir, onProgress }, resolveComfy))
|
|
105
|
+
const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir, onProgress, context?: ParentContext) =>
|
|
106
|
+
generateRoll(found, { prompt, seed, destDir, onProgress, context }, resolveComfy))
|
|
105
107
|
|
|
106
108
|
const queue = new StudioQueue(async (job, seed, onProgress) => {
|
|
107
109
|
const found = findAsset(kit, job.assetId)
|
|
108
110
|
if (found == null) throw new Error(`asset '${job.assetId}' not in the kit — job dropped`)
|
|
109
|
-
|
|
111
|
+
// derived assets: parents' picks resolved at RUN time (a pick made
|
|
112
|
+
// while the job queued is honored); a blocker names the parent
|
|
113
|
+
let context: ParentContext | undefined
|
|
114
|
+
if (parentIdsOf(found).length > 0) {
|
|
115
|
+
const r = await resolveParentContext(kit, workspace, found)
|
|
116
|
+
if ('missing' in r) throw new Error(`'${job.assetId}' blocked — parents not ready: ${r.missing.join(', ')}`)
|
|
117
|
+
context = r
|
|
118
|
+
}
|
|
119
|
+
await generateOne(found, job.prompt, seed, dirOf(job.assetId), onProgress, context)
|
|
110
120
|
}, randomSeed)
|
|
111
121
|
|
|
122
|
+
// machine-clearable red clears itself: re-picking a PARENT auto-enqueues
|
|
123
|
+
// every now-stale child (1 roll each) — no button, no nag. Generation
|
|
124
|
+
// spend is gated on the intentful pick, never on passive page loads.
|
|
125
|
+
const regenStaleChildren = async (parentId: string): Promise<void> => {
|
|
126
|
+
for (const f of allAssets(kit)) {
|
|
127
|
+
if (!parentIdsOf(f).includes(parentId)) continue
|
|
128
|
+
if (!(await isStale(kit, workspace, f))) continue
|
|
129
|
+
if (queue.pendingIds().includes(f.asset.id)) continue // already queued — never double-spend
|
|
130
|
+
queue.enqueue([{ assetId: f.asset.id, prompt: await promptOf(f), count: 1 }])
|
|
131
|
+
console.log(`ok: ${f.asset.id} auto-regen queued (parent '${parentId}' re-picked)`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
112
135
|
// ── session/live rect plumbing ─────────────────────────────────────────
|
|
113
136
|
const rectPath = (id: string, roll: string): string => join(dirOf(id), `${roll}.rect.json`)
|
|
114
137
|
const sessionRect = async (f: FoundAsset, roll: string): Promise<GroundRect> => {
|
|
@@ -207,6 +230,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
|
|
|
207
230
|
id: f.asset.id, cat: f.kind.category, label: f.asset.label ?? f.asset.id,
|
|
208
231
|
prompt: await promptOf(f), rolls: listRolls(dir).length, picked: pick.pick != null,
|
|
209
232
|
icon: f.kind.mode === 'icon', media: mediaOf(f.kind), manual: f.kind.generate.backend === 'manual',
|
|
233
|
+
derived: parentIdsOf(f).length > 0, stale: await isStale(kit, workspace, f),
|
|
210
234
|
}
|
|
211
235
|
}))
|
|
212
236
|
return json({ ...queue.state, queued: queue.queuedCount, pending: queue.pendingIds(), errors: queue.errors.slice(-5), assets })
|
|
@@ -355,6 +379,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
|
|
|
355
379
|
s.pick = name
|
|
356
380
|
s.shippedAt = res.shipped != null ? new Date().toISOString() : s.shippedAt
|
|
357
381
|
})
|
|
382
|
+
void regenStaleChildren(f.asset.id).catch((e: Error) => queue.pushError(`auto-regen after '${f.asset.id}' pick: ${e.message}`))
|
|
358
383
|
return json(res)
|
|
359
384
|
} catch (e) {
|
|
360
385
|
queue.pushError(`ship ${String(b.id)}: ${(e as Error).message}`)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// shipkit mv — md-page rename with repo-wide ref sweep. Thin clipanion
|
|
2
|
+
// surface; logic in mv/mv-md.ts.
|
|
3
|
+
import { Command, Option } from 'clipanion'
|
|
4
|
+
import { movePage } from '../mv/mv-md.ts'
|
|
5
|
+
|
|
6
|
+
export class MvCommand extends Command {
|
|
7
|
+
static override paths = [['mv']]
|
|
8
|
+
|
|
9
|
+
static override usage = Command.Usage({
|
|
10
|
+
category: 'Wiki',
|
|
11
|
+
description: 'rename an md page + sibling dir + check.ts, sweeping every tracked ref; renames must be CHEAP',
|
|
12
|
+
examples: [
|
|
13
|
+
['dry run (default): show what would change', 'shipkit mv docs/old-name.md docs/new-name.md'],
|
|
14
|
+
['apply + one conventional commit', 'shipkit mv docs/old-name.md docs/new-name.md --commit'],
|
|
15
|
+
],
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
oldPath = Option.String()
|
|
19
|
+
newPath = Option.String()
|
|
20
|
+
write = Option.Boolean('--write', false, { description: 'apply (default is a dry run)' })
|
|
21
|
+
commit = Option.Boolean('--commit', false, { description: 'apply + git commit (pre-commit hook is the gate)' })
|
|
22
|
+
|
|
23
|
+
async execute(): Promise<number> {
|
|
24
|
+
try {
|
|
25
|
+
return movePage(process.cwd(), this.oldPath, this.newPath, {
|
|
26
|
+
print: (l) => this.context.stdout.write(`${l}\n`),
|
|
27
|
+
printErr: (l) => this.context.stderr.write(`${l}\n`),
|
|
28
|
+
}, { write: this.write, commit: this.commit })
|
|
29
|
+
} catch (e) {
|
|
30
|
+
this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
|
|
31
|
+
return 1
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { CheckCommand } from './cli/cmd_Check.ts'
|
|
|
10
10
|
import { CiCommand } from './cli/cmd_Ci.ts'
|
|
11
11
|
import { ContractsCommand } from './cli/cmd_Contracts.ts'
|
|
12
12
|
import { InitCommand } from './cli/cmd_Init.ts'
|
|
13
|
+
import { MvCommand } from './cli/cmd_Mv.ts'
|
|
13
14
|
import { ServeCommand } from './cli/cmd_Serve.ts'
|
|
14
15
|
import { TuiCommand } from './cli/cmd_Tui.ts'
|
|
15
16
|
import { WikiLintCommand, WikiServeCommand } from './cli/cmd_Wiki.ts'
|
|
@@ -27,6 +28,7 @@ const cli = new Cli({
|
|
|
27
28
|
cli.register(Builtins.HelpCommand)
|
|
28
29
|
cli.register(Builtins.VersionCommand)
|
|
29
30
|
cli.register(CiCommand)
|
|
31
|
+
cli.register(MvCommand)
|
|
30
32
|
cli.register(CheckCommand)
|
|
31
33
|
cli.register(TuiCommand)
|
|
32
34
|
cli.register(ServeCommand)
|