goosetools-worker 0.2.5 → 0.2.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "goosetools-worker",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "The Goose Tools carousel worker — your computer drafts and renders carousel slides for goosetools.com using your own Claude account.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/render.js CHANGED
@@ -5,6 +5,7 @@ import yaml from "js-yaml";
5
5
  import { chromium } from "playwright";
6
6
  import { FONT_SPECS, customFontFaces, sanitizeFonts } from "./fonts.js";
7
7
  import { assertFfmpeg, composite, posterFrame } from "./video.js";
8
+ import { styleDirFor } from "./state.js";
8
9
 
9
10
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
10
11
 
@@ -39,7 +40,8 @@ const spec = yaml.load(readFileSync(join(dir, "slides.yml"), "utf8"));
39
40
 
40
41
  // resolve active style: slides.yml `style:` > brand.yml defaultStyle
41
42
  const styleName = spec.style ?? brandBase.defaultStyle;
42
- const styleDir = join(ROOT, "styles", styleName);
43
+ // Built-ins ship in the package; everything else is per-machine (state.js).
44
+ const styleDir = styleDirFor(styleName);
43
45
  if (!existsSync(styleDir)) {
44
46
  console.error(`Unknown style "${styleName}" — expected ${styleDir}`);
45
47
  process.exit(1);
package/src/state.js ADDED
@@ -0,0 +1,55 @@
1
+ // Where the Carousel Maker's per-machine work lives.
2
+ //
3
+ // Generated styles and rendered decks used to be written inside the package
4
+ // folder (styles/<slug>, carousels/web-*). Installed from npm, that folder is
5
+ // ~/.goosetools/app/node_modules/goosetools-worker, which `update` deletes and
6
+ // reinstalls — so every update silently deleted every style the user had
7
+ // generated (it happened on 2026-09-26). They now live in ~/.goosetools/carousel,
8
+ // which nothing reinstalls. The built-in styles and the demo deck still ship
9
+ // in the package, because they ARE the package.
10
+
11
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
17
+
18
+ /** Styles that ship in the package (see package.json `files`). */
19
+ export const SHIPPED_STYLES = ["graph-paper", "polka-dot"];
20
+
21
+ export const STATE_DIR = process.env.GOOSETOOLS_CAROUSEL_DIR ?? join(homedir(), ".goosetools", "carousel");
22
+ export const STYLES_DIR = join(STATE_DIR, "styles");
23
+ /** Per-job work folders: the deck, its photos, its rendered slides. */
24
+ export const WORK_DIR = join(STATE_DIR, "carousels");
25
+
26
+ /** Where a style's folder is: in the package if it ships, otherwise in state. */
27
+ export function styleDirFor(slug) {
28
+ return SHIPPED_STYLES.includes(slug) ? join(ROOT, "styles", slug) : join(STYLES_DIR, slug);
29
+ }
30
+
31
+ /**
32
+ * Move any style that isn't a built-in out of the package folder.
33
+ *
34
+ * Only a checkout still has them there (an npm install lost them when it was
35
+ * replaced), but a checkout is exactly where a style made during development
36
+ * sits — so this runs on every start and is a no-op once done. A style
37
+ * already in state wins; the package copy is then just removed.
38
+ */
39
+ export function migrateStylesOutOfPackage() {
40
+ const pkgStyles = join(ROOT, "styles");
41
+ if (!existsSync(pkgStyles)) return 0;
42
+ mkdirSync(STYLES_DIR, { recursive: true });
43
+ let moved = 0;
44
+ for (const d of readdirSync(pkgStyles, { withFileTypes: true })) {
45
+ if (!d.isDirectory() || SHIPPED_STYLES.includes(d.name)) continue;
46
+ const from = join(pkgStyles, d.name);
47
+ const to = join(STYLES_DIR, d.name);
48
+ if (!existsSync(to)) {
49
+ cpSync(from, to, { recursive: true });
50
+ moved++;
51
+ }
52
+ rmSync(from, { recursive: true, force: true });
53
+ }
54
+ return moved;
55
+ }
@@ -146,7 +146,11 @@ export function detect(agent) {
146
146
  }
147
147
 
148
148
  // Tools Claude must NOT touch in oneshot mode — everything is inlined.
149
- const CLAUDE_TOOLS_OFF = "Read,Edit,Write,Bash,Glob,Grep,WebFetch,WebSearch";
149
+ const CLAUDE_TOOLS_OFF = "Read,Edit,Write,Bash,Glob,Grep,WebFetch,WebSearch,NotebookEdit,Task";
150
+ // "read" may open the files a prompt names (photos, references) and nothing
151
+ // else: no writing, no shell, no web. Denied explicitly, because an allow-list
152
+ // alone doesn't override a user whose own Claude settings pre-approve more.
153
+ const CLAUDE_READ_ONLY_OFF = "Edit,Write,Bash,WebFetch,WebSearch,NotebookEdit,Task";
150
154
 
151
155
  /**
152
156
  * Run a prompt through the chosen agent CLI.
@@ -181,6 +185,9 @@ export async function runAgent({
181
185
  "acceptEdits",
182
186
  "--allowedTools",
183
187
  tools,
188
+ // The user's own MCP servers (a Gmail "send", say) must never be in
189
+ // reach of a prompt built from untrusted email.
190
+ "--strict-mcp-config",
184
191
  ];
185
192
  if (resume) args.push("--resume", resume);
186
193
  const raw = await run(normalized, args, { timeoutMs, cwd });
@@ -199,9 +206,9 @@ export async function runAgent({
199
206
  return { text: raw, sessionId: namespaced(normalized, resume) };
200
207
  }
201
208
  }
202
- const args = ["-p", prompt, "--output-format", "text"];
203
- if (mode === "oneshot") args.push("--disallowedTools", CLAUDE_TOOLS_OFF);
204
- if (mode === "read") args.push("--allowedTools", "Read");
209
+ const args = ["-p", prompt, "--output-format", "text", "--strict-mcp-config"];
210
+ if (mode === "oneshot" || mode === "text") args.push("--disallowedTools", CLAUDE_TOOLS_OFF);
211
+ if (mode === "read") args.push("--allowedTools", "Read", "--disallowedTools", CLAUDE_READ_ONLY_OFF);
205
212
  const text = await run(normalized, args, { timeoutMs, cwd });
206
213
  return { text, sessionId: null };
207
214
  }
package/worker/index.js CHANGED
@@ -13,6 +13,7 @@ import { promisify } from "node:util";
13
13
  import yaml from "js-yaml";
14
14
  import { upload } from "./upload.js";
15
15
  import { skillSection } from "./skill.js";
16
+ import { migrateStylesOutOfPackage, STYLES_DIR, styleDirFor, WORK_DIR } from "../src/state.js";
16
17
  import { extFromUrl } from "./style.js";
17
18
  import { FONT_SPECS, sanitizeFonts } from "../src/fonts.js";
18
19
  import { assertFfmpeg, isVideoPath, posterFrame } from "../src/video.js";
@@ -120,7 +121,7 @@ const MAX_PROMPT_REFS = 6;
120
121
  async function ensureStyleRefs(styleSlug, styleRefUrls) {
121
122
  const urls = (styleRefUrls ?? []).slice(0, 20);
122
123
  if (urls.length === 0) return [];
123
- const refsDir = join(ROOT, "styles", styleSlug, "refs");
124
+ const refsDir = join(styleDirFor(styleSlug), "refs");
124
125
  const manifestPath = join(refsDir, "manifest.json");
125
126
 
126
127
  const cachedPaths = () => {
@@ -485,10 +486,10 @@ async function draftSlides(job, styleDir, media, refPaths, brand) {
485
486
  async function runJob(job) {
486
487
  console.log(`▶ job ${job.id}: "${(job.prompt ?? "").slice(0, 60)}" [${job.styleSlug}]`);
487
488
 
488
- const styleDir = join(ROOT, "styles", job.styleSlug);
489
+ const styleDir = styleDirFor(job.styleSlug);
489
490
  if (!existsSync(styleDir)) throw new Error(`Style "${job.styleSlug}" isn't installed on this computer yet`);
490
491
 
491
- const dir = join(ROOT, "carousels", `web-${job.id.slice(0, 8)}`);
492
+ const dir = join(WORK_DIR, `web-${job.id.slice(0, 8)}`);
492
493
  mkdirSync(join(dir, "photos"), { recursive: true });
493
494
  mkdirSync(join(dir, "videos"), { recursive: true });
494
495
  // Older servers don't send these — default to applying the brand, matching
@@ -565,6 +566,10 @@ async function runJob(job) {
565
566
 
566
567
  // Render.
567
568
  const outDir = join(dir, "output");
569
+ // A requeued job reuses this folder. Slides left from the earlier attempt
570
+ // (an 8-slide draft re-drafted as 6, or an old slide-03.mp4 that outranks the
571
+ // new slide-03.png) would be uploaded with the new deck.
572
+ rmSync(outDir, { recursive: true, force: true });
568
573
  await execFileAsync(
569
574
  "node",
570
575
  [join(ROOT, "src", "render.js"), dir, "--aspect", job.aspect, "--out", outDir],
@@ -699,6 +704,14 @@ Do not rewrite any slide's text and do not switch templates.`;
699
704
 
700
705
  // ── Main loop ────────────────────────────────────────────────────────────────
701
706
  console.log(`Goose Tools worker connected to ${BASE_URL} — waiting for jobs (Ctrl+C to stop)`);
707
+ // Styles made from a checkout sat inside the package folder; move them to
708
+ // ~/.goosetools/carousel so an update can't delete them (src/state.js).
709
+ try {
710
+ const moved = migrateStylesOutOfPackage();
711
+ if (moved) console.log(`Moved ${moved} style(s) to ${STYLES_DIR}.`);
712
+ } catch (e) {
713
+ console.error(`Couldn't move styles out of the package: ${e.message}`);
714
+ }
702
715
  let firstPoll = true;
703
716
 
704
717
  while (true) {
@@ -0,0 +1,113 @@
1
+ // SHARED FILE: the source of truth is workers/shared/install-helpers.js in the
2
+ // goosetools repo; copied into each worker (as install-helpers.mjs for the
3
+ // overlay worker, a CommonJS package). Edit the shared one, then
4
+ // `npm run workers:sync`.
5
+ //
6
+ // The parts of installing a background worker that every worker gets wrong in
7
+ // the same way if each writes its own.
8
+
9
+ import { execSync } from "node:child_process";
10
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
11
+ import { dirname } from "node:path";
12
+
13
+ const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
14
+
15
+ /**
16
+ * The node binary the background service should run.
17
+ *
18
+ * `process.execPath` under Homebrew is a versioned folder
19
+ * (/opt/homebrew/Cellar/node/25.8.2/bin/node) that `brew upgrade` + cleanup
20
+ * deletes, after which every worker fails to start, silently, forever. The
21
+ * stable link (/opt/homebrew/bin/node) follows upgrades, so prefer it whenever
22
+ * it points at the same install.
23
+ */
24
+ export function stableNodePath(execPath = process.execPath) {
25
+ if (!/\/Cellar\/node(@\d+)?\//.test(execPath)) return execPath;
26
+ for (const candidate of ["/opt/homebrew/bin/node", "/usr/local/bin/node"]) {
27
+ try {
28
+ if (existsSync(candidate) && realpathSync(candidate).includes("/Cellar/node")) return candidate;
29
+ } catch {}
30
+ }
31
+ return execPath;
32
+ }
33
+
34
+ /** The newest published version, asked of the registry itself, not a cache. */
35
+ export function latestPublished(pkg) {
36
+ try {
37
+ return execSync(`npm view ${pkg} version --prefer-online`, {
38
+ stdio: ["ignore", "pipe", "pipe"],
39
+ timeout: 20000,
40
+ shell: true,
41
+ })
42
+ .toString()
43
+ .trim();
44
+ } catch {
45
+ return null; // offline, or npm unreachable
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Install exactly the newest version of `pkg` into `prefix`, and prove it.
51
+ *
52
+ * `npm install pkg@latest` reads a cached, abbreviated copy of the package
53
+ * listing that can lag a publish by minutes, so right after a release it
54
+ * quietly reinstalled the previous version and reported success. This asks
55
+ * the registry for the version first, installs that exact version with
56
+ * --prefer-online, and retries (for a few minutes) until what's on disk is
57
+ * what was asked for.
58
+ */
59
+ export function installExact({ pkg, prefix, installedVersion, log = console.log }) {
60
+ const target = latestPublished(pkg);
61
+ if (!target) {
62
+ // Can't reach the registry to ask; fall back to whatever it will give.
63
+ execSync(`npm install --no-fund --no-audit --loglevel=error --prefix "${prefix}" ${pkg}@latest`, {
64
+ stdio: ["ignore", "inherit", "inherit"],
65
+ shell: true,
66
+ });
67
+ return installedVersion();
68
+ }
69
+ const attempts = 8;
70
+ for (let i = 1; i <= attempts; i++) {
71
+ try {
72
+ execSync(
73
+ `npm install --no-fund --no-audit --loglevel=error --prefer-online --prefix "${prefix}" ${pkg}@${target}`,
74
+ { stdio: ["ignore", "inherit", "pipe"], shell: true },
75
+ );
76
+ if (installedVersion() === target) return target;
77
+ } catch (e) {
78
+ if (i === attempts) throw e;
79
+ }
80
+ if (i < attempts) {
81
+ log(` npm hasn't caught up with ${pkg}@${target} yet — trying again in 20s (${i}/${attempts})`);
82
+ sleepSync(20000);
83
+ }
84
+ }
85
+ throw new Error(`installed ${installedVersion() ?? "nothing"} but ${target} is the latest — try again in a few minutes`);
86
+ }
87
+
88
+ /**
89
+ * Write the shared ~/.goosetools/env, keeping any keys this installer doesn't
90
+ * own (VOICE_DIR and friends) and never leaving it world-readable, even for a
91
+ * moment: written 0600 to a temp file and renamed into place.
92
+ */
93
+ export function writeEnv(file, updates) {
94
+ const existing = {};
95
+ if (existsSync(file)) {
96
+ for (const line of readFileSync(file, "utf8").split("\n")) {
97
+ const i = line.indexOf("=");
98
+ if (i > 0 && !line.trim().startsWith("#")) existing[line.slice(0, i).trim()] = line.slice(i + 1).trim();
99
+ }
100
+ }
101
+ const merged = { ...existing, ...updates };
102
+ mkdirSync(dirname(file), { recursive: true });
103
+ const tmp = `${file}.tmp-${process.pid}`;
104
+ writeFileSync(
105
+ tmp,
106
+ Object.entries(merged)
107
+ .map(([k, v]) => `${k}=${v}`)
108
+ .join("\n") + "\n",
109
+ { mode: 0o600 },
110
+ );
111
+ chmodSync(tmp, 0o600);
112
+ renameSync(tmp, file);
113
+ }
package/worker/lock.js CHANGED
@@ -35,7 +35,7 @@ export function acquireWorkerLock(name, { label, stopHint }) {
35
35
  const file = join(LOCK_DIR, `${name}.pid`);
36
36
 
37
37
  const holder = readHolder(file);
38
- if (holder && isAlive(holder.pid)) {
38
+ if (holder && isAlive(holder.pid, holder.script)) {
39
39
  console.log(
40
40
  `\n${label} is already running on this computer (pid ${holder.pid}${
41
41
  holder.since ? `, since ${holder.since}` : ""
@@ -58,7 +58,9 @@ export function acquireWorkerLock(name, { label, stopHint }) {
58
58
  console.log(`\n${label} started somewhere else a moment ago. Stopping here.\n`);
59
59
  process.exit(0);
60
60
  }
61
- writeSync(fd, `${process.pid}\n${new Date().toISOString()}\n`);
61
+ // The script path lets a later start tell THIS worker apart from another
62
+ // process that has since been given the same pid.
63
+ writeSync(fd, `${process.pid}\n${new Date().toISOString()}\n${process.argv[1] ?? ""}\n`);
62
64
  closeSync(fd);
63
65
 
64
66
  const release = () => rmSync(file, { force: true });
@@ -74,29 +76,35 @@ export function acquireWorkerLock(name, { label, stopHint }) {
74
76
  function readHolder(file) {
75
77
  if (!existsSync(file)) return null;
76
78
  try {
77
- const [pid, since] = readFileSync(file, "utf8").split("\n");
79
+ const [pid, since, script] = readFileSync(file, "utf8").split("\n");
78
80
  const n = Number.parseInt(pid, 10);
79
- return Number.isFinite(n) ? { pid: n, since: since?.trim() || null } : null;
81
+ return Number.isFinite(n)
82
+ ? { pid: n, since: since?.trim() || null, script: script?.trim() || null }
83
+ : null;
80
84
  } catch {
81
85
  return null;
82
86
  }
83
87
  }
84
88
 
85
89
  // A pid file outlives a SIGKILLed worker, and pids get reused — so "is that
86
- // pid alive" isn't enough on its own. Checking that it's a node process is
87
- // cheap and rules out the reuse case that would otherwise lock out the daemon
88
- // until someone deleted the file by hand.
89
- function isAlive(pid) {
90
+ // pid alive" isn't enough on its own. And "is it a node process" isn't either:
91
+ // all four workers are node processes started together at login, so after a
92
+ // crash and reboot a sibling worker can inherit the dead one's pid and lock it
93
+ // out indefinitely. So the lock records the worker's script, and only a
94
+ // process running that script counts. (Older lock files without it fall back
95
+ // to the node check.)
96
+ function isAlive(pid, script) {
90
97
  try {
91
98
  process.kill(pid, 0);
92
99
  } catch {
93
100
  return false;
94
101
  }
95
102
  try {
96
- return /node/.test(execFileSync("ps", ["-p", String(pid), "-o", "command="], {
103
+ const command = execFileSync("ps", ["-p", String(pid), "-o", "command="], {
97
104
  encoding: "utf8",
98
105
  stdio: ["ignore", "pipe", "ignore"],
99
- }));
106
+ });
107
+ return script ? command.includes(script) : /node/.test(command);
100
108
  } catch {
101
109
  return false;
102
110
  }
@@ -22,7 +22,6 @@
22
22
 
23
23
  import { execSync } from "node:child_process";
24
24
  import {
25
- chmodSync,
26
25
  existsSync,
27
26
  mkdirSync,
28
27
  readFileSync,
@@ -32,6 +31,7 @@ import {
32
31
  } from "node:fs";
33
32
  import { homedir } from "node:os";
34
33
  import { dirname, join } from "node:path";
34
+ import { installExact, latestPublished, stableNodePath, writeEnv } from "./install-helpers.js";
35
35
 
36
36
  export const GOOSE_DIR = join(homedir(), ".goosetools");
37
37
  const ENV_FILE = join(GOOSE_DIR, "env");
@@ -65,7 +65,7 @@ function npmGlobalBin() {
65
65
  // `claude` lives), plus the usual suspects.
66
66
  function servicePath() {
67
67
  const parts = [
68
- dirname(process.execPath),
68
+ dirname(stableNodePath()),
69
69
  npmGlobalBin(),
70
70
  ...(process.platform === "win32"
71
71
  ? [process.env.PATH ?? ""]
@@ -93,10 +93,9 @@ function readEnvFile() {
93
93
  );
94
94
  }
95
95
 
96
+ // Keeps keys other tools put there, and is never world-readable (install-helpers).
96
97
  function writeEnvFile({ url, token }) {
97
- mkdirSync(GOOSE_DIR, { recursive: true });
98
- writeFileSync(ENV_FILE, `GOOSETOOLS_URL=${url}\nWORKER_TOKEN=${token}\n`);
99
- chmodSync(ENV_FILE, 0o600);
98
+ writeEnv(ENV_FILE, { GOOSETOOLS_URL: url, WORKER_TOKEN: token });
100
99
  }
101
100
 
102
101
  function compareVersions(a, b) {
@@ -163,12 +162,15 @@ export function createService(cfg) {
163
162
  console.log("Setting up the background copy (this can take a minute)…");
164
163
  mkdirSync(APP_PREFIX, { recursive: true });
165
164
  // From a git checkout (development), install the checkout itself; normal
166
- // npx users get the published package.
167
- const source = isCheckout(root) ? `"${root}"` : `${cfg.pkg}@latest`;
168
- execSync(`npm install --no-fund --no-audit --loglevel=error --prefix "${APP_PREFIX}" ${source}`, {
169
- stdio: ["ignore", "inherit", "inherit"],
170
- shell: true,
171
- });
165
+ // npx users get exactly the newest published version (install-helpers).
166
+ if (isCheckout(root)) {
167
+ execSync(`npm install --no-fund --no-audit --loglevel=error --prefix "${APP_PREFIX}" "${root}"`, {
168
+ stdio: ["ignore", "inherit", "inherit"],
169
+ shell: true,
170
+ });
171
+ } else {
172
+ installExact({ pkg: cfg.pkg, prefix: APP_PREFIX, installedVersion });
173
+ }
172
174
  if (!existsSync(INSTALLED_CLI)) {
173
175
  throw new Error("background copy did not install where expected");
174
176
  }
@@ -186,7 +188,7 @@ export function createService(cfg) {
186
188
  <string>${cfg.macLabel}</string>
187
189
  <key>ProgramArguments</key>
188
190
  <array>
189
- <string>${esc(process.execPath)}</string>
191
+ <string>${esc(stableNodePath())}</string>
190
192
  <string>${esc(INSTALLED_CLI)}</string>
191
193
  <string>run</string>
192
194
  </array>
@@ -272,11 +274,7 @@ export function createService(cfg) {
272
274
  }
273
275
 
274
276
  function latestVersion() {
275
- try {
276
- return sh(`npm view ${cfg.pkg} version`, { shell: true, timeout: 15000 });
277
- } catch {
278
- return null; // offline, or npm unreachable — not an error worth failing on
279
- }
277
+ return latestPublished(cfg.pkg); // null when offline — not worth failing on
280
278
  }
281
279
 
282
280
  // launchctl prints "<pid>\t<last exit>\t<label>"; a "-" pid means registered
package/worker/style.js CHANGED
@@ -17,6 +17,7 @@ import { extname, join } from "node:path";
17
17
  import yaml from "js-yaml";
18
18
  import { upload } from "./upload.js";
19
19
  import { skillSection } from "./skill.js";
20
+ import { styleDirFor, WORK_DIR } from "../src/state.js";
20
21
 
21
22
  const RESERVED_SLUGS = new Set(["graph-paper", "polka-dot"]);
22
23
  const REQUIRED_TEMPLATES = ["cover", "text", "photo", "cta"];
@@ -137,13 +138,13 @@ export async function runStyleJob(job, { api, execFileAsync, ROOT, TOKEN, BASE_U
137
138
  console.log(`▶ style ${job.id}: "${name}" (${refUrls.length} refs) [${slug}]`);
138
139
 
139
140
  if (RESERVED_SLUGS.has(slug)) throw new Error(`"${slug}" is a built-in style`);
140
- const styleDir = join(ROOT, "styles", slug);
141
+ const styleDir = styleDirFor(slug);
141
142
  const marker = join(styleDir, ".generated");
142
143
  if (existsSync(styleDir) && !existsSync(marker)) {
143
- throw new Error(`A hand-made style already exists at styles/${slug} on this computer`);
144
+ throw new Error(`A hand-made style already exists at ${styleDir} on this computer`);
144
145
  }
145
146
 
146
- const workDir = join(ROOT, "carousels", `style-${job.id.slice(0, 8)}`);
147
+ const workDir = join(WORK_DIR, `style-${job.id.slice(0, 8)}`);
147
148
  const refsDir = join(workDir, "refs");
148
149
  mkdirSync(refsDir, { recursive: true });
149
150
 
@@ -157,7 +158,7 @@ export async function runStyleJob(job, { api, execFileAsync, ROOT, TOKEN, BASE_U
157
158
  refPaths.push(p);
158
159
  }
159
160
 
160
- const exemplarDir = join(ROOT, "styles", "graph-paper");
161
+ const exemplarDir = styleDirFor("graph-paper");
161
162
  const exemplar = {
162
163
  styleYml: readFileSync(join(exemplarDir, "style.yml"), "utf8"),
163
164
  cover: readFileSync(join(exemplarDir, "templates", "cover.html"), "utf8"),
@@ -207,7 +208,7 @@ export async function runStyleJob(job, { api, execFileAsync, ROOT, TOKEN, BASE_U
207
208
  join(styleDir, "refs", "manifest.json"),
208
209
  JSON.stringify({ urls: refUrls.slice(0, 20) }, null, 2),
209
210
  );
210
- console.log(` styles/${slug}/ written (${Object.keys(files).length} files + ${refPaths.length} refs)`);
211
+ console.log(` ${styleDir} written (${Object.keys(files).length} files + ${refPaths.length} refs)`);
211
212
 
212
213
  // Preview: render the new style's cover + text slide with the user's brand.
213
214
  const previewDir = join(workDir, "preview");