goosetools-worker 0.2.2 → 0.2.4

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.2",
3
+ "version": "0.2.4",
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",
@@ -25,7 +25,8 @@
25
25
  "scripts": {
26
26
  "render": "node src/render.js",
27
27
  "demo": "node src/render.js carousels/demo",
28
- "worker": "node worker/index.js"
28
+ "worker": "node worker/index.js",
29
+ "prepublishOnly": "node ../sync-shared.mjs --check"
29
30
  },
30
31
  "dependencies": {
31
32
  "@vercel/blob": "^0.27.0",
@@ -1,9 +1,11 @@
1
1
  // Agent CLI adapter — runs a job's prompt through whichever coding-agent CLI
2
2
  // the user picked on goosetools.com (claim payloads carry `agentCli`).
3
3
  //
4
- // VENDORED FILE: keep byte-identical across brand-manager-worker,
5
- // caption-maker, and carousel-maker (worker/agent-cli.js in each). Fix a bug
6
- // here, copy it to the other two.
4
+ // SHARED FILE: the source of truth is workers/shared/agent-cli.js in the
5
+ // goosetools repo. Each worker ships its own copy (worker/agent-cli.js) so the
6
+ // npm packages stay self-contained. Edit the shared one, then run
7
+ // `npm run workers:sync` from the repo root; `npm run workers:check` fails if
8
+ // a copy has drifted.
7
9
  //
8
10
  // Modes, chosen for speed-vs-capability (see brand-manager commit c0c1b33 —
9
11
  // tool-looping turned 36-second drafts into 10-minute ones):
@@ -0,0 +1,88 @@
1
+ // SHARED FILE: the source of truth is workers/shared/cli-core.js in the
2
+ // goosetools repo; each worker ships a copy as worker/cli-core.js. Edit the
3
+ // shared one, then `npm run workers:sync`.
4
+ //
5
+ // The command-line entrypoint every worker shares. Each worker's
6
+ // worker/cli.js (its npm `bin`) is a few lines calling runCli().
7
+ //
8
+ // install save token + register a login service (worker runs forever)
9
+ // update pull the newest code into the background copy + restart
10
+ // status what's installed, whether it's running, recent log lines
11
+ // uninstall remove the service (the shared token stays)
12
+ // doctor which coding-agent CLIs are installed
13
+ // run run the worker loop in this window (default)
14
+
15
+ import { execSync } from "node:child_process";
16
+
17
+ const COMMANDS = ["install", "update", "status", "uninstall", "run", "doctor"];
18
+
19
+ function flag(name) {
20
+ const i = process.argv.indexOf(name);
21
+ return i !== -1 ? process.argv[i + 1] : undefined;
22
+ }
23
+
24
+ function has(cmd) {
25
+ try {
26
+ execSync(process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`, {
27
+ stdio: "ignore",
28
+ shell: true,
29
+ });
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * @param {object} opts
38
+ * @param {string} opts.root the package root (parent of worker/)
39
+ * @param {string} opts.writes finishes "Claude Code isn't installed yet (it writes …)"
40
+ * @param {() => Promise<{install, update, uninstall, status}>} opts.service loads worker/service.js
41
+ * @param {() => Promise<unknown>} opts.worker loads worker/index.js
42
+ * @param {() => void} [opts.preflight] extra setup before install/update/run (e.g. a browser)
43
+ */
44
+ export async function runCli({ root, writes, service, worker, preflight }) {
45
+ const command = COMMANDS.includes(process.argv[2]) ? process.argv[2] : "run";
46
+
47
+ // These only inspect or tear down what's already there, so they skip the
48
+ // preflight below — `status` in particular has to work precisely when
49
+ // something IS wrong.
50
+ if (command === "uninstall") return (await service()).uninstall();
51
+ if (command === "status") return (await service()).status();
52
+
53
+ // Claude Code is the default agent, so it's required to set up; the other
54
+ // agents (picked in Setup on goosetools.com) are checked lazily per job and
55
+ // reported here.
56
+ if (command === "doctor") {
57
+ const { AGENT_IDS, detect } = await import("./agent-cli.js");
58
+ for (const id of AGENT_IDS) {
59
+ const d = detect(id);
60
+ console.log(`${d.installed ? "✓" : "✗"} ${d.label}${d.installed ? "" : ` — install: ${d.hint}`}`);
61
+ }
62
+ return;
63
+ }
64
+
65
+ if (!has("claude")) {
66
+ console.error(
67
+ `\nClaude Code isn't installed yet (it writes ${writes}).\n` +
68
+ "Install it with: npm install -g @anthropic-ai/claude-code\n" +
69
+ "Then run: claude (once, to sign in — type /exit to leave)\n" +
70
+ "Then run this command again.\n",
71
+ );
72
+ process.exit(1);
73
+ }
74
+
75
+ preflight?.();
76
+
77
+ if (command === "install") {
78
+ // No --url means "keep the saved server, else goosetools.com" — decided in
79
+ // install(), which can see the saved one.
80
+ return (await service()).install({
81
+ url: flag("--url")?.replace(/\/$/, ""),
82
+ token: flag("--token"),
83
+ root,
84
+ });
85
+ }
86
+ if (command === "update") return (await service()).update({ root });
87
+ await worker();
88
+ }
package/worker/cli.js CHANGED
@@ -1,124 +1,56 @@
1
1
  #!/usr/bin/env node
2
- // goosetools-worker entrypoint — makes first run painless for non-technical
3
- // users: checks for Claude Code + the Playwright browser, installs the
4
- // browser if missing, then runs the requested command.
5
- //
6
- // install save token + register a login service (worker runs forever)
7
- // update pull the newest code into the background copy + restart
8
- // status what's installed, whether it's running, recent log lines
9
- // uninstall remove the service + saved token
10
- // run run the worker loop in this window (default)
2
+ // goosetools-worker (Carousel Maker) entrypoint. The commands live in
3
+ // worker/cli-core.js (shared by every worker); this adds the one thing
4
+ // carousels need first: the Playwright browser that renders slides, installed
5
+ // if missing so first run is painless for non-technical users.
11
6
 
12
- import { execFileSync, execSync } from "node:child_process";
7
+ import { execFileSync } from "node:child_process";
13
8
  import { existsSync } from "node:fs";
14
9
  import { createRequire } from "node:module";
15
10
  import { dirname, join, resolve } from "node:path";
16
11
  import { fileURLToPath } from "node:url";
12
+ import { runCli } from "./cli-core.js";
17
13
 
18
14
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
19
15
 
20
- const command = ["install", "update", "status", "uninstall", "run", "doctor"].includes(process.argv[2])
21
- ? process.argv[2]
22
- : "run";
23
-
24
- // These two only inspect or tear down what's already there, so they skip the
25
- // Claude Code / browser preflight below — `status` in particular has to work
26
- // precisely when something IS wrong.
27
- if (command === "uninstall") {
28
- const { uninstall } = await import("./service.js");
29
- uninstall();
30
- process.exit(0);
31
- }
32
-
33
- if (command === "status") {
34
- const { status } = await import("./service.js");
35
- status();
36
- process.exit(0);
37
- }
38
-
39
- function flag(name) {
40
- const i = process.argv.indexOf(name);
41
- return i !== -1 ? process.argv[i + 1] : undefined;
42
- }
43
-
44
- function has(cmd) {
16
+ // Make sure the rendering browser is present (quiet no-op when it is).
17
+ function ensureBrowser() {
18
+ // Run the playwright CLI that ships with our own dependencies via node —
19
+ // going through `npx playwright` instead prints a scary warning box.
45
20
  try {
46
- execSync(process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`, {
47
- stdio: "ignore",
48
- shell: true,
49
- });
50
- return true;
51
- } catch {
52
- return false;
53
- }
54
- }
55
-
56
- // Claude Code is the default agent, so it's required to set up; the other
57
- // agents (picked in Setup on goosetools.com) are checked lazily per job and
58
- // reported by `doctor`.
59
- if (command === "doctor") {
60
- const { AGENT_IDS, detect } = await import("./agent-cli.js");
61
- for (const id of AGENT_IDS) {
62
- const d = detect(id);
63
- console.log(
64
- `${d.installed ? "✓" : "✗"} ${d.label}${d.installed ? "" : ` — install: ${d.hint}`}`,
21
+ console.log("Checking the rendering browser (first run can take a minute)…");
22
+ const require = createRequire(join(ROOT, "package.json"));
23
+ const playwrightCli = join(
24
+ dirname(require.resolve("playwright/package.json")),
25
+ "cli.js",
65
26
  );
27
+ if (existsSync(playwrightCli)) {
28
+ // Quiet on success — playwright's CLI prints a misleading "run npm
29
+ // install first" box in the npx cache layout. Its output is only shown
30
+ // (via the catch below) when the install genuinely fails.
31
+ execFileSync(process.execPath, [playwrightCli, "install", "chromium"], {
32
+ cwd: ROOT,
33
+ stdio: ["ignore", "pipe", "pipe"],
34
+ });
35
+ } else {
36
+ execFileSync("npx", ["--yes", "playwright", "install", "chromium"], {
37
+ cwd: ROOT,
38
+ stdio: "inherit",
39
+ shell: process.platform === "win32",
40
+ });
41
+ }
42
+ } catch (err) {
43
+ const detail = err?.stderr?.toString().trim();
44
+ if (detail) console.error(detail);
45
+ console.error("Couldn't install the rendering browser — check your internet connection and retry.");
46
+ process.exit(1);
66
47
  }
67
- process.exit(0);
68
48
  }
69
49
 
70
- if (!has("claude")) {
71
- console.error(
72
- "\nClaude Code isn't installed yet (it writes your slides).\n" +
73
- "Install it with: npm install -g @anthropic-ai/claude-code\n" +
74
- "Then run: claude (once, to sign in — type /exit to leave)\n" +
75
- "Then run this command again.\n",
76
- );
77
- process.exit(1);
78
- }
79
-
80
- // Make sure the rendering browser is present (quiet no-op when it is).
81
- // Run the playwright CLI that ships with our own dependencies via node —
82
- // going through `npx playwright` instead prints a scary warning box.
83
- try {
84
- console.log("Checking the rendering browser (first run can take a minute)…");
85
- const require = createRequire(join(ROOT, "package.json"));
86
- const playwrightCli = join(
87
- dirname(require.resolve("playwright/package.json")),
88
- "cli.js",
89
- );
90
- if (existsSync(playwrightCli)) {
91
- // Quiet on success — playwright's CLI prints a misleading "run npm
92
- // install first" box in the npx cache layout. Its output is only shown
93
- // (via the catch below) when the install genuinely fails.
94
- execFileSync(process.execPath, [playwrightCli, "install", "chromium"], {
95
- cwd: ROOT,
96
- stdio: ["ignore", "pipe", "pipe"],
97
- });
98
- } else {
99
- execFileSync("npx", ["--yes", "playwright", "install", "chromium"], {
100
- cwd: ROOT,
101
- stdio: "inherit",
102
- shell: process.platform === "win32",
103
- });
104
- }
105
- } catch (err) {
106
- const detail = err?.stderr?.toString().trim();
107
- if (detail) console.error(detail);
108
- console.error("Couldn't install the rendering browser — check your internet connection and retry.");
109
- process.exit(1);
110
- }
111
-
112
- if (command === "install") {
113
- const { install } = await import("./service.js");
114
- install({
115
- url: (flag("--url") ?? "https://goosetools.com").replace(/\/$/, ""),
116
- token: flag("--token"),
117
- root: ROOT,
118
- });
119
- } else if (command === "update") {
120
- const { update } = await import("./service.js");
121
- update({ root: ROOT });
122
- } else {
123
- await import("./index.js");
124
- }
50
+ await runCli({
51
+ root: ROOT,
52
+ writes: "your slides",
53
+ service: () => import("./service.js"),
54
+ worker: () => import("./index.js"),
55
+ preflight: ensureBrowser,
56
+ });
package/worker/index.js CHANGED
@@ -11,11 +11,19 @@ import { join, dirname, resolve, extname } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { promisify } from "node:util";
13
13
  import yaml from "js-yaml";
14
- import { upload } from "@vercel/blob/client";
14
+ import { upload } from "./upload.js";
15
15
  import { skillSection } from "./skill.js";
16
16
  import { extFromUrl } from "./style.js";
17
17
  import { FONT_SPECS, sanitizeFonts } from "../src/fonts.js";
18
18
  import { assertFfmpeg, isVideoPath, posterFrame } from "../src/video.js";
19
+ import { acquireWorkerLock } from "./lock.js";
20
+
21
+ // One worker of each kind per machine — a second one would split the queue
22
+ // with this one. Stands down with an explanation if another already holds it.
23
+ acquireWorkerLock("worker", {
24
+ label: "The Carousel Maker worker",
25
+ stopHint: "launchctl bootout gui/$(id -u)/com.goosetools.worker (or close its terminal)",
26
+ });
19
27
 
20
28
  const execFileAsync = promisify(execFile);
21
29
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -683,11 +691,10 @@ Do not rewrite any slide's text and do not switch templates.`;
683
691
  await api("complete", { jobId: job.id, ok: true, slidesYml, resultUrls });
684
692
  console.log(`✓ job ${job.id}: ${resultUrls.length} slides uploaded`);
685
693
 
686
- // The slides are safely in Blob storage now, so the local scratch dir (photos
687
- // + rendered PNGs, tens of MB per job) has done its job. Without this it
688
- // accumulates forever under carousels/. Only reached on success — a failed
689
- // job keeps its dir for debugging (the main loop's catch handles those).
690
- rmSync(dir, { recursive: true, force: true });
694
+ // The local copy stays. carousels/web-<id>/output/ is the creator's own
695
+ // archive of every deck this machine rendered — it is what let the renders
696
+ // the 2026 retention purge deleted be put back. Disk is theirs to spend;
697
+ // nothing a creator made is thrown away by a tool.
691
698
  }
692
699
 
693
700
  // ── Main loop ────────────────────────────────────────────────────────────────
package/worker/lock.js ADDED
@@ -0,0 +1,103 @@
1
+ // One worker of each kind per machine.
2
+ //
3
+ // Nothing stops you starting a second worker: the daemon runs in the
4
+ // background, and `run` in a terminal is the normal way to watch one work or
5
+ // to try a change from a checkout. Both then poll the same queue with the same
6
+ // token.
7
+ //
8
+ // The server is safe — claiming a job is a single compare-and-set, so two
9
+ // workers never get the same one. The damage is quieter than that. They SPLIT
10
+ // the queue, so jobs land on whichever copy happened to pick them up: half
11
+ // from the code you're editing, half from the installed release, with a
12
+ // different state directory behind each. And the polling doubles, which is
13
+ // what the idle interval exists to keep down in the first place.
14
+ //
15
+ // So: whoever gets here first holds the lock, and the second one stands down
16
+ // with an explanation instead of quietly competing.
17
+
18
+ import { execFileSync } from "node:child_process";
19
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ const LOCK_DIR = join(homedir(), ".goosetools", "locks");
24
+
25
+ /**
26
+ * Take the lock for `name` ("worker", "caption", "brand", "overlay"), or print
27
+ * who has it and exit. Returns nothing — it either succeeds or ends the
28
+ * process.
29
+ *
30
+ * `stopHint` is the command that stops the OTHER copy, and it's the whole
31
+ * point of the message: "already running" without it just moves the puzzle.
32
+ */
33
+ export function acquireWorkerLock(name, { label, stopHint }) {
34
+ mkdirSync(LOCK_DIR, { recursive: true });
35
+ const file = join(LOCK_DIR, `${name}.pid`);
36
+
37
+ const holder = readHolder(file);
38
+ if (holder && isAlive(holder.pid)) {
39
+ console.log(
40
+ `\n${label} is already running on this computer (pid ${holder.pid}${
41
+ holder.since ? `, since ${holder.since}` : ""
42
+ }).\n\n` +
43
+ "Two of them would split the queue between them — some jobs done by\n" +
44
+ "one copy, some by the other. Stopping here instead.\n\n" +
45
+ ` Stop the other one: ${stopHint}\n`,
46
+ );
47
+ process.exit(0);
48
+ }
49
+
50
+ // Either no lock, or one left behind by a worker that was killed. Both are
51
+ // ours to take: an O_EXCL create loses to a worker that beat us here by
52
+ // milliseconds, which is the one race worth caring about.
53
+ if (holder) rmSync(file, { force: true });
54
+ let fd;
55
+ try {
56
+ fd = openSync(file, "wx");
57
+ } catch {
58
+ console.log(`\n${label} started somewhere else a moment ago. Stopping here.\n`);
59
+ process.exit(0);
60
+ }
61
+ writeSync(fd, `${process.pid}\n${new Date().toISOString()}\n`);
62
+ closeSync(fd);
63
+
64
+ const release = () => rmSync(file, { force: true });
65
+ process.on("exit", release);
66
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
67
+ process.on(sig, () => {
68
+ release();
69
+ process.exit(0);
70
+ });
71
+ }
72
+ }
73
+
74
+ function readHolder(file) {
75
+ if (!existsSync(file)) return null;
76
+ try {
77
+ const [pid, since] = readFileSync(file, "utf8").split("\n");
78
+ const n = Number.parseInt(pid, 10);
79
+ return Number.isFinite(n) ? { pid: n, since: since?.trim() || null } : null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ // 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
+ try {
91
+ process.kill(pid, 0);
92
+ } catch {
93
+ return false;
94
+ }
95
+ try {
96
+ return /node/.test(execFileSync("ps", ["-p", String(pid), "-o", "command="], {
97
+ encoding: "utf8",
98
+ stdio: ["ignore", "pipe", "ignore"],
99
+ }));
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
@@ -0,0 +1,411 @@
1
+ // SHARED FILE: the source of truth is workers/shared/service-core.js in the
2
+ // goosetools repo; each worker ships a copy as worker/service-core.js. Edit the
3
+ // shared one, then `npm run workers:sync`.
4
+ //
5
+ // Background-service install/uninstall for a Goose Tools worker. Each worker's
6
+ // own worker/service.js is a thin config around this (package name, service
7
+ // label, log paths, wording), so the three can't drift apart again.
8
+ //
9
+ // `install` makes the worker persistent: token saved to ~/.goosetools/env, a
10
+ // stable copy of the package installed under ~/.goosetools/<appDir>, and a
11
+ // login service registered (launchd LaunchAgent on macOS, a logon Scheduled
12
+ // Task on Windows) so the worker runs whenever the computer is on — no
13
+ // terminal window needed. `update` re-runs that install against the newest
14
+ // code, reusing the saved token; `status` reports what's actually running.
15
+ //
16
+ // The worker TOKEN is shared: every worker reads ~/.goosetools/env, so a
17
+ // computer already connected for one tool needs no --token for the next.
18
+ //
19
+ // Service labels, app dirs and log names are per worker and must never change:
20
+ // an update re-registers the service under the same name, which is what
21
+ // replaces the old one on machines that already have it.
22
+
23
+ import { execSync } from "node:child_process";
24
+ import {
25
+ chmodSync,
26
+ existsSync,
27
+ mkdirSync,
28
+ readFileSync,
29
+ rmSync,
30
+ statSync,
31
+ writeFileSync,
32
+ } from "node:fs";
33
+ import { homedir } from "node:os";
34
+ import { dirname, join } from "node:path";
35
+
36
+ export const GOOSE_DIR = join(homedir(), ".goosetools");
37
+ const ENV_FILE = join(GOOSE_DIR, "env");
38
+ const DEFAULT_URL = "https://goosetools.com";
39
+
40
+ function sh(cmd, opts = {}) {
41
+ return execSync(cmd, { stdio: ["ignore", "pipe", "pipe"], ...opts })
42
+ .toString()
43
+ .trim();
44
+ }
45
+
46
+ export function quietSh(cmd) {
47
+ try {
48
+ sh(cmd);
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ function npmGlobalBin() {
56
+ try {
57
+ const prefix = sh("npm prefix -g", { shell: true });
58
+ return process.platform === "win32" ? prefix : join(prefix, "bin");
59
+ } catch {
60
+ return "";
61
+ }
62
+ }
63
+
64
+ // Everything the daemon needs to find: node itself, the npm-global bin (where
65
+ // `claude` lives), plus the usual suspects.
66
+ function servicePath() {
67
+ const parts = [
68
+ dirname(process.execPath),
69
+ npmGlobalBin(),
70
+ ...(process.platform === "win32"
71
+ ? [process.env.PATH ?? ""]
72
+ : [
73
+ "/opt/homebrew/bin",
74
+ "/usr/local/bin",
75
+ // opencode's installer and some npm setups drop binaries here.
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 readEnvFile() {
87
+ if (!existsSync(ENV_FILE)) return {};
88
+ return Object.fromEntries(
89
+ readFileSync(ENV_FILE, "utf8")
90
+ .split("\n")
91
+ .filter((l) => l.includes("=") && !l.trim().startsWith("#"))
92
+ .map((l) => [l.slice(0, l.indexOf("=")).trim(), l.slice(l.indexOf("=") + 1).trim()]),
93
+ );
94
+ }
95
+
96
+ 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);
100
+ }
101
+
102
+ function compareVersions(a, b) {
103
+ // -1 / 0 / 1, comparing x.y.z numerically. Anything unparseable sorts equal
104
+ // so an odd version string never nags the user to "update" in a loop.
105
+ const parts = (v) => String(v).split(".").map((n) => Number.parseInt(n, 10) || 0);
106
+ const [pa, pb] = [parts(a), parts(b)];
107
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
108
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) < (pb[i] ?? 0) ? -1 : 1;
109
+ }
110
+ return 0;
111
+ }
112
+
113
+ function tail(file, n) {
114
+ try {
115
+ return readFileSync(file, "utf8").trimEnd().split("\n").slice(-n).join("\n");
116
+ } catch {
117
+ return "";
118
+ }
119
+ }
120
+
121
+ // How long ago a log was last written. Without this, days-old errors read as a
122
+ // live outage — the single most misleading thing in a log tail.
123
+ function lastWritten(file) {
124
+ try {
125
+ const ms = Date.now() - statSync(file).mtimeMs;
126
+ const mins = Math.round(ms / 60000);
127
+ if (mins < 1) return "just now";
128
+ if (mins < 60) return `${mins} min ago`;
129
+ const hours = Math.round(mins / 60);
130
+ if (hours < 24) return `${hours} hr ago`;
131
+ return `${Math.round(hours / 24)} days ago`;
132
+ } catch {
133
+ return "unknown";
134
+ }
135
+ }
136
+
137
+ /**
138
+ * @param {object} cfg
139
+ * @param {string} cfg.pkg npm package name, e.g. "caption-maker-worker"
140
+ * @param {string} cfg.name for messages, e.g. "Caption worker"
141
+ * @param {string} cfg.appDir background copy under ~/.goosetools, e.g. "caption-app"
142
+ * @param {string} cfg.macLabel launchd label, e.g. "com.goosetools.caption"
143
+ * @param {string} cfg.winTask Scheduled Task name
144
+ * @param {string} cfg.winVbs launcher file name under ~/.goosetools
145
+ * @param {string} cfg.logName log file name under ~/.goosetools (".log" / ".err.log" appended)
146
+ * @param {string} cfg.dashboard where a token comes from, e.g. "/dashboard/caption"
147
+ * @param {string} cfg.doing finishes "This computer now …", e.g. "writes your captions"
148
+ * @param {() => string | void} [cfg.afterInstall] extra lines printed after a successful install
149
+ * @param {() => string[]} [cfg.statusLines] extra " ✓ Label value" rows for status
150
+ */
151
+ export function createService(cfg) {
152
+ const APP_PREFIX = join(GOOSE_DIR, cfg.appDir);
153
+ const INSTALLED_CLI = join(APP_PREFIX, "node_modules", cfg.pkg, "worker", "cli.js");
154
+ const MAC_PLIST = join(homedir(), "Library", "LaunchAgents", `${cfg.macLabel}.plist`);
155
+ const WIN_VBS = join(GOOSE_DIR, cfg.winVbs);
156
+ const LOG_FILE = join(GOOSE_DIR, `${cfg.logName}.log`);
157
+ const ERR_FILE = join(GOOSE_DIR, `${cfg.logName}.err.log`);
158
+ const connectHint =
159
+ `Get a token at ${DEFAULT_URL}${cfg.dashboard} (any tool — same token), then run:\n` +
160
+ ` npx --yes ${cfg.pkg}@latest install --token gt_…`;
161
+
162
+ function installStableCopy(root) {
163
+ console.log("Setting up the background copy (this can take a minute)…");
164
+ mkdirSync(APP_PREFIX, { recursive: true });
165
+ // 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
+ });
172
+ if (!existsSync(INSTALLED_CLI)) {
173
+ throw new Error("background copy did not install where expected");
174
+ }
175
+ }
176
+
177
+ // ── macOS (launchd) ────────────────────────────────────────────────────────
178
+
179
+ function macPlist() {
180
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
181
+ return `<?xml version="1.0" encoding="UTF-8"?>
182
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
183
+ <plist version="1.0">
184
+ <dict>
185
+ <key>Label</key>
186
+ <string>${cfg.macLabel}</string>
187
+ <key>ProgramArguments</key>
188
+ <array>
189
+ <string>${esc(process.execPath)}</string>
190
+ <string>${esc(INSTALLED_CLI)}</string>
191
+ <string>run</string>
192
+ </array>
193
+ <key>RunAtLoad</key>
194
+ <true/>
195
+ <key>KeepAlive</key>
196
+ <true/>
197
+ <key>ThrottleInterval</key>
198
+ <integer>30</integer>
199
+ <key>StandardOutPath</key>
200
+ <string>${esc(LOG_FILE)}</string>
201
+ <key>StandardErrorPath</key>
202
+ <string>${esc(ERR_FILE)}</string>
203
+ <key>EnvironmentVariables</key>
204
+ <dict>
205
+ <key>PATH</key>
206
+ <string>${esc(servicePath())}</string>
207
+ </dict>
208
+ </dict>
209
+ </plist>
210
+ `;
211
+ }
212
+
213
+ function macInstall() {
214
+ mkdirSync(dirname(MAC_PLIST), { recursive: true });
215
+ writeFileSync(MAC_PLIST, macPlist());
216
+ const uid = process.getuid();
217
+ // Re-registering: tear down any previous copy first, then bootstrap.
218
+ quietSh(`launchctl bootout gui/${uid}/${cfg.macLabel}`);
219
+ if (!quietSh(`launchctl bootstrap gui/${uid} "${MAC_PLIST}"`)) {
220
+ // Older macOS fallback.
221
+ quietSh(`launchctl unload "${MAC_PLIST}"`);
222
+ execSync(`launchctl load -w "${MAC_PLIST}"`, { stdio: "ignore" });
223
+ }
224
+ }
225
+
226
+ function macUninstall() {
227
+ quietSh(`launchctl bootout gui/${process.getuid()}/${cfg.macLabel}`);
228
+ quietSh(`launchctl unload "${MAC_PLIST}"`);
229
+ rmSync(MAC_PLIST, { force: true });
230
+ }
231
+
232
+ // ── Windows (Scheduled Task at logon, hidden window) ───────────────────────
233
+
234
+ function winInstall() {
235
+ // wscript launches the worker with no console window.
236
+ const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${INSTALLED_CLI}"" run", 0, False\r\n`;
237
+ writeFileSync(WIN_VBS, vbs);
238
+ quietSh(`schtasks /End /TN "${cfg.winTask}"`);
239
+ quietSh(`schtasks /Delete /F /TN "${cfg.winTask}"`);
240
+ execSync(`schtasks /Create /F /SC ONLOGON /TN "${cfg.winTask}" /TR "wscript.exe \\"${WIN_VBS}\\""`, {
241
+ stdio: "ignore",
242
+ shell: true,
243
+ });
244
+ execSync(`schtasks /Run /TN "${cfg.winTask}"`, { stdio: "ignore", shell: true });
245
+ }
246
+
247
+ function winUninstall() {
248
+ quietSh(`schtasks /End /TN "${cfg.winTask}"`);
249
+ quietSh(`schtasks /Delete /F /TN "${cfg.winTask}"`);
250
+ rmSync(WIN_VBS, { force: true });
251
+ }
252
+
253
+ function registerService() {
254
+ if (process.platform === "darwin") macInstall();
255
+ else if (process.platform === "win32") winInstall();
256
+ else {
257
+ console.error(
258
+ "Automatic background setup isn't available on this OS yet.\n" +
259
+ `Run the worker directly instead: ${cfg.pkg} run`,
260
+ );
261
+ process.exit(1);
262
+ }
263
+ }
264
+
265
+ function installedVersion() {
266
+ try {
267
+ const pkg = join(APP_PREFIX, "node_modules", cfg.pkg, "package.json");
268
+ return JSON.parse(readFileSync(pkg, "utf8")).version ?? null;
269
+ } catch {
270
+ return null;
271
+ }
272
+ }
273
+
274
+ 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
+ }
280
+ }
281
+
282
+ // launchctl prints "<pid>\t<last exit>\t<label>"; a "-" pid means registered
283
+ // but not currently running.
284
+ function servicePid() {
285
+ try {
286
+ if (process.platform === "darwin") {
287
+ const line = sh(`launchctl list | grep ${cfg.macLabel}`, { shell: true });
288
+ const pid = line.split(/\s+/)[0];
289
+ return pid === "-" ? null : pid;
290
+ }
291
+ const out = sh(`schtasks /Query /TN "${cfg.winTask}" /FO LIST`, { shell: true });
292
+ return /Running/i.test(out) ? "running" : null;
293
+ } catch {
294
+ return null;
295
+ }
296
+ }
297
+
298
+ // ── Public commands ────────────────────────────────────────────────────────
299
+
300
+ function install({ url, token, root }) {
301
+ // Reuse the saved token and server when the caller didn't pass them — a
302
+ // computer already connected for another tool.
303
+ const saved = readEnvFile();
304
+ const effectiveToken = token ?? saved.WORKER_TOKEN;
305
+ const effectiveUrl = url ?? saved.GOOSETOOLS_URL ?? DEFAULT_URL;
306
+ if (!effectiveToken) {
307
+ console.error(`No worker token found. ${connectHint}`);
308
+ process.exit(1);
309
+ }
310
+ writeEnvFile({ url: effectiveUrl, token: effectiveToken });
311
+ installStableCopy(root);
312
+ registerService();
313
+ console.log(
314
+ `\n✓ All set! This computer now ${cfg.doing} in the background —\n` +
315
+ " whenever it's on, even after a restart. You can close this window.\n\n" +
316
+ ` Logs: ${LOG_FILE}\n` +
317
+ ` Turn off: npx --yes ${cfg.pkg} uninstall\n`,
318
+ );
319
+ const extra = cfg.afterInstall?.();
320
+ if (extra) console.log(extra);
321
+ }
322
+
323
+ // Removes THIS worker, not the whole ~/.goosetools directory: the Overlay
324
+ // Factory keeps its puzzle library, learned looks and used-card record in
325
+ // ~/.goosetools/overlay, and nothing else has a copy of those. The shared
326
+ // token file stays too — the other workers are still using it.
327
+ function uninstall() {
328
+ if (process.platform === "darwin") macUninstall();
329
+ else if (process.platform === "win32") winUninstall();
330
+ rmSync(APP_PREFIX, { recursive: true, force: true });
331
+ rmSync(LOG_FILE, { force: true });
332
+ rmSync(ERR_FILE, { force: true });
333
+ console.log(`✓ ${cfg.name} removed. (Your other workers and connection are untouched.)`);
334
+ }
335
+
336
+ // The daemon runs the COPY under ~/.goosetools/<appDir>, not wherever you ran
337
+ // this from — so new code only reaches it by reinstalling that copy.
338
+ // Re-registering the service also restarts it, which is what actually loads
339
+ // the new code (Node reads every module once, at startup).
340
+ function update({ root }) {
341
+ const saved = readEnvFile();
342
+ if (!saved.WORKER_TOKEN) {
343
+ console.error(`No saved worker token — this computer hasn't been connected yet.\n${connectHint}`);
344
+ process.exit(1);
345
+ }
346
+ const before = installedVersion();
347
+ installStableCopy(root);
348
+ registerService();
349
+ const after = installedVersion();
350
+ console.log(
351
+ after && before && after !== before
352
+ ? `\n✓ Updated ${before} → ${after} and restarted. Nothing else to do.\n`
353
+ : `\n✓ ${cfg.name} reinstalled (version ${after ?? "unknown"}) and restarted.\n`,
354
+ );
355
+ }
356
+
357
+ function status() {
358
+ const ok = (b) => (b ? "✓" : "✗");
359
+ const env = readEnvFile();
360
+ const installed = installedVersion();
361
+ const latest = latestVersion();
362
+ const pid = servicePid();
363
+ const url = env.GOOSETOOLS_URL ?? DEFAULT_URL;
364
+
365
+ console.log(`\nGoose Tools ${cfg.name.toLowerCase()} — status\n`);
366
+ console.log(` ${ok(installed)} Installed ${installed ?? "not installed"}`);
367
+ const cmp = installed && latest ? compareVersions(installed, latest) : 0;
368
+ const stale = cmp < 0;
369
+ if (latest) {
370
+ // Ahead of npm means a local dev install — say so rather than telling
371
+ // the user to "update" to something older than what they're running.
372
+ const note = stale ? " ← update available" : cmp > 0 ? " (you're ahead — local dev build)" : "";
373
+ console.log(` ${ok(!stale)} Latest on npm ${latest}${note}`);
374
+ } else {
375
+ console.log(" · Latest on npm couldn't check (offline?)");
376
+ }
377
+ console.log(` ${ok(pid)} Running ${pid ? `yes (pid ${pid})` : "no"}`);
378
+ console.log(
379
+ ` ${ok(env.WORKER_TOKEN)} Token saved ${env.WORKER_TOKEN ? "yes (shared)" : "no — run install --token gt_…"}`,
380
+ );
381
+ for (const line of cfg.statusLines?.() ?? []) console.log(line);
382
+ console.log(` · Server ${url}`);
383
+ console.log(` · Logs ${LOG_FILE}`);
384
+
385
+ const errors = tail(ERR_FILE, 15);
386
+ if (errors) {
387
+ console.log(`\nErrors (last written ${lastWritten(ERR_FILE)}):\n${errors.replace(/^/gm, " ")}`);
388
+ }
389
+ const log = tail(LOG_FILE, 15);
390
+ if (log) {
391
+ console.log(`\nActivity (last written ${lastWritten(LOG_FILE)}):\n${log.replace(/^/gm, " ")}`);
392
+ }
393
+
394
+ if (stale) {
395
+ console.log(`\nTo update: npx --yes ${cfg.pkg}@latest update\n`);
396
+ } else if (!pid && installed) {
397
+ console.log(`\nNot running. Restart it with: npx --yes ${cfg.pkg}@latest update\n`);
398
+ } else {
399
+ console.log("");
400
+ }
401
+ }
402
+
403
+ return { install, uninstall, update, status };
404
+ }
405
+
406
+ // A checkout (development) rather than an npm install. Inside the goosetools
407
+ // repo the worker's folder has no .git of its own, so look for the sync
408
+ // script next to it — that file is never published.
409
+ function isCheckout(root) {
410
+ return existsSync(join(root, ".git")) || existsSync(join(root, "..", "sync-shared.mjs"));
411
+ }
package/worker/service.js CHANGED
@@ -1,395 +1,40 @@
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.
1
+ // Background service for the carousel worker. The install/update/status logic
2
+ // is shared (worker/service-core.js, synced from workers/shared/); this file is
3
+ // only what's specific to carousels: the optional ffmpeg for video slides.
4
+ // Labels and paths must never change — see service-core.js. (This was the
5
+ // first worker, which is why its names are the generic "worker"/"app".)
10
6
 
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
7
  import { FFMPEG_INSTALL_CMD } from "../src/video.js";
8
+ import { createService, quietSh } from "./service-core.js";
24
9
 
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.
10
+ // Only ever two cheap `-version` calls.
52
11
  function hasFfmpeg() {
53
12
  return quietSh("ffmpeg -version") && quietSh("ffprobe -version");
54
13
  }
55
14
 
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
- );
15
+ export const { install, uninstall, update, status } = createService({
16
+ pkg: "goosetools-worker",
17
+ name: "Carousel worker",
18
+ appDir: "app",
19
+ macLabel: "com.goosetools.worker",
20
+ winTask: "GooseTools Worker",
21
+ winVbs: "run-worker.vbs",
22
+ logName: "worker",
23
+ dashboard: "/dashboard/carousel",
24
+ doing: "makes your carousels",
208
25
  // Optional, and only worth a mention once the required setup has succeeded:
209
26
  // photo carousels are already fully working at this point.
210
- if (!hasFfmpeg()) {
211
- console.log(
212
- " Optional — want video slides?\n" +
27
+ afterInstall: () =>
28
+ hasFfmpeg()
29
+ ? undefined
30
+ : " Optional — want video slides?\n" +
213
31
  " Carousels made from photos work right now. To also put video clips\n" +
214
32
  " in a carousel, install ffmpeg (one time, a couple of minutes):\n\n" +
215
33
  ` ${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
34
  // Not an "✗": a worker without ffmpeg is healthy, it just can't do video.
369
- console.log(
35
+ statusLines: () => [
370
36
  ` ${hasFfmpeg() ? "✓" : "·"} Video slides ${
371
37
  hasFfmpeg() ? "yes (ffmpeg found)" : `photos only — for video: ${FFMPEG_INSTALL_CMD}`
372
38
  }`,
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
- }
39
+ ],
40
+ });
package/worker/style.js CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  } from "node:fs";
16
16
  import { extname, join } from "node:path";
17
17
  import yaml from "js-yaml";
18
- import { upload } from "@vercel/blob/client";
18
+ import { upload } from "./upload.js";
19
19
  import { skillSection } from "./skill.js";
20
20
 
21
21
  const RESERVED_SLUGS = new Set(["graph-paper", "polka-dot"]);
@@ -0,0 +1,27 @@
1
+ // Worker-side upload, same signature as `upload` from "@vercel/blob/client":
2
+ //
3
+ // const blob = await upload(pathname, bytes, {
4
+ // handleUploadUrl, clientPayload, contentType,
5
+ // });
6
+ // blob.url
7
+ //
8
+ // Goose Tools moved file storage to Cloudflare R2. The upload route still
9
+ // decides who may write where (worker token in clientPayload), but instead
10
+ // of a Blob token it hands back a signed PUT URL; the bytes go straight to
11
+ // the bucket.
12
+
13
+ export async function upload(pathname, bytes, opts) {
14
+ const contentType = opts.contentType ?? "application/octet-stream";
15
+ const res = await fetch(opts.handleUploadUrl, {
16
+ method: "POST",
17
+ headers: { "content-type": "application/json" },
18
+ body: JSON.stringify({ pathname, contentType, size: bytes.length ?? bytes.byteLength ?? 0, clientPayload: opts.clientPayload ?? null }),
19
+ });
20
+ const grant = await res.json().catch(() => ({}));
21
+ if (!res.ok || !grant.uploadUrl || !grant.url) {
22
+ throw new Error(grant.error ?? `upload not allowed (${res.status})`);
23
+ }
24
+ const put = await fetch(grant.uploadUrl, { method: "PUT", headers: { "content-type": contentType }, body: bytes });
25
+ if (!put.ok) throw new Error(`upload PUT failed (${put.status})`);
26
+ return { url: grant.url, pathname: grant.pathname ?? pathname, contentType };
27
+ }