rvlib-shipkit 0.17.2 → 0.19.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 +19 -0
- package/package.json +2 -1
- package/src/assets/gen/comfyts-gen.ts +202 -0
- package/src/assets/gen/generate-roll.ts +13 -16
- package/src/assets/kit-config.ts +12 -3
- package/src/assets/roll-workspace.ts +2 -1
- package/src/assets/run-assets.ts +111 -35
- package/src/assets/server/studio-queue.ts +3 -3
- package/src/assets/server/studio-server.ts +32 -14
- package/src/cli/cmd_Assets.ts +1 -1
- package/src/cli/cmd_Hooks.ts +2 -1
- package/src/contracts/manifests/itch.contract.ts +18 -2
- package/src/hooks/commit-message.ts +6 -3
- package/src/hooks/leak-guard.ts +0 -0
- package/src/hooks/run-hooks.ts +56 -6
- package/src/init-repo.ts +3 -0
- package/src/release/run-release.ts +2 -0
- package/src/rules/SK004-gitignore-policy.ts +5 -0
- package/src/rules/SK009-commit-ai-tells.ts +14 -3
- package/src/shipkit.ts +2 -4
- package/src/assets/gen/comfy-gen.ts +0 -157
- package/src/assets/gen/workflow-loader.ts +0 -67
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.19.0
|
|
4
|
+
|
|
5
|
+
- **The attribution tell list covers session trailers and assistant share urls.** `<assistant>-Session:` trailers (claude, chatgpt, copilot, cursor, aider, devin, gemini, codex) and claude.ai/claude.com/chatgpt.com/chat.openai.com urls now trip the shared predicate (commit-msg hook + SK009 history scan). Field case: 162 session links sat in a history both gates called clean.
|
|
6
|
+
- **SK009 scans ref names too**, local and remote-tracking. A checkpoint tag named after an assistant (`FABLE-checkpoint-3`) outs the agent as loudly as a trailer. Bare match only for names with no other meaning (claude, chatgpt, copilot, aider, devin); vendor and codename words (cursor, openai, gemini, fable, sonnet, haiku, opus) name legitimate branches, so they only match in a `-checkpoint`/`-session` shape.
|
|
7
|
+
- **The leak guard reads a machine-global list.** Every run also scans `~/.rv-private/banned-keywords.txt` when it exists: one private list per machine, armed in every repo at once, never copied into a repo (a copy rots and a copy can leak). `hooks install` seeds the repo's own configured keyword file with generic secret-shape starters, never touching an existing one. Both files absent stays a loud skip.
|
|
8
|
+
- **The itch listing answered-check catches empty fields.** A `## field` heading with no body line under it is an unanswered field wearing no `<placeholder>`; the contract names it now. Prose-level open decisions stay a human read.
|
|
9
|
+
|
|
10
|
+
## 0.18.0
|
|
11
|
+
|
|
12
|
+
- **The comfy backend runs on comfy-ts.** `.rv-comfy/workflows/` holds `*.cflow.ts` workflow modules (one `host.defineWorkflow` per graph family, plus a `_host.ts` the modules import). Exported json graphs and `.ts` sidekick pairs are gone, and the raw HTTP engine (`comfy-gen.ts`) and pair loader (`workflow-loader.ts`) are deleted with them. Every module must declare `vars.prompt` (`v.prompt`) and `vars.seed` (`v.seed`), shipkit drives both.
|
|
13
|
+
- **One comfy-ts draft per asset.** Shipkit seeds `.comfy-ts/drafts/<workflowId>/<assetId>.json` once and never overwrites it, so `bunx comfy-ts tui` lists every asset as a tweakable row under its workflow. At generation: reset vars → kit `vars` → asset draft → prompt and seed pinned programmatically. The kit + studio overlay stay the prompt source of truth.
|
|
14
|
+
- **BREAKING: `comfy({ workflow, vars })` replaces `bg`.** `vars` is a record of workflow var values applied before the draft, an unknown var name fails loud.
|
|
15
|
+
- **BREAKING: `KitConfig.comfy.host` is removed.** The workflow's `_host.ts` module owns the box connection; `comfy:` keeps `{ workflows, heal }`.
|
|
16
|
+
- **BREAKING: removed exports** `generate`, `submit`, `waitFor`, `applyInputs`, `expectNode`, `ComfyOpts`, `loadWorkflows`, and the pair types `Workflow`, `Sidekick`, `Binding`, `Graph`. New surface: `generateComfyTs`, `loadCflowWorkflows`, `workflowFor` (same resolution semantics, cflow-backed), `applyVarValues`, `ensureAssetDraft`, `readAssetDraft`, `draftPath`, `disconnectAll`, plus types `ComfyTsRuntime`, `LoadedWorkflow`, `GenProgress`, `OnProgress`. `randomSeed` and `batchFolderName` survive unchanged.
|
|
17
|
+
- **The `.comfy-ts/` privacy rule is universal, not one repo's.** The SK004 gitignore policy now carries `.comfy-ts/` and `*.loras.local.txt` into every repo's managed block (shipkit itself writes `.comfy-ts/drafts/` at hub boot, so every consumer needs both ignored before its first commit), and `shipkit init` scaffolds `hooks: { blockedPaths: ['.comfy-ts/'] }`. `validateKit` rejects unknown `comfy:` keys, so a kit still passing the removed `host` gets the migration message instead of silence.
|
|
18
|
+
- **The leak guard scans added lines only.** Deleting a file that carries a banned name used to block the deletion, because the guard read the whole staged diff. Removed and context lines are already in history, so only what the commit ADDS to the object store is scanned. Staged paths and the commit message are scanned as before.
|
|
19
|
+
- **New pre-commit gate `blocked-paths`.** `repo.config.ts hooks: { blockedPaths: ['.comfy-ts/'] }` fails a commit on any staged path under a blocked prefix. Gitignore is advisory and editable; local state that must never enter git (schema dumps naming a private model collection) gets a gate instead. Listed by `shipkit hooks list`.
|
|
20
|
+
- **`assets check` now validates graphs offline.** Modules import with the box off (schema cache or loud base-type fallback), each workflow prints its `.cflow.ts` provenance and vars, kind `workflow`/`vars` resolve dry, drafts are ensured per asset, and a loaded schema cache runs `build()` and fails on graph problems. A missing cache or an unreachable box stays a WARN with the fix command.
|
|
21
|
+
|
|
3
22
|
## 0.17.2
|
|
4
23
|
|
|
5
24
|
- **A release no longer probes the world.** The release gate runs only the checks that can BLOCK. A check declaring `blocking: false` cannot change the outcome, so running it spent time and a third party API call per publish to learn whether a DNS provider was reachable. `release.requireHealthy` still makes those failures fatal, and then they run.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@napi-rs/canvas": "^1.0.2",
|
|
29
29
|
"clipanion": "^4.0.0-rc.4",
|
|
30
|
+
"comfy-ts": "^2.4.2",
|
|
30
31
|
"ink": "^7.1.1",
|
|
31
32
|
"marked": "^18.0.7",
|
|
32
33
|
"mobx": "^6.16.1",
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// the ONE comfy engine: comfy-ts *.cflow.ts workflow modules in the kit's
|
|
2
|
+
// workflows dir, one draft per asset (the TUI's tweakable entry), rolls
|
|
3
|
+
// written from in-memory buffers. Every CLI path that resolves a runtime
|
|
4
|
+
// must disconnectAll() in a finally, or the host websocket keeps bun alive.
|
|
5
|
+
// Workflows are discriminated by duck-typing, never instanceof: a consumer's
|
|
6
|
+
// .cflow.ts may hold a second copy of comfy-ts in its module graph.
|
|
7
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
|
|
8
|
+
import { basename, join } from 'path'
|
|
9
|
+
import { pathToFileURL } from 'url'
|
|
10
|
+
import { ComfyTS, type AnyVar, type ComfyExecution, type DefinedWorkflow, type MediaImage } from 'comfy-ts'
|
|
11
|
+
|
|
12
|
+
export type GenProgress = { value: number; max: number; node: string | null }
|
|
13
|
+
export type OnProgress = (p: GenProgress) => void
|
|
14
|
+
|
|
15
|
+
export type LoadedWorkflow = { id: string; file: string; wf: DefinedWorkflow }
|
|
16
|
+
export type ComfyTsRuntime = { workflows: Map<string, LoadedWorkflow>; heal?: string }
|
|
17
|
+
|
|
18
|
+
// function declaration, not const — TS only narrows on never-calls to
|
|
19
|
+
// directly-declared functions
|
|
20
|
+
function fail(msg: string): never { throw new Error(msg) }
|
|
21
|
+
|
|
22
|
+
// same duck predicate the comfy-ts TUI and serve bind to: a module valid
|
|
23
|
+
// for shipkit is valid for `bunx comfy-ts tui` and vice versa
|
|
24
|
+
function findDefinedWorkflow(mod: Record<string, unknown>): DefinedWorkflow | null {
|
|
25
|
+
const isDefined = (x: unknown): x is DefinedWorkflow =>
|
|
26
|
+
typeof x === 'object' && x != null && 'spec' in x &&
|
|
27
|
+
typeof (x as DefinedWorkflow).run === 'function' &&
|
|
28
|
+
typeof (x as DefinedWorkflow).entries === 'function'
|
|
29
|
+
return [mod.default, ...Object.values(mod)].find(isDefined) ?? null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const varsOf = (wf: DefinedWorkflow): Record<string, AnyVar> => wf.vars as Record<string, AnyVar>
|
|
33
|
+
|
|
34
|
+
/** true when the image belongs in a roll. Preview-family nodes (PreviewImage,
|
|
35
|
+
* PreviewBridge, …) emit raw temp images that comfy-ts downloads like any
|
|
36
|
+
* output, in completion order, and MediaImage drops the server-side
|
|
37
|
+
* 'temp' marker, so the producing node's class_type is the only signal left.
|
|
38
|
+
* no node json (SaveImageWebsocket frames) = deliberate output, kept. */
|
|
39
|
+
export const isRollImage = (img: Pick<MediaImage, 'ComfyNode'>): boolean =>
|
|
40
|
+
!/preview/i.test(img.ComfyNode?.class_type ?? '')
|
|
41
|
+
|
|
42
|
+
/** dir = the flat folder holding the <id>.cflow.ts workflow modules.
|
|
43
|
+
* root pins `.comfy-ts/` (drafts, hosts cache) to the repo, cwd-independent. */
|
|
44
|
+
export async function loadCflowWorkflows(root: string, dir: string): Promise<Map<string, LoadedWorkflow>> {
|
|
45
|
+
// shipkit is a driver like the TUI: comfy-ts INFO chatter would pollute
|
|
46
|
+
// the wire output (and STATUS.md). Errors still print. Respect an
|
|
47
|
+
// explicit COMFY_TS_QUIET=0 from the caller.
|
|
48
|
+
process.env.COMFY_TS_QUIET ??= '1'
|
|
49
|
+
// before the first module import: a bare ComfyTS.create() inside the
|
|
50
|
+
// module then returns this same instance. A pre-existing instance with a
|
|
51
|
+
// different root throws loud — a real misconfiguration.
|
|
52
|
+
ComfyTS.create({ rootPath: root })
|
|
53
|
+
if (!existsSync(dir)) fail(`${dir}/ missing — ship at least one <id>.cflow.ts comfy-ts workflow module (copy from the rv-comfy template)`)
|
|
54
|
+
const out = new Map<string, LoadedWorkflow>()
|
|
55
|
+
for (const f of readdirSync(dir).filter((x) => x.endsWith('.cflow.ts')).sort()) {
|
|
56
|
+
const file = join(dir, f)
|
|
57
|
+
const id = basename(f, '.cflow.ts')
|
|
58
|
+
const mod = (await import(pathToFileURL(file).href)) as Record<string, unknown>
|
|
59
|
+
const wf = findDefinedWorkflow(mod)
|
|
60
|
+
if (wf == null) fail(`${file}: exports no DefinedWorkflow — export default host.defineWorkflow({...})`)
|
|
61
|
+
// the file name IS the id: it is also the comfy-ts draft folder key,
|
|
62
|
+
// so letting them diverge would strand every per-asset draft
|
|
63
|
+
if (wf.spec.id != null && wf.spec.id !== id) fail(`workflow file '${f}' declares id '${wf.spec.id}' — the file name IS the id, rename one`)
|
|
64
|
+
wf.spec.id ??= id
|
|
65
|
+
const vars = varsOf(wf)
|
|
66
|
+
if (vars.prompt?.kind !== 'prompt' || vars.seed?.kind !== 'seed')
|
|
67
|
+
fail(`workflow '${id}' must declare vars.prompt (v.prompt) and vars.seed (v.seed) — shipkit drives both`)
|
|
68
|
+
out.set(id, { id, file, wf })
|
|
69
|
+
}
|
|
70
|
+
if (out.size === 0) fail(`${dir}/ has no *.cflow.ts workflow modules — seed one from the rv-comfy template`)
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Resolve a workflow: explicit id, else the sole loaded workflow. */
|
|
75
|
+
export function workflowFor(workflows: Map<string, LoadedWorkflow>, ref: { workflow?: string; assetId?: string }): LoadedWorkflow {
|
|
76
|
+
if (ref.workflow != null) {
|
|
77
|
+
const lw = workflows.get(ref.workflow)
|
|
78
|
+
if (lw == null) fail(`asset '${ref.assetId ?? '?'}': unknown workflow '${ref.workflow}' (have: ${[...workflows.keys()].join(', ')})`)
|
|
79
|
+
return lw
|
|
80
|
+
}
|
|
81
|
+
if (workflows.size === 1) return [...workflows.values()][0]!
|
|
82
|
+
return fail(`asset '${ref.assetId ?? '?'}' names no workflow and ${workflows.size} are loaded (${[...workflows.keys()].join(', ')}) — set workflow on the kind`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Apply a name→value record onto a workflow's vars: strings go through
|
|
86
|
+
* parse (validated), everything else through loadJSON (the drafts setter).
|
|
87
|
+
* Unknown names throw listing what the workflow has. */
|
|
88
|
+
export function applyVarValues(wf: DefinedWorkflow, vars: Record<string, unknown>, source: string): void {
|
|
89
|
+
const defs = varsOf(wf)
|
|
90
|
+
for (const [name, value] of Object.entries(vars)) {
|
|
91
|
+
const varDef = defs[name]
|
|
92
|
+
if (varDef == null) fail(`${source}: unknown var '${name}' on workflow '${wf.spec.id ?? '?'}' (has: ${wf.entries().map(([n, d]) => `${n}:${d.kind}`).join(', ')})`)
|
|
93
|
+
if (typeof value === 'string') {
|
|
94
|
+
if (!varDef.parse(value)) fail(`${source}: var '${name}' (${varDef.kind}) rejected '${value}'`)
|
|
95
|
+
} else varDef.loadJSON(value)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── per-asset drafts — comfy-ts disk format, path rule taken from the
|
|
100
|
+
// ComfyTS instance so it cannot drift: .comfy-ts/drafts/<wfId>/<assetId>.json.
|
|
101
|
+
// Shipkit does its own IO here because comfy-ts exports no drafts API yet;
|
|
102
|
+
// swap when it lands.
|
|
103
|
+
const draftDir = (lw: LoadedWorkflow): string => ComfyTS.create().resolveFromDrafts(lw.id)
|
|
104
|
+
export const draftPath = (lw: LoadedWorkflow, name: string): string => join(draftDir(lw), `${name}.json`)
|
|
105
|
+
|
|
106
|
+
export function readAssetDraft(lw: LoadedWorkflow, assetId: string): Record<string, unknown> | null {
|
|
107
|
+
const p = draftPath(lw, assetId)
|
|
108
|
+
if (!existsSync(p)) return null
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(readFileSync(p, 'utf8')) as Record<string, unknown>
|
|
111
|
+
} catch (e) {
|
|
112
|
+
throw new Error(`draft ${p} is not valid JSON (${(e as Error).message}) — fix or delete it`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Seed `{ prompt }` ONLY when the draft is absent (hand-tuned state is never
|
|
117
|
+
* overwritten). The stored prompt is TUI context; generation overrides it.
|
|
118
|
+
* Returns true when just seeded. */
|
|
119
|
+
export function ensureAssetDraft(lw: LoadedWorkflow, assetId: string, prompt: string): boolean {
|
|
120
|
+
const p = draftPath(lw, assetId)
|
|
121
|
+
if (existsSync(p)) return false
|
|
122
|
+
mkdirSync(draftDir(lw), { recursive: true })
|
|
123
|
+
writeFileSync(p, JSON.stringify({ prompt }, null, 2) + '\n')
|
|
124
|
+
return true
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Run one generation through a cflow workflow: reset vars → kit vars → asset
|
|
129
|
+
* draft → prompt/seed override, run WITHOUT save (in-memory buffers), write
|
|
130
|
+
* <destDir>/<name>.<ext> (suffix -2, -3… on extra images). The kit prompt +
|
|
131
|
+
* studio overlay stay the prompt source of truth; the draft owns every other var.
|
|
132
|
+
*/
|
|
133
|
+
export async function generateComfyTs(
|
|
134
|
+
rt: ComfyTsRuntime,
|
|
135
|
+
lw: LoadedWorkflow,
|
|
136
|
+
args: { prompt: string; seed: number; assetId: string; destDir: string; name: string; vars?: Record<string, unknown>; onProgress?: OnProgress },
|
|
137
|
+
): Promise<string[]> {
|
|
138
|
+
ensureAssetDraft(lw, args.assetId, args.prompt)
|
|
139
|
+
const defs = varsOf(lw.wf)
|
|
140
|
+
// modules are import-cached: a previous asset's values must not leak
|
|
141
|
+
for (const [, varDef] of lw.wf.entries()) varDef.reset()
|
|
142
|
+
if (args.vars != null) applyVarValues(lw.wf, args.vars, `asset '${args.assetId}' kit vars`)
|
|
143
|
+
const draft = readAssetDraft(lw, args.assetId)
|
|
144
|
+
if (draft != null) {
|
|
145
|
+
for (const [key, value] of Object.entries(draft)) {
|
|
146
|
+
const varDef = defs[key]
|
|
147
|
+
if (varDef == null) { console.log(`WARN draft '${args.assetId}': stale var '${key}' not on workflow '${lw.id}' — ignored`); continue }
|
|
148
|
+
varDef.loadJSON(value)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
defs.prompt!.parse(args.prompt)
|
|
152
|
+
// '= N' pins mode AND value, neutralizing any ?/+ mode a draft restored,
|
|
153
|
+
// so the v<seed> file name stays truthful
|
|
154
|
+
defs.seed!.parse(`= ${args.seed}`)
|
|
155
|
+
mkdirSync(args.destDir, { recursive: true })
|
|
156
|
+
const healHint = rt.heal != null ? ` — is the backend up? (${rt.heal})` : ''
|
|
157
|
+
let execution: ComfyExecution
|
|
158
|
+
try {
|
|
159
|
+
execution = await lw.wf.run({
|
|
160
|
+
onProgress: args.onProgress == null ? undefined : (p) => args.onProgress!({ value: Math.round(p.percent), max: 100, node: p.nodeName }),
|
|
161
|
+
})
|
|
162
|
+
} catch (e) {
|
|
163
|
+
throw new Error(`workflow '${lw.id}' run failed on ${lw.wf.host.getServerHostHTTP()}: ${(e as Error).message}${healHint}`)
|
|
164
|
+
}
|
|
165
|
+
if (execution.status === 'Failure')
|
|
166
|
+
throw new Error(`workflow '${lw.id}' failed: ${String(execution.data.error ?? 'unknown error')}${healHint}`)
|
|
167
|
+
const rolls = execution.images.filter(isRollImage)
|
|
168
|
+
const saved: string[] = []
|
|
169
|
+
for (const [i, img] of rolls.entries()) {
|
|
170
|
+
const ext = img.metadata.type ?? 'png'
|
|
171
|
+
const dest = join(args.destDir, `${args.name}${i > 0 ? `-${i + 1}` : ''}.${ext}`)
|
|
172
|
+
await Bun.write(dest, img.buffer as Uint8Array<ArrayBuffer>)
|
|
173
|
+
saved.push(dest)
|
|
174
|
+
}
|
|
175
|
+
const errSummary = execution.imageErrors.length === 0 ? '' : ` (${execution.imageErrors.length} image retrieval(s) failed)`
|
|
176
|
+
const previewSummary = execution.images.length === rolls.length ? '' : ` (${execution.images.length - rolls.length} preview image(s) skipped)`
|
|
177
|
+
if (saved.length === 0) throw new Error(`workflow '${lw.id}' produced no images — does the graph end in a SaveImage node?${previewSummary}${errSummary}`)
|
|
178
|
+
// partial output stays visible on disk, the failure stays loud
|
|
179
|
+
if (execution.imageErrors.length > 0)
|
|
180
|
+
throw new Error(`workflow '${lw.id}': ${saved.length} image(s) written, ${execution.imageErrors.length} retrieval(s) failed`)
|
|
181
|
+
return saved
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Close every loaded workflow's host websocket — CLI paths call this in a
|
|
185
|
+
* finally or the process never exits. */
|
|
186
|
+
export function disconnectAll(rt: ComfyTsRuntime): void {
|
|
187
|
+
const seen = new Set<unknown>()
|
|
188
|
+
for (const lw of rt.workflows.values()) {
|
|
189
|
+
if (seen.has(lw.wf.host)) continue
|
|
190
|
+
seen.add(lw.wf.host)
|
|
191
|
+
lw.wf.host.disconnect()
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export const randomSeed = (): number => Math.floor(Math.random() * 2_000_000_000) + 1
|
|
196
|
+
|
|
197
|
+
/** Shell-stamped batch folder name — the one-shot output convention. */
|
|
198
|
+
export function batchFolderName(slug: string): string {
|
|
199
|
+
const d = new Date()
|
|
200
|
+
const p = (n: number) => String(n).padStart(2, '0')
|
|
201
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}-${slug}`
|
|
202
|
+
}
|
|
@@ -1,22 +1,20 @@
|
|
|
1
1
|
// The ONE roll generator — canvas (procedural, in-process via @napi-rs/canvas)
|
|
2
|
-
// or comfy (
|
|
3
|
-
// fill/add/gen CLI both call this, so a canvas kit rolls the SAME way
|
|
4
|
-
// (before this, canvas draw lived only in the studio and the CLI
|
|
5
|
-
// canvas kinds — A30). Comfy context is resolved LAZILY: canvas-only
|
|
6
|
-
// never touch a comfy config or the box.
|
|
2
|
+
// or comfy (comfy-ts through a .cflow.ts workflow module). The studio worker
|
|
3
|
+
// AND the fill/add/gen CLI both call this, so a canvas kit rolls the SAME way
|
|
4
|
+
// in both (before this, canvas draw lived only in the studio and the CLI
|
|
5
|
+
// refused canvas kinds — A30). Comfy context is resolved LAZILY: canvas-only
|
|
6
|
+
// kits never touch a comfy config or the box.
|
|
7
7
|
import { mkdirSync } from 'fs'
|
|
8
8
|
import { join } from 'path'
|
|
9
9
|
import type { FoundAsset } from '../kit-config.ts'
|
|
10
|
-
import {
|
|
10
|
+
import { generateComfyTs, loadCflowWorkflows, workflowFor, type ComfyTsRuntime, type OnProgress } from './comfyts-gen.ts'
|
|
11
11
|
import { recordParents, type ParentContext } from './derived.ts'
|
|
12
12
|
import { generateGemini } from './gemini-gen.ts'
|
|
13
|
-
import { loadWorkflows, workflowFor, type Workflow } from './workflow-loader.ts'
|
|
14
13
|
|
|
15
14
|
/** roll canvas master size (square) — favicon/logo/sprites derive from it */
|
|
16
15
|
export const CANVAS_SIZE = 1024
|
|
17
16
|
|
|
18
|
-
export type
|
|
19
|
-
export type ComfyResolver = () => Promise<ComfyRuntime>
|
|
17
|
+
export type ComfyResolver = () => Promise<ComfyTsRuntime>
|
|
20
18
|
|
|
21
19
|
/** Draw/generate one roll for `found` into destDir as v<seed>.png (canvas) or
|
|
22
20
|
* v<seed>.<ext> (comfy). `resolveComfy` is only awaited for comfy backends. */
|
|
@@ -46,24 +44,23 @@ export async function generateRoll(
|
|
|
46
44
|
return
|
|
47
45
|
}
|
|
48
46
|
const rt = await resolveComfy()
|
|
49
|
-
const
|
|
50
|
-
await
|
|
47
|
+
const lw = workflowFor(rt.workflows, { workflow: gen.workflow, assetId: found.asset.id })
|
|
48
|
+
await generateComfyTs(rt, lw, { prompt: args.prompt, seed: args.seed, assetId: found.asset.id, destDir: args.destDir, name: `v${args.seed}`, vars: gen.vars, onProgress: args.onProgress })
|
|
51
49
|
}
|
|
52
50
|
|
|
53
|
-
/** Memoized lazy comfy runtime for a resolved kit — loads the
|
|
51
|
+
/** Memoized lazy comfy runtime for a resolved kit — loads the cflow modules on
|
|
54
52
|
* the FIRST comfy roll only. `onMissing` is called (never returns) if a comfy
|
|
55
53
|
* asset needs it but the kit declares no comfy config. */
|
|
56
54
|
export function comfyResolver(
|
|
57
55
|
root: string,
|
|
58
|
-
kit: { comfy?: {
|
|
56
|
+
kit: { comfy?: { workflows: string; heal?: string } },
|
|
59
57
|
onMissing: () => never,
|
|
60
58
|
): ComfyResolver {
|
|
61
|
-
let cached: Promise<
|
|
59
|
+
let cached: Promise<ComfyTsRuntime> | null = null
|
|
62
60
|
return () => {
|
|
63
61
|
if (kit.comfy == null) onMissing()
|
|
64
62
|
return (cached ??= (async () => ({
|
|
65
|
-
workflows: await
|
|
66
|
-
host: kit.comfy!.host,
|
|
63
|
+
workflows: await loadCflowWorkflows(root, join(root, kit.comfy!.workflows)),
|
|
67
64
|
heal: kit.comfy!.heal,
|
|
68
65
|
}))())
|
|
69
66
|
}
|
package/src/assets/kit-config.ts
CHANGED
|
@@ -21,7 +21,9 @@ export type AssetSpec = {
|
|
|
21
21
|
ship?: unknown
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
/** comfy-ts backend: workflow = a .cflow.ts module id in the kit's workflows
|
|
25
|
+
* dir; vars = kind-level var baseline applied before the asset draft */
|
|
26
|
+
export type ComfyGen = { backend: 'comfy'; workflow?: string; vars?: Record<string, unknown> }
|
|
25
27
|
export type CanvasGen = { backend: 'canvas'; draw: (ctx: unknown, asset: AssetSpec) => void | Promise<void> }
|
|
26
28
|
/** Human-provided assets (any media): the studio's upload mints rolls, a
|
|
27
29
|
* manual asset with no rolls is RED + FAILs `shipkit assets check`. The
|
|
@@ -30,7 +32,7 @@ export type ManualGen = { backend: 'manual' }
|
|
|
30
32
|
/** Gemini image backend (edit-from-parent flows; comfy stays for from-scratch). */
|
|
31
33
|
export type GeminiGen = { backend: 'gemini'; model?: 'flash' | 'pro' }
|
|
32
34
|
export type GeneratorSpec = ComfyGen | CanvasGen | ManualGen | GeminiGen
|
|
33
|
-
export const comfy = (p?: { workflow?: string;
|
|
35
|
+
export const comfy = (p?: { workflow?: string; vars?: Record<string, unknown> }): ComfyGen => ({ backend: 'comfy', workflow: p?.workflow, vars: p?.vars })
|
|
34
36
|
export const canvas = (draw: CanvasGen['draw']): CanvasGen => ({ backend: 'canvas', draw })
|
|
35
37
|
export const manual = (): ManualGen => ({ backend: 'manual' })
|
|
36
38
|
export const gemini = (p?: { model?: 'flash' | 'pro' }): GeminiGen => ({ backend: 'gemini', model: p?.model })
|
|
@@ -114,7 +116,9 @@ export type Kind = {
|
|
|
114
116
|
export type KitConfig = {
|
|
115
117
|
/** roll workspace root, repo-root-relative; gitignored */
|
|
116
118
|
workspace?: string
|
|
117
|
-
|
|
119
|
+
/** workflows = dir of comfy-ts .cflow.ts modules (each module owns its
|
|
120
|
+
* host); heal = shell command shown in error hints, never executed */
|
|
121
|
+
comfy?: { workflows: string; heal?: string }
|
|
118
122
|
kinds: Kind[]
|
|
119
123
|
}
|
|
120
124
|
|
|
@@ -123,6 +127,9 @@ export type ResolvedKit = KitConfig & { workspace: string }
|
|
|
123
127
|
export function validateKit(k: KitConfig): string[] {
|
|
124
128
|
const errs: string[] = []
|
|
125
129
|
if (!Array.isArray(k.kinds) || k.kinds.length === 0) errs.push('kinds must be a non-empty array')
|
|
130
|
+
for (const key of Object.keys(k.comfy ?? {}))
|
|
131
|
+
if (key !== 'workflows' && key !== 'heal')
|
|
132
|
+
errs.push(`comfy config has unknown key '${key}'${key === 'host' ? " — host is gone, the workflow's _host.ts owns the box (comfy-ts migration)" : ''}`)
|
|
126
133
|
const kindIds = new Set<string>()
|
|
127
134
|
const assetIds = new Set<string>()
|
|
128
135
|
for (const kind of k.kinds ?? []) {
|
|
@@ -130,6 +137,8 @@ export function validateKit(k: KitConfig): string[] {
|
|
|
130
137
|
if (kindIds.has(kind.id)) errs.push(`duplicate kind id '${kind.id}'`)
|
|
131
138
|
kindIds.add(kind.id)
|
|
132
139
|
if (kind.generate?.backend === 'comfy' && k.comfy == null) errs.push(`kind '${kind.id}' uses the comfy backend but the kit has no comfy config`)
|
|
140
|
+
if (kind.generate?.backend === 'comfy' && kind.generate.vars != null && (typeof kind.generate.vars !== 'object' || Array.isArray(kind.generate.vars)))
|
|
141
|
+
errs.push(`kind '${kind.id}': comfy vars must be a plain object`)
|
|
133
142
|
if (kind.media != null && !(kind.media in MEDIA_EXTS))
|
|
134
143
|
errs.push(`kind '${kind.id}': unknown media '${String(kind.media)}' (${Object.keys(MEDIA_EXTS).join('/')})`)
|
|
135
144
|
if (kind.media === 'audio' && kind.generate?.backend !== 'manual')
|
|
@@ -16,7 +16,8 @@ export type PromptOverlay = { prompt: string | null }
|
|
|
16
16
|
export const PROMPT_INIT: PromptOverlay = { prompt: null }
|
|
17
17
|
|
|
18
18
|
// extension list mirrors kit-config MEDIA_EXTS (audio added 0.6.0)
|
|
19
|
-
|
|
19
|
+
// -N suffix: a run that returned several images (batch_size, second save node)
|
|
20
|
+
export const isRollFile = (f: string): boolean => /^v\d+(-\d+)?\.(webp|png|mp3|wav|ogg|m4a)$/.test(f)
|
|
20
21
|
export const isAudioRoll = (f: string): boolean => /\.(mp3|wav|ogg|m4a)$/.test(f)
|
|
21
22
|
|
|
22
23
|
export const assetDir = (workspace: string, assetId: string): string => join(workspace, 'studio', assetId)
|
package/src/assets/run-assets.ts
CHANGED
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
// sinks, and return exit codes — 0 ok · 1 fail).
|
|
4
4
|
import { existsSync } from 'fs'
|
|
5
5
|
import { join, relative } from 'path'
|
|
6
|
-
import { randomSeed } from './gen/
|
|
6
|
+
import { applyVarValues, disconnectAll, ensureAssetDraft, loadCflowWorkflows, randomSeed, workflowFor, type ComfyTsRuntime, type LoadedWorkflow } from './gen/comfyts-gen.ts'
|
|
7
7
|
import { comfyResolver, generateRoll, type ComfyResolver } from './gen/generate-roll.ts'
|
|
8
8
|
import { parentIdsOf, resolveParentContext, topoSort, type ParentContext } from './gen/derived.ts'
|
|
9
|
-
import {
|
|
10
|
-
import { allAssets, findAsset, loadKit, type ResolvedKit } from './kit-config.ts'
|
|
9
|
+
import { allAssets, findAsset, loadKit, type ComfyGen, type ResolvedKit } from './kit-config.ts'
|
|
11
10
|
import { NO_PICK_FIX, renderIdentity, type Brand } from './kits/identity-kit.ts'
|
|
12
11
|
import { assetDir, listRolls, readPick, readPromptOverlay } from './roll-workspace.ts'
|
|
13
12
|
|
|
@@ -19,8 +18,17 @@ const die = (msg: string): never => { throw new AssetsFail(msg) }
|
|
|
19
18
|
|
|
20
19
|
// Lazy comfy runtime — a canvas-only kit (no comfy: block) never loads
|
|
21
20
|
// workflows or needs the box; only a real comfy roll triggers resolution.
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
// The wrapper keeps a handle on the resolved runtime: a CLI path that ran a
|
|
22
|
+
// roll holds an open host websocket and must disconnect or bun never exits.
|
|
23
|
+
type ComfyHandle = { resolve: ComfyResolver; disconnect: () => void }
|
|
24
|
+
function comfyFor(root: string, kit: ResolvedKit): ComfyHandle {
|
|
25
|
+
const inner = comfyResolver(root, kit, () => die('this kit has no comfy config — add comfy: { workflows } to fill comfy assets'))
|
|
26
|
+
let rt: ComfyTsRuntime | null = null
|
|
27
|
+
return {
|
|
28
|
+
resolve: async () => (rt = await inner()),
|
|
29
|
+
disconnect: () => { if (rt != null) disconnectAll(rt) },
|
|
30
|
+
}
|
|
31
|
+
}
|
|
24
32
|
|
|
25
33
|
async function rollOnce(root: string, io: AssetsIo, kit: ResolvedKit, resolveComfy: ComfyResolver, assetId: string, count: number): Promise<number> {
|
|
26
34
|
const f = findAsset(kit, assetId)
|
|
@@ -62,29 +70,38 @@ async function guarded(io: AssetsIo, body: () => Promise<number>): Promise<numbe
|
|
|
62
70
|
export const runAssetsTopUp = (root: string, io: AssetsIo, o: { mode: 'fill' | 'add'; n: number; categories: string[] }): Promise<number> =>
|
|
63
71
|
guarded(io, async () => {
|
|
64
72
|
const kit = await loadKit(root)
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
73
|
+
const comfy = comfyFor(root, kit)
|
|
74
|
+
try {
|
|
75
|
+
// canvas AND comfy kinds top up (A30 — canvas kinds draw in-process);
|
|
76
|
+
// manual kinds have nothing to generate — visible skip, never silence
|
|
77
|
+
const inScope = allAssets(kit).filter((f) => o.categories.length === 0 || o.categories.includes(f.kind.category))
|
|
78
|
+
const manualCount = inScope.filter((f) => f.kind.generate.backend === 'manual').length
|
|
79
|
+
if (manualCount > 0) io.print(`skip: ${manualCount} manual asset(s) — upload in the studio`)
|
|
80
|
+
// topo (parents-first) replaced sparsest-first: derived children need
|
|
81
|
+
// their parents' rolls to exist before they can ever generate
|
|
82
|
+
const targets = topoSort(inScope.filter((f) => f.kind.generate.backend !== 'manual'))
|
|
83
|
+
.map((f) => ({ f, have: listRolls(assetDir(join(root, kit.workspace), f.asset.id)).length }))
|
|
84
|
+
.map((t) => ({ ...t, want: o.mode === 'add' ? o.n : Math.max(0, o.n - t.have) }))
|
|
85
|
+
.filter((t) => t.want > 0)
|
|
86
|
+
let failed = 0
|
|
87
|
+
for (const t of targets) failed += await rollOnce(root, io, kit, comfy.resolve, t.f.asset.id, t.want)
|
|
88
|
+
io.print(`${o.mode}: ${targets.length} asset(s) topped up${failed > 0 ? ` — ${failed} roll(s) FAILED` : ''}`)
|
|
89
|
+
return failed > 0 ? 1 : 0
|
|
90
|
+
} finally {
|
|
91
|
+
comfy.disconnect()
|
|
92
|
+
}
|
|
81
93
|
})
|
|
82
94
|
|
|
83
95
|
export const runAssetsGen = (root: string, io: AssetsIo, o: { assetId: string; n: number }): Promise<number> =>
|
|
84
96
|
guarded(io, async () => {
|
|
85
97
|
const kit = await loadKit(root)
|
|
86
|
-
const
|
|
87
|
-
|
|
98
|
+
const comfy = comfyFor(root, kit)
|
|
99
|
+
try {
|
|
100
|
+
const failed = await rollOnce(root, io, kit, comfy.resolve, o.assetId, o.n)
|
|
101
|
+
return failed > 0 ? 1 : 0
|
|
102
|
+
} finally {
|
|
103
|
+
comfy.disconnect()
|
|
104
|
+
}
|
|
88
105
|
})
|
|
89
106
|
|
|
90
107
|
// ── identity: ONE render path + a brand.ts content hash so drift is
|
|
@@ -148,23 +165,82 @@ export const runAssetsCheck = (root: string, io: AssetsIo): Promise<number> =>
|
|
|
148
165
|
if (kit == null) return 1
|
|
149
166
|
io.print(`ok: kit loads — ${kit.kinds.length} kind(s), ${allAssets(kit).length} asset(s), workspace ${kit.workspace}`)
|
|
150
167
|
if (kit.comfy != null) {
|
|
168
|
+
// modules import offline (schema cache, or a loud base-type
|
|
169
|
+
// fallback inside comfy-ts) — no box needed for any FAIL below
|
|
170
|
+
let wfs: Map<string, LoadedWorkflow> | null = null
|
|
151
171
|
try {
|
|
152
|
-
|
|
153
|
-
io.print(`ok: ${wfs.size} workflow
|
|
154
|
-
// provenance: where each
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const rel = relative(root, wf.jsonPath).replace(/\.json$/, '')
|
|
160
|
-
io.print(`ok: workflow '${wf.id}' → ${rel}.{json,ts} (pair valid)`)
|
|
161
|
-
}
|
|
172
|
+
wfs = await loadCflowWorkflows(root, join(root, kit.comfy.workflows))
|
|
173
|
+
io.print(`ok: ${wfs.size} comfy-ts workflow(s) loaded`)
|
|
174
|
+
// provenance: where each module lives + its declared knobs.
|
|
175
|
+
// Git owns each repo's modules; the house library
|
|
176
|
+
// (wiki/build/comfyui/references) is the copy-from source.
|
|
177
|
+
for (const lw of wfs.values())
|
|
178
|
+
io.print(`ok: workflow '${lw.id}' → ${relative(root, lw.file)} (vars: ${lw.wf.entries().map(([n, d]) => `${n}:${d.kind}`).join(', ')})`)
|
|
162
179
|
} catch (e) {
|
|
163
180
|
io.print(`FAIL: ${(e as Error).message}`)
|
|
164
181
|
bad++
|
|
165
182
|
}
|
|
166
|
-
|
|
167
|
-
|
|
183
|
+
if (wfs != null) {
|
|
184
|
+
// every comfy kind's workflow + vars resolve (dry pass, vars reset after)
|
|
185
|
+
for (const kind of kit.kinds) {
|
|
186
|
+
const gen = kind.generate
|
|
187
|
+
if (gen.backend !== 'comfy') continue
|
|
188
|
+
try {
|
|
189
|
+
const lw = workflowFor(wfs, { workflow: gen.workflow, assetId: kind.assets[0]?.id })
|
|
190
|
+
if (gen.vars != null) {
|
|
191
|
+
for (const [, d] of lw.wf.entries()) d.reset()
|
|
192
|
+
applyVarValues(lw.wf, gen.vars, `kind '${kind.id}'`)
|
|
193
|
+
for (const [, d] of lw.wf.entries()) d.reset()
|
|
194
|
+
}
|
|
195
|
+
} catch (e) {
|
|
196
|
+
io.print(`FAIL: ${(e as Error).message}`)
|
|
197
|
+
bad++
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// one draft per comfy asset — the TUI's tweakable entry.
|
|
201
|
+
// ONE aggregate line: per-asset rows would drown the check
|
|
202
|
+
let draftCount = 0
|
|
203
|
+
let draftSeeded = 0
|
|
204
|
+
for (const f of allAssets(kit).filter((x) => x.kind.generate.backend === 'comfy')) {
|
|
205
|
+
let lw: LoadedWorkflow
|
|
206
|
+
try {
|
|
207
|
+
lw = workflowFor(wfs, { workflow: (f.kind.generate as ComfyGen).workflow, assetId: f.asset.id })
|
|
208
|
+
} catch {
|
|
209
|
+
continue // an unresolvable workflow already FAILed on its kind above
|
|
210
|
+
}
|
|
211
|
+
const prompt = (await readPromptOverlay(assetDir(join(root, kit.workspace), f.asset.id))).prompt ?? f.asset.prompt
|
|
212
|
+
if (ensureAssetDraft(lw, f.asset.id, prompt)) draftSeeded++
|
|
213
|
+
draftCount++
|
|
214
|
+
}
|
|
215
|
+
if (draftCount > 0) io.print(`ok: drafts ensured for ${draftCount} comfy asset(s) in .comfy-ts/drafts/${draftSeeded > 0 ? ` (${draftSeeded} seeded now)` : ''}`)
|
|
216
|
+
// offline graph validation when the host has a schema cache;
|
|
217
|
+
// a missing cache is world state → WARN, never a repo FAIL
|
|
218
|
+
const hostsWarned = new Set<string>()
|
|
219
|
+
for (const lw of wfs.values()) {
|
|
220
|
+
// schema.size counts real spec entries; .nodes always
|
|
221
|
+
// carries the injected UnknownNodeXX even with no cache
|
|
222
|
+
if (lw.wf.host.schema.size > 0) {
|
|
223
|
+
try {
|
|
224
|
+
const built = await lw.wf.build()
|
|
225
|
+
if (built.problems.length > 0) { io.print(`FAIL: workflow '${lw.id}' graph problems: ${built.problems.map((p) => p.title).join('; ')}`); bad++ }
|
|
226
|
+
} catch (e) {
|
|
227
|
+
io.print(`FAIL: workflow '${lw.id}' build failed: ${(e as Error).message}`)
|
|
228
|
+
bad++
|
|
229
|
+
}
|
|
230
|
+
} else if (!hostsWarned.has(lw.wf.host.data.id)) {
|
|
231
|
+
hostsWarned.add(lw.wf.host.data.id)
|
|
232
|
+
io.print(`WARN no schema cache for host '${lw.wf.host.data.id}' — bunx comfy-ts gen --id ${lw.wf.host.data.id} --host ${lw.wf.host.getServerHostHTTP()}`)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
for (const url of new Set([...wfs.values()].map((lw) => lw.wf.host.getServerHostHTTP()))) {
|
|
237
|
+
const up = await fetch(`${url}/system_stats`, { signal: AbortSignal.timeout(3000) }).then((r) => r.ok).catch(() => false)
|
|
238
|
+
io.print(up ? `ok: comfy backend reachable (${url})` : `WARN comfy backend unreachable (${url})${kit.comfy.heal != null ? ` — ${kit.comfy.heal}` : ''}`)
|
|
239
|
+
}
|
|
240
|
+
} finally {
|
|
241
|
+
disconnectAll({ workflows: wfs })
|
|
242
|
+
}
|
|
243
|
+
}
|
|
168
244
|
}
|
|
169
245
|
// machine-clearable red clears itself: drifted/missing identity
|
|
170
246
|
// outputs re-render HERE; only a missing pick (taste) reaches a human
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Generation queue: single worker, IN-MEMORY (never run the studio with
|
|
2
2
|
// --watch — a restart drops every queued roll; the fill CLI is the recovery
|
|
3
3
|
// path), errors surfaced in a ring buffer, never fatal. Per-step progress
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
import type { GenProgress } from '../gen/
|
|
4
|
+
// lives on state; cancel drops the CURRENT job's remaining rolls — queued
|
|
5
|
+
// jobs are `clear()`'s business.
|
|
6
|
+
import type { GenProgress } from '../gen/comfyts-gen.ts'
|
|
7
7
|
|
|
8
8
|
export type Job = { assetId: string; prompt: string; count: number }
|
|
9
9
|
export type QueueState = { generating: boolean; current: string | null; done: number; total: number; progress: GenProgress | null }
|
|
@@ -8,10 +8,9 @@
|
|
|
8
8
|
import { existsSync, mkdirSync, unlinkSync } from 'fs'
|
|
9
9
|
import { join, relative } from 'path'
|
|
10
10
|
import { findAsset, allAssets, mediaOf, missingPanelScripts, MEDIA_EXTS, type CustomPanelSpec, type FoundAsset, type GeometryPanelSpec, type ResolvedKit, type ShipFn } from '../kit-config.ts'
|
|
11
|
-
import {
|
|
11
|
+
import { ensureAssetDraft, loadCflowWorkflows, randomSeed, workflowFor, type ComfyTsRuntime, type LoadedWorkflow, type OnProgress } from '../gen/comfyts-gen.ts'
|
|
12
12
|
import { isStale, parentIdsOf, resolveParentContext, type ParentContext } from '../gen/derived.ts'
|
|
13
|
-
import { generateRoll
|
|
14
|
-
import { loadWorkflows, type Workflow } from '../gen/workflow-loader.ts'
|
|
13
|
+
import { generateRoll } from '../gen/generate-roll.ts'
|
|
15
14
|
import { alphaHeight, toPng } from '../image-ops.ts'
|
|
16
15
|
import { readJson, updateJson } from '../json-store.ts'
|
|
17
16
|
import { assetDir, isRollFile, listRolls, metaPath, readPick, readPromptOverlay, updatePick, updatePromptOverlay } from '../roll-workspace.ts'
|
|
@@ -31,7 +30,7 @@ export type AssetInfo = {
|
|
|
31
30
|
backend: 'canvas' | 'comfy' | 'manual' | 'gemini'
|
|
32
31
|
/** canvas rolls have no seed input — every roll is identical */
|
|
33
32
|
deterministic: boolean
|
|
34
|
-
/** comfy only: resolved workflow id + its .
|
|
33
|
+
/** comfy only: resolved workflow id + its .cflow.ts path (note = why unresolved) */
|
|
35
34
|
workflow: { id: string | null; path: string | null; note?: string } | null
|
|
36
35
|
ship: { mode: 'built-in' | 'hook' | 'record'; path?: string; note?: string }
|
|
37
36
|
/** live write-through target (kind.meta.file), repo-root-relative */
|
|
@@ -49,8 +48,8 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
|
|
|
49
48
|
const { kit, root } = deps
|
|
50
49
|
const workspace = join(root, kit.workspace)
|
|
51
50
|
const dirOf = (id: string): string => assetDir(workspace, id)
|
|
52
|
-
let workflows: Map<string,
|
|
53
|
-
if (kit.comfy != null) workflows = await
|
|
51
|
+
let workflows: Map<string, LoadedWorkflow> | null = null
|
|
52
|
+
if (kit.comfy != null) workflows = await loadCflowWorkflows(root, join(root, kit.comfy.workflows))
|
|
54
53
|
|
|
55
54
|
// custom panel scripts fail-fast here too (tests/embedders may bypass loadKit)
|
|
56
55
|
const missingPanels = missingPanelScripts(kit, root)
|
|
@@ -76,9 +75,9 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
|
|
|
76
75
|
if (workflows != null && workflows.size === 1) id = [...workflows.keys()][0]!
|
|
77
76
|
else if (workflows != null) note = `names no workflow; ${workflows.size} loaded — set workflow on the kind`
|
|
78
77
|
}
|
|
79
|
-
const
|
|
80
|
-
if (id != null &&
|
|
81
|
-
const path =
|
|
78
|
+
const lw = id != null && workflows != null ? workflows.get(id) : undefined
|
|
79
|
+
if (id != null && lw == null && note == null) note = `workflow '${id}' not loaded`
|
|
80
|
+
const path = lw != null ? relOf(lw.file) : id != null && kit.comfy != null ? join(kit.comfy.workflows, `${id}.cflow.ts`) : null
|
|
82
81
|
workflow = { id, path, note }
|
|
83
82
|
}
|
|
84
83
|
let ship: AssetInfo['ship']
|
|
@@ -94,11 +93,25 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
|
|
|
94
93
|
const promptOf = async (f: FoundAsset): Promise<string> =>
|
|
95
94
|
(await readPromptOverlay(dirOf(f.asset.id))).prompt ?? f.asset.prompt
|
|
96
95
|
|
|
96
|
+
// one draft per comfy asset, seeded at boot so `bunx comfy-ts tui` lists
|
|
97
|
+
// every asset as a tweakable entry under its workflow
|
|
98
|
+
if (workflows != null) {
|
|
99
|
+
for (const f of allAssets(kit).filter((x) => x.kind.generate.backend === 'comfy')) {
|
|
100
|
+
try {
|
|
101
|
+
const gen = f.kind.generate as { workflow?: string }
|
|
102
|
+
const lw = workflowFor(workflows, { workflow: gen.workflow, assetId: f.asset.id })
|
|
103
|
+
if (ensureAssetDraft(lw, f.asset.id, await promptOf(f))) console.log(`ok: draft '${f.asset.id}' seeded → .comfy-ts/drafts/${lw.id}/${f.asset.id}.json`)
|
|
104
|
+
} catch (e) {
|
|
105
|
+
console.log(`WARN draft for '${f.asset.id}' not seeded — ${(e as Error).message}`)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
97
110
|
// workflows are pre-loaded above (fail-fast for a comfy studio); the
|
|
98
111
|
// resolver just hands the shared roll generator the ready runtime.
|
|
99
|
-
const resolveComfy = async (): Promise<
|
|
112
|
+
const resolveComfy = async (): Promise<ComfyTsRuntime> => {
|
|
100
113
|
if (kit.comfy == null || workflows == null) throw new Error('kit has no comfy config — only canvas kinds can generate')
|
|
101
|
-
return { workflows,
|
|
114
|
+
return { workflows, heal: kit.comfy.heal }
|
|
102
115
|
}
|
|
103
116
|
const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir, onProgress, context?: ParentContext) =>
|
|
104
117
|
generateRoll(found, { prompt, seed, destDir, onProgress, context }, resolveComfy))
|
|
@@ -359,9 +372,14 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
|
|
|
359
372
|
// only touch the box when the RUNNING job is a comfy kind — the
|
|
360
373
|
// box is shared, an unrelated prompt must never be interrupted
|
|
361
374
|
const running = queue.state.current != null ? findAsset(kit, queue.state.current) : null
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
375
|
+
const rgen = running?.kind.generate
|
|
376
|
+
if (workflows != null && rgen?.backend === 'comfy') {
|
|
377
|
+
try {
|
|
378
|
+
await workflowFor(workflows, { workflow: rgen.workflow, assetId: running!.asset.id }).wf.host.interrupt()
|
|
379
|
+
} catch (e) {
|
|
380
|
+
queue.pushError(`interrupt: ${(e as Error).message} — current roll will finish, its job is still dropped`)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
365
383
|
return json({ ok: true })
|
|
366
384
|
}
|
|
367
385
|
if (pathname === '/api/pick') {
|
package/src/cli/cmd_Assets.ts
CHANGED
|
@@ -99,7 +99,7 @@ export class AssetsCheckCommand extends Command {
|
|
|
99
99
|
|
|
100
100
|
static override usage = Command.Usage({
|
|
101
101
|
category: 'Assets',
|
|
102
|
-
description: 'kit loads, kinds valid, workflows
|
|
102
|
+
description: 'kit loads, kinds valid, cflow workflows load, drafts present, backend reachable, identity outputs present',
|
|
103
103
|
examples: [['health-check the asset pipeline', 'shipkit assets check']],
|
|
104
104
|
})
|
|
105
105
|
|
package/src/cli/cmd_Hooks.ts
CHANGED
|
@@ -20,7 +20,8 @@ export class HooksInstallCommand extends Command {
|
|
|
20
20
|
bin = Option.String('--bin', 'bunx rvlib-shipkit', { description: 'how the hook body invokes shipkit (in-repo consumers pass `bun <path to cli.ts>`)' })
|
|
21
21
|
|
|
22
22
|
async execute(): Promise<number> {
|
|
23
|
-
const
|
|
23
|
+
const config = await loadRepoConfig(process.cwd()).catch(() => null)
|
|
24
|
+
const r = installHooks(process.cwd(), this.bin, config?.hooks?.keywordFile)
|
|
24
25
|
for (const w of r.written) this.context.stdout.write(`ok: wrote ${w}\n`)
|
|
25
26
|
if (r.written.length === 0) this.context.stdout.write('ok: hook bodies already current\n')
|
|
26
27
|
this.context.stdout.write(`${r.note.startsWith('WARN') ? r.note : `ok: ${r.note}`}\n`)
|
|
@@ -21,8 +21,24 @@ export default defineContract({
|
|
|
21
21
|
custom('.rv-itch/listing.md answered', (env) => {
|
|
22
22
|
const p = join(env.root, '.rv-itch/listing.md')
|
|
23
23
|
if (!existsSync(p)) return []
|
|
24
|
-
const
|
|
25
|
-
|
|
24
|
+
const text = readFileSync(p, 'utf8')
|
|
25
|
+
const out: string[] = []
|
|
26
|
+
const left = [...text.matchAll(/<[a-z][^<>\n]{2,60}>/g)].map((m) => m[0])
|
|
27
|
+
if (left.length > 0) out.push(`.rv-itch/listing.md still holds ${left.length} unanswered placeholder(s): ${[...new Set(left)].slice(0, 3).join(' ')} — it is pasted into the store form as-is`)
|
|
28
|
+
// a heading with an EMPTY body is an unanswered field wearing no
|
|
29
|
+
// placeholder (prose-level open decisions stay a human read).
|
|
30
|
+
// fenced blocks stripped first (a ## inside one is not a field),
|
|
31
|
+
// and an inline `## Field: answer` heading counts as answered
|
|
32
|
+
const sections = text.replace(/^```[\s\S]*?^```/gm, '').split(/^## /m).slice(1)
|
|
33
|
+
const empty = sections
|
|
34
|
+
.filter((s) => {
|
|
35
|
+
const [head = '', ...body] = s.split('\n')
|
|
36
|
+
const inline = head.includes(':') && head.slice(head.indexOf(':') + 1).trim() !== ''
|
|
37
|
+
return !inline && body.every((l) => l.trim() === '')
|
|
38
|
+
})
|
|
39
|
+
.map((s) => s.split('\n')[0]!.trim())
|
|
40
|
+
if (empty.length > 0) out.push(`.rv-itch/listing.md field(s) with no answer under the heading: ${empty.slice(0, 3).join(', ')}${empty.length > 3 ? ' …' : ''}`)
|
|
41
|
+
return out
|
|
26
42
|
}),
|
|
27
43
|
// itch DISPLAYS 630x500 and accepts larger — atLeast, never exact-size
|
|
28
44
|
png('.rv-itch/cover.png', 630, 500, { atLeast: true }),
|
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
export type OffenseKind = 'dash' | 'attribution'
|
|
8
8
|
export type Offense = { line: number; text: string; kind: OffenseKind; why: string }
|
|
9
9
|
|
|
10
|
-
/** trailers and footers that name an assistant,
|
|
10
|
+
/** trailers and footers that name an assistant, assistant session trailers,
|
|
11
|
+
* assistant share-domain urls (a session link is a louder tell than any
|
|
12
|
+
* trailer), plus the robot emoji. the session/url family lists every
|
|
13
|
+
* assistant, not just the one that bit first. */
|
|
11
14
|
export const AI_ATTRIBUTION =
|
|
12
|
-
/(?:co-authored-by|assisted-by|signed-off-by):[^\n]*\b(?:claude|anthropic|copilot|chatgpt|openai|cursor|aider|devin|gemini)\b|generated with[^\n]*\b(?:claude|copilot|chatgpt|gemini)\b|🤖/i
|
|
15
|
+
/(?:co-authored-by|assisted-by|signed-off-by):[^\n]*\b(?:claude|anthropic|copilot|chatgpt|openai|cursor|aider|devin|gemini)\b|generated with[^\n]*\b(?:claude|copilot|chatgpt|gemini)\b|\b(?:claude|chatgpt|copilot|cursor|aider|devin|gemini|codex)-session:|\b(?:claude\.(?:ai|com)|chatgpt\.com|chat\.openai\.com)\b|🤖/i
|
|
13
16
|
|
|
14
17
|
/** everything below git's scissors line is the verbose-mode diff, which git
|
|
15
18
|
* strips before committing, diff content is not ours to police. */
|
|
@@ -33,7 +36,7 @@ export function commitMessageOffenses(message: string): Offense[] {
|
|
|
33
36
|
if (body.includes(' - '))
|
|
34
37
|
offenses.push({ line: i + 1, text: raw, kind: 'dash', why: 'inline " - " separator: same fix' })
|
|
35
38
|
if (AI_ATTRIBUTION.test(raw))
|
|
36
|
-
offenses.push({ line: i + 1, text: raw, kind: 'attribution', why: 'AI attribution: no assistant trailer, "Generated with" footer, or robot emoji' })
|
|
39
|
+
offenses.push({ line: i + 1, text: raw, kind: 'attribution', why: 'AI attribution: no assistant trailer, session trailer, claude.ai/chatgpt.com url, "Generated with" footer, or robot emoji' })
|
|
37
40
|
})
|
|
38
41
|
return offenses
|
|
39
42
|
}
|
package/src/hooks/leak-guard.ts
CHANGED
|
Binary file
|
package/src/hooks/run-hooks.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
// while its next commit is already doomed, and nothing can enumerate what
|
|
5
5
|
// the hooks enforce. `shipkit hooks list` answers that without reading shell.
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
7
|
-
import { join } from 'path'
|
|
7
|
+
import { dirname, join } from 'path'
|
|
8
8
|
import { commitMessageOffenses } from './commit-message.ts'
|
|
9
|
-
import { runLeakGuard } from './leak-guard.ts'
|
|
9
|
+
import { globalKeywordFile, runLeakGuard } from './leak-guard.ts'
|
|
10
10
|
import { runRootIdentity } from './root-identity.ts'
|
|
11
11
|
import { spawnWithTimeout } from '../core/spawn-timeout.ts'
|
|
12
12
|
import type { RepoConfig } from '../repo-config.ts'
|
|
@@ -20,6 +20,9 @@ export type HooksConfig = {
|
|
|
20
20
|
commitMsg?: { id: string; cmd: string[] }[]
|
|
21
21
|
/** keyword file for the leak guard (default '.rv-private/banned-keywords.txt') */
|
|
22
22
|
keywordFile?: string
|
|
23
|
+
/** staged-path prefixes that may never enter git (local state like
|
|
24
|
+
* '.comfy-ts/'). gitignore is advisory and editable; this gate is not. */
|
|
25
|
+
blockedPaths?: string[]
|
|
23
26
|
/** disable a built-in gate, visible in `hooks list`, never silent */
|
|
24
27
|
disable?: ('leak-guard' | 'commit-message' | 'root-identity')[]
|
|
25
28
|
}
|
|
@@ -27,8 +30,9 @@ export type HooksConfig = {
|
|
|
27
30
|
const HOOK_DIR = 'hooks'
|
|
28
31
|
const HOOKS = ['pre-commit', 'commit-msg'] as const
|
|
29
32
|
|
|
30
|
-
/** wire `core.hooksPath` and write the two hook bodies. Idempotent.
|
|
31
|
-
|
|
33
|
+
/** wire `core.hooksPath` and write the two hook bodies. Idempotent.
|
|
34
|
+
* `keywordFile` = the repo's configured leak-guard file, seeding target. */
|
|
35
|
+
export function installHooks(root: string, binInvocation: string, keywordFile?: string): { written: string[]; note: string } {
|
|
32
36
|
const dir = join(root, HOOK_DIR)
|
|
33
37
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
34
38
|
const written: string[] = []
|
|
@@ -41,12 +45,41 @@ export function installHooks(root: string, binInvocation: string): { written: st
|
|
|
41
45
|
if (prev !== body) { writeFileSync(path, body, { mode: 0o755 }); written.push(`${HOOK_DIR}/${h}`) }
|
|
42
46
|
}
|
|
43
47
|
const cfg = Bun.spawnSync(['git', 'config', 'core.hooksPath', HOOK_DIR], { cwd: root })
|
|
48
|
+
const seeded = seedKeywordFile(root, keywordFile)
|
|
49
|
+
if (seeded != null) written.push(seeded)
|
|
44
50
|
return {
|
|
45
51
|
written,
|
|
46
52
|
note: cfg.exitCode === 0 ? `core.hooksPath=${HOOK_DIR}` : `WARN could not set core.hooksPath (exit ${cfg.exitCode}) — hooks will not fire`,
|
|
47
53
|
}
|
|
48
54
|
}
|
|
49
55
|
|
|
56
|
+
// a starter so the leak guard bites from day one instead of skipping forever:
|
|
57
|
+
// generic secret shapes only. private names live in the machine-global
|
|
58
|
+
// ~/.rv-private/banned-keywords.txt, which the guard reads on every run
|
|
59
|
+
// (never copied into a repo: a copy rots and a copy can leak)
|
|
60
|
+
const KEYWORD_STARTER = `# banned-keywords — the leak guard greps every commit (added lines, paths,
|
|
61
|
+
# message) for these rows. one row = one case-insensitive substring, or
|
|
62
|
+
# re:<regex>. add repo-specific names here; machine-wide private names go in
|
|
63
|
+
# ~/.rv-private/banned-keywords.txt, read automatically alongside this file.
|
|
64
|
+
# this file is gitignored on purpose: the list itself is private.
|
|
65
|
+
re:-----BEGIN [A-Z ]*PRIVATE KEY-----
|
|
66
|
+
re:AKIA[0-9A-Z]{16}
|
|
67
|
+
re:sk-[A-Za-z0-9_-]{20,}
|
|
68
|
+
re:ghp_[A-Za-z0-9]{36}
|
|
69
|
+
re:xox[baprs]-[A-Za-z0-9-]{10,}
|
|
70
|
+
`
|
|
71
|
+
|
|
72
|
+
/** seed the configured keyword file with the generic starter so the guard is
|
|
73
|
+
* ARMED, not a skip. an existing file is never touched. */
|
|
74
|
+
function seedKeywordFile(root: string, keywordFile?: string): string | null {
|
|
75
|
+
const rel = keywordFile ?? '.rv-private/banned-keywords.txt'
|
|
76
|
+
const dest = join(root, rel)
|
|
77
|
+
if (existsSync(dest)) return null
|
|
78
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
79
|
+
writeFileSync(dest, KEYWORD_STARTER)
|
|
80
|
+
return `${rel} (starter shapes)`
|
|
81
|
+
}
|
|
82
|
+
|
|
50
83
|
export type GateOutcome = { id: string; ok: boolean; lines: string[] }
|
|
51
84
|
|
|
52
85
|
/** the pre-commit gate: built-in leak guard, then the repo's own commands. */
|
|
@@ -67,6 +100,22 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
|
|
|
67
100
|
],
|
|
68
101
|
})
|
|
69
102
|
}
|
|
103
|
+
if ((h.blockedPaths ?? []).length > 0) {
|
|
104
|
+
const names = Bun.spawnSync(['git', 'diff', '--cached', '--name-only'], { cwd: root })
|
|
105
|
+
const staged = names.stdout.toString().split('\n').filter(Boolean)
|
|
106
|
+
const blocked = staged.filter((f) => h.blockedPaths!.some((p) => f.startsWith(p)))
|
|
107
|
+
out.push(blocked.length === 0
|
|
108
|
+
? { id: 'blocked-paths', ok: true, lines: [`ok: blocked-paths clean (${h.blockedPaths!.join(', ')})`] }
|
|
109
|
+
: {
|
|
110
|
+
id: 'blocked-paths',
|
|
111
|
+
ok: false,
|
|
112
|
+
lines: [
|
|
113
|
+
`FAIL: ${blocked.length} staged file(s) under a blocked path — this local state never enters git`,
|
|
114
|
+
...blocked.slice(0, 10).map((f) => ` ${f}`),
|
|
115
|
+
` fix: git reset -- ${[...new Set(blocked.map((f) => h.blockedPaths!.find((p) => f.startsWith(p))))].join(' ')}`,
|
|
116
|
+
],
|
|
117
|
+
})
|
|
118
|
+
}
|
|
70
119
|
if (!(h.disable ?? []).includes('root-identity')) {
|
|
71
120
|
const r = runRootIdentity(root)
|
|
72
121
|
if (r.status === 'skipped') out.push({ id: 'root-identity', ok: true, lines: [`skip: root-identity (${r.reason})`] })
|
|
@@ -137,9 +186,10 @@ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
|
|
|
137
186
|
const kw = h.keywordFile ?? '.rv-private/banned-keywords.txt'
|
|
138
187
|
// a gate listed as active but unable to fire is worse than no listing:
|
|
139
188
|
// this surface exists so nobody has to guess
|
|
140
|
-
const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) ? '' : ` (INACTIVE, ${kw} absent)`
|
|
189
|
+
const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) || globalKeywordFile() != null ? '' : ` (INACTIVE, ${kw} absent and no ~/.rv-private list)`
|
|
141
190
|
return [
|
|
142
|
-
`pre-commit leak-guard${leakState} — staged
|
|
191
|
+
`pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.rv-private/banned-keywords.txt`,
|
|
192
|
+
...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git`] : []),
|
|
143
193
|
`pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
|
|
144
194
|
...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
|
|
145
195
|
`commit-msg commit-message${off.has('commit-message') ? ' (DISABLED by config)' : ''} — dash punctuation + AI attribution`,
|
package/src/init-repo.ts
CHANGED
|
@@ -21,6 +21,9 @@ export default defineRepo({
|
|
|
21
21
|
venues: [], // e.g. ['itch', 'steam'] — selects which .rv-<venue>/ contracts are REQUIRED
|
|
22
22
|
social: [], // e.g. ['x', 'discord'] — .rv-social/<channel>/ post folders (+ .rv-discord/)
|
|
23
23
|
tasks: [], // repo commit-gate tasks; deps double as race guards
|
|
24
|
+
// .comfy-ts/ holds schema dumps naming a private model collection —
|
|
25
|
+
// gitignore (SK004) is advisory, this pre-commit gate is not
|
|
26
|
+
hooks: { blockedPaths: ['.comfy-ts/'] },
|
|
24
27
|
// the mandated reviewer agent type (rule SK008) — uncomment with YOUR
|
|
25
28
|
// standing brief, then \`shipkit check --fix SK008\` writes the agent file:
|
|
26
29
|
// reviewer: { name: 'my-reviewer', brief: '~/path/to/reviewer-brief.md' },
|
|
@@ -15,6 +15,8 @@ import { npmPublishArgv, unitBlockers } from './unit-gate.ts'
|
|
|
15
15
|
import { formatUnitError, resolveUnits, unitById, type ReleaseUnit } from './units.ts'
|
|
16
16
|
import type { RepoConfig } from '../repo-config.ts'
|
|
17
17
|
|
|
18
|
+
// adding a FOURTH knob here is the signal that this shape wants rethinking
|
|
19
|
+
// rather than another key (meta/gaps.md, release-path config keys accreting)
|
|
18
20
|
export type ReleaseConfig = {
|
|
19
21
|
/** releases ship from this branch only (default 'main') */
|
|
20
22
|
branch?: string
|
|
@@ -32,6 +32,11 @@ const POLICY_LINES = [
|
|
|
32
32
|
'.rv-discord/',
|
|
33
33
|
'CLAUDE.local.md',
|
|
34
34
|
'tmp/',
|
|
35
|
+
// comfy-ts state (schema dumps, drafts) and per-workflow lora tags name a
|
|
36
|
+
// private model collection; shipkit itself writes .comfy-ts/ at hub boot,
|
|
37
|
+
// so every consumer repo needs both ignored before its first commit
|
|
38
|
+
'.comfy-ts/',
|
|
39
|
+
'*.loras.local.txt',
|
|
35
40
|
]
|
|
36
41
|
|
|
37
42
|
export function expectedBlock(config: RepoConfig): string {
|
|
@@ -53,14 +53,25 @@ export const commitAiTells: Rule = {
|
|
|
53
53
|
const sample = (hits: { hash: string }[]): string => hits.slice(0, 5).map((h) => h.hash).join(' ') + (hits.length > 5 ? ' …' : '')
|
|
54
54
|
const attributed = commits.filter((c) => hasAiAttribution(c.body))
|
|
55
55
|
const dashed = commits.filter((c) => hasDashPunctuation(c.body))
|
|
56
|
+
// ref NAMES tell too (a FABLE-checkpoint-3 tag outs the agent as
|
|
57
|
+
// loudly as a trailer). bare match only for names with no other
|
|
58
|
+
// meaning; vendor and codename words (cursor, openai, gemini, opus,
|
|
59
|
+
// fable, …) name legitimate branches and features, they only match in
|
|
60
|
+
// a checkpoint/session shape
|
|
61
|
+
const refTell = /\b(?:claude|chatgpt|copilot|aider|devin)\b|(?:^|[-_/])(?:fable|sonnet|haiku|opus|gemini|codex)[-_](?:checkpoint|session|ckpt)/i
|
|
62
|
+
const refs = await t.exec(['git', 'for-each-ref', '--format=%(refname:short)', 'refs/tags', 'refs/heads', 'refs/remotes'], { cwd: env.root, timeoutMs: 30_000 })
|
|
63
|
+
if (refs.code !== 0) t.lines.push({ kind: 'skip', text: `ref names unverifiable — git for-each-ref exit ${refs.code}` })
|
|
64
|
+
const tellRefs = refs.code === 0 ? refs.out.split('\n').map((r) => r.trim()).filter((r) => r !== '' && refTell.test(r)) : []
|
|
65
|
+
if (tellRefs.length > 0)
|
|
66
|
+
t.fail(`${tellRefs.length} ref name(s) carry an AI tell in a declared-public repo (${tellRefs.slice(0, 5).join(' ')}${tellRefs.length > 5 ? ' …' : ''}) — rename or delete the refs before the flip; a pushed tag also needs the remote delete`)
|
|
56
67
|
if (attributed.length > 0)
|
|
57
|
-
t.fail(`${attributed.length}/${commits.length} commits carry AI attribution (a trailer naming an assistant / "Generated with" / 🤖) in a declared-public repo — e.g. ${sample(attributed)}; ${remedy}`)
|
|
68
|
+
t.fail(`${attributed.length}/${commits.length} commits carry AI attribution (a trailer naming an assistant / a session trailer or claude.ai url / "Generated with" / 🤖) in a declared-public repo — e.g. ${sample(attributed)}; ${remedy}`)
|
|
58
69
|
if (dashed.length > 0)
|
|
59
70
|
t.fail(`${dashed.length}/${commits.length} commit messages carry dash punctuation (em/en dash or inline " - ") in a declared-public repo — e.g. ${sample(dashed)}; ${remedy} (identity/writing-voice.md owns the punctuation rule)`)
|
|
60
71
|
const silenced = attributionSilenced(env.root)
|
|
61
72
|
if (!silenced)
|
|
62
73
|
t.fail(`.claude/settings.json does not silence AI attribution — track it with {"attribution": {"commit": "", "pr": ""}} so no agent's harness ever writes the trailer or the PR footer`)
|
|
63
|
-
if (attributed.length === 0 && dashed.length === 0 && silenced)
|
|
64
|
-
t.ok(`${commits.length} commits scanned, attribution silenced in .claude/settings.json — no AI tells`)
|
|
74
|
+
if (attributed.length === 0 && dashed.length === 0 && tellRefs.length === 0 && silenced)
|
|
75
|
+
t.ok(`${commits.length} commits + ${refs.code === 0 ? refs.out.split('\n').filter((r) => r.trim() !== '').length : 0} ref names scanned, attribution silenced in .claude/settings.json — no AI tells`)
|
|
65
76
|
},
|
|
66
77
|
}
|
package/src/shipkit.ts
CHANGED
|
@@ -60,10 +60,8 @@ export { StudioQueue } from './assets/server/studio-queue.ts'
|
|
|
60
60
|
export type { Job, QueueState } from './assets/server/studio-queue.ts'
|
|
61
61
|
export { cleanRect, liveRect, defaultLegs } from './assets/panels/geometry-rects.ts'
|
|
62
62
|
export type { GroundRect, LegRect } from './assets/panels/geometry-rects.ts'
|
|
63
|
-
export {
|
|
64
|
-
export type {
|
|
65
|
-
export { loadWorkflows, workflowFor } from './assets/gen/workflow-loader.ts'
|
|
66
|
-
export type { Workflow, Sidekick, Binding, Graph } from './assets/gen/workflow-loader.ts'
|
|
63
|
+
export { generateComfyTs, loadCflowWorkflows, workflowFor, applyVarValues, ensureAssetDraft, readAssetDraft, draftPath, disconnectAll, randomSeed, batchFolderName } from './assets/gen/comfyts-gen.ts'
|
|
64
|
+
export type { ComfyTsRuntime, LoadedWorkflow, GenProgress, OnProgress } from './assets/gen/comfyts-gen.ts'
|
|
67
65
|
export { toPng, alphaHeight } from './assets/image-ops.ts'
|
|
68
66
|
export { updateJson, readJson } from './assets/json-store.ts'
|
|
69
67
|
export { assetDir, listRolls, isRollFile, isAudioRoll, readPick, updatePick, metaPath, pickPath, readPromptOverlay, updatePromptOverlay, promptPath } from './assets/roll-workspace.ts'
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
// ComfyUI HTTP engine — post/wait/fetch plumbing, zero node-id knowledge
|
|
2
|
-
// (node ids live in each workflow's .ts sidekick).
|
|
3
|
-
import { mkdirSync } from 'fs'
|
|
4
|
-
import { extname, join } from 'path'
|
|
5
|
-
import type { Graph, Sidekick, Workflow } from './workflow-loader.ts'
|
|
6
|
-
|
|
7
|
-
export type ComfyOpts = {
|
|
8
|
-
/** e.g. 'http://127.0.0.1:8085' */
|
|
9
|
-
host: string
|
|
10
|
-
/** shell command that heals the backend (tunnel/boot) — shown in error hints, never executed by the lib */
|
|
11
|
-
heal?: string
|
|
12
|
-
waitTimeoutMs?: number
|
|
13
|
-
pollMs?: number
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const WAIT_TIMEOUT_MS = 10 * 60_000 // covers cold model loads
|
|
17
|
-
const POLL_MS = 1500
|
|
18
|
-
|
|
19
|
-
const healHint = (o: ComfyOpts): string => (o.heal != null ? ` — is the backend up? (${o.heal})` : '')
|
|
20
|
-
|
|
21
|
-
/** Loud guard for setInputs escape hatches: the node must exist and match. */
|
|
22
|
-
export function expectNode(graph: Graph, id: string, classType: string): Graph[string] {
|
|
23
|
-
const n = graph[id]
|
|
24
|
-
if (n == null) throw new Error(`graph has no node '${id}' (expected ${classType})`)
|
|
25
|
-
if (n.class_type !== classType) throw new Error(`node '${id}' is ${n.class_type}, expected ${classType}`)
|
|
26
|
-
return n
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function applyInputs(graph: Graph, sk: Sidekick, args: { prompt: string; seed: number; prefix: string }): void {
|
|
30
|
-
if (sk.setInputs != null) return sk.setInputs(graph, args)
|
|
31
|
-
for (const [role, value] of [['prompt', args.prompt], ['seed', args.seed], ['prefix', args.prefix]] as const) {
|
|
32
|
-
const b = sk.bindings[role]
|
|
33
|
-
graph[b.node]!.inputs[b.input] = value
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export async function submit(o: ComfyOpts, graph: Graph, clientId?: string): Promise<string> {
|
|
38
|
-
const res = await fetch(`${o.host}/prompt`, {
|
|
39
|
-
method: 'POST',
|
|
40
|
-
headers: { 'Content-Type': 'application/json' },
|
|
41
|
-
body: JSON.stringify({ prompt: graph, client_id: clientId ?? crypto.randomUUID() }),
|
|
42
|
-
})
|
|
43
|
-
if (!res.ok) throw new Error(`POST /prompt → ${res.status}: ${(await res.text()).slice(0, 300)} — bad graph or server mid-boot${healHint(o)}`)
|
|
44
|
-
return ((await res.json()) as { prompt_id: string }).prompt_id
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// ── live progress over ComfyUI's native /ws ────────────────────────────────
|
|
48
|
-
// ADDITIVE by contract: a dead socket degrades to no-progress, completion
|
|
49
|
-
// truth stays the /history poll. The clientId MUST match the /prompt body
|
|
50
|
-
// or the server sends nothing.
|
|
51
|
-
export type GenProgress = { value: number; max: number; node: string | null }
|
|
52
|
-
export type OnProgress = (p: GenProgress) => void
|
|
53
|
-
|
|
54
|
-
const wsWarned = new Set<string>() // one WARN per host per process, not per roll
|
|
55
|
-
export function subscribeProgress(o: ComfyOpts, clientId: string, on: OnProgress): { close: () => void } {
|
|
56
|
-
const url = `${o.host.replace(/^http/, 'ws')}/ws?clientId=${encodeURIComponent(clientId)}`
|
|
57
|
-
const warnOnce = (detail: string): void => {
|
|
58
|
-
if (wsWarned.has(o.host)) return
|
|
59
|
-
wsWarned.add(o.host)
|
|
60
|
-
console.log(`WARN comfy /ws unavailable — per-step progress disabled${detail}`)
|
|
61
|
-
}
|
|
62
|
-
let node: string | null = null
|
|
63
|
-
let ws: WebSocket | null = null
|
|
64
|
-
try {
|
|
65
|
-
ws = new WebSocket(url)
|
|
66
|
-
ws.onmessage = (ev) => {
|
|
67
|
-
if (typeof ev.data !== 'string') return // binary preview frames — not consumed
|
|
68
|
-
let msg: { type?: string; data?: { value?: number; max?: number; node?: string | null } }
|
|
69
|
-
try { msg = JSON.parse(ev.data) as typeof msg } catch { return }
|
|
70
|
-
if (msg.type === 'executing') { node = msg.data?.node ?? null; on({ value: 0, max: 0, node }) }
|
|
71
|
-
else if (msg.type === 'progress') on({ value: msg.data?.value ?? 0, max: msg.data?.max ?? 0, node })
|
|
72
|
-
}
|
|
73
|
-
// progress is a bonus — a refused/dropped ws warns once and stays quiet
|
|
74
|
-
ws.onerror = () => warnOnce('')
|
|
75
|
-
} catch (e) {
|
|
76
|
-
warnOnce(` (${(e as Error).message})`)
|
|
77
|
-
}
|
|
78
|
-
return { close: () => { try { ws?.close() } catch {} } }
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Cancel the RUNNING prompt (queued ones are untouched). */
|
|
82
|
-
export async function interrupt(o: ComfyOpts): Promise<void> {
|
|
83
|
-
const res = await fetch(`${o.host}/interrupt`, { method: 'POST' })
|
|
84
|
-
if (!res.ok) throw new Error(`POST /interrupt → ${res.status}${healHint(o)}`)
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
type HistoryOutputs = Record<string, { images?: { filename: string; subfolder?: string; type?: string }[] }>
|
|
88
|
-
|
|
89
|
-
export async function waitFor(o: ComfyOpts, promptId: string): Promise<HistoryOutputs> {
|
|
90
|
-
const timeoutMs = o.waitTimeoutMs ?? WAIT_TIMEOUT_MS
|
|
91
|
-
const deadline = Date.now() + timeoutMs
|
|
92
|
-
while (Date.now() < deadline) {
|
|
93
|
-
const res = await fetch(`${o.host}/history/${promptId}`)
|
|
94
|
-
if (res.ok) {
|
|
95
|
-
const h = (await res.json()) as Record<string, { outputs: HistoryOutputs; status?: { status_str?: string } }>
|
|
96
|
-
const entry = h[promptId]
|
|
97
|
-
if (entry != null) {
|
|
98
|
-
if (entry.status?.status_str === 'error') throw new Error(`prompt ${promptId} errored on the server — open the ComfyUI UI (${o.host}) for the node error`)
|
|
99
|
-
return entry.outputs
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
await Bun.sleep(o.pollMs ?? POLL_MS)
|
|
103
|
-
}
|
|
104
|
-
throw new Error(`prompt ${promptId} still not done after ${Math.round(timeoutMs / 60000)} min — server stuck? check ${o.host} queue${healHint(o)}`)
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async function fetchImage(o: ComfyOpts, img: { filename: string; subfolder?: string; type?: string }, dest: string): Promise<void> {
|
|
108
|
-
const q = new URLSearchParams({ filename: img.filename, subfolder: img.subfolder ?? '', type: img.type ?? 'output' })
|
|
109
|
-
const res = await fetch(`${o.host}/view?${q}`)
|
|
110
|
-
if (!res.ok) throw new Error(`GET /view ${img.filename} → ${res.status}`)
|
|
111
|
-
await Bun.write(dest, await res.arrayBuffer())
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Run one generation through a workflow pair: fresh graph copy, sidekick
|
|
116
|
-
* bindings applied, submitted, waited, non-preview outputs saved as
|
|
117
|
-
* <destDir>/<name>.<ext> (ONE clean file per asset — no _00001_ counter noise).
|
|
118
|
-
*/
|
|
119
|
-
export async function generate(o: ComfyOpts, wf: Workflow, args: { prompt: string; seed: number; destDir: string; name: string; onProgress?: OnProgress }): Promise<string[]> {
|
|
120
|
-
const graph = structuredClone(wf.graph)
|
|
121
|
-
applyInputs(graph, wf.sidekick, { prompt: args.prompt, seed: args.seed, prefix: args.name })
|
|
122
|
-
mkdirSync(args.destDir, { recursive: true })
|
|
123
|
-
let outputs: HistoryOutputs
|
|
124
|
-
if (args.onProgress != null) {
|
|
125
|
-
const clientId = crypto.randomUUID()
|
|
126
|
-
const sub = subscribeProgress(o, clientId, args.onProgress)
|
|
127
|
-
try {
|
|
128
|
-
outputs = await waitFor(o, await submit(o, graph, clientId))
|
|
129
|
-
} finally {
|
|
130
|
-
sub.close()
|
|
131
|
-
}
|
|
132
|
-
} else {
|
|
133
|
-
outputs = await waitFor(o, await submit(o, graph))
|
|
134
|
-
}
|
|
135
|
-
const saved: string[] = []
|
|
136
|
-
for (const node of Object.values(outputs)) {
|
|
137
|
-
for (const img of node.images ?? []) {
|
|
138
|
-
if (img.type === 'temp') continue // PreviewImage — raw, non-transparent
|
|
139
|
-
// >1 saved image (batch_size, second Save node): suffix instead of silently overwriting
|
|
140
|
-
const ext = extname(img.filename) || '.webp'
|
|
141
|
-
const dest = join(args.destDir, `${args.name}${saved.length > 0 ? `-${saved.length + 1}` : ''}${ext}`)
|
|
142
|
-
await fetchImage(o, img, dest)
|
|
143
|
-
saved.push(dest)
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
if (saved.length === 0) throw new Error(`workflow '${wf.id}' produced no saved images (only previews?) — does the graph end in a Save node?`)
|
|
147
|
-
return saved
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export const randomSeed = (): number => Math.floor(Math.random() * 2_000_000_000) + 1
|
|
151
|
-
|
|
152
|
-
/** Shell-stamped batch folder name — the one-shot output convention. */
|
|
153
|
-
export function batchFolderName(slug: string): string {
|
|
154
|
-
const d = new Date()
|
|
155
|
-
const p = (n: number) => String(n).padStart(2, '0')
|
|
156
|
-
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}-${slug}`
|
|
157
|
-
}
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
// Workflow pairs: <id>.json exported graph + <id>.ts sidekick (bindings +
|
|
2
|
-
// optional expects/setInputs). Every violation THROWS loud with the fix in
|
|
3
|
-
// the message — CLIs die, checkers turn it into a FAIL line.
|
|
4
|
-
import { existsSync, readdirSync } from 'fs'
|
|
5
|
-
import { basename, join } from 'path'
|
|
6
|
-
|
|
7
|
-
export type Binding = { node: string; input: string }
|
|
8
|
-
export type Sidekick = {
|
|
9
|
-
bindings: { prompt: Binding; seed: Binding; prefix: Binding }
|
|
10
|
-
expects?: Record<string, string>
|
|
11
|
-
requires?: { customNodes?: string[]; models?: string[] }
|
|
12
|
-
setInputs?: (graph: Graph, args: { prompt: string; seed: number; prefix: string }) => void
|
|
13
|
-
}
|
|
14
|
-
export type Graph = Record<string, { inputs: Record<string, unknown>; class_type: string }>
|
|
15
|
-
export type Workflow = { id: string; jsonPath: string; graph: Graph; sidekick: Sidekick }
|
|
16
|
-
|
|
17
|
-
// function declaration, not const — TS only narrows on never-calls to
|
|
18
|
-
// directly-declared functions
|
|
19
|
-
function fail(msg: string): never { throw new Error(msg) }
|
|
20
|
-
|
|
21
|
-
/** dir = the folder holding the <id>.json + <id>.ts pairs */
|
|
22
|
-
export async function loadWorkflows(dir: string): Promise<Map<string, Workflow>> {
|
|
23
|
-
const out = new Map<string, Workflow>()
|
|
24
|
-
if (!existsSync(dir)) fail(`${dir}/ missing — ship at least one <id>.json + <id>.ts workflow pair`)
|
|
25
|
-
for (const f of readdirSync(dir).filter((x) => x.endsWith('.json')).sort()) {
|
|
26
|
-
const id = basename(f, '.json')
|
|
27
|
-
const jsonPath = join(dir, f)
|
|
28
|
-
const tsPath = join(dir, `${id}.ts`)
|
|
29
|
-
if (!existsSync(tsPath)) fail(`workflow '${id}': ${f} has no sidekick ${id}.ts — every graph json ships with its adjacent .ts module`)
|
|
30
|
-
const graph = (await Bun.file(jsonPath).json()) as Graph
|
|
31
|
-
// ?rvload = a DISTINCT module instance: when a self-executing sidekick
|
|
32
|
-
// is the running script, importing its bare path would await its own
|
|
33
|
-
// in-flight top-level evaluation — a deadlock. NOTE the busted
|
|
34
|
-
// instance STILL sees import.meta.main=true (bun compares paths sans
|
|
35
|
-
// query), so its CLI tail runs too — the cliRunning guard in the
|
|
36
|
-
// sidekick runner is what keeps it inert. Never remove that guard.
|
|
37
|
-
const mod = (await import(`${tsPath}?rvload`)) as Partial<Sidekick>
|
|
38
|
-
const b = mod.bindings
|
|
39
|
-
if (b == null || !b.prompt?.node || !b.seed?.node || !b.prefix?.node) {
|
|
40
|
-
fail(`workflow '${id}': sidekick must export bindings = { prompt|seed|prefix: { node, input } }`)
|
|
41
|
-
}
|
|
42
|
-
for (const [role, bind] of Object.entries(b!) as [string, Binding][]) {
|
|
43
|
-
const n = graph[bind.node]
|
|
44
|
-
if (n == null) fail(`workflow '${id}': bindings.${role} points at node '${bind.node}' which is not in ${f} — graph re-exported with renumbered nodes? update the sidekick`)
|
|
45
|
-
if (!(bind.input in n!.inputs)) fail(`workflow '${id}': node '${bind.node}' (${n!.class_type}) has no input '${bind.input}' (has: ${Object.keys(n!.inputs).join(', ')})`)
|
|
46
|
-
}
|
|
47
|
-
for (const [node, cls] of Object.entries(mod.expects ?? {})) {
|
|
48
|
-
const n = graph[node]
|
|
49
|
-
if (n == null) fail(`workflow '${id}': expects node '${node}' missing from ${f}`)
|
|
50
|
-
if (n.class_type !== cls) fail(`workflow '${id}': node '${node}' is ${n.class_type}, sidekick expects ${cls} — graph changed under the sidekick, re-check bindings`)
|
|
51
|
-
}
|
|
52
|
-
out.set(id, { id, jsonPath, graph, sidekick: mod as Sidekick })
|
|
53
|
-
}
|
|
54
|
-
if (out.size === 0) fail(`${dir}/ has no <id>.json graphs`)
|
|
55
|
-
return out
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Resolve a workflow: explicit id, else the sole loaded workflow. */
|
|
59
|
-
export function workflowFor(workflows: Map<string, Workflow>, ref: { workflow?: string; assetId?: string }): Workflow {
|
|
60
|
-
if (ref.workflow != null) {
|
|
61
|
-
const w = workflows.get(ref.workflow)
|
|
62
|
-
if (w == null) fail(`asset '${ref.assetId ?? '?'}': unknown workflow '${ref.workflow}' (have: ${[...workflows.keys()].join(', ')})`)
|
|
63
|
-
return w!
|
|
64
|
-
}
|
|
65
|
-
if (workflows.size === 1) return [...workflows.values()][0]!
|
|
66
|
-
return fail(`asset '${ref.assetId ?? '?'}' names no workflow and ${workflows.size} are loaded (${[...workflows.keys()].join(', ')}) — set workflow on the kind`)
|
|
67
|
-
}
|