goosetools-worker 0.1.2 → 0.2.2
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/README.md +140 -6
- package/package.json +3 -2
- package/src/fonts.js +102 -0
- package/src/normalize-photos.js +53 -0
- package/src/render.js +251 -22
- package/src/video.js +205 -0
- package/styles/graph-paper/templates/closer.html +47 -0
- package/styles/graph-paper/templates/cover.html +53 -0
- package/styles/graph-paper/templates/detail.html +56 -0
- package/worker/agent-cli.js +275 -0
- package/worker/cli.js +58 -2
- package/worker/index.js +542 -30
- package/worker/service.js +395 -0
- package/worker/skill.js +26 -0
- package/worker/style.js +250 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// Background-service install/uninstall for the Goose Tools worker.
|
|
2
|
+
// `install` makes the worker persistent: token saved to ~/.goosetools/env,
|
|
3
|
+
// a stable copy of this package installed under ~/.goosetools/app, and a
|
|
4
|
+
// login service registered (launchd LaunchAgent on macOS, a logon Scheduled
|
|
5
|
+
// Task on Windows) so the worker runs whenever the computer is on — no
|
|
6
|
+
// terminal window needed.
|
|
7
|
+
//
|
|
8
|
+
// `update` re-runs that install against the newest code, reusing the saved
|
|
9
|
+
// token; `status` reports what's actually running, for debugging.
|
|
10
|
+
|
|
11
|
+
import { execSync } from "node:child_process";
|
|
12
|
+
import {
|
|
13
|
+
chmodSync,
|
|
14
|
+
existsSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
statSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { FFMPEG_INSTALL_CMD } from "../src/video.js";
|
|
24
|
+
|
|
25
|
+
export const GOOSE_DIR = join(homedir(), ".goosetools");
|
|
26
|
+
const ENV_FILE = join(GOOSE_DIR, "env");
|
|
27
|
+
const APP_PREFIX = join(GOOSE_DIR, "app");
|
|
28
|
+
const INSTALLED_CLI = join(APP_PREFIX, "node_modules", "goosetools-worker", "worker", "cli.js");
|
|
29
|
+
|
|
30
|
+
const MAC_LABEL = "com.goosetools.worker";
|
|
31
|
+
const MAC_PLIST = join(homedir(), "Library", "LaunchAgents", `${MAC_LABEL}.plist`);
|
|
32
|
+
const WIN_TASK = "GooseTools Worker";
|
|
33
|
+
const WIN_VBS = join(GOOSE_DIR, "run-worker.vbs");
|
|
34
|
+
|
|
35
|
+
function sh(cmd, opts = {}) {
|
|
36
|
+
return execSync(cmd, { stdio: ["ignore", "pipe", "pipe"], ...opts })
|
|
37
|
+
.toString()
|
|
38
|
+
.trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function quietSh(cmd) {
|
|
42
|
+
try {
|
|
43
|
+
sh(cmd);
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Sync probe — the CLI's install/status paths are synchronous, and this is
|
|
51
|
+
// only ever two cheap `-version` calls.
|
|
52
|
+
function hasFfmpeg() {
|
|
53
|
+
return quietSh("ffmpeg -version") && quietSh("ffprobe -version");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function npmGlobalBin() {
|
|
57
|
+
try {
|
|
58
|
+
const prefix = sh("npm prefix -g", { shell: true });
|
|
59
|
+
return process.platform === "win32" ? prefix : join(prefix, "bin");
|
|
60
|
+
} catch {
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function servicePath() {
|
|
66
|
+
// Everything the daemon needs to find: node itself, the npm-global bin
|
|
67
|
+
// (where `claude` lives), plus the usual suspects.
|
|
68
|
+
const parts = [
|
|
69
|
+
dirname(process.execPath),
|
|
70
|
+
npmGlobalBin(),
|
|
71
|
+
...(process.platform === "win32"
|
|
72
|
+
? [process.env.PATH ?? ""]
|
|
73
|
+
: [
|
|
74
|
+
"/opt/homebrew/bin",
|
|
75
|
+
"/usr/local/bin",
|
|
76
|
+
`${process.env.HOME ?? ""}/.local/bin`,
|
|
77
|
+
"/usr/bin",
|
|
78
|
+
"/bin",
|
|
79
|
+
"/usr/sbin",
|
|
80
|
+
"/sbin",
|
|
81
|
+
]),
|
|
82
|
+
].filter(Boolean);
|
|
83
|
+
return [...new Set(parts)].join(process.platform === "win32" ? ";" : ":");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function writeEnvFile({ url, token }) {
|
|
87
|
+
mkdirSync(GOOSE_DIR, { recursive: true });
|
|
88
|
+
writeFileSync(ENV_FILE, `GOOSETOOLS_URL=${url}\nWORKER_TOKEN=${token}\n`);
|
|
89
|
+
chmodSync(ENV_FILE, 0o600);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function installStableCopy(root) {
|
|
93
|
+
console.log("Setting up the background copy (this can take a minute)…");
|
|
94
|
+
mkdirSync(APP_PREFIX, { recursive: true });
|
|
95
|
+
// From a git checkout (development), install the checkout itself;
|
|
96
|
+
// normal npx users get the published package.
|
|
97
|
+
const source = existsSync(join(root, ".git")) ? `"${root}"` : "goosetools-worker@latest";
|
|
98
|
+
execSync(`npm install --no-fund --no-audit --loglevel=error --prefix "${APP_PREFIX}" ${source}`, {
|
|
99
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
100
|
+
shell: true,
|
|
101
|
+
});
|
|
102
|
+
if (!existsSync(INSTALLED_CLI)) {
|
|
103
|
+
throw new Error("background copy did not install where expected");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── macOS (launchd) ──────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
function macPlist() {
|
|
110
|
+
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
111
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
112
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
113
|
+
<plist version="1.0">
|
|
114
|
+
<dict>
|
|
115
|
+
<key>Label</key>
|
|
116
|
+
<string>${MAC_LABEL}</string>
|
|
117
|
+
<key>ProgramArguments</key>
|
|
118
|
+
<array>
|
|
119
|
+
<string>${esc(process.execPath)}</string>
|
|
120
|
+
<string>${esc(INSTALLED_CLI)}</string>
|
|
121
|
+
<string>run</string>
|
|
122
|
+
</array>
|
|
123
|
+
<key>RunAtLoad</key>
|
|
124
|
+
<true/>
|
|
125
|
+
<key>KeepAlive</key>
|
|
126
|
+
<true/>
|
|
127
|
+
<key>ThrottleInterval</key>
|
|
128
|
+
<integer>30</integer>
|
|
129
|
+
<key>StandardOutPath</key>
|
|
130
|
+
<string>${esc(join(GOOSE_DIR, "worker.log"))}</string>
|
|
131
|
+
<key>StandardErrorPath</key>
|
|
132
|
+
<string>${esc(join(GOOSE_DIR, "worker.err.log"))}</string>
|
|
133
|
+
<key>EnvironmentVariables</key>
|
|
134
|
+
<dict>
|
|
135
|
+
<key>PATH</key>
|
|
136
|
+
<string>${esc(servicePath())}</string>
|
|
137
|
+
</dict>
|
|
138
|
+
</dict>
|
|
139
|
+
</plist>
|
|
140
|
+
`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function macInstall() {
|
|
144
|
+
mkdirSync(dirname(MAC_PLIST), { recursive: true });
|
|
145
|
+
writeFileSync(MAC_PLIST, macPlist());
|
|
146
|
+
const uid = process.getuid();
|
|
147
|
+
// Re-registering: tear down any previous copy first, then bootstrap.
|
|
148
|
+
quietSh(`launchctl bootout gui/${uid}/${MAC_LABEL}`);
|
|
149
|
+
if (!quietSh(`launchctl bootstrap gui/${uid} "${MAC_PLIST}"`)) {
|
|
150
|
+
// Older macOS fallback.
|
|
151
|
+
quietSh(`launchctl unload "${MAC_PLIST}"`);
|
|
152
|
+
execSync(`launchctl load -w "${MAC_PLIST}"`, { stdio: "ignore" });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function macUninstall() {
|
|
157
|
+
quietSh(`launchctl bootout gui/${process.getuid()}/${MAC_LABEL}`);
|
|
158
|
+
quietSh(`launchctl unload "${MAC_PLIST}"`);
|
|
159
|
+
rmSync(MAC_PLIST, { force: true });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Windows (Scheduled Task at logon, hidden window) ─────────────────────────
|
|
163
|
+
|
|
164
|
+
function winInstall() {
|
|
165
|
+
// wscript launches the worker with no console window.
|
|
166
|
+
const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${INSTALLED_CLI}"" run", 0, False\r\n`;
|
|
167
|
+
writeFileSync(WIN_VBS, vbs);
|
|
168
|
+
quietSh(`schtasks /End /TN "${WIN_TASK}"`);
|
|
169
|
+
quietSh(`schtasks /Delete /F /TN "${WIN_TASK}"`);
|
|
170
|
+
execSync(`schtasks /Create /F /SC ONLOGON /TN "${WIN_TASK}" /TR "wscript.exe \\"${WIN_VBS}\\""`, {
|
|
171
|
+
stdio: "ignore",
|
|
172
|
+
shell: true,
|
|
173
|
+
});
|
|
174
|
+
execSync(`schtasks /Run /TN "${WIN_TASK}"`, { stdio: "ignore", shell: true });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function winUninstall() {
|
|
178
|
+
quietSh(`schtasks /End /TN "${WIN_TASK}"`);
|
|
179
|
+
quietSh(`schtasks /Delete /F /TN "${WIN_TASK}"`);
|
|
180
|
+
rmSync(WIN_VBS, { force: true });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Public commands ──────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
export function install({ url, token, root }) {
|
|
186
|
+
if (!token) {
|
|
187
|
+
console.error("Missing worker token. Run: goosetools-worker install --url " + url + " --token gt_…");
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
writeEnvFile({ url, token });
|
|
191
|
+
installStableCopy(root);
|
|
192
|
+
if (process.platform === "darwin") macInstall();
|
|
193
|
+
else if (process.platform === "win32") winInstall();
|
|
194
|
+
else {
|
|
195
|
+
console.error(
|
|
196
|
+
"Automatic background setup isn't available on this OS yet.\n" +
|
|
197
|
+
"Run the worker directly instead: goosetools-worker run",
|
|
198
|
+
);
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
console.log(
|
|
202
|
+
"\n✓ All set! Your computer now connects to Goose Tools by itself —\n" +
|
|
203
|
+
" in the background, whenever it's on, even after a restart.\n" +
|
|
204
|
+
" You can close this window.\n\n" +
|
|
205
|
+
` Logs: ${join(GOOSE_DIR, "worker.log")}\n` +
|
|
206
|
+
" Turn off: npx --yes goosetools-worker uninstall\n",
|
|
207
|
+
);
|
|
208
|
+
// Optional, and only worth a mention once the required setup has succeeded:
|
|
209
|
+
// photo carousels are already fully working at this point.
|
|
210
|
+
if (!hasFfmpeg()) {
|
|
211
|
+
console.log(
|
|
212
|
+
" Optional — want video slides?\n" +
|
|
213
|
+
" Carousels made from photos work right now. To also put video clips\n" +
|
|
214
|
+
" in a carousel, install ffmpeg (one time, a couple of minutes):\n\n" +
|
|
215
|
+
` ${FFMPEG_INSTALL_CMD}\n`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Removes THIS worker, not the whole ~/.goosetools directory. It used to take
|
|
221
|
+
// the directory, which was harmless while everything in there was either this
|
|
222
|
+
// worker's or re-derivable from the server — and stopped being harmless when
|
|
223
|
+
// the Overlay Factory started keeping its puzzle library, learned looks and
|
|
224
|
+
// used-card record in ~/.goosetools/overlay. Nothing else has a copy of those,
|
|
225
|
+
// and `npx goosetools uninstall` runs this. The shared token file stays: the
|
|
226
|
+
// other workers are still using it.
|
|
227
|
+
export function uninstall() {
|
|
228
|
+
if (process.platform === "darwin") macUninstall();
|
|
229
|
+
else if (process.platform === "win32") winUninstall();
|
|
230
|
+
rmSync(APP_PREFIX, { recursive: true, force: true });
|
|
231
|
+
rmSync(join(GOOSE_DIR, "worker.log"), { force: true });
|
|
232
|
+
rmSync(join(GOOSE_DIR, "worker.err.log"), { force: true });
|
|
233
|
+
console.log("✓ Carousel worker removed. This computer no longer makes carousels.");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── update ───────────────────────────────────────────────────────────────────
|
|
237
|
+
// The daemon runs the COPY under ~/.goosetools/app, not wherever you ran this
|
|
238
|
+
// from — so new code only reaches it by reinstalling that copy. Re-registering
|
|
239
|
+
// the service also restarts it, which is what actually loads the new code
|
|
240
|
+
// (Node reads every module once, at startup).
|
|
241
|
+
export function update({ root }) {
|
|
242
|
+
const saved = readEnvFile();
|
|
243
|
+
if (!saved.WORKER_TOKEN) {
|
|
244
|
+
console.error(
|
|
245
|
+
"No saved worker token — this computer hasn't been connected yet.\n" +
|
|
246
|
+
"Get a token at https://goosetools.com/dashboard/carousel and run:\n" +
|
|
247
|
+
" npx --yes goosetools-worker@latest install --token gt_…",
|
|
248
|
+
);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
const before = installedVersion();
|
|
252
|
+
installStableCopy(root);
|
|
253
|
+
if (process.platform === "darwin") macInstall();
|
|
254
|
+
else if (process.platform === "win32") winInstall();
|
|
255
|
+
else {
|
|
256
|
+
console.error("Automatic background setup isn't available on this OS yet.");
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
const after = installedVersion();
|
|
260
|
+
console.log(
|
|
261
|
+
after && before && after !== before
|
|
262
|
+
? `\n✓ Updated ${before} → ${after} and restarted. Nothing else to do.\n`
|
|
263
|
+
: `\n✓ Worker reinstalled (version ${after ?? "unknown"}) and restarted.\n`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── status ───────────────────────────────────────────────────────────────────
|
|
268
|
+
function readEnvFile() {
|
|
269
|
+
if (!existsSync(ENV_FILE)) return {};
|
|
270
|
+
return Object.fromEntries(
|
|
271
|
+
readFileSync(ENV_FILE, "utf8")
|
|
272
|
+
.split("\n")
|
|
273
|
+
.filter((l) => l.includes("=") && !l.trim().startsWith("#"))
|
|
274
|
+
.map((l) => [l.slice(0, l.indexOf("=")).trim(), l.slice(l.indexOf("=") + 1).trim()]),
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function installedVersion() {
|
|
279
|
+
try {
|
|
280
|
+
const pkg = join(APP_PREFIX, "node_modules", "goosetools-worker", "package.json");
|
|
281
|
+
return JSON.parse(readFileSync(pkg, "utf8")).version ?? null;
|
|
282
|
+
} catch {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function latestVersion() {
|
|
288
|
+
try {
|
|
289
|
+
return sh("npm view goosetools-worker version", { shell: true, timeout: 15000 });
|
|
290
|
+
} catch {
|
|
291
|
+
return null; // offline, or npm unreachable — not an error worth failing on
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// -1 / 0 / 1, comparing x.y.z numerically. Anything unparseable sorts equal so
|
|
296
|
+
// an odd version string never nags the user to "update" in a loop.
|
|
297
|
+
function compareVersions(a, b) {
|
|
298
|
+
const parts = (v) => String(v).split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
299
|
+
const [pa, pb] = [parts(a), parts(b)];
|
|
300
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
301
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) < (pb[i] ?? 0) ? -1 : 1;
|
|
302
|
+
}
|
|
303
|
+
return 0;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// launchctl prints "<pid>\t<last exit>\t<label>"; a "-" pid means registered
|
|
307
|
+
// but not currently running.
|
|
308
|
+
function servicePid() {
|
|
309
|
+
try {
|
|
310
|
+
if (process.platform === "darwin") {
|
|
311
|
+
const line = sh(`launchctl list | grep ${MAC_LABEL}`, { shell: true });
|
|
312
|
+
const pid = line.split(/\s+/)[0];
|
|
313
|
+
return pid === "-" ? null : pid;
|
|
314
|
+
}
|
|
315
|
+
const out = sh(`schtasks /Query /TN "${WIN_TASK}" /FO LIST`, { shell: true });
|
|
316
|
+
return /Running/i.test(out) ? "running" : null;
|
|
317
|
+
} catch {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function tail(file, n) {
|
|
323
|
+
try {
|
|
324
|
+
return readFileSync(file, "utf8").trimEnd().split("\n").slice(-n).join("\n");
|
|
325
|
+
} catch {
|
|
326
|
+
return "";
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// How long ago a log was last written. Without this, days-old errors read as
|
|
331
|
+
// a live outage — the single most misleading thing in a log tail.
|
|
332
|
+
function lastWritten(file) {
|
|
333
|
+
try {
|
|
334
|
+
const ms = Date.now() - statSync(file).mtimeMs;
|
|
335
|
+
const mins = Math.round(ms / 60000);
|
|
336
|
+
if (mins < 1) return "just now";
|
|
337
|
+
if (mins < 60) return `${mins} min ago`;
|
|
338
|
+
const hours = Math.round(mins / 60);
|
|
339
|
+
if (hours < 24) return `${hours} hr ago`;
|
|
340
|
+
return `${Math.round(hours / 24)} days ago`;
|
|
341
|
+
} catch {
|
|
342
|
+
return "unknown";
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function status() {
|
|
347
|
+
const ok = (b) => (b ? "✓" : "✗");
|
|
348
|
+
const env = readEnvFile();
|
|
349
|
+
const installed = installedVersion();
|
|
350
|
+
const latest = latestVersion();
|
|
351
|
+
const pid = servicePid();
|
|
352
|
+
const url = env.GOOSETOOLS_URL ?? "https://goosetools.com";
|
|
353
|
+
|
|
354
|
+
console.log("\nGoose Tools worker — status\n");
|
|
355
|
+
console.log(` ${ok(installed)} Installed ${installed ?? "not installed"}`);
|
|
356
|
+
// Ahead of npm means a local dev install — say so rather than telling the
|
|
357
|
+
// user to "update" to something older than what they're running.
|
|
358
|
+
const cmp = installed && latest ? compareVersions(installed, latest) : 0;
|
|
359
|
+
const stale = cmp < 0;
|
|
360
|
+
if (latest) {
|
|
361
|
+
const note = stale ? " ← update available" : cmp > 0 ? " (you're ahead — local dev build)" : "";
|
|
362
|
+
console.log(` ${ok(!stale)} Latest on npm ${latest}${note}`);
|
|
363
|
+
} else {
|
|
364
|
+
console.log(" · Latest on npm couldn't check (offline?)");
|
|
365
|
+
}
|
|
366
|
+
console.log(` ${ok(pid)} Running ${pid ? `yes (pid ${pid})` : "no"}`);
|
|
367
|
+
console.log(` ${ok(env.WORKER_TOKEN)} Token saved ${env.WORKER_TOKEN ? "yes" : "no — run install --token gt_…"}`);
|
|
368
|
+
// Not an "✗": a worker without ffmpeg is healthy, it just can't do video.
|
|
369
|
+
console.log(
|
|
370
|
+
` ${hasFfmpeg() ? "✓" : "·"} Video slides ${
|
|
371
|
+
hasFfmpeg() ? "yes (ffmpeg found)" : `photos only — for video: ${FFMPEG_INSTALL_CMD}`
|
|
372
|
+
}`,
|
|
373
|
+
);
|
|
374
|
+
console.log(` · Server ${url}`);
|
|
375
|
+
console.log(` · Logs ${join(GOOSE_DIR, "worker.log")}`);
|
|
376
|
+
|
|
377
|
+
const errFile = join(GOOSE_DIR, "worker.err.log");
|
|
378
|
+
const errors = tail(errFile, 15);
|
|
379
|
+
if (errors) {
|
|
380
|
+
console.log(`\nErrors (last written ${lastWritten(errFile)}):\n${errors.replace(/^/gm, " ")}`);
|
|
381
|
+
}
|
|
382
|
+
const logFile = join(GOOSE_DIR, "worker.log");
|
|
383
|
+
const log = tail(logFile, 15);
|
|
384
|
+
if (log) {
|
|
385
|
+
console.log(`\nActivity (last written ${lastWritten(logFile)}):\n${log.replace(/^/gm, " ")}`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (stale) {
|
|
389
|
+
console.log("\nTo update: npx --yes goosetools-worker@latest update\n");
|
|
390
|
+
} else if (!pid && installed) {
|
|
391
|
+
console.log("\nNot running. Restart it with: npx --yes goosetools-worker@latest update\n");
|
|
392
|
+
} else {
|
|
393
|
+
console.log("");
|
|
394
|
+
}
|
|
395
|
+
}
|
package/worker/skill.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Splices sections out of skills/reference-analysis.md — the single source
|
|
2
|
+
// of truth for the ref-analysis methodology shared by the style-gen,
|
|
3
|
+
// drafting, and self-check prompts.
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const SKILL_PATH = join(
|
|
10
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
11
|
+
"..",
|
|
12
|
+
"skills",
|
|
13
|
+
"reference-analysis.md",
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
let cached;
|
|
17
|
+
|
|
18
|
+
export function skillSection(name) {
|
|
19
|
+
cached ??= readFileSync(SKILL_PATH, "utf8");
|
|
20
|
+
const re = new RegExp(
|
|
21
|
+
`<!-- SECTION: ${name} -->\\n([\\s\\S]*?)(?=<!-- SECTION: |$)`,
|
|
22
|
+
);
|
|
23
|
+
const m = cached.match(re);
|
|
24
|
+
if (!m) throw new Error(`skill section "${name}" not found in ${SKILL_PATH}`);
|
|
25
|
+
return m[1].trim();
|
|
26
|
+
}
|
package/worker/style.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Style generation — fulfills kind='style' jobs: the user's reference images
|
|
2
|
+
// go in, a new styles/<slug>/ template set comes out, distilled by the local
|
|
3
|
+
// Claude Code login. The style is PURELY the references — palette, fonts,
|
|
4
|
+
// layout, composition, mood, motifs all come from them; the user's brand
|
|
5
|
+
// only enters later, at render time, through placeholders like {{handle}}.
|
|
6
|
+
|
|
7
|
+
import { runAgent } from "./agent-cli.js";
|
|
8
|
+
import {
|
|
9
|
+
cpSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { extname, join } from "node:path";
|
|
17
|
+
import yaml from "js-yaml";
|
|
18
|
+
import { upload } from "@vercel/blob/client";
|
|
19
|
+
import { skillSection } from "./skill.js";
|
|
20
|
+
|
|
21
|
+
const RESERVED_SLUGS = new Set(["graph-paper", "polka-dot"]);
|
|
22
|
+
const REQUIRED_TEMPLATES = ["cover", "text", "photo", "cta"];
|
|
23
|
+
const MAX_FILE_BYTES = 20 * 1024;
|
|
24
|
+
|
|
25
|
+
function stripFences(s) {
|
|
26
|
+
return s.replace(/^```(?:yaml)?\n?/m, "").replace(/\n?```\s*$/m, "").trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function extFromUrl(url, contentType) {
|
|
30
|
+
const fromPath = extname(new URL(url).pathname).toLowerCase();
|
|
31
|
+
if ([".png", ".jpg", ".jpeg", ".webp"].includes(fromPath)) return fromPath;
|
|
32
|
+
return { "image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp" }[contentType] ?? ".png";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function styleGenPrompt({ slug, refPaths, exemplar }) {
|
|
36
|
+
return `You are designing a reusable Instagram-carousel visual style for a slide-rendering engine.
|
|
37
|
+
|
|
38
|
+
## Reference images — THE SOURCE OF TRUTH
|
|
39
|
+
Read each of these image files and study them closely (they are in order — the first is the set's cover/pinned image):
|
|
40
|
+
${refPaths.map((p) => `- ${p}`).join("\n")}
|
|
41
|
+
|
|
42
|
+
### Step 1 — understand what the references are DOING (before any code)
|
|
43
|
+
${skillSection("analyze")}
|
|
44
|
+
|
|
45
|
+
The style you build must make it possible to create slides that DO the same thing, not just slides painted the same colors. If the references present color palettes, the style needs a template that renders labeled swatches. If they compare two things, the style needs a two-panel template. Purpose first, paint second.
|
|
46
|
+
|
|
47
|
+
### Step 2 — reproduce their look faithfully
|
|
48
|
+
Sample the ACTUAL colors from the reference images (background, text, accents) and use those as the style's palette via colors: overrides in style.yml; match their typography treatment (serif vs sans, italic, weight, case) with the closest Google Fonts via fonts: overrides; copy their layout, composition, spacing, and decorative motifs. A finished slide should look like it belongs in the same account as these references. The style is built ONLY from the references — no outside branding.
|
|
49
|
+
|
|
50
|
+
## Engine contract (hard rules)
|
|
51
|
+
- Slides are 1080x1350 (also rendered at 1:1 and 9:16 — use flexible layouts, no hardcoded heights). Each template is the inner HTML of a .slide div that already has {{style.padding}}px padding applied and position: relative.
|
|
52
|
+
- Placeholders available in templates (substituted before render): {{colors.primary}} {{colors.primaryLight}} {{colors.primaryDark}} {{colors.accent}} {{colors.dark}} {{colors.lightBg}} {{colors.lightBorder}} {{colors.text}} {{colors.textOnLight}} {{fonts.heading}} {{fonts.headingWeight}} {{fonts.body}} {{fonts.mono}} {{style.radius}} {{text}} {{name}} {{subtitle}} {{photoSrc}} {{screenshotSrc}} {{handle}} {{gridBg}} {{ui}} {{swatches}} {{variant}}
|
|
53
|
+
- {{swatches}} renders a slide's \`swatches: ["#hex", ...]\` list as <span class="swatch" style="background:#hex"><span class="swatch-label">#hex</span></span> elements — the template lays them out and styles .swatch / .swatch-label (size, shape, gaps, label typography). Use this to build palette-presentation templates when the references call for one.
|
|
54
|
+
- {{variant}} renders as "flip" when a slide sets \`variant: flip\`, else as "". Every template whose layout has a movable overlay (a swatch stack, a badge column, a caption block anchored to one side) SHOULD support flipping: put {{variant}} in the root class list (e.g. <div class="slide xx-palette {{variant}}">) and define a .flip rule that mirrors the overlay to the opposite side (right:0 becomes left:0, text-align flips, etc.) so a slide can move the overlay off a photo's subject.
|
|
55
|
+
- Beyond the required four templates you may add up to 4 more, NAMED FOR WHAT THEY DO (e.g. templates/palette.html, templates/compare.html, templates/list.html) whenever Step 1 found a content format the required templates can't express.
|
|
56
|
+
- {{text}} arrives pre-rendered: *word* becomes <em> (style it as the color pop), _word_ becomes <i> (italic). Every template that shows {{text}} MUST style em and i.
|
|
57
|
+
- {{gridBg}} is CSS declarations for the style's background (the engine builds it from style.yml) — use it as: style="position:absolute; inset:0; {{gridBg}}" on a background layer in light templates, or set your own flat background color.
|
|
58
|
+
- Each template file is self-contained HTML with a <style> block scoped by unique class names. NO <script>. NO external URLs of any kind — fonts are injected by the engine, do not add font links or imports. NO gradients, ever — flat color or subtle pattern only.
|
|
59
|
+
- Required template files: cover.html (bold opening hook), text.html (statement slide), photo.html (full-bleed {{photoSrc}} image with {{text}} caption), cta.html (closer with {{handle}}). Optionally also: light.html, resource.html (uses {{name}}, {{text}}, {{screenshotSrc}} as a site screenshot "receipt"), blue.html (flat brand-color pop slide) — include them if the references support those moods.
|
|
60
|
+
- style.yml keys: name (the slug), optional background: dots (pale offset polka dots) — omit for graph-paper grid, optional fonts:{heading, headingWeight, mono} and colors:{...} overrides, notes (short description of the look).
|
|
61
|
+
|
|
62
|
+
## Exemplar — an existing style that follows the contract
|
|
63
|
+
--- style.yml ---
|
|
64
|
+
${exemplar.styleYml}
|
|
65
|
+
--- templates/cover.html ---
|
|
66
|
+
${exemplar.cover}
|
|
67
|
+
--- templates/text.html ---
|
|
68
|
+
${exemplar.text}
|
|
69
|
+
--- templates/cta.html ---
|
|
70
|
+
${exemplar.cta}
|
|
71
|
+
|
|
72
|
+
## Output format
|
|
73
|
+
Return ONLY a single YAML document (no markdown fences, no prose, no commentary before or after) mapping relative filenames to complete file contents. THE VERY FIRST LINE of your reply must be exactly \`style.yml: |\` — do not describe the references or your plan first. Example shape:
|
|
74
|
+
|
|
75
|
+
style.yml: |
|
|
76
|
+
name: ${slug}
|
|
77
|
+
...
|
|
78
|
+
templates/cover.html: |
|
|
79
|
+
...
|
|
80
|
+
templates/text.html: |
|
|
81
|
+
...
|
|
82
|
+
`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const FILENAME_RE = /^(style\.yml|templates\/[a-z0-9-]+\.html)$/;
|
|
86
|
+
|
|
87
|
+
// Claude sometimes narrates before the YAML ("The refs are ..."). Slice the
|
|
88
|
+
// output down to the first real filename key, then drop any stray
|
|
89
|
+
// commentary keys instead of failing the whole attempt on them.
|
|
90
|
+
function extractFiles(stdout) {
|
|
91
|
+
const cleaned = stripFences(stdout);
|
|
92
|
+
const lines = cleaned.split("\n");
|
|
93
|
+
const start = lines.findIndex((l) =>
|
|
94
|
+
/^(style\.yml|templates\/[a-z0-9-]+\.html):/.test(l),
|
|
95
|
+
);
|
|
96
|
+
if (start === -1) throw new Error("no file mapping found in the output");
|
|
97
|
+
const parsed = yaml.load(lines.slice(start).join("\n"));
|
|
98
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
99
|
+
throw new Error("output is not a filename→contents mapping");
|
|
100
|
+
}
|
|
101
|
+
const files = {};
|
|
102
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
103
|
+
if (FILENAME_RE.test(k)) files[k] = v;
|
|
104
|
+
else console.error(` (dropping stray key "${String(k).slice(0, 60)}")`);
|
|
105
|
+
}
|
|
106
|
+
return files;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function validateStyleFiles(files) {
|
|
110
|
+
const names = Object.keys(files);
|
|
111
|
+
for (const f of names) {
|
|
112
|
+
if (typeof files[f] !== "string") throw new Error(`"${f}" is not a string`);
|
|
113
|
+
if (files[f].length > MAX_FILE_BYTES) throw new Error(`"${f}" is too large`);
|
|
114
|
+
}
|
|
115
|
+
if (!files["style.yml"]) throw new Error("style.yml missing");
|
|
116
|
+
const styleSpec = yaml.load(files["style.yml"]);
|
|
117
|
+
if (!styleSpec?.name) throw new Error("style.yml has no name");
|
|
118
|
+
for (const t of REQUIRED_TEMPLATES) {
|
|
119
|
+
if (!files[`templates/${t}.html`]) throw new Error(`templates/${t}.html missing`);
|
|
120
|
+
}
|
|
121
|
+
for (const [f, content] of Object.entries(files)) {
|
|
122
|
+
if (!f.startsWith("templates/")) continue;
|
|
123
|
+
if (!/\{\{(text|name)\}\}/.test(content)) {
|
|
124
|
+
throw new Error(`${f} uses neither {{text}} nor {{name}}`);
|
|
125
|
+
}
|
|
126
|
+
if (/<script/i.test(content)) throw new Error(`${f} contains a <script> tag`);
|
|
127
|
+
if (/https?:\/\//i.test(content)) throw new Error(`${f} references an external URL`);
|
|
128
|
+
}
|
|
129
|
+
return styleSpec;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function runStyleJob(job, { api, execFileAsync, ROOT, TOKEN, BASE_URL, writeBrandFile }) {
|
|
133
|
+
const { slug, name, refUrls } = job.newStyle ?? {};
|
|
134
|
+
if (!slug || !Array.isArray(refUrls) || refUrls.length === 0) {
|
|
135
|
+
throw new Error("Style job is missing its name or reference images");
|
|
136
|
+
}
|
|
137
|
+
console.log(`▶ style ${job.id}: "${name}" (${refUrls.length} refs) [${slug}]`);
|
|
138
|
+
|
|
139
|
+
if (RESERVED_SLUGS.has(slug)) throw new Error(`"${slug}" is a built-in style`);
|
|
140
|
+
const styleDir = join(ROOT, "styles", slug);
|
|
141
|
+
const marker = join(styleDir, ".generated");
|
|
142
|
+
if (existsSync(styleDir) && !existsSync(marker)) {
|
|
143
|
+
throw new Error(`A hand-made style already exists at styles/${slug} on this computer`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const workDir = join(ROOT, "carousels", `style-${job.id.slice(0, 8)}`);
|
|
147
|
+
const refsDir = join(workDir, "refs");
|
|
148
|
+
mkdirSync(refsDir, { recursive: true });
|
|
149
|
+
|
|
150
|
+
const refPaths = [];
|
|
151
|
+
for (const [i, url] of refUrls.slice(0, 20).entries()) {
|
|
152
|
+
const res = await fetch(url);
|
|
153
|
+
if (!res.ok) throw new Error(`Reference download failed: ${url}`);
|
|
154
|
+
const ext = extFromUrl(url, res.headers.get("content-type"));
|
|
155
|
+
const p = join(refsDir, `ref-${String(i + 1).padStart(2, "0")}${ext}`);
|
|
156
|
+
writeFileSync(p, Buffer.from(await res.arrayBuffer()));
|
|
157
|
+
refPaths.push(p);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const exemplarDir = join(ROOT, "styles", "graph-paper");
|
|
161
|
+
const exemplar = {
|
|
162
|
+
styleYml: readFileSync(join(exemplarDir, "style.yml"), "utf8"),
|
|
163
|
+
cover: readFileSync(join(exemplarDir, "templates", "cover.html"), "utf8"),
|
|
164
|
+
text: readFileSync(join(exemplarDir, "templates", "text.html"), "utf8"),
|
|
165
|
+
cta: readFileSync(join(exemplarDir, "templates", "cta.html"), "utf8"),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
let prompt = styleGenPrompt({ slug, refPaths, exemplar });
|
|
169
|
+
let files;
|
|
170
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
171
|
+
// mode "read": the agent must look at the reference images under ROOT.
|
|
172
|
+
const { text: stdout } = await runAgent({
|
|
173
|
+
agent: job.agentCli,
|
|
174
|
+
prompt,
|
|
175
|
+
mode: "read",
|
|
176
|
+
cwd: ROOT,
|
|
177
|
+
timeoutMs: 15 * 60 * 1000,
|
|
178
|
+
});
|
|
179
|
+
try {
|
|
180
|
+
files = extractFiles(stdout);
|
|
181
|
+
validateStyleFiles(files);
|
|
182
|
+
// style.yml name must be the slug render.js will look up
|
|
183
|
+
const spec = yaml.load(files["style.yml"]);
|
|
184
|
+
if (spec.name !== slug) {
|
|
185
|
+
spec.name = slug;
|
|
186
|
+
files["style.yml"] = yaml.dump(spec);
|
|
187
|
+
}
|
|
188
|
+
break;
|
|
189
|
+
} catch (e) {
|
|
190
|
+
console.error(`Style attempt ${attempt} invalid: ${e.message}`);
|
|
191
|
+
if (attempt === 2) throw new Error(`Style generation failed: ${e.message}`);
|
|
192
|
+
prompt += `\n\nYour previous attempt was rejected: ${e.message}. Return the corrected full YAML document.`;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
rmSync(styleDir, { recursive: true, force: true });
|
|
197
|
+
mkdirSync(join(styleDir, "templates"), { recursive: true });
|
|
198
|
+
for (const [f, content] of Object.entries(files)) {
|
|
199
|
+
writeFileSync(join(styleDir, f), content);
|
|
200
|
+
}
|
|
201
|
+
writeFileSync(marker, JSON.stringify({ jobId: job.id, generatedAt: new Date().toISOString() }));
|
|
202
|
+
// Keep the refs with the style — carousel jobs re-read them to draft and
|
|
203
|
+
// self-check against the originals. The manifest records which URLs (in
|
|
204
|
+
// order) these files came from so a stale cache can be detected.
|
|
205
|
+
cpSync(refsDir, join(styleDir, "refs"), { recursive: true });
|
|
206
|
+
writeFileSync(
|
|
207
|
+
join(styleDir, "refs", "manifest.json"),
|
|
208
|
+
JSON.stringify({ urls: refUrls.slice(0, 20) }, null, 2),
|
|
209
|
+
);
|
|
210
|
+
console.log(` styles/${slug}/ written (${Object.keys(files).length} files + ${refPaths.length} refs)`);
|
|
211
|
+
|
|
212
|
+
// Preview: render the new style's cover + text slide with the user's brand.
|
|
213
|
+
const previewDir = join(workDir, "preview");
|
|
214
|
+
mkdirSync(previewDir, { recursive: true });
|
|
215
|
+
writeBrandFile(previewDir, job.brand);
|
|
216
|
+
writeFileSync(
|
|
217
|
+
join(previewDir, "slides.yml"),
|
|
218
|
+
yaml.dump({
|
|
219
|
+
style: slug,
|
|
220
|
+
slides: [
|
|
221
|
+
{
|
|
222
|
+
template: "cover",
|
|
223
|
+
text: "a headline\n*looks like this*",
|
|
224
|
+
subtitle: "sample slide — your words go here",
|
|
225
|
+
},
|
|
226
|
+
{ template: "text", text: "body copy looks\n*like this*" },
|
|
227
|
+
],
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
const outDir = join(previewDir, "output");
|
|
231
|
+
await execFileAsync(
|
|
232
|
+
"node",
|
|
233
|
+
[join(ROOT, "src", "render.js"), previewDir, "--aspect", "4:5", "--out", outDir],
|
|
234
|
+
{ maxBuffer: 10 * 1024 * 1024, timeout: 5 * 60 * 1000 },
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const blob = await upload(
|
|
238
|
+
`carousel/style-previews/${job.id}/preview.png`,
|
|
239
|
+
readFileSync(join(outDir, "slide-01.png")),
|
|
240
|
+
{
|
|
241
|
+
access: "public",
|
|
242
|
+
handleUploadUrl: `${BASE_URL}/api/carousel/upload`,
|
|
243
|
+
clientPayload: JSON.stringify({ workerToken: TOKEN }),
|
|
244
|
+
contentType: "image/png",
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
await api("complete", { jobId: job.id, ok: true, previewUrl: blob.url });
|
|
249
|
+
console.log(`✓ style ${job.id}: "${name}" ready — preview uploaded`);
|
|
250
|
+
}
|