@forwardimpact/basecamp 0.1.0

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.
Files changed (36) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +229 -0
  3. package/build.js +124 -0
  4. package/config/scheduler.json +28 -0
  5. package/package.json +37 -0
  6. package/scheduler.js +552 -0
  7. package/scripts/build-pkg.sh +117 -0
  8. package/scripts/compile.sh +26 -0
  9. package/scripts/install.sh +108 -0
  10. package/scripts/pkg-resources/conclusion.html +62 -0
  11. package/scripts/pkg-resources/welcome.html +64 -0
  12. package/scripts/postinstall +46 -0
  13. package/scripts/uninstall.sh +56 -0
  14. package/template/.claude/settings.json +40 -0
  15. package/template/.claude/skills/create-presentations/SKILL.md +75 -0
  16. package/template/.claude/skills/create-presentations/references/slide.css +35 -0
  17. package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.js +32 -0
  18. package/template/.claude/skills/doc-collab/SKILL.md +112 -0
  19. package/template/.claude/skills/draft-emails/SKILL.md +191 -0
  20. package/template/.claude/skills/draft-emails/scripts/scan-emails.sh +33 -0
  21. package/template/.claude/skills/extract-entities/SKILL.md +466 -0
  22. package/template/.claude/skills/extract-entities/references/TEMPLATES.md +131 -0
  23. package/template/.claude/skills/extract-entities/scripts/state.py +100 -0
  24. package/template/.claude/skills/meeting-prep/SKILL.md +135 -0
  25. package/template/.claude/skills/organize-files/SKILL.md +146 -0
  26. package/template/.claude/skills/organize-files/scripts/organize-by-type.sh +42 -0
  27. package/template/.claude/skills/organize-files/scripts/summarize.sh +21 -0
  28. package/template/.claude/skills/sync-apple-calendar/SKILL.md +101 -0
  29. package/template/.claude/skills/sync-apple-calendar/references/SCHEMA.md +80 -0
  30. package/template/.claude/skills/sync-apple-calendar/scripts/sync.py +233 -0
  31. package/template/.claude/skills/sync-apple-mail/SKILL.md +131 -0
  32. package/template/.claude/skills/sync-apple-mail/references/SCHEMA.md +88 -0
  33. package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.py +104 -0
  34. package/template/.claude/skills/sync-apple-mail/scripts/sync.py +348 -0
  35. package/template/CLAUDE.md +152 -0
  36. package/template/USER.md +5 -0
package/scheduler.js ADDED
@@ -0,0 +1,552 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Basecamp Scheduler — Runs scheduled tasks across multiple knowledge bases.
4
+ //
5
+ // Usage:
6
+ // node scheduler.js Run due tasks once and exit
7
+ // node scheduler.js --daemon Run continuously (poll every 60s)
8
+ // node scheduler.js --run <task> Run a specific task immediately
9
+ // node scheduler.js --init <path> Initialize a new knowledge base
10
+ // node scheduler.js --install-launchd Install macOS LaunchAgent
11
+ // node scheduler.js --uninstall-launchd Remove macOS LaunchAgent
12
+ // node scheduler.js --validate Validate agents and skills exist
13
+ // node scheduler.js --status Show task status
14
+ // node scheduler.js --help Show this help
15
+
16
+ import {
17
+ readFileSync,
18
+ writeFileSync,
19
+ existsSync,
20
+ mkdirSync,
21
+ readdirSync,
22
+ } from "node:fs";
23
+ import { execSync } from "node:child_process";
24
+ import { join, dirname, resolve } from "node:path";
25
+ import { homedir } from "node:os";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ const HOME = homedir();
29
+ const BASECAMP_HOME = join(HOME, ".fit", "basecamp");
30
+ const CONFIG_PATH = join(BASECAMP_HOME, "scheduler.json");
31
+ const STATE_PATH = join(BASECAMP_HOME, "state.json");
32
+ const LOG_DIR = join(BASECAMP_HOME, "logs");
33
+ const PLIST_NAME = "com.fit-basecamp.scheduler";
34
+ const PLIST_PATH = join(HOME, "Library", "LaunchAgents", `${PLIST_NAME}.plist`);
35
+ const __dirname =
36
+ import.meta.dirname || dirname(fileURLToPath(import.meta.url));
37
+ const KB_TEMPLATE_DIR = join(__dirname, "template");
38
+ const IS_COMPILED =
39
+ typeof Deno !== "undefined" &&
40
+ Deno.execPath &&
41
+ !Deno.execPath().endsWith("deno");
42
+
43
+ // --- Helpers ----------------------------------------------------------------
44
+
45
+ function ensureDir(dir) {
46
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
47
+ }
48
+
49
+ function readJSON(path, fallback) {
50
+ try {
51
+ return JSON.parse(readFileSync(path, "utf8"));
52
+ } catch {
53
+ return fallback;
54
+ }
55
+ }
56
+
57
+ function writeJSON(path, data) {
58
+ ensureDir(dirname(path));
59
+ writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
60
+ }
61
+
62
+ function expandPath(p) {
63
+ return p.startsWith("~/") ? join(HOME, p.slice(2)) : resolve(p);
64
+ }
65
+
66
+ function log(msg) {
67
+ const ts = new Date().toISOString();
68
+ const line = `[${ts}] ${msg}`;
69
+ console.log(line);
70
+ try {
71
+ ensureDir(LOG_DIR);
72
+ writeFileSync(
73
+ join(LOG_DIR, `scheduler-${ts.slice(0, 10)}.log`),
74
+ line + "\n",
75
+ { flag: "a" },
76
+ );
77
+ } catch {
78
+ /* best effort */
79
+ }
80
+ }
81
+
82
+ function findClaude() {
83
+ for (const c of [
84
+ "claude",
85
+ "/usr/local/bin/claude",
86
+ join(HOME, ".claude", "bin", "claude"),
87
+ join(HOME, ".local", "bin", "claude"),
88
+ ]) {
89
+ try {
90
+ execSync(`which "${c}" 2>/dev/null || command -v "${c}" 2>/dev/null`, {
91
+ encoding: "utf8",
92
+ });
93
+ return c;
94
+ } catch {}
95
+ if (existsSync(c)) return c;
96
+ }
97
+ return "claude";
98
+ }
99
+
100
+ function loadConfig() {
101
+ return readJSON(CONFIG_PATH, { tasks: {} });
102
+ }
103
+ function loadState() {
104
+ const raw = readJSON(STATE_PATH, null);
105
+ if (!raw || typeof raw !== "object" || !raw.tasks) {
106
+ const state = { tasks: {} };
107
+ saveState(state);
108
+ return state;
109
+ }
110
+ return raw;
111
+ }
112
+ function saveState(state) {
113
+ writeJSON(STATE_PATH, state);
114
+ }
115
+
116
+ // --- Cron matching ----------------------------------------------------------
117
+
118
+ function matchField(field, value) {
119
+ if (field === "*") return true;
120
+ if (field.startsWith("*/")) return value % parseInt(field.slice(2)) === 0;
121
+ return field.split(",").some((part) => {
122
+ if (part.includes("-")) {
123
+ const [lo, hi] = part.split("-").map(Number);
124
+ return value >= lo && value <= hi;
125
+ }
126
+ return parseInt(part) === value;
127
+ });
128
+ }
129
+
130
+ function cronMatches(expr, d) {
131
+ const [min, hour, dom, month, dow] = expr.trim().split(/\s+/);
132
+ return (
133
+ matchField(min, d.getMinutes()) &&
134
+ matchField(hour, d.getHours()) &&
135
+ matchField(dom, d.getDate()) &&
136
+ matchField(month, d.getMonth() + 1) &&
137
+ matchField(dow, d.getDay())
138
+ );
139
+ }
140
+
141
+ // --- Scheduling logic -------------------------------------------------------
142
+
143
+ function floorToMinute(d) {
144
+ return new Date(
145
+ d.getFullYear(),
146
+ d.getMonth(),
147
+ d.getDate(),
148
+ d.getHours(),
149
+ d.getMinutes(),
150
+ ).getTime();
151
+ }
152
+
153
+ function shouldRun(task, taskState, now) {
154
+ if (task.enabled === false) return false;
155
+ const { schedule } = task;
156
+ if (!schedule) return false;
157
+ const lastRun = taskState.lastRunAt ? new Date(taskState.lastRunAt) : null;
158
+
159
+ if (schedule.type === "cron") {
160
+ if (lastRun && floorToMinute(lastRun) === floorToMinute(now)) return false;
161
+ return cronMatches(schedule.expression, now);
162
+ }
163
+ if (schedule.type === "interval") {
164
+ const ms = (schedule.minutes || 5) * 60_000;
165
+ return !lastRun || now.getTime() - lastRun.getTime() >= ms;
166
+ }
167
+ if (schedule.type === "once") {
168
+ return !taskState.lastRunAt && now >= new Date(schedule.runAt);
169
+ }
170
+ return false;
171
+ }
172
+
173
+ // --- Task execution ---------------------------------------------------------
174
+
175
+ function runTask(taskName, task, _config, state) {
176
+ if (!task.kb) {
177
+ log(`Task ${taskName}: no "kb" specified, skipping.`);
178
+ return;
179
+ }
180
+ const kbPath = expandPath(task.kb);
181
+ if (!existsSync(kbPath)) {
182
+ log(`Task ${taskName}: path "${kbPath}" does not exist, skipping.`);
183
+ return;
184
+ }
185
+
186
+ const claude = findClaude();
187
+ const prompt = task.skill
188
+ ? `Use the skill "${task.skill}" — ${task.prompt || `Run the ${taskName} task.`}`
189
+ : task.prompt || `Run the ${taskName} task.`;
190
+
191
+ log(
192
+ `Running task: ${taskName} (kb: ${task.kb}${task.agent ? `, agent: ${task.agent}` : ""}${task.skill ? `, skill: ${task.skill}` : ""})`,
193
+ );
194
+
195
+ const ts = (state.tasks[taskName] ||= {});
196
+ ts.status = "running";
197
+ ts.startedAt = new Date().toISOString();
198
+ saveState(state);
199
+
200
+ try {
201
+ const args = ["--print"];
202
+ if (task.agent) args.push("--agent", task.agent);
203
+ args.push("-p", prompt);
204
+
205
+ const result = execSync(
206
+ `${claude} ${args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ")}`,
207
+ {
208
+ cwd: kbPath,
209
+ encoding: "utf8",
210
+ timeout: 30 * 60_000,
211
+ stdio: ["pipe", "pipe", "pipe"],
212
+ },
213
+ );
214
+
215
+ log(`Task ${taskName} completed. Output: ${result.slice(0, 200)}...`);
216
+ Object.assign(ts, {
217
+ status: "finished",
218
+ lastRunAt: new Date().toISOString(),
219
+ lastError: null,
220
+ runCount: (ts.runCount || 0) + 1,
221
+ });
222
+ } catch (err) {
223
+ const errMsg = err.stderr || err.stdout || err.message || String(err);
224
+ log(`Task ${taskName} failed: ${errMsg.slice(0, 300)}`);
225
+ Object.assign(ts, {
226
+ status: "failed",
227
+ lastRunAt: new Date().toISOString(),
228
+ lastError: errMsg.slice(0, 500),
229
+ });
230
+ }
231
+ saveState(state);
232
+ }
233
+
234
+ function runDueTasks() {
235
+ const config = loadConfig(),
236
+ state = loadState(),
237
+ now = new Date();
238
+ let ranAny = false;
239
+ for (const [name, task] of Object.entries(config.tasks)) {
240
+ if (shouldRun(task, state.tasks[name] || {}, now)) {
241
+ runTask(name, task, config, state);
242
+ ranAny = true;
243
+ }
244
+ }
245
+ if (!ranAny) log("No tasks due.");
246
+ }
247
+
248
+ // --- Daemon -----------------------------------------------------------------
249
+
250
+ function daemon() {
251
+ log("Scheduler daemon started. Polling every 60 seconds.");
252
+ log(`Config: ${CONFIG_PATH} State: ${STATE_PATH}`);
253
+ runDueTasks();
254
+ setInterval(() => {
255
+ try {
256
+ runDueTasks();
257
+ } catch (err) {
258
+ log(`Error: ${err.message}`);
259
+ }
260
+ }, 60_000);
261
+ }
262
+
263
+ // --- Init knowledge base ----------------------------------------------------
264
+
265
+ function copyDirRecursive(src, dest) {
266
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
267
+ const s = join(src, entry.name),
268
+ d = join(dest, entry.name);
269
+ if (entry.isDirectory()) {
270
+ ensureDir(d);
271
+ copyDirRecursive(s, d);
272
+ } else if (!existsSync(d)) {
273
+ writeFileSync(d, readFileSync(s));
274
+ }
275
+ }
276
+ }
277
+
278
+ function initKB(targetPath) {
279
+ const dest = expandPath(targetPath);
280
+ if (existsSync(join(dest, "CLAUDE.md"))) {
281
+ console.error(`Knowledge base already exists at ${dest}`);
282
+ process.exit(1);
283
+ }
284
+
285
+ ensureDir(dest);
286
+ for (const d of [
287
+ "knowledge/People",
288
+ "knowledge/Organizations",
289
+ "knowledge/Projects",
290
+ "knowledge/Topics",
291
+ ".claude/skills",
292
+ ])
293
+ ensureDir(join(dest, d));
294
+
295
+ if (existsSync(KB_TEMPLATE_DIR)) copyDirRecursive(KB_TEMPLATE_DIR, dest);
296
+
297
+ console.log(
298
+ `Knowledge base initialized at ${dest}\n\nNext steps:\n 1. Edit ${dest}/USER.md with your name, email, and domain\n 2. cd ${dest} && claude`,
299
+ );
300
+ }
301
+
302
+ // --- LaunchAgent ------------------------------------------------------------
303
+
304
+ function installLaunchd() {
305
+ const execPath =
306
+ typeof Deno !== "undefined" ? Deno.execPath() : process.execPath;
307
+ const isCompiled = IS_COMPILED || !execPath.includes("node");
308
+ const progArgs = isCompiled
309
+ ? ` <string>${execPath}</string>\n <string>--daemon</string>`
310
+ : ` <string>${execPath}</string>\n <string>${join(__dirname, "scheduler.js")}</string>\n <string>--daemon</string>`;
311
+
312
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
313
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
314
+ <plist version="1.0">
315
+ <dict>
316
+ <key>Label</key>
317
+ <string>${PLIST_NAME}</string>
318
+ <key>ProgramArguments</key>
319
+ <array>
320
+ ${progArgs}
321
+ </array>
322
+ <key>RunAtLoad</key>
323
+ <true/>
324
+ <key>KeepAlive</key>
325
+ <true/>
326
+ <key>StandardOutPath</key>
327
+ <string>${join(LOG_DIR, "launchd-stdout.log")}</string>
328
+ <key>StandardErrorPath</key>
329
+ <string>${join(LOG_DIR, "launchd-stderr.log")}</string>
330
+ <key>WorkingDirectory</key>
331
+ <string>${BASECAMP_HOME}</string>
332
+ <key>EnvironmentVariables</key>
333
+ <dict>
334
+ <key>PATH</key>
335
+ <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${join(HOME, ".local", "bin")}</string>
336
+ </dict>
337
+ </dict>
338
+ </plist>`;
339
+
340
+ ensureDir(dirname(PLIST_PATH));
341
+ ensureDir(LOG_DIR);
342
+ writeFileSync(PLIST_PATH, plist);
343
+
344
+ try {
345
+ execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`, {
346
+ stdio: "ignore",
347
+ });
348
+ } catch {}
349
+ execSync(`launchctl load "${PLIST_PATH}"`);
350
+ console.log(
351
+ `LaunchAgent installed and loaded.\n Plist: ${PLIST_PATH}\n Logs: ${LOG_DIR}/\n Config: ${CONFIG_PATH}`,
352
+ );
353
+ }
354
+
355
+ function uninstallLaunchd() {
356
+ try {
357
+ execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`);
358
+ } catch {}
359
+ try {
360
+ execSync(`rm -f "${PLIST_PATH}"`);
361
+ } catch {}
362
+ console.log("LaunchAgent uninstalled.");
363
+ }
364
+
365
+ // --- Status -----------------------------------------------------------------
366
+
367
+ function showStatus() {
368
+ const config = loadConfig(),
369
+ state = loadState();
370
+ console.log("\nBasecamp Scheduler\n==================\n");
371
+
372
+ const tasks = Object.entries(config.tasks || {});
373
+ if (tasks.length === 0) {
374
+ console.log(
375
+ `Tasks: (none configured)\n\nEdit ${CONFIG_PATH} to add tasks.`,
376
+ );
377
+ return;
378
+ }
379
+
380
+ console.log("Tasks:");
381
+ for (const [name, task] of tasks) {
382
+ const s = state.tasks[name] || {};
383
+ const kbPath = task.kb ? expandPath(task.kb) : null;
384
+ const kbStatus = kbPath ? (existsSync(kbPath) ? "" : " (not found)") : "";
385
+ const lines = [
386
+ ` ${task.enabled !== false ? "+" : "-"} ${name}`,
387
+ ` KB: ${task.kb || "(none)"}${kbStatus} Schedule: ${JSON.stringify(task.schedule)}`,
388
+ ` Status: ${s.status || "never-run"} Last run: ${s.lastRunAt ? new Date(s.lastRunAt).toLocaleString() : "never"} Runs: ${s.runCount || 0}`,
389
+ ];
390
+ if (task.agent) lines.push(` Agent: ${task.agent}`);
391
+ if (task.skill) lines.push(` Skill: ${task.skill}`);
392
+ if (s.lastError) lines.push(` Error: ${s.lastError.slice(0, 80)}`);
393
+ console.log(lines.join("\n"));
394
+ }
395
+
396
+ try {
397
+ execSync(`launchctl list 2>/dev/null | grep ${PLIST_NAME}`, {
398
+ encoding: "utf8",
399
+ });
400
+ console.log("\nLaunchAgent: loaded");
401
+ } catch {
402
+ console.log("\nLaunchAgent: not loaded (run --install-launchd to start)");
403
+ }
404
+ }
405
+
406
+ // --- Validate ---------------------------------------------------------------
407
+
408
+ function findInLocalOrGlobal(kbPath, subPath) {
409
+ const local = join(kbPath, ".claude", subPath);
410
+ const global = join(HOME, ".claude", subPath);
411
+ if (existsSync(local)) return local;
412
+ if (existsSync(global)) return global;
413
+ return null;
414
+ }
415
+
416
+ function validate() {
417
+ const config = loadConfig();
418
+ const tasks = Object.entries(config.tasks || {});
419
+ if (tasks.length === 0) {
420
+ console.log("No tasks configured. Nothing to validate.");
421
+ return;
422
+ }
423
+
424
+ console.log("\nValidating tasks...\n");
425
+ let errors = 0;
426
+
427
+ for (const [name, task] of tasks) {
428
+ if (!task.kb) {
429
+ console.log(` [FAIL] ${name}: no "kb" path specified`);
430
+ errors++;
431
+ continue;
432
+ }
433
+ const kbPath = expandPath(task.kb);
434
+ if (!existsSync(kbPath)) {
435
+ console.log(` [FAIL] ${name}: path does not exist: ${kbPath}`);
436
+ errors++;
437
+ continue;
438
+ }
439
+
440
+ for (const [kind, sub] of [
441
+ ["agent", task.agent],
442
+ ["skill", task.skill],
443
+ ]) {
444
+ if (!sub) continue;
445
+ const relPath =
446
+ kind === "agent"
447
+ ? join("agents", sub.endsWith(".md") ? sub : sub + ".md")
448
+ : join("skills", sub, "SKILL.md");
449
+ const found = findInLocalOrGlobal(kbPath, relPath);
450
+ if (found) {
451
+ console.log(` [OK] ${name}: ${kind} "${sub}" found at ${found}`);
452
+ } else {
453
+ console.log(
454
+ ` [FAIL] ${name}: ${kind} "${sub}" not found in ${join(kbPath, ".claude", relPath)} or ${join(HOME, ".claude", relPath)}`,
455
+ );
456
+ errors++;
457
+ }
458
+ }
459
+
460
+ if (!task.agent && !task.skill)
461
+ console.log(` [OK] ${name}: no agent or skill to validate`);
462
+ }
463
+
464
+ console.log(
465
+ errors > 0
466
+ ? `\nValidation failed: ${errors} error(s) found.`
467
+ : "\nAll tasks validated successfully.",
468
+ );
469
+ if (errors > 0) process.exit(1);
470
+ }
471
+
472
+ // --- Help -------------------------------------------------------------------
473
+
474
+ function showHelp() {
475
+ const bin = "fit-basecamp";
476
+ console.log(`
477
+ Basecamp Scheduler — Run scheduled tasks across multiple knowledge bases.
478
+
479
+ Usage:
480
+ ${bin} Run due tasks once and exit
481
+ ${bin} --daemon Run continuously (poll every 60s)
482
+ ${bin} --run <task> Run a specific task immediately
483
+ ${bin} --init <path> Initialize a new knowledge base
484
+ ${bin} --install-launchd Install macOS LaunchAgent for auto-start
485
+ ${bin} --uninstall-launchd Remove macOS LaunchAgent
486
+ ${bin} --validate Validate agents and skills exist
487
+ ${bin} --status Show task status
488
+ ${bin} --help Show this help
489
+
490
+ Config: ~/.fit/basecamp/scheduler.json
491
+ State: ~/.fit/basecamp/state.json
492
+ Logs: ~/.fit/basecamp/logs/
493
+
494
+ Config format:
495
+ {
496
+ "tasks": {
497
+ "sync-mail": {
498
+ "kb": "~/Documents/Personal",
499
+ "schedule": { "type": "interval", "minutes": 5 },
500
+ "prompt": "Sync Apple Mail.", "skill": "sync-apple-mail",
501
+ "agent": null, "enabled": true
502
+ }
503
+ }
504
+ }
505
+
506
+ Schedule types:
507
+ interval: { "type": "interval", "minutes": 5 }
508
+ cron: { "type": "cron", "expression": "0 8 * * *" }
509
+ once: { "type": "once", "runAt": "2025-02-12T10:00:00Z" }
510
+ `);
511
+ }
512
+
513
+ // --- CLI entry point --------------------------------------------------------
514
+
515
+ const args = process.argv.slice(2);
516
+ const command = args[0];
517
+ ensureDir(BASECAMP_HOME);
518
+
519
+ const commands = {
520
+ "--help": showHelp,
521
+ "-h": showHelp,
522
+ "--daemon": daemon,
523
+ "--install-launchd": installLaunchd,
524
+ "--uninstall-launchd": uninstallLaunchd,
525
+ "--validate": validate,
526
+ "--status": showStatus,
527
+ "--init": () => {
528
+ if (!args[1]) {
529
+ console.error("Usage: node scheduler.js --init <path>");
530
+ process.exit(1);
531
+ }
532
+ initKB(args[1]);
533
+ },
534
+ "--run": () => {
535
+ if (!args[1]) {
536
+ console.error("Usage: node scheduler.js --run <task-name>");
537
+ process.exit(1);
538
+ }
539
+ const config = loadConfig(),
540
+ state = loadState(),
541
+ task = config.tasks[args[1]];
542
+ if (!task) {
543
+ console.error(
544
+ `Task "${args[1]}" not found. Available: ${Object.keys(config.tasks).join(", ") || "(none)"}`,
545
+ );
546
+ process.exit(1);
547
+ }
548
+ runTask(args[1], task, config, state);
549
+ },
550
+ };
551
+
552
+ (commands[command] || runDueTasks)();
@@ -0,0 +1,117 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # Build a macOS installer package (.pkg) for Basecamp.
5
+ #
6
+ # Uses pkgbuild (component) + productbuild (distribution) to create a .pkg
7
+ # that installs the binary to /usr/local/bin/ and runs a postinstall script
8
+ # to set up the LaunchAgent, config, and default knowledge base.
9
+ #
10
+ # Usage: build-pkg.sh <dist_dir> <app_name> <version> <target>
11
+ # e.g. build-pkg.sh dist basecamp 1.0.0 aarch64-apple-darwin
12
+
13
+ DIST_DIR="${1:?Usage: build-pkg.sh <dist_dir> <app_name> <version> <target>}"
14
+ APP_NAME="${2:?Usage: build-pkg.sh <dist_dir> <app_name> <version> <target>}"
15
+ VERSION="${3:?Usage: build-pkg.sh <dist_dir> <app_name> <version> <target>}"
16
+ TARGET="${4:?Usage: build-pkg.sh <dist_dir> <app_name> <version> <target>}"
17
+
18
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
19
+ PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
20
+ BINARY_PATH="$DIST_DIR/$APP_NAME-$TARGET"
21
+ IDENTIFIER="com.fit-basecamp.scheduler"
22
+
23
+ if [ ! -f "$BINARY_PATH" ]; then
24
+ echo "Error: binary not found at $BINARY_PATH"
25
+ echo "Run compile.sh first."
26
+ exit 1
27
+ fi
28
+
29
+ # Determine short arch name
30
+ case "$TARGET" in
31
+ *aarch64*) ARCH_SHORT="arm64" ;;
32
+ *) ARCH_SHORT="x86_64" ;;
33
+ esac
34
+
35
+ PKG_NAME="$APP_NAME-$VERSION-$ARCH_SHORT.pkg"
36
+ PKG_PATH="$DIST_DIR/$PKG_NAME"
37
+ PAYLOAD_DIR="$DIST_DIR/pkg-payload-$ARCH_SHORT"
38
+ SCRIPTS_DIR="$DIST_DIR/pkg-scripts-$ARCH_SHORT"
39
+ RESOURCES_DIR="$DIST_DIR/pkg-resources-$ARCH_SHORT"
40
+ COMPONENT_PKG="$DIST_DIR/pkg-component-$ARCH_SHORT.pkg"
41
+
42
+ echo ""
43
+ echo "Building pkg: $PKG_NAME..."
44
+
45
+ # --- Clean previous artifacts ------------------------------------------------
46
+
47
+ rm -rf "$PAYLOAD_DIR" "$SCRIPTS_DIR" "$RESOURCES_DIR" "$COMPONENT_PKG"
48
+ rm -f "$PKG_PATH"
49
+
50
+ # --- Create payload (files to install) ---------------------------------------
51
+
52
+ mkdir -p "$PAYLOAD_DIR/usr/local/bin"
53
+ mkdir -p "$PAYLOAD_DIR/usr/local/share/fit-basecamp/config"
54
+
55
+ cp "$BINARY_PATH" "$PAYLOAD_DIR/usr/local/bin/$APP_NAME"
56
+ chmod +x "$PAYLOAD_DIR/usr/local/bin/$APP_NAME"
57
+
58
+ cp "$PROJECT_DIR/config/scheduler.json" "$PAYLOAD_DIR/usr/local/share/fit-basecamp/config/scheduler.json"
59
+ cp "$SCRIPT_DIR/uninstall.sh" "$PAYLOAD_DIR/usr/local/share/fit-basecamp/uninstall.sh"
60
+ chmod +x "$PAYLOAD_DIR/usr/local/share/fit-basecamp/uninstall.sh"
61
+
62
+ # --- Create scripts directory ------------------------------------------------
63
+
64
+ mkdir -p "$SCRIPTS_DIR"
65
+ cp "$SCRIPT_DIR/postinstall" "$SCRIPTS_DIR/postinstall"
66
+ chmod +x "$SCRIPTS_DIR/postinstall"
67
+
68
+ # --- Build component package -------------------------------------------------
69
+
70
+ pkgbuild \
71
+ --root "$PAYLOAD_DIR" \
72
+ --scripts "$SCRIPTS_DIR" \
73
+ --identifier "$IDENTIFIER" \
74
+ --version "$VERSION" \
75
+ --install-location "/" \
76
+ "$COMPONENT_PKG"
77
+
78
+ # --- Create distribution resources -------------------------------------------
79
+
80
+ mkdir -p "$RESOURCES_DIR"
81
+ cp "$SCRIPT_DIR/pkg-resources/welcome.html" "$RESOURCES_DIR/welcome.html"
82
+ cp "$SCRIPT_DIR/pkg-resources/conclusion.html" "$RESOURCES_DIR/conclusion.html"
83
+
84
+ # --- Create distribution.xml ------------------------------------------------
85
+
86
+ DIST_XML="$DIST_DIR/distribution-$ARCH_SHORT.xml"
87
+ cat > "$DIST_XML" <<EOF
88
+ <?xml version="1.0" encoding="utf-8"?>
89
+ <installer-gui-script minSpecVersion="2">
90
+ <title>Basecamp ${VERSION}</title>
91
+ <welcome file="welcome.html" mime-type="text/html" />
92
+ <conclusion file="conclusion.html" mime-type="text/html" />
93
+ <options customize="never" require-scripts="false" hostArchitectures="$ARCH_SHORT" />
94
+ <domains enable_localSystem="true" />
95
+ <pkg-ref id="$IDENTIFIER" version="$VERSION">pkg-component-$ARCH_SHORT.pkg</pkg-ref>
96
+ <choices-outline>
97
+ <line choice="$IDENTIFIER" />
98
+ </choices-outline>
99
+ <choice id="$IDENTIFIER" visible="false">
100
+ <pkg-ref id="$IDENTIFIER" />
101
+ </choice>
102
+ </installer-gui-script>
103
+ EOF
104
+
105
+ # --- Build distribution package ----------------------------------------------
106
+
107
+ productbuild \
108
+ --distribution "$DIST_XML" \
109
+ --resources "$RESOURCES_DIR" \
110
+ --package-path "$DIST_DIR" \
111
+ "$PKG_PATH"
112
+
113
+ # --- Clean up staging --------------------------------------------------------
114
+
115
+ rm -rf "$PAYLOAD_DIR" "$SCRIPTS_DIR" "$RESOURCES_DIR" "$COMPONENT_PKG" "$DIST_XML"
116
+
117
+ echo " -> $PKG_PATH"
@@ -0,0 +1,26 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # Compile Basecamp into a standalone Deno binary.
5
+ #
6
+ # Usage: compile.sh <dist_dir> <app_name> <target>
7
+ # e.g. compile.sh dist basecamp aarch64-apple-darwin
8
+
9
+ DIST_DIR="${1:?Usage: compile.sh <dist_dir> <app_name> <target>}"
10
+ APP_NAME="${2:?Usage: compile.sh <dist_dir> <app_name> <target>}"
11
+ TARGET="${3:?Usage: compile.sh <dist_dir> <app_name> <target>}"
12
+
13
+ OUTPUT="$DIST_DIR/$APP_NAME-$TARGET"
14
+
15
+ echo ""
16
+ echo "Compiling $APP_NAME for $TARGET..."
17
+ mkdir -p "$DIST_DIR"
18
+
19
+ deno compile \
20
+ --allow-all \
21
+ --target "$TARGET" \
22
+ --output "$OUTPUT" \
23
+ --include template/ \
24
+ scheduler.js
25
+
26
+ echo " -> $OUTPUT"