@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.
- package/README.md +69 -89
- package/package.json +9 -14
- package/{basecamp.js → src/basecamp.js} +153 -204
- package/template/.claude/settings.json +49 -0
- package/template/.claude/skills/draft-emails/SKILL.md +32 -3
- package/template/.claude/skills/draft-emails/scripts/scan-emails.sh +3 -2
- package/template/.claude/skills/extract-entities/SKILL.md +0 -1
- package/template/.claude/skills/extract-entities/references/TEMPLATES.md +1 -0
- package/template/.claude/skills/process-hyprnote/SKILL.md +335 -0
- package/template/.claude/skills/sync-apple-calendar/SKILL.md +6 -3
- package/template/.claude/skills/sync-apple-calendar/scripts/sync.py +13 -4
- package/template/.claude/skills/sync-apple-mail/SKILL.md +17 -5
- package/template/.claude/skills/sync-apple-mail/references/SCHEMA.md +32 -5
- package/template/.claude/skills/sync-apple-mail/scripts/sync.py +134 -27
- package/template/CLAUDE.md +60 -14
- package/build.js +0 -122
- package/scripts/build-pkg.sh +0 -115
- package/scripts/compile.sh +0 -25
- package/scripts/install.sh +0 -108
- package/scripts/pkg-resources/conclusion.html +0 -62
- package/scripts/pkg-resources/welcome.html +0 -64
- package/scripts/postinstall +0 -84
- package/scripts/uninstall.sh +0 -73
|
@@ -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:
|
|
79
|
-
return int((datetime.now(timezone.utc) - timedelta(days=
|
|
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
|
|
180
|
-
"""
|
|
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
|
-
|
|
220
|
+
["find", mail_dir, "(", "-name", "*.emlx", "-o",
|
|
221
|
+
"-path", "*/Attachments/*", ")", "-type", "f"],
|
|
222
|
+
capture_output=True, text=True, timeout=60
|
|
186
223
|
)
|
|
187
|
-
|
|
224
|
+
emlx_index = {}
|
|
225
|
+
attachment_index = {}
|
|
188
226
|
for path in result.stdout.strip().splitlines():
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
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
|
|
321
|
-
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)
|
package/template/CLAUDE.md
CHANGED
|
@@ -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
|
-
│
|
|
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/`
|
|
104
|
-
|
|
105
|
-
|
|
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/");
|
package/scripts/build-pkg.sh
DELETED
|
@@ -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"
|
package/scripts/compile.sh
DELETED
|
@@ -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"
|