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 +1 -1
- package/src/render.js +3 -1
- package/src/state.js +55 -0
- package/worker/agent-cli.js +11 -4
- package/worker/index.js +16 -3
- package/worker/install-helpers.js +113 -0
- package/worker/lock.js +18 -10
- package/worker/service-core.js +15 -17
- package/worker/style.js +6 -5
package/package.json
CHANGED
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
|
-
|
|
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
|
+
}
|
package/worker/agent-cli.js
CHANGED
|
@@ -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(
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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)
|
|
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.
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
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
|
-
|
|
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
|
}
|
package/worker/service-core.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
|
144
|
+
throw new Error(`A hand-made style already exists at ${styleDir} on this computer`);
|
|
144
145
|
}
|
|
145
146
|
|
|
146
|
-
const workDir = join(
|
|
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 =
|
|
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(`
|
|
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");
|