@forwardimpact/basecamp 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -186,7 +186,7 @@ fit-basecamp --status Show knowledge bases and task status
186
186
  fit-basecamp --help Show this help
187
187
  ```
188
188
 
189
- When running from source, use `node scheduler.js` or `npx fit-basecamp` instead
189
+ When running from source, use `node basecamp.js` or `npx fit-basecamp` instead
190
190
  of `fit-basecamp`.
191
191
 
192
192
  ## How It Works
@@ -1,17 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // Basecamp Scheduler Runs scheduled tasks across multiple knowledge bases.
3
+ // Basecamp — CLI and scheduler for personal knowledge bases.
4
4
  //
5
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
6
+ // node basecamp.js Run due tasks once and exit
7
+ // node basecamp.js --daemon Run continuously (poll every 60s)
8
+ // node basecamp.js --run <task> Run a specific task immediately
9
+ // node basecamp.js --init <path> Initialize a new knowledge base
10
+ // node basecamp.js --install-launchd Install macOS LaunchAgent
11
+ // node basecamp.js --uninstall-launchd Remove macOS LaunchAgent
12
+ // node basecamp.js --validate Validate agents and skills exist
13
+ // node basecamp.js --status Show task status
14
+ // node basecamp.js --help Show this help
15
15
 
16
16
  import {
17
17
  readFileSync,
@@ -19,11 +19,15 @@ import {
19
19
  existsSync,
20
20
  mkdirSync,
21
21
  readdirSync,
22
+ unlinkSync,
23
+ chmodSync,
22
24
  } from "node:fs";
23
25
  import { execSync } from "node:child_process";
26
+ import { spawn } from "node:child_process";
24
27
  import { join, dirname, resolve } from "node:path";
25
28
  import { homedir } from "node:os";
26
29
  import { fileURLToPath } from "node:url";
30
+ import { createServer } from "node:net";
27
31
 
28
32
  const HOME = homedir();
29
33
  const BASECAMP_HOME = join(HOME, ".fit", "basecamp");
@@ -35,11 +39,14 @@ const PLIST_PATH = join(HOME, "Library", "LaunchAgents", `${PLIST_NAME}.plist`);
35
39
  const __dirname =
36
40
  import.meta.dirname || dirname(fileURLToPath(import.meta.url));
37
41
  const KB_TEMPLATE_DIR = join(__dirname, "template");
42
+ const SOCKET_PATH = join(BASECAMP_HOME, "basecamp.sock");
38
43
  const IS_COMPILED =
39
44
  typeof Deno !== "undefined" &&
40
45
  Deno.execPath &&
41
46
  !Deno.execPath().endsWith("deno");
42
47
 
48
+ let daemonStartedAt = null;
49
+
43
50
  // --- Helpers ----------------------------------------------------------------
44
51
 
45
52
  function ensureDir(dir) {
@@ -152,6 +159,7 @@ function floorToMinute(d) {
152
159
 
153
160
  function shouldRun(task, taskState, now) {
154
161
  if (task.enabled === false) return false;
162
+ if (taskState.status === "running") return false;
155
163
  const { schedule } = task;
156
164
  if (!schedule) return false;
157
165
  const lastRun = taskState.lastRunAt ? new Date(taskState.lastRunAt) : null;
@@ -197,63 +205,254 @@ function runTask(taskName, task, _config, state) {
197
205
  ts.startedAt = new Date().toISOString();
198
206
  saveState(state);
199
207
 
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
- );
208
+ const spawnArgs = ["--print"];
209
+ if (task.agent) spawnArgs.push("--agent", task.agent);
210
+ spawnArgs.push("-p", prompt);
214
211
 
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,
212
+ return new Promise((resolve) => {
213
+ const child = spawn(claude, spawnArgs, {
214
+ cwd: kbPath,
215
+ stdio: ["pipe", "pipe", "pipe"],
216
+ timeout: 30 * 60_000,
221
217
  });
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),
218
+
219
+ let stdout = "";
220
+ let stderr = "";
221
+ child.stdout.on("data", (d) => (stdout += d));
222
+ child.stderr.on("data", (d) => (stderr += d));
223
+
224
+ child.on("close", (code) => {
225
+ if (code === 0) {
226
+ log(`Task ${taskName} completed. Output: ${stdout.slice(0, 200)}...`);
227
+ Object.assign(ts, {
228
+ status: "finished",
229
+ startedAt: null,
230
+ lastRunAt: new Date().toISOString(),
231
+ lastError: null,
232
+ runCount: (ts.runCount || 0) + 1,
233
+ });
234
+ } else {
235
+ const errMsg = stderr || stdout || `Exit code ${code}`;
236
+ log(`Task ${taskName} failed: ${errMsg.slice(0, 300)}`);
237
+ Object.assign(ts, {
238
+ status: "failed",
239
+ startedAt: null,
240
+ lastRunAt: new Date().toISOString(),
241
+ lastError: errMsg.slice(0, 500),
242
+ });
243
+ }
244
+ saveState(state);
245
+ resolve();
229
246
  });
230
- }
231
- saveState(state);
247
+
248
+ child.on("error", (err) => {
249
+ log(`Task ${taskName} failed: ${err.message}`);
250
+ Object.assign(ts, {
251
+ status: "failed",
252
+ startedAt: null,
253
+ lastRunAt: new Date().toISOString(),
254
+ lastError: err.message.slice(0, 500),
255
+ });
256
+ saveState(state);
257
+ resolve();
258
+ });
259
+ });
232
260
  }
233
261
 
234
- function runDueTasks() {
262
+ async function runDueTasks() {
235
263
  const config = loadConfig(),
236
264
  state = loadState(),
237
265
  now = new Date();
238
266
  let ranAny = false;
239
267
  for (const [name, task] of Object.entries(config.tasks)) {
240
268
  if (shouldRun(task, state.tasks[name] || {}, now)) {
241
- runTask(name, task, config, state);
269
+ await runTask(name, task, config, state);
242
270
  ranAny = true;
243
271
  }
244
272
  }
245
273
  if (!ranAny) log("No tasks due.");
246
274
  }
247
275
 
276
+ // --- Next-run computation ---------------------------------------------------
277
+
278
+ /** @param {object} task @param {object} taskState @param {Date} now */
279
+ function computeNextRunAt(task, taskState, now) {
280
+ if (task.enabled === false) return null;
281
+ const { schedule } = task;
282
+ if (!schedule) return null;
283
+
284
+ if (schedule.type === "interval") {
285
+ const ms = (schedule.minutes || 5) * 60_000;
286
+ const lastRun = taskState.lastRunAt ? new Date(taskState.lastRunAt) : null;
287
+ if (!lastRun) return now.toISOString();
288
+ return new Date(lastRun.getTime() + ms).toISOString();
289
+ }
290
+
291
+ if (schedule.type === "cron") {
292
+ const limit = 24 * 60;
293
+ const start = new Date(floorToMinute(now) + 60_000);
294
+ for (let i = 0; i < limit; i++) {
295
+ const candidate = new Date(start.getTime() + i * 60_000);
296
+ if (cronMatches(schedule.expression, candidate)) {
297
+ return candidate.toISOString();
298
+ }
299
+ }
300
+ return null;
301
+ }
302
+
303
+ if (schedule.type === "once") {
304
+ if (taskState.lastRunAt) return null;
305
+ return schedule.runAt;
306
+ }
307
+
308
+ return null;
309
+ }
310
+
311
+ // --- Socket server ----------------------------------------------------------
312
+
313
+ /** @param {import('node:net').Socket} socket @param {object} data */
314
+ function send(socket, data) {
315
+ try {
316
+ socket.write(JSON.stringify(data) + "\n");
317
+ } catch {}
318
+ }
319
+
320
+ function handleStatusRequest(socket) {
321
+ const config = loadConfig();
322
+ const state = loadState();
323
+ const now = new Date();
324
+ const tasks = {};
325
+
326
+ for (const [name, task] of Object.entries(config.tasks)) {
327
+ const ts = state.tasks[name] || {};
328
+ tasks[name] = {
329
+ enabled: task.enabled !== false,
330
+ status: ts.status || "never-run",
331
+ lastRunAt: ts.lastRunAt || null,
332
+ nextRunAt: computeNextRunAt(task, ts, now),
333
+ runCount: ts.runCount || 0,
334
+ lastError: ts.lastError || null,
335
+ };
336
+ if (ts.startedAt) tasks[name].startedAt = ts.startedAt;
337
+ }
338
+
339
+ send(socket, {
340
+ type: "status",
341
+ uptime: daemonStartedAt
342
+ ? Math.floor((Date.now() - daemonStartedAt) / 1000)
343
+ : 0,
344
+ tasks,
345
+ });
346
+ }
347
+
348
+ function handleRestartRequest(socket) {
349
+ send(socket, { type: "ack", command: "restart" });
350
+ const uid = execSync("id -u", { encoding: "utf8" }).trim();
351
+ setTimeout(() => {
352
+ try {
353
+ execSync(`launchctl kickstart -k gui/${uid}/${PLIST_NAME}`);
354
+ } catch {
355
+ process.exit(0);
356
+ }
357
+ }, 100);
358
+ }
359
+
360
+ function handleRunRequest(socket, taskName) {
361
+ if (!taskName) {
362
+ send(socket, { type: "error", message: "Missing task name" });
363
+ return;
364
+ }
365
+ const config = loadConfig();
366
+ const task = config.tasks[taskName];
367
+ if (!task) {
368
+ send(socket, { type: "error", message: `Task not found: ${taskName}` });
369
+ return;
370
+ }
371
+ send(socket, { type: "ack", command: "run", task: taskName });
372
+ const state = loadState();
373
+ runTask(taskName, task, config, state).catch((err) => {
374
+ console.error(`[socket] runTask error for ${taskName}:`, err.message);
375
+ });
376
+ }
377
+
378
+ function handleMessage(socket, line) {
379
+ let request;
380
+ try {
381
+ request = JSON.parse(line);
382
+ } catch {
383
+ send(socket, { type: "error", message: "Invalid JSON" });
384
+ return;
385
+ }
386
+
387
+ const handlers = {
388
+ status: () => handleStatusRequest(socket),
389
+ restart: () => handleRestartRequest(socket),
390
+ run: () => handleRunRequest(socket, request.task),
391
+ };
392
+
393
+ const handler = handlers[request.type];
394
+ if (handler) {
395
+ handler();
396
+ } else {
397
+ send(socket, {
398
+ type: "error",
399
+ message: `Unknown request type: ${request.type}`,
400
+ });
401
+ }
402
+ }
403
+
404
+ function startSocketServer() {
405
+ try {
406
+ unlinkSync(SOCKET_PATH);
407
+ } catch {}
408
+
409
+ const server = createServer((socket) => {
410
+ let buffer = "";
411
+ socket.on("data", (data) => {
412
+ buffer += data.toString();
413
+ let idx;
414
+ while ((idx = buffer.indexOf("\n")) !== -1) {
415
+ const line = buffer.slice(0, idx).trim();
416
+ buffer = buffer.slice(idx + 1);
417
+ if (line) handleMessage(socket, line);
418
+ }
419
+ });
420
+ socket.on("error", () => {});
421
+ });
422
+
423
+ server.listen(SOCKET_PATH, () => {
424
+ chmodSync(SOCKET_PATH, 0o600);
425
+ log(`Socket server listening on ${SOCKET_PATH}`);
426
+ });
427
+
428
+ server.on("error", (err) => {
429
+ log(`Socket server error: ${err.message}`);
430
+ });
431
+
432
+ const cleanup = () => {
433
+ server.close();
434
+ try {
435
+ unlinkSync(SOCKET_PATH);
436
+ } catch {}
437
+ process.exit(0);
438
+ };
439
+ process.on("SIGTERM", cleanup);
440
+ process.on("SIGINT", cleanup);
441
+
442
+ return server;
443
+ }
444
+
248
445
  // --- Daemon -----------------------------------------------------------------
249
446
 
250
447
  function daemon() {
448
+ daemonStartedAt = Date.now();
251
449
  log("Scheduler daemon started. Polling every 60 seconds.");
252
450
  log(`Config: ${CONFIG_PATH} State: ${STATE_PATH}`);
451
+ startSocketServer();
253
452
  runDueTasks();
254
- setInterval(() => {
453
+ setInterval(async () => {
255
454
  try {
256
- runDueTasks();
455
+ await runDueTasks();
257
456
  } catch (err) {
258
457
  log(`Error: ${err.message}`);
259
458
  }
@@ -307,7 +506,7 @@ function installLaunchd() {
307
506
  const isCompiled = IS_COMPILED || !execPath.includes("node");
308
507
  const progArgs = isCompiled
309
508
  ? ` <string>${execPath}</string>\n <string>--daemon</string>`
310
- : ` <string>${execPath}</string>\n <string>${join(__dirname, "scheduler.js")}</string>\n <string>--daemon</string>`;
509
+ : ` <string>${execPath}</string>\n <string>${join(__dirname, "basecamp.js")}</string>\n <string>--daemon</string>`;
311
510
 
312
511
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
313
512
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -526,14 +725,14 @@ const commands = {
526
725
  "--status": showStatus,
527
726
  "--init": () => {
528
727
  if (!args[1]) {
529
- console.error("Usage: node scheduler.js --init <path>");
728
+ console.error("Usage: node basecamp.js --init <path>");
530
729
  process.exit(1);
531
730
  }
532
731
  initKB(args[1]);
533
732
  },
534
- "--run": () => {
733
+ "--run": async () => {
535
734
  if (!args[1]) {
536
- console.error("Usage: node scheduler.js --run <task-name>");
735
+ console.error("Usage: node basecamp.js --run <task-name>");
537
736
  process.exit(1);
538
737
  }
539
738
  const config = loadConfig(),
@@ -545,8 +744,8 @@ const commands = {
545
744
  );
546
745
  process.exit(1);
547
746
  }
548
- runTask(args[1], task, config, state);
747
+ await runTask(args[1], task, config, state);
549
748
  },
550
749
  };
551
750
 
552
- (commands[command] || runDueTasks)();
751
+ await (commands[command] || runDueTasks)();
package/build.js CHANGED
@@ -1,17 +1,12 @@
1
1
  #!/usr/bin/env -S deno run --allow-all
2
2
 
3
- // Build script for Basecamp.
3
+ // Build script for Basecamp (arm64 macOS).
4
4
  //
5
5
  // Usage:
6
- // deno run --allow-all build.js Build standalone executable (current arch)
7
- // deno run --allow-all build.js --pkg Build executable + macOS .pkg installer
8
- // deno run --allow-all build.js --all Build for both arm64 and x86_64 + .pkg
9
- //
10
- // Or via deno tasks:
11
- // deno task build
12
- // deno task build:pkg
6
+ // deno run --allow-all build.js Build standalone executable
7
+ // deno run --allow-all build.js --pkg Build executable + macOS .pkg installer
13
8
 
14
- import { existsSync, mkdirSync } from "node:fs";
9
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
15
10
  import { join, dirname } from "node:path";
16
11
  import { execSync } from "node:child_process";
17
12
 
@@ -19,7 +14,11 @@ const __dirname =
19
14
  import.meta.dirname || dirname(new URL(import.meta.url).pathname);
20
15
  const DIST_DIR = join(__dirname, "dist");
21
16
  const APP_NAME = "fit-basecamp";
22
- const VERSION = "1.0.0";
17
+ const STATUS_MENU_NAME = "BasecampStatus";
18
+ const STATUS_MENU_DIR = join(__dirname, "StatusMenu");
19
+ const VERSION = JSON.parse(
20
+ readFileSync(join(__dirname, "package.json"), "utf8"),
21
+ ).version;
23
22
 
24
23
  // ---------------------------------------------------------------------------
25
24
  // Helpers
@@ -34,32 +33,23 @@ function run(cmd, opts = {}) {
34
33
  return execSync(cmd, { encoding: "utf8", stdio: "inherit", ...opts });
35
34
  }
36
35
 
37
- function runCapture(cmd) {
38
- return execSync(cmd, {
39
- encoding: "utf8",
40
- stdio: ["pipe", "pipe", "pipe"],
41
- }).trim();
42
- }
43
-
44
36
  // ---------------------------------------------------------------------------
45
37
  // Compile standalone binary
46
38
  // ---------------------------------------------------------------------------
47
39
 
48
- function compile(target = null) {
49
- const arch = target || detectArch();
50
- const outputName = target ? `${APP_NAME}-${arch}` : APP_NAME;
51
- const outputPath = join(DIST_DIR, outputName);
40
+ function compile() {
41
+ const outputPath = join(DIST_DIR, APP_NAME);
52
42
 
53
- console.log(`\nCompiling ${APP_NAME} for ${arch}...`);
43
+ console.log(`\nCompiling ${APP_NAME}...`);
54
44
  ensureDir(DIST_DIR);
55
45
 
56
46
  const cmd = [
57
47
  "deno compile",
58
48
  "--allow-all",
59
- `--target ${arch}`,
49
+ "--no-check",
60
50
  `--output "${outputPath}"`,
61
51
  "--include template/",
62
- "scheduler.js",
52
+ "basecamp.js",
63
53
  ].join(" ");
64
54
 
65
55
  run(cmd, { cwd: __dirname });
@@ -68,26 +58,43 @@ function compile(target = null) {
68
58
  return outputPath;
69
59
  }
70
60
 
71
- function detectArch() {
72
- const arch = runCapture("uname -m");
73
- if (arch === "arm64") return "aarch64-apple-darwin";
74
- return "x86_64-apple-darwin";
61
+ // ---------------------------------------------------------------------------
62
+ // Compile Swift status menu binary
63
+ // ---------------------------------------------------------------------------
64
+
65
+ function compileStatusMenu() {
66
+ console.log(`\nCompiling ${STATUS_MENU_NAME}...`);
67
+ ensureDir(DIST_DIR);
68
+
69
+ const buildDir = join(STATUS_MENU_DIR, ".build");
70
+ rmSync(buildDir, { recursive: true, force: true });
71
+
72
+ run("swift build -c release", { cwd: STATUS_MENU_DIR });
73
+
74
+ const binary = join(buildDir, "release", STATUS_MENU_NAME);
75
+ const outputPath = join(DIST_DIR, STATUS_MENU_NAME);
76
+ run(`cp "${binary}" "${outputPath}"`);
77
+
78
+ rmSync(buildDir, { recursive: true, force: true });
79
+
80
+ console.log(` -> ${outputPath}`);
81
+ return outputPath;
75
82
  }
76
83
 
77
84
  // ---------------------------------------------------------------------------
78
85
  // Build macOS installer package (.pkg)
79
86
  // ---------------------------------------------------------------------------
80
87
 
81
- function buildPKG(binaryPath, arch) {
82
- const archShort = arch.includes("aarch64") ? "arm64" : "x86_64";
83
- const pkgName = `${APP_NAME}-${VERSION}-${archShort}.pkg`;
88
+ function buildPKG(statusMenuBinaryPath) {
89
+ const pkgName = `${APP_NAME}-${VERSION}.pkg`;
84
90
 
85
91
  console.log(`\nBuilding pkg: ${pkgName}...`);
86
92
 
87
93
  const buildPkg = join(__dirname, "scripts", "build-pkg.sh");
88
- run(`"${buildPkg}" "${DIST_DIR}" "${APP_NAME}" "${VERSION}" "${arch}"`, {
89
- cwd: __dirname,
90
- });
94
+ run(
95
+ `"${buildPkg}" "${DIST_DIR}" "${APP_NAME}" "${VERSION}" "${statusMenuBinaryPath}"`,
96
+ { cwd: __dirname },
97
+ );
91
98
 
92
99
  console.log(` -> ${join(DIST_DIR, pkgName)}`);
93
100
  return join(DIST_DIR, pkgName);
@@ -99,26 +106,17 @@ function buildPKG(binaryPath, arch) {
99
106
 
100
107
  const args = Deno?.args || process.argv.slice(2);
101
108
  const wantPKG = args.includes("--pkg");
102
- const wantAll = args.includes("--all");
103
109
 
104
110
  console.log(`Basecamp Build (v${VERSION})`);
105
111
  console.log("==========================");
106
112
 
107
- if (wantAll) {
108
- // Build for both architectures
109
- const targets = ["aarch64-apple-darwin", "x86_64-apple-darwin"];
110
- for (const target of targets) {
111
- const binary = compile(target);
112
- buildPKG(binary, target);
113
- }
114
- } else {
115
- // Build for current architecture
116
- const arch = detectArch();
117
- const binary = compile(arch);
118
-
119
- if (wantPKG) {
120
- buildPKG(binary, arch);
121
- }
113
+ // Compile Deno binary first (before status menu exists in dist/),
114
+ // so the status menu binary is not embedded in the Deno binary.
115
+ compile();
116
+ const statusMenuBinary = compileStatusMenu();
117
+
118
+ if (wantPKG) {
119
+ buildPKG(statusMenuBinary);
122
120
  }
123
121
 
124
122
  console.log("\nBuild complete! Output in dist/");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/basecamp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Claude Code-native personal knowledge system with scheduled tasks",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -10,22 +10,22 @@
10
10
  },
11
11
  "homepage": "https://www.forwardimpact.team/basecamp",
12
12
  "type": "module",
13
- "main": "scheduler.js",
13
+ "main": "basecamp.js",
14
14
  "bin": {
15
- "fit-basecamp": "./scheduler.js"
15
+ "fit-basecamp": "./basecamp.js"
16
16
  },
17
17
  "scripts": {
18
- "start": "node scheduler.js",
19
- "status": "node scheduler.js --status",
18
+ "start": "node basecamp.js",
19
+ "status": "node basecamp.js --status",
20
20
  "build": "deno run --allow-all build.js",
21
21
  "build:pkg": "deno run --allow-all build.js --pkg",
22
22
  "build:all": "deno run --allow-all build.js --all",
23
23
  "install:basecamp": "./scripts/install.sh",
24
- "scheduler:install": "node scheduler.js --install-launchd",
25
- "scheduler:uninstall": "node scheduler.js --uninstall-launchd"
24
+ "scheduler:install": "node basecamp.js --install-launchd",
25
+ "scheduler:uninstall": "node basecamp.js --uninstall-launchd"
26
26
  },
27
27
  "files": [
28
- "scheduler.js",
28
+ "basecamp.js",
29
29
  "build.js",
30
30
  "config/",
31
31
  "scripts/",
@@ -1,23 +1,23 @@
1
1
  #!/bin/bash
2
2
  set -e
3
3
 
4
- # Build a macOS installer package (.pkg) for Basecamp.
4
+ # Build a macOS installer package (.pkg) for Basecamp (arm64).
5
5
  #
6
6
  # Uses pkgbuild (component) + productbuild (distribution) to create a .pkg
7
7
  # that installs the binary to /usr/local/bin/ and runs a postinstall script
8
8
  # to set up the LaunchAgent, config, and default knowledge base.
9
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
10
+ # Usage: build-pkg.sh <dist_dir> <app_name> <version> <status_menu_binary>
11
+ # e.g. build-pkg.sh dist fit-basecamp 1.0.0 dist/BasecampStatus
12
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>}"
13
+ DIST_DIR="${1:?Usage: build-pkg.sh <dist_dir> <app_name> <version> <status_menu_binary>}"
14
+ APP_NAME="${2:?Usage: build-pkg.sh <dist_dir> <app_name> <version> <status_menu_binary>}"
15
+ VERSION="${3:?Usage: build-pkg.sh <dist_dir> <app_name> <version> <status_menu_binary>}"
16
+ STATUS_MENU_BINARY="${4:?Usage: build-pkg.sh <dist_dir> <app_name> <version> <status_menu_binary>}"
17
17
 
18
18
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
19
19
  PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
20
- BINARY_PATH="$DIST_DIR/$APP_NAME-$TARGET"
20
+ BINARY_PATH="$DIST_DIR/$APP_NAME"
21
21
  IDENTIFIER="com.fit-basecamp.scheduler"
22
22
 
23
23
  if [ ! -f "$BINARY_PATH" ]; then
@@ -26,18 +26,12 @@ if [ ! -f "$BINARY_PATH" ]; then
26
26
  exit 1
27
27
  fi
28
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"
29
+ PKG_NAME="$APP_NAME-$VERSION.pkg"
36
30
  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"
31
+ PAYLOAD_DIR="$DIST_DIR/pkg-payload"
32
+ SCRIPTS_DIR="$DIST_DIR/pkg-scripts"
33
+ RESOURCES_DIR="$DIST_DIR/pkg-resources"
34
+ COMPONENT_PKG="$DIST_DIR/pkg-component.pkg"
41
35
 
42
36
  echo ""
43
37
  echo "Building pkg: $PKG_NAME..."
@@ -59,6 +53,10 @@ cp "$PROJECT_DIR/config/scheduler.json" "$PAYLOAD_DIR/usr/local/share/fit-baseca
59
53
  cp "$SCRIPT_DIR/uninstall.sh" "$PAYLOAD_DIR/usr/local/share/fit-basecamp/uninstall.sh"
60
54
  chmod +x "$PAYLOAD_DIR/usr/local/share/fit-basecamp/uninstall.sh"
61
55
 
56
+ # Status menu binary
57
+ cp "$STATUS_MENU_BINARY" "$PAYLOAD_DIR/usr/local/bin/BasecampStatus"
58
+ chmod +x "$PAYLOAD_DIR/usr/local/bin/BasecampStatus"
59
+
62
60
  # --- Create scripts directory ------------------------------------------------
63
61
 
64
62
  mkdir -p "$SCRIPTS_DIR"
@@ -83,16 +81,16 @@ cp "$SCRIPT_DIR/pkg-resources/conclusion.html" "$RESOURCES_DIR/conclusion.html"
83
81
 
84
82
  # --- Create distribution.xml ------------------------------------------------
85
83
 
86
- DIST_XML="$DIST_DIR/distribution-$ARCH_SHORT.xml"
84
+ DIST_XML="$DIST_DIR/distribution.xml"
87
85
  cat > "$DIST_XML" <<EOF
88
86
  <?xml version="1.0" encoding="utf-8"?>
89
87
  <installer-gui-script minSpecVersion="2">
90
88
  <title>Basecamp ${VERSION}</title>
91
89
  <welcome file="welcome.html" mime-type="text/html" />
92
90
  <conclusion file="conclusion.html" mime-type="text/html" />
93
- <options customize="never" require-scripts="false" hostArchitectures="$ARCH_SHORT" />
91
+ <options customize="never" require-scripts="false" hostArchitectures="arm64" />
94
92
  <domains enable_localSystem="true" />
95
- <pkg-ref id="$IDENTIFIER" version="$VERSION">pkg-component-$ARCH_SHORT.pkg</pkg-ref>
93
+ <pkg-ref id="$IDENTIFIER" version="$VERSION">pkg-component.pkg</pkg-ref>
96
94
  <choices-outline>
97
95
  <line choice="$IDENTIFIER" />
98
96
  </choices-outline>
@@ -114,4 +112,4 @@ productbuild \
114
112
 
115
113
  rm -rf "$PAYLOAD_DIR" "$SCRIPTS_DIR" "$RESOURCES_DIR" "$COMPONENT_PKG" "$DIST_XML"
116
114
 
117
- echo " -> $PKG_PATH"
115
+ echo " -> $PKG_NAME"
@@ -1,26 +1,25 @@
1
1
  #!/bin/bash
2
2
  set -e
3
3
 
4
- # Compile Basecamp into a standalone Deno binary.
4
+ # Compile Basecamp into a standalone Deno binary (arm64 macOS).
5
5
  #
6
- # Usage: compile.sh <dist_dir> <app_name> <target>
7
- # e.g. compile.sh dist basecamp aarch64-apple-darwin
6
+ # Usage: compile.sh <dist_dir> <app_name>
7
+ # e.g. compile.sh dist fit-basecamp
8
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>}"
9
+ DIST_DIR="${1:?Usage: compile.sh <dist_dir> <app_name>}"
10
+ APP_NAME="${2:?Usage: compile.sh <dist_dir> <app_name>}"
12
11
 
13
- OUTPUT="$DIST_DIR/$APP_NAME-$TARGET"
12
+ OUTPUT="$DIST_DIR/$APP_NAME"
14
13
 
15
14
  echo ""
16
- echo "Compiling $APP_NAME for $TARGET..."
15
+ echo "Compiling $APP_NAME..."
17
16
  mkdir -p "$DIST_DIR"
18
17
 
19
18
  deno compile \
20
19
  --allow-all \
21
- --target "$TARGET" \
20
+ --no-check \
22
21
  --output "$OUTPUT" \
23
22
  --include template/ \
24
- scheduler.js
23
+ basecamp.js
25
24
 
26
25
  echo " -> $OUTPUT"
@@ -61,7 +61,7 @@ fi
61
61
  echo ""
62
62
  if [ ! -d "$DEFAULT_KB" ]; then
63
63
  echo "Initializing default knowledge base at $DEFAULT_KB ..."
64
- SCHEDULER="$SCRIPT_DIR/../scheduler.js"
64
+ SCHEDULER="$SCRIPT_DIR/../basecamp.js"
65
65
  if command -v deno &>/dev/null && [ -f "$SCHEDULER" ]; then
66
66
  deno run --allow-all "$SCHEDULER" --init "$DEFAULT_KB"
67
67
  elif command -v node &>/dev/null && [ -f "$SCHEDULER" ]; then
@@ -79,7 +79,7 @@ fi
79
79
 
80
80
  echo ""
81
81
  echo "Installing background scheduler (LaunchAgent)..."
82
- SCHEDULER="$SCRIPT_DIR/../scheduler.js"
82
+ SCHEDULER="$SCRIPT_DIR/../basecamp.js"
83
83
  if command -v deno &>/dev/null && [ -f "$SCHEDULER" ]; then
84
84
  deno run --allow-all "$SCHEDULER" --install-launchd
85
85
  elif command -v node &>/dev/null && [ -f "$SCHEDULER" ]; then
@@ -102,7 +102,7 @@ echo ""
102
102
  echo "Next steps:"
103
103
  echo " 1. Edit $DEFAULT_KB/USER.md with your name, email, and domain"
104
104
  echo " 2. Edit $BASECAMP_HOME/scheduler.json to configure tasks"
105
- echo " 3. Run the scheduler: deno run --allow-all scheduler.js --status"
106
- echo " 4. Start the daemon: deno run --allow-all scheduler.js --install-launchd"
105
+ echo " 3. Run the scheduler: deno run --allow-all basecamp.js --status"
106
+ echo " 4. Start the daemon: deno run --allow-all basecamp.js --install-launchd"
107
107
  echo " 5. Open your KB: cd $DEFAULT_KB && claude"
108
108
  echo ""
@@ -12,6 +12,7 @@ BASECAMP_HOME="$REAL_HOME/.fit/basecamp"
12
12
  DEFAULT_KB="$REAL_HOME/Documents/Personal"
13
13
  SHARE_DIR="/usr/local/share/fit-basecamp"
14
14
  BINARY="/usr/local/bin/fit-basecamp"
15
+ STATUS_MENU_BINARY="/usr/local/bin/BasecampStatus"
15
16
 
16
17
  # --- Config home -------------------------------------------------------------
17
18
 
@@ -43,4 +44,41 @@ else
43
44
  /usr/bin/sudo -u "$REAL_USER" "$BINARY" --install-launchd 2>/dev/null || true
44
45
  fi
45
46
 
47
+ # --- Status menu LaunchAgent (runs as user, not root) -----------------------
48
+
49
+ STATUS_PLIST="$REAL_HOME/Library/LaunchAgents/com.fit-basecamp.status-menu.plist"
50
+
51
+ if [ -f "$STATUS_MENU_BINARY" ]; then
52
+ # Kill old status menu if running (picks up new binary on relaunch)
53
+ /usr/bin/killall BasecampStatus 2>/dev/null || true
54
+
55
+ # Write LaunchAgent plist
56
+ /usr/bin/sudo -u "$REAL_USER" /usr/bin/tee "$STATUS_PLIST" > /dev/null <<PLIST
57
+ <?xml version="1.0" encoding="UTF-8"?>
58
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
59
+ <plist version="1.0">
60
+ <dict>
61
+ <key>Label</key>
62
+ <string>com.fit-basecamp.status-menu</string>
63
+ <key>ProgramArguments</key>
64
+ <array>
65
+ <string>/usr/local/bin/BasecampStatus</string>
66
+ </array>
67
+ <key>RunAtLoad</key>
68
+ <true/>
69
+ <key>KeepAlive</key>
70
+ <true/>
71
+ <key>StandardOutPath</key>
72
+ <string>$BASECAMP_HOME/logs/status-menu-stdout.log</string>
73
+ <key>StandardErrorPath</key>
74
+ <string>$BASECAMP_HOME/logs/status-menu-stderr.log</string>
75
+ </dict>
76
+ </plist>
77
+ PLIST
78
+
79
+ # Load the LaunchAgent (unload first for upgrades)
80
+ /usr/bin/sudo -u "$REAL_USER" /usr/bin/launchctl unload "$STATUS_PLIST" 2>/dev/null || true
81
+ /usr/bin/sudo -u "$REAL_USER" /usr/bin/launchctl load "$STATUS_PLIST"
82
+ fi
83
+
46
84
  exit 0
@@ -24,6 +24,23 @@ else
24
24
  echo " LaunchAgent not found, skipping."
25
25
  fi
26
26
 
27
+ # Remove status menu LaunchAgent
28
+ STATUS_PLIST="$HOME/Library/LaunchAgents/com.fit-basecamp.status-menu.plist"
29
+ if [ -f "$STATUS_PLIST" ]; then
30
+ launchctl unload "$STATUS_PLIST" 2>/dev/null || true
31
+ rm -f "$STATUS_PLIST"
32
+ echo " Removed status menu LaunchAgent"
33
+ fi
34
+ killall BasecampStatus 2>/dev/null || true
35
+ if [ -f "/usr/local/bin/BasecampStatus" ]; then
36
+ sudo rm -f "/usr/local/bin/BasecampStatus"
37
+ echo " Removed /usr/local/bin/BasecampStatus"
38
+ fi
39
+
40
+ # Remove stale socket file
41
+ # Socket path: ~/.fit/basecamp/basecamp.sock (must match SOCKET_PATH in basecamp.js)
42
+ rm -f "$HOME/.fit/basecamp/basecamp.sock"
43
+
27
44
  # Remove binary
28
45
  if [ -f "/usr/local/bin/$APP_NAME" ]; then
29
46
  sudo rm -f "/usr/local/bin/$APP_NAME"