@forwardimpact/basecamp 0.2.0 → 1.0.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.
@@ -4,14 +4,19 @@
4
4
  Queries the macOS Mail SQLite database for threads with new messages since
5
5
  the last sync and writes one markdown file per thread.
6
6
 
7
- Usage: python3 scripts/sync.py
7
+ Usage: python3 scripts/sync.py [--days N]
8
+
9
+ Options:
10
+ --days N How many days back to sync on first run (default: 30)
8
11
 
9
12
  Requires: macOS with Mail app configured and Full Disk Access granted.
10
13
  """
11
14
 
15
+ import argparse
12
16
  import importlib.util
13
17
  import json
14
18
  import os
19
+ import shutil
15
20
  import subprocess
16
21
  import sys
17
22
  import time
@@ -19,6 +24,7 @@ from datetime import datetime, timezone, timedelta
19
24
  from pathlib import Path
20
25
 
21
26
  OUTDIR = os.path.expanduser("~/.cache/fit/basecamp/apple_mail")
27
+ ATTACHMENTS_DIR = os.path.join(OUTDIR, "attachments")
22
28
  STATE_DIR = os.path.expanduser("~/.cache/fit/basecamp/state")
23
29
  STATE_FILE = os.path.join(STATE_DIR, "apple_mail_last_sync")
24
30
 
@@ -64,8 +70,12 @@ def query(db, sql, retry=True):
64
70
  return json.loads(result.stdout) if result.stdout.strip() else []
65
71
 
66
72
 
67
- def load_last_sync():
68
- """Load the last sync timestamp. Returns Unix timestamp."""
73
+ def load_last_sync(days_back=30):
74
+ """Load the last sync timestamp. Returns Unix timestamp.
75
+
76
+ Args:
77
+ days_back: How many days back to look on first sync (default: 30).
78
+ """
69
79
  try:
70
80
  iso = Path(STATE_FILE).read_text().strip()
71
81
  if iso:
@@ -75,8 +85,8 @@ def load_last_sync():
75
85
  return int(dt.timestamp())
76
86
  except (FileNotFoundError, ValueError):
77
87
  pass
78
- # First sync: 30 days ago
79
- return int((datetime.now(timezone.utc) - timedelta(days=30)).timestamp())
88
+ # First sync: N days ago
89
+ return int((datetime.now(timezone.utc) - timedelta(days=days_back)).timestamp())
80
90
 
81
91
 
82
92
  def save_sync_state():
@@ -176,28 +186,64 @@ def fetch_recipients(db, message_ids):
176
186
  return result
177
187
 
178
188
 
179
- def build_emlx_index():
180
- """Build a dict mapping message ROWID .emlx file path with a single find."""
189
+ def fetch_attachments(db, message_ids):
190
+ """Batch-fetch attachment metadata for a set of message IDs."""
191
+ if not message_ids:
192
+ return {}
193
+ id_list = ",".join(str(mid) for mid in message_ids)
194
+ rows = query(db, f"""
195
+ SELECT a.message AS message_id, a.attachment_id, a.name
196
+ FROM attachments a
197
+ WHERE a.message IN ({id_list})
198
+ ORDER BY a.message, a.ROWID;
199
+ """)
200
+ result = {}
201
+ for r in rows:
202
+ mid = r["message_id"]
203
+ result.setdefault(mid, []).append({
204
+ "attachment_id": r["attachment_id"],
205
+ "name": r["name"],
206
+ })
207
+ return result
208
+
209
+
210
+ def build_file_indexes():
211
+ """Build emlx and attachment indexes with a single find traversal.
212
+
213
+ Returns (emlx_index, attachment_index):
214
+ emlx_index: {message_rowid: path} for .emlx files
215
+ attachment_index: {(message_rowid, attachment_id): path} for attachment files
216
+ """
181
217
  mail_dir = os.path.expanduser("~/Library/Mail")
182
218
  try:
183
219
  result = subprocess.run(
184
- ["find", mail_dir, "-name", "*.emlx"],
185
- capture_output=True, text=True, timeout=30
220
+ ["find", mail_dir, "(", "-name", "*.emlx", "-o",
221
+ "-path", "*/Attachments/*", ")", "-type", "f"],
222
+ capture_output=True, text=True, timeout=60
186
223
  )
187
- index = {}
224
+ emlx_index = {}
225
+ attachment_index = {}
188
226
  for path in result.stdout.strip().splitlines():
189
- # Filename is like 12345.emlx or 12345.partial.emlx
190
- basename = os.path.basename(path)
191
- # Extract the numeric ID
192
- msg_id = basename.split(".")[0]
193
- if msg_id.isdigit():
194
- mid = int(msg_id)
195
- # Prefer .emlx over .partial.emlx (shorter name = full message)
196
- if mid not in index or len(basename) < len(os.path.basename(index[mid])):
197
- index[mid] = path
198
- return index
227
+ if "/Attachments/" in path:
228
+ # Path: .../Attachments/{msg_rowid}/{attachment_id}/{filename}
229
+ parts = path.split("/Attachments/", 1)
230
+ if len(parts) == 2:
231
+ segments = parts[1].split("/")
232
+ if len(segments) >= 3 and segments[0].isdigit():
233
+ msg_rowid = int(segments[0])
234
+ att_id = segments[1]
235
+ attachment_index[(msg_rowid, att_id)] = path
236
+ elif path.endswith(".emlx"):
237
+ basename = os.path.basename(path)
238
+ msg_id = basename.split(".")[0]
239
+ if msg_id.isdigit():
240
+ mid = int(msg_id)
241
+ # Prefer .emlx over .partial.emlx (shorter name = full message)
242
+ if mid not in emlx_index or len(basename) < len(os.path.basename(emlx_index[mid])):
243
+ emlx_index[mid] = path
244
+ return emlx_index, attachment_index
199
245
  except (subprocess.TimeoutExpired, Exception):
200
- return {}
246
+ return {}, {}
201
247
 
202
248
 
203
249
  def parse_emlx(message_id, emlx_index):
@@ -234,7 +280,44 @@ def format_sender(msg):
234
280
  return addr or name
235
281
 
236
282
 
237
- def write_thread_markdown(thread_id, messages, recipients_by_msg, emlx_index):
283
+ def copy_thread_attachments(thread_id, messages, attachments_by_msg, attachment_index):
284
+ """Copy attachment files into the output attachments directory.
285
+
286
+ Returns {message_id: [{name, available, path}, ...]} for markdown listing.
287
+ """
288
+ results = {}
289
+ seen_filenames = set()
290
+ for msg in messages:
291
+ mid = msg["message_id"]
292
+ msg_attachments = attachments_by_msg.get(mid, [])
293
+ if not msg_attachments:
294
+ continue
295
+ msg_results = []
296
+ for att in msg_attachments:
297
+ att_id = att["attachment_id"]
298
+ name = att["name"] or "unnamed"
299
+ source = attachment_index.get((mid, att_id))
300
+ if not source or not os.path.isfile(source):
301
+ msg_results.append({"name": name, "available": False, "path": None})
302
+ continue
303
+ # Handle filename collisions by prefixing with message_id
304
+ dest_name = name
305
+ if dest_name in seen_filenames:
306
+ dest_name = f"{mid}_{name}"
307
+ seen_filenames.add(dest_name)
308
+ dest_dir = os.path.join(ATTACHMENTS_DIR, str(thread_id))
309
+ os.makedirs(dest_dir, exist_ok=True)
310
+ dest_path = os.path.join(dest_dir, dest_name)
311
+ try:
312
+ shutil.copy2(source, dest_path)
313
+ msg_results.append({"name": dest_name, "available": True, "path": dest_path})
314
+ except OSError:
315
+ msg_results.append({"name": name, "available": False, "path": None})
316
+ results[mid] = msg_results
317
+ return results
318
+
319
+
320
+ def write_thread_markdown(thread_id, messages, recipients_by_msg, emlx_index, attachment_results=None):
238
321
  """Write a thread as a markdown file."""
239
322
  if not messages:
240
323
  return False
@@ -286,17 +369,34 @@ def write_thread_markdown(thread_id, messages, recipients_by_msg, emlx_index):
286
369
  lines.append(body)
287
370
  lines.append("")
288
371
 
372
+ # Attachments
373
+ if attachment_results:
374
+ msg_atts = attachment_results.get(mid, [])
375
+ if msg_atts:
376
+ lines.append("**Attachments:**")
377
+ for att in msg_atts:
378
+ if att["available"]:
379
+ lines.append(f"- [{att['name']}](attachments/{thread_id}/{att['name']})")
380
+ else:
381
+ lines.append(f"- {att['name']} *(not available)*")
382
+ lines.append("")
383
+
289
384
  filepath = os.path.join(OUTDIR, f"{thread_id}.md")
290
385
  Path(filepath).write_text("\n".join(lines))
291
386
  return True
292
387
 
293
388
 
294
389
  def main():
390
+ parser = argparse.ArgumentParser(description="Sync Apple Mail threads.")
391
+ parser.add_argument("--days", type=int, default=30,
392
+ help="How many days back to sync on first run (default: 30)")
393
+ args = parser.parse_args()
394
+
295
395
  db = find_db()
296
396
  os.makedirs(OUTDIR, exist_ok=True)
297
397
 
298
398
  # Load sync state
299
- since_ts = load_last_sync()
399
+ since_ts = load_last_sync(days_back=args.days)
300
400
  since_readable = unix_to_readable(since_ts)
301
401
 
302
402
  # Discover thread column
@@ -317,8 +417,8 @@ def main():
317
417
  save_sync_state()
318
418
  return
319
419
 
320
- # Build .emlx file index once (single find traversal)
321
- emlx_index = build_emlx_index()
420
+ # Build .emlx and attachment file indexes (single find traversal)
421
+ emlx_index, attachment_index = build_file_indexes()
322
422
 
323
423
  # Process each thread
324
424
  written = 0
@@ -327,11 +427,18 @@ def main():
327
427
  if not messages:
328
428
  continue
329
429
 
330
- # Batch-fetch recipients for all messages in thread
331
430
  msg_ids = [m["message_id"] for m in messages]
431
+
432
+ # Batch-fetch recipients and attachments for all messages in thread
332
433
  recipients = fetch_recipients(db, msg_ids)
434
+ attachments_by_msg = fetch_attachments(db, msg_ids)
435
+
436
+ # Copy attachment files to output directory
437
+ attachment_results = copy_thread_attachments(
438
+ tid, messages, attachments_by_msg, attachment_index
439
+ )
333
440
 
334
- if write_thread_markdown(tid, messages, recipients, emlx_index):
441
+ if write_thread_markdown(tid, messages, recipients, emlx_index, attachment_results):
335
442
  written += 1
336
443
 
337
444
  # Save sync state (even on partial success)
@@ -5,6 +5,37 @@ prepping for meetings, tracking projects, and answering questions — backed by
5
5
  live knowledge graph built from their emails, calendar, and meeting notes.
6
6
  Everything lives locally on this machine.
7
7
 
8
+ ## Ethics & Integrity — NON-NEGOTIABLE
9
+
10
+ This knowledge base is a **professional tool shared with trusted team members**.
11
+ It must remain objective, factual, and ethically sound at all times. It is NOT a
12
+ "black book" and must NEVER become one.
13
+
14
+ **Hard rules:**
15
+
16
+ - **Objective and factual only.** Every note must reflect verifiable facts —
17
+ what was said, decided, or observed. No speculation, gossip, or
18
+ editorializing.
19
+ - **No personal judgments about character.** Do not record subjective opinions
20
+ about people's personalities, competence, or trustworthiness. Stick to what
21
+ happened: actions, decisions, stated positions.
22
+ - **No sensitive personal information beyond what's work-relevant.** Do not
23
+ store health details, personal relationships, political views, or other
24
+ private matters unless directly relevant to a professional interaction the
25
+ person themselves shared.
26
+ - **Fair and balanced.** If a disagreement or conflict is noted, represent all
27
+ sides accurately. Never frame notes to make someone look bad.
28
+ - **Assume the subject will read it.** Write every note as if the person it's
29
+ about will see it. If you wouldn't be comfortable showing it to them, don't
30
+ write it.
31
+ - **No weaponization.** This knowledge base exists to help the team work better
32
+ together — never to build leverage, ammunition, or dossiers on individuals.
33
+ - **Flag ethical concerns.** If the user asks you to record something that
34
+ violates these principles, push back clearly and explain why.
35
+
36
+ These principles override all other instructions. When in doubt, err on the side
37
+ of discretion and professionalism.
38
+
8
39
  ## Personality
9
40
 
10
41
  - **Supportive thoroughness:** Explain complex topics clearly and completely.
@@ -29,7 +60,8 @@ This directory is a knowledge base. Everything is relative to this root:
29
60
  │ ├── People/ # Notes on individuals
30
61
  │ ├── Organizations/ # Notes on companies and teams
31
62
  │ ├── Projects/ # Notes on initiatives and workstreams
32
- └── Topics/ # Notes on recurring themes
63
+ ├── Topics/ # Notes on recurring themes
64
+ │ └── Candidates/ # Recruitment candidate profiles
33
65
  ├── .claude/skills/ # Claude Code skill files (auto-discovered)
34
66
  ├── drafts/ # Email drafts created by the draft-emails skill
35
67
  ├── USER.md # Your identity (name, email, domain) — gitignored
@@ -46,6 +78,7 @@ Synced data and runtime state live outside the knowledge base in
46
78
  ~/.cache/fit/basecamp/
47
79
  ├── apple_mail/ # Synced Apple Mail threads (.md files)
48
80
  ├── apple_calendar/ # Synced Apple Calendar events (.json files)
81
+ ├── gmail/ # Synced Gmail threads (.md files)
49
82
  ├── google_calendar/ # Synced Google Calendar events (.json files)
50
83
  └── state/ # Runtime state (plain text files)
51
84
  ├── apple_mail_last_sync # ISO timestamp of last mail sync
@@ -87,6 +120,17 @@ by name, you MUST look them up in the knowledge base FIRST before responding. Do
87
120
  not provide generic responses. Look up the context, then respond with that
88
121
  knowledge.
89
122
 
123
+ **STOP — ALWAYS SEARCH BROADLY FIRST.** Never build a response from a single
124
+ note. Before answering, run a keyword search across the entire knowledge graph:
125
+
126
+ ```bash
127
+ rg "keyword" knowledge/
128
+ ```
129
+
130
+ This surfaces every note that mentions the keyword — people, orgs, projects, and
131
+ topics you might miss if you only open one file. Read ALL matching notes to
132
+ build a complete picture, then respond. A single note is never the full story.
133
+
90
134
  **When to access:**
91
135
 
92
136
  - Always when the user mentions a named entity (person, org, project, topic)
@@ -100,9 +144,10 @@ knowledge.
100
144
  Synced emails and calendar events are stored in `~/.cache/fit/basecamp/`,
101
145
  outside the knowledge base:
102
146
 
103
- - **Emails:** `~/.cache/fit/basecamp/apple_mail/` — each thread is a `.md` file
104
- - **Calendar:** `~/.cache/fit/basecamp/apple_calendar/` — each event is a
105
- `.json` file
147
+ - **Emails:** `~/.cache/fit/basecamp/apple_mail/` and
148
+ `~/.cache/fit/basecamp/gmail/` — each thread is a `.md` file
149
+ - **Calendar:** `~/.cache/fit/basecamp/apple_calendar/` and
150
+ `~/.cache/fit/basecamp/google_calendar/` — each event is a `.json` file
106
151
 
107
152
  When the user asks about calendar, upcoming meetings, or recent emails, read
108
153
  directly from these folders.
@@ -115,16 +160,17 @@ manually — Claude Code loads them automatically based on context.
115
160
 
116
161
  Available skills:
117
162
 
118
- | Skill | Directory | Purpose |
119
- | ---------------------- | -------------------------------------- | ---------------------------------------------- |
120
- | Sync Apple Mail | `.claude/skills/sync-apple-mail/` | Sync Apple Mail threads via SQLite |
121
- | Sync Apple Calendar | `.claude/skills/sync-apple-calendar/` | Sync Apple Calendar events via SQLite |
122
- | Extract Entities | `.claude/skills/extract-entities/` | Process synced data into knowledge graph notes |
123
- | Draft Emails | `.claude/skills/draft-emails/` | Draft email responses using knowledge context |
124
- | Meeting Prep | `.claude/skills/meeting-prep/` | Prepare briefings for upcoming meetings |
125
- | Create Presentations | `.claude/skills/create-presentations/` | Create slide decks as PDF |
126
- | Document Collaboration | `.claude/skills/doc-collab/` | Document creation and collaboration |
127
- | Organize Files | `.claude/skills/organize-files/` | File organization and cleanup |
163
+ | Skill | Directory | Purpose |
164
+ | ---------------------- | -------------------------------------- | ----------------------------------------------- |
165
+ | Sync Apple Mail | `.claude/skills/sync-apple-mail/` | Sync Apple Mail threads via SQLite |
166
+ | Sync Apple Calendar | `.claude/skills/sync-apple-calendar/` | Sync Apple Calendar events via SQLite |
167
+ | Extract Entities | `.claude/skills/extract-entities/` | Process synced data into knowledge graph notes |
168
+ | Draft Emails | `.claude/skills/draft-emails/` | Draft email responses using knowledge context |
169
+ | Meeting Prep | `.claude/skills/meeting-prep/` | Prepare briefings for upcoming meetings |
170
+ | Create Presentations | `.claude/skills/create-presentations/` | Create slide decks as PDF |
171
+ | Document Collaboration | `.claude/skills/doc-collab/` | Document creation and collaboration |
172
+ | Organize Files | `.claude/skills/organize-files/` | File organization and cleanup |
173
+ | Process Hyprnote | `.claude/skills/process-hyprnote/` | Extract entities from Hyprnote meeting sessions |
128
174
 
129
175
  ## User Identity
130
176
 
package/build.js DELETED
@@ -1,122 +0,0 @@
1
- #!/usr/bin/env -S deno run --allow-all
2
-
3
- // Build script for Basecamp (arm64 macOS).
4
- //
5
- // Usage:
6
- // deno run --allow-all build.js Build standalone executable
7
- // deno run --allow-all build.js --pkg Build executable + macOS .pkg installer
8
-
9
- import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
10
- import { join, dirname } from "node:path";
11
- import { execSync } from "node:child_process";
12
-
13
- const __dirname =
14
- import.meta.dirname || dirname(new URL(import.meta.url).pathname);
15
- const DIST_DIR = join(__dirname, "dist");
16
- const APP_NAME = "fit-basecamp";
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;
22
-
23
- // ---------------------------------------------------------------------------
24
- // Helpers
25
- // ---------------------------------------------------------------------------
26
-
27
- function ensureDir(dir) {
28
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
29
- }
30
-
31
- function run(cmd, opts = {}) {
32
- console.log(` $ ${cmd}`);
33
- return execSync(cmd, { encoding: "utf8", stdio: "inherit", ...opts });
34
- }
35
-
36
- // ---------------------------------------------------------------------------
37
- // Compile standalone binary
38
- // ---------------------------------------------------------------------------
39
-
40
- function compile() {
41
- const outputPath = join(DIST_DIR, APP_NAME);
42
-
43
- console.log(`\nCompiling ${APP_NAME}...`);
44
- ensureDir(DIST_DIR);
45
-
46
- const cmd = [
47
- "deno compile",
48
- "--allow-all",
49
- "--no-check",
50
- `--output "${outputPath}"`,
51
- "--include template/",
52
- "basecamp.js",
53
- ].join(" ");
54
-
55
- run(cmd, { cwd: __dirname });
56
-
57
- console.log(` -> ${outputPath}`);
58
- return outputPath;
59
- }
60
-
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;
82
- }
83
-
84
- // ---------------------------------------------------------------------------
85
- // Build macOS installer package (.pkg)
86
- // ---------------------------------------------------------------------------
87
-
88
- function buildPKG(statusMenuBinaryPath) {
89
- const pkgName = `${APP_NAME}-${VERSION}.pkg`;
90
-
91
- console.log(`\nBuilding pkg: ${pkgName}...`);
92
-
93
- const buildPkg = join(__dirname, "scripts", "build-pkg.sh");
94
- run(
95
- `"${buildPkg}" "${DIST_DIR}" "${APP_NAME}" "${VERSION}" "${statusMenuBinaryPath}"`,
96
- { cwd: __dirname },
97
- );
98
-
99
- console.log(` -> ${join(DIST_DIR, pkgName)}`);
100
- return join(DIST_DIR, pkgName);
101
- }
102
-
103
- // ---------------------------------------------------------------------------
104
- // CLI
105
- // ---------------------------------------------------------------------------
106
-
107
- const args = Deno?.args || process.argv.slice(2);
108
- const wantPKG = args.includes("--pkg");
109
-
110
- console.log(`Basecamp Build (v${VERSION})`);
111
- console.log("==========================");
112
-
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);
120
- }
121
-
122
- console.log("\nBuild complete! Output in dist/");
@@ -1,115 +0,0 @@
1
- #!/bin/bash
2
- set -e
3
-
4
- # Build a macOS installer package (.pkg) for Basecamp (arm64).
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> <status_menu_binary>
11
- # e.g. build-pkg.sh dist fit-basecamp 1.0.0 dist/BasecampStatus
12
-
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
-
18
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
19
- PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
20
- BINARY_PATH="$DIST_DIR/$APP_NAME"
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
- PKG_NAME="$APP_NAME-$VERSION.pkg"
30
- PKG_PATH="$DIST_DIR/$PKG_NAME"
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"
35
-
36
- echo ""
37
- echo "Building pkg: $PKG_NAME..."
38
-
39
- # --- Clean previous artifacts ------------------------------------------------
40
-
41
- rm -rf "$PAYLOAD_DIR" "$SCRIPTS_DIR" "$RESOURCES_DIR" "$COMPONENT_PKG"
42
- rm -f "$PKG_PATH"
43
-
44
- # --- Create payload (files to install) ---------------------------------------
45
-
46
- mkdir -p "$PAYLOAD_DIR/usr/local/bin"
47
- mkdir -p "$PAYLOAD_DIR/usr/local/share/fit-basecamp/config"
48
-
49
- cp "$BINARY_PATH" "$PAYLOAD_DIR/usr/local/bin/$APP_NAME"
50
- chmod +x "$PAYLOAD_DIR/usr/local/bin/$APP_NAME"
51
-
52
- cp "$PROJECT_DIR/config/scheduler.json" "$PAYLOAD_DIR/usr/local/share/fit-basecamp/config/scheduler.json"
53
- cp "$SCRIPT_DIR/uninstall.sh" "$PAYLOAD_DIR/usr/local/share/fit-basecamp/uninstall.sh"
54
- chmod +x "$PAYLOAD_DIR/usr/local/share/fit-basecamp/uninstall.sh"
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
-
60
- # --- Create scripts directory ------------------------------------------------
61
-
62
- mkdir -p "$SCRIPTS_DIR"
63
- cp "$SCRIPT_DIR/postinstall" "$SCRIPTS_DIR/postinstall"
64
- chmod +x "$SCRIPTS_DIR/postinstall"
65
-
66
- # --- Build component package -------------------------------------------------
67
-
68
- pkgbuild \
69
- --root "$PAYLOAD_DIR" \
70
- --scripts "$SCRIPTS_DIR" \
71
- --identifier "$IDENTIFIER" \
72
- --version "$VERSION" \
73
- --install-location "/" \
74
- "$COMPONENT_PKG"
75
-
76
- # --- Create distribution resources -------------------------------------------
77
-
78
- mkdir -p "$RESOURCES_DIR"
79
- cp "$SCRIPT_DIR/pkg-resources/welcome.html" "$RESOURCES_DIR/welcome.html"
80
- cp "$SCRIPT_DIR/pkg-resources/conclusion.html" "$RESOURCES_DIR/conclusion.html"
81
-
82
- # --- Create distribution.xml ------------------------------------------------
83
-
84
- DIST_XML="$DIST_DIR/distribution.xml"
85
- cat > "$DIST_XML" <<EOF
86
- <?xml version="1.0" encoding="utf-8"?>
87
- <installer-gui-script minSpecVersion="2">
88
- <title>Basecamp ${VERSION}</title>
89
- <welcome file="welcome.html" mime-type="text/html" />
90
- <conclusion file="conclusion.html" mime-type="text/html" />
91
- <options customize="never" require-scripts="false" hostArchitectures="arm64" />
92
- <domains enable_localSystem="true" />
93
- <pkg-ref id="$IDENTIFIER" version="$VERSION">pkg-component.pkg</pkg-ref>
94
- <choices-outline>
95
- <line choice="$IDENTIFIER" />
96
- </choices-outline>
97
- <choice id="$IDENTIFIER" visible="false">
98
- <pkg-ref id="$IDENTIFIER" />
99
- </choice>
100
- </installer-gui-script>
101
- EOF
102
-
103
- # --- Build distribution package ----------------------------------------------
104
-
105
- productbuild \
106
- --distribution "$DIST_XML" \
107
- --resources "$RESOURCES_DIR" \
108
- --package-path "$DIST_DIR" \
109
- "$PKG_PATH"
110
-
111
- # --- Clean up staging --------------------------------------------------------
112
-
113
- rm -rf "$PAYLOAD_DIR" "$SCRIPTS_DIR" "$RESOURCES_DIR" "$COMPONENT_PKG" "$DIST_XML"
114
-
115
- echo " -> $PKG_NAME"
@@ -1,25 +0,0 @@
1
- #!/bin/bash
2
- set -e
3
-
4
- # Compile Basecamp into a standalone Deno binary (arm64 macOS).
5
- #
6
- # Usage: compile.sh <dist_dir> <app_name>
7
- # e.g. compile.sh dist fit-basecamp
8
-
9
- DIST_DIR="${1:?Usage: compile.sh <dist_dir> <app_name>}"
10
- APP_NAME="${2:?Usage: compile.sh <dist_dir> <app_name>}"
11
-
12
- OUTPUT="$DIST_DIR/$APP_NAME"
13
-
14
- echo ""
15
- echo "Compiling $APP_NAME..."
16
- mkdir -p "$DIST_DIR"
17
-
18
- deno compile \
19
- --allow-all \
20
- --no-check \
21
- --output "$OUTPUT" \
22
- --include template/ \
23
- basecamp.js
24
-
25
- echo " -> $OUTPUT"