goosetools-worker 0.2.3 → 0.2.5

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.3",
3
+ "version": "0.2.5",
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",
@@ -13,7 +13,8 @@
13
13
  "styles/polka-dot",
14
14
  "carousels/demo/slides.yml",
15
15
  "brand.yml",
16
- "README.md"
16
+ "README.md",
17
+ "skills"
17
18
  ],
18
19
  "type": "module",
19
20
  "bin": {
@@ -25,7 +26,8 @@
25
26
  "scripts": {
26
27
  "render": "node src/render.js",
27
28
  "demo": "node src/render.js carousels/demo",
28
- "worker": "node worker/index.js"
29
+ "worker": "node worker/index.js",
30
+ "prepublishOnly": "node ../sync-shared.mjs --check"
29
31
  },
30
32
  "dependencies": {
31
33
  "@vercel/blob": "^0.27.0",
@@ -0,0 +1,149 @@
1
+ # Reference Analysis Methodology
2
+
3
+ How to study a set of reference images, map them onto something new, and check
4
+ the finished render against them. Shared by the Carousel Maker (slides) and the
5
+ Overlay Factory (overlays on reels) — the reference images are the source of
6
+ truth for EVERY behavior described here; there are no hardcoded rules. Worker
7
+ prompts splice individual sections by the SECTION markers below.
8
+
9
+ SHARED FILE: the source of truth is workers/shared/reference-analysis.md in the
10
+ goosetools repo, copied into each worker's skills/ by `npm run workers:sync`.
11
+
12
+ <!-- SECTION: principles -->
13
+ ## Principles — both tools
14
+
15
+ - **Purpose first, paint second.** Work out what each reference is FOR — the
16
+ recurring format a follower would recognize — before its colours and fonts.
17
+ A copy that nails the palette but misses the format is wrong.
18
+ - **Variance vs constants.** Across the set, note what varies and what never
19
+ changes. Freedom lives exactly where the references vary; fidelity is owed
20
+ everywhere they don't.
21
+ - **Same-account test.** The finished thing should read as the same account's
22
+ work if posted next to the references — same format, same typographic
23
+ voice, same palette logic. Pixel-matching any one reference is NOT the goal.
24
+ - **Look, don't assume.** Read every image before deciding anything, and judge
25
+ your own output by looking at the render, not by re-reading your markup.
26
+
27
+ <!-- SECTION: analyze -->
28
+ ## Phase A — understand what each reference is DOING
29
+
30
+ Read every reference image first. For each one, work out:
31
+
32
+ - **Role**: is this the cover (the hook slide), a middle content slide, or
33
+ the closer/CTA? The first reference is usually the cover; use layout and
34
+ copy cues (page counters, "swipe", "follow") to confirm.
35
+ - **Content job**: what is the slide FOR? Presenting a color palette with
36
+ labeled swatches? Comparing a before vs an after? Listing tips? Showcasing
37
+ a product photo? The recurring format a follower would recognize is the
38
+ thing to reproduce — purpose first, paint second.
39
+ - **Photo and subject**: where does the photo's main subject sit in the
40
+ frame? How tight is the crop?
41
+ - **Overlays**: where do overlays (swatch stacks, badges, labels, text
42
+ blocks) sit relative to the subject? Record which behavior this set uses:
43
+ some styles keep overlays OFF the subject (overlays live in the empty
44
+ side of the frame); others deliberately lay elements OVER the photo as
45
+ part of the look. Both are legitimate — copy whichever these references do.
46
+ - **Variance vs constants**: across the whole set, what varies (number of
47
+ swatches, which side the overlay sits on, crop tightness) and what never
48
+ changes (the format, the typography, the palette treatment, the border
49
+ style)? Freedom lives exactly where the references vary; fidelity is owed
50
+ everywhere they don't.
51
+
52
+ <!-- SECTION: map -->
53
+ ## Phase B — map references onto the new carousel, role to role
54
+
55
+ Match by ROLE and FORMAT, never image-to-image: your cover should do what
56
+ the reference cover does, your closer what their closer does, and every
57
+ middle slide should follow the references' middle-slide format. Your slide 3
58
+ does not need to mirror reference slide 3.
59
+
60
+ - **Colors**: if the user's photos share a coherent palette (e.g. they are
61
+ all sunsets, all one product line), sample the carousel's colors from THE
62
+ USER'S PHOTOS so the deck feels made from their images. If the photos are
63
+ visually unrelated, keep the references' original colors. Never mix in
64
+ colors from anywhere else.
65
+ - **Variance**: whatever varied across the references may vary across your
66
+ slides the same way — swatch counts, overlay side, crop tightness. Do not
67
+ make every slide identical when the references weren't.
68
+ - **Overlay placement**: follow the coverage behavior recorded in Phase A.
69
+ If the references keep overlays clear of the subject, look at each of the
70
+ user's photos (and what the user's prompt says the photo is about) and
71
+ place overlays on the empty side — use `variant: flip` on a slide to
72
+ mirror a template's overlay to the other side. If the references overlay
73
+ the photo deliberately, do the same.
74
+ - **Photo count adaptation** — the references decide the slide mix:
75
+ - More photos than slides: choose the photos that best serve each slide's
76
+ message, but if the user's prompt asks to show something specific, the
77
+ photo showing it MUST be used.
78
+ - Fewer photos than the format wants: if the references put a photo on
79
+ every slide, reuse a photo with a DIFFERENT focus/zoom crop so it reads
80
+ as a new frame; if the references include photo-free formats (statement
81
+ slides, text slides), use those instead. Never pad with invented filler
82
+ content.
83
+
84
+ <!-- SECTION: compare -->
85
+ ## Phase C — compare each rendered slide against the references
86
+
87
+ For each rendered slide, find the reference(s) with the same ROLE (slide 1 =
88
+ cover, last slide = CTA/closer, everything else = middle) and ask:
89
+
90
+ - **Same-account test**: if this slide were posted next to the references,
91
+ would it read as the same account's work — same format, same typographic
92
+ voice, same palette logic? Exact alignment with any single reference is
93
+ NOT required; matching the format is.
94
+ - **Overlay-behavior test**: does the slide follow the references' coverage
95
+ behavior? If they keep overlays off the subject, is this slide's subject
96
+ (or a face) hidden under a swatch stack, badge, or caption? If so it
97
+ fails — fix with `variant: flip` (mirror the overlay side) or a focus/zoom
98
+ change that moves the subject clear.
99
+ - **Framing test**: is the thing the slide is about actually prominent and
100
+ centered by the crop, exactly as the reference format frames its subjects?
101
+
102
+ Fix vocabulary (per slide): `focus` ("X% Y%" on the original photo),
103
+ `zoom` (1–3), `variant: flip` (mirror the overlay side), `swatches`
104
+ (recount/recolor a swatch list — only on slides that already have one).
105
+ Never rewrite a slide's text or switch its template during comparison.
106
+
107
+ <!-- SECTION: overlay-analyze -->
108
+ ## Overlay Phase A — understand what the references are DOING
109
+
110
+ These are frames from reels (or screenshots of them) with text or graphics laid
111
+ over footage. For the set, work out:
112
+
113
+ - **Format**: what one episode of this is — a question, a hot take, a stat, a
114
+ quote, a before/after label? That is what the fields should capture.
115
+ - **Hierarchy**: what the eye reads first, second, third, and how that is
116
+ achieved (size, weight, colour, a box, a bar, a label above the main line).
117
+ - **Type**: weight, case, letter-spacing, serif vs sans vs mono, line length,
118
+ how many lines the main text runs to.
119
+ - **Containers**: solid box, translucent scrim, outline, shadow only, or text
120
+ straight on footage — and corner radius, border, padding.
121
+ - **Placement**: where on the 9:16 frame each element sits, and how much of
122
+ the frame the overlay takes. Note anything placed where Instagram's UI would
123
+ cover it — keep the format but move it into the safe band.
124
+ - **Colour logic**: which colours are fixed accents and which come from the
125
+ footage; how contrast is kept over busy or bright video.
126
+ - **Variance vs constants** across the set (see Principles).
127
+
128
+ <!-- SECTION: overlay-compare -->
129
+ ## Overlay Phase C — compare the rendered frame against the references
130
+
131
+ Look at the rendered frame (sample text in every field, over grey) next to the
132
+ references and ask:
133
+
134
+ - **Same-account test**: would this sit next to the references as the same
135
+ series? Same format, hierarchy, type treatment, container style, colour
136
+ logic.
137
+ - **Hierarchy test**: does the eye land where it lands in the references, in
138
+ the same order?
139
+ - **Proportion test**: is the overlay roughly the size the references use — not
140
+ a tiny label where they fill the band, not a slab where they are a caption?
141
+ - **Legibility test**: would it read over ANY footage, on a phone at arm's
142
+ length? Thin text straight on video fails.
143
+ - **Safe-area test**: is anything in Instagram's top bar, right action rail or
144
+ bottom caption zone?
145
+
146
+ Fix by editing the templates (sizes, weights, spacing, colours, containers,
147
+ widthPct, anchor). Never change field keys, the content brief, or wording that
148
+ the user typed or asked for.
149
+
@@ -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
+ });
@@ -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
+ });