birdclaw 0.5.0 → 0.6.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 (105) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +55 -5
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +9 -7
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +400 -0
  7. package/scripts/build-docs-site.mjs +940 -0
  8. package/scripts/docs-site-assets.mjs +311 -0
  9. package/scripts/run-vitest.mjs +21 -0
  10. package/scripts/sanitize-node-options.mjs +23 -0
  11. package/scripts/start-test-server.mjs +42 -0
  12. package/src/cli.ts +422 -14
  13. package/src/components/AccountSwitcher.tsx +186 -0
  14. package/src/components/AppNav.tsx +29 -9
  15. package/src/components/AvatarChip.tsx +9 -3
  16. package/src/components/BrandMark.tsx +67 -0
  17. package/src/components/ConversationThread.tsx +11 -10
  18. package/src/components/DmWorkspace.tsx +39 -14
  19. package/src/components/FeedState.tsx +147 -0
  20. package/src/components/InboxCard.tsx +14 -2
  21. package/src/components/LinkPreviewCard.tsx +40 -18
  22. package/src/components/MarkdownViewer.tsx +452 -0
  23. package/src/components/SavedTimelineView.tsx +64 -56
  24. package/src/components/SyncNowButton.tsx +137 -0
  25. package/src/components/ThemeSlider.tsx +49 -93
  26. package/src/components/TimelineCard.tsx +364 -136
  27. package/src/components/TimelineRouteFrame.tsx +170 -0
  28. package/src/components/TweetMediaGrid.tsx +170 -24
  29. package/src/components/TweetRichText.tsx +28 -13
  30. package/src/components/account-selection.ts +64 -0
  31. package/src/components/useTimelineRouteData.ts +153 -0
  32. package/src/lib/account-sync-job.ts +654 -0
  33. package/src/lib/actions-transport.ts +216 -146
  34. package/src/lib/api-client.ts +304 -0
  35. package/src/lib/archive-finder.ts +72 -53
  36. package/src/lib/archive-import.ts +1377 -1298
  37. package/src/lib/authored-live.ts +261 -204
  38. package/src/lib/avatar-cache.ts +159 -44
  39. package/src/lib/backup.ts +1532 -951
  40. package/src/lib/bird-actions.ts +139 -57
  41. package/src/lib/bird-command.ts +101 -28
  42. package/src/lib/bird.ts +549 -194
  43. package/src/lib/blocklist.ts +40 -23
  44. package/src/lib/blocks-write.ts +129 -80
  45. package/src/lib/blocks.ts +165 -97
  46. package/src/lib/bookmark-sync-job.ts +250 -160
  47. package/src/lib/conversation-surface.ts +205 -0
  48. package/src/lib/db.ts +35 -3
  49. package/src/lib/dms-live.ts +720 -66
  50. package/src/lib/effect-runtime.ts +45 -0
  51. package/src/lib/follow-graph.ts +224 -180
  52. package/src/lib/http-effect.ts +222 -0
  53. package/src/lib/inbox.ts +74 -43
  54. package/src/lib/link-index.ts +88 -76
  55. package/src/lib/link-insights.ts +24 -0
  56. package/src/lib/link-preview-metadata.ts +472 -52
  57. package/src/lib/media-fetch.ts +286 -213
  58. package/src/lib/mention-threads-live.ts +352 -288
  59. package/src/lib/mentions-live.ts +390 -342
  60. package/src/lib/moderation-target.ts +102 -65
  61. package/src/lib/moderation-write.ts +77 -18
  62. package/src/lib/mutes-write.ts +129 -80
  63. package/src/lib/mutes.ts +8 -1
  64. package/src/lib/openai.ts +84 -53
  65. package/src/lib/period-digest.ts +953 -0
  66. package/src/lib/profile-affiliation-hydration.ts +93 -54
  67. package/src/lib/profile-hydration.ts +124 -72
  68. package/src/lib/profile-replies.ts +60 -43
  69. package/src/lib/profile-resolver.ts +402 -294
  70. package/src/lib/queries.ts +1024 -189
  71. package/src/lib/research.ts +165 -120
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +60 -39
  75. package/src/lib/tweet-lookup.ts +30 -19
  76. package/src/lib/types.ts +38 -1
  77. package/src/lib/ui.ts +41 -10
  78. package/src/lib/url-expansion.ts +226 -55
  79. package/src/lib/url-safety.ts +220 -0
  80. package/src/lib/web-sync.ts +511 -0
  81. package/src/lib/whois.ts +166 -147
  82. package/src/lib/x-web.ts +102 -71
  83. package/src/lib/xurl.ts +681 -411
  84. package/src/routeTree.gen.ts +63 -0
  85. package/src/routes/__root.tsx +25 -5
  86. package/src/routes/api/action.tsx +127 -78
  87. package/src/routes/api/avatar.tsx +39 -30
  88. package/src/routes/api/blocks.tsx +26 -23
  89. package/src/routes/api/conversation.tsx +25 -14
  90. package/src/routes/api/inbox.tsx +27 -21
  91. package/src/routes/api/link-insights.tsx +31 -25
  92. package/src/routes/api/link-preview.tsx +25 -21
  93. package/src/routes/api/period-digest.tsx +123 -0
  94. package/src/routes/api/profile-hydrate.tsx +31 -28
  95. package/src/routes/api/query.tsx +79 -55
  96. package/src/routes/api/status.tsx +18 -10
  97. package/src/routes/api/sync.tsx +105 -0
  98. package/src/routes/dms.tsx +195 -55
  99. package/src/routes/inbox.tsx +32 -19
  100. package/src/routes/index.tsx +21 -127
  101. package/src/routes/links.tsx +50 -5
  102. package/src/routes/mentions.tsx +21 -127
  103. package/src/routes/today.tsx +441 -0
  104. package/src/styles.css +74 -11
  105. package/vite.config.ts +8 -0
@@ -0,0 +1,654 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { maybeAutoSyncBackup, type BackupAutoUpdateResult } from "./backup";
7
+ import { ensureBirdclawDirs, getBirdclawPaths } from "./config";
8
+ import { getNativeDb } from "./db";
9
+ import { syncDirectMessagesViaCachedBird } from "./dms-live";
10
+ import { syncMentionThreads } from "./mention-threads-live";
11
+ import { syncMentions } from "./mentions-live";
12
+ import type { Database } from "./sqlite";
13
+ import {
14
+ syncTimelineCollection,
15
+ type TimelineCollectionKind,
16
+ type TimelineCollectionMode,
17
+ } from "./timeline-collections-live";
18
+ import { syncHomeTimeline } from "./timeline-live";
19
+
20
+ const execFileAsync = promisify(execFile);
21
+ const DEFAULT_ACCOUNT_SYNC_INTERVAL_SECONDS = 30 * 60;
22
+ const DEFAULT_ACCOUNT_SYNC_LIMIT = 100;
23
+ const DEFAULT_ACCOUNT_SYNC_MAX_PAGES = 3;
24
+ const DEFAULT_ACCOUNT_SYNC_LABEL = "com.steipete.birdclaw.account-sync";
25
+ const DEFAULT_LAUNCHD_PATH =
26
+ "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
27
+ const DEFAULT_LOCK_STALE_MS = 60 * 60 * 1000;
28
+
29
+ export type AccountSyncStepKind =
30
+ | "timeline"
31
+ | "mentions"
32
+ | "mention-threads"
33
+ | "likes"
34
+ | "bookmarks"
35
+ | "dms";
36
+
37
+ export interface AccountSyncJobOptions {
38
+ account?: string;
39
+ steps?: AccountSyncStepKind[];
40
+ mode?: TimelineCollectionMode;
41
+ limit?: number;
42
+ maxPages?: number;
43
+ refresh?: boolean;
44
+ cacheTtlMs?: number;
45
+ allowBirdAccount?: boolean;
46
+ logPath?: string;
47
+ lockPath?: string;
48
+ db?: Database;
49
+ }
50
+
51
+ export interface AccountSyncAuditStep {
52
+ kind: AccountSyncStepKind;
53
+ ok: boolean;
54
+ count: number;
55
+ source?: string;
56
+ error?: string;
57
+ }
58
+
59
+ export interface AccountSyncAuditEntry {
60
+ job: "account-sync";
61
+ ok: boolean;
62
+ startedAt: string;
63
+ finishedAt: string;
64
+ durationMs: number;
65
+ host: string;
66
+ pid: number;
67
+ options: {
68
+ account?: string;
69
+ steps: AccountSyncStepKind[];
70
+ mode: TimelineCollectionMode;
71
+ limit: number;
72
+ maxPages: number;
73
+ refresh: boolean;
74
+ cacheTtlMs?: number;
75
+ allowBirdAccount?: boolean;
76
+ };
77
+ steps: AccountSyncAuditStep[];
78
+ skipped?: "already-running";
79
+ backup?: BackupAutoUpdateResult;
80
+ error?: string;
81
+ }
82
+
83
+ export interface AccountSyncLaunchAgentOptions {
84
+ label?: string;
85
+ intervalSeconds?: number;
86
+ program?: string;
87
+ account?: string;
88
+ steps?: AccountSyncStepKind[];
89
+ mode?: TimelineCollectionMode;
90
+ limit?: number;
91
+ maxPages?: number;
92
+ refresh?: boolean;
93
+ allowBirdAccount?: boolean;
94
+ cacheTtlSeconds?: number;
95
+ logPath?: string;
96
+ envFile?: string;
97
+ stdoutPath?: string;
98
+ stderrPath?: string;
99
+ launchAgentsDir?: string;
100
+ load?: boolean;
101
+ }
102
+
103
+ export interface AccountSyncLaunchAgentInstallResult {
104
+ ok: true;
105
+ label: string;
106
+ plistPath: string;
107
+ loaded: boolean;
108
+ programArguments: string[];
109
+ logPath: string;
110
+ stdoutPath: string;
111
+ stderrPath: string;
112
+ intervalSeconds: number;
113
+ envFile?: string;
114
+ }
115
+
116
+ const DEFAULT_STEPS: AccountSyncStepKind[] = [
117
+ "timeline",
118
+ "mentions",
119
+ "mention-threads",
120
+ "likes",
121
+ "bookmarks",
122
+ "dms",
123
+ ];
124
+
125
+ function expandHome(input: string) {
126
+ return input === "~" || input.startsWith("~/")
127
+ ? path.join(os.homedir(), input.slice(2))
128
+ : input;
129
+ }
130
+
131
+ function resolvePath(input: string) {
132
+ return path.resolve(expandHome(input));
133
+ }
134
+
135
+ export function getDefaultAccountSyncAuditLogPath() {
136
+ return path.join(getBirdclawPaths().rootDir, "audit", "account-sync.jsonl");
137
+ }
138
+
139
+ export function getDefaultAccountSyncLockPath() {
140
+ return path.join(getBirdclawPaths().rootDir, "locks", "account-sync.lock");
141
+ }
142
+
143
+ function messageFromError(error: unknown) {
144
+ return error instanceof Error ? error.message : String(error);
145
+ }
146
+
147
+ async function appendAuditEntry(logPath: string, entry: AccountSyncAuditEntry) {
148
+ await fs.mkdir(path.dirname(logPath), { recursive: true });
149
+ await fs.appendFile(logPath, `${JSON.stringify(entry)}\n`, "utf8");
150
+ }
151
+
152
+ async function acquireLock(lockPath: string) {
153
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
154
+ try {
155
+ const handle = await fs.open(lockPath, "wx");
156
+ await handle.writeFile(
157
+ `${JSON.stringify({
158
+ pid: process.pid,
159
+ host: os.hostname(),
160
+ startedAt: new Date().toISOString(),
161
+ })}\n`,
162
+ "utf8",
163
+ );
164
+ await handle.close();
165
+ return async () => {
166
+ await fs.rm(lockPath, { force: true });
167
+ };
168
+ } catch (error) {
169
+ if (
170
+ typeof error === "object" &&
171
+ error !== null &&
172
+ "code" in error &&
173
+ error.code === "EEXIST"
174
+ ) {
175
+ const stats = await fs.stat(lockPath).catch(() => undefined);
176
+ if (stats && Date.now() - stats.mtimeMs > DEFAULT_LOCK_STALE_MS) {
177
+ await fs.rm(lockPath, { force: true });
178
+ return acquireLock(lockPath);
179
+ }
180
+ return undefined;
181
+ }
182
+ throw error;
183
+ }
184
+ }
185
+
186
+ function readNumber(value: unknown, key: string): number {
187
+ if (!value || typeof value !== "object") return 0;
188
+ const raw = (value as Record<string, unknown>)[key];
189
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
190
+ }
191
+
192
+ function readString(value: unknown, key: string) {
193
+ if (!value || typeof value !== "object") return undefined;
194
+ const raw = (value as Record<string, unknown>)[key];
195
+ return typeof raw === "string" ? raw : undefined;
196
+ }
197
+
198
+ function defaultAccountId(db: Database) {
199
+ const row = db
200
+ .prepare(
201
+ `
202
+ select id
203
+ from accounts
204
+ order by is_default desc, created_at asc
205
+ limit 1
206
+ `,
207
+ )
208
+ .get() as { id: string } | undefined;
209
+ return row?.id;
210
+ }
211
+
212
+ function isExplicitNonDefaultAccount(
213
+ db: Database,
214
+ account: string | undefined,
215
+ ) {
216
+ if (!account) return false;
217
+ return account !== defaultAccountId(db);
218
+ }
219
+
220
+ function birdAccountError(kind: AccountSyncStepKind) {
221
+ return `Bird-backed ${kind} sync requires --allow-bird-account for non-default accounts; source matching cookies with --env-path first.`;
222
+ }
223
+
224
+ function resolveCollectionModeForAccount({
225
+ mode,
226
+ allowBirdAccount,
227
+ }: {
228
+ mode: TimelineCollectionMode;
229
+ allowBirdAccount: boolean | undefined;
230
+ }) {
231
+ if (allowBirdAccount || mode === "xurl") return mode;
232
+ return mode === "bird" ? undefined : "xurl";
233
+ }
234
+
235
+ async function runStep({
236
+ kind,
237
+ account,
238
+ mode,
239
+ limit,
240
+ maxPages,
241
+ refresh,
242
+ cacheTtlMs,
243
+ allowBirdAccount,
244
+ }: Required<
245
+ Pick<AccountSyncJobOptions, "mode" | "limit" | "maxPages" | "refresh">
246
+ > &
247
+ Pick<AccountSyncJobOptions, "account" | "cacheTtlMs" | "allowBirdAccount"> & {
248
+ kind: AccountSyncStepKind;
249
+ }): Promise<AccountSyncAuditStep> {
250
+ try {
251
+ if (kind === "timeline") {
252
+ if (!allowBirdAccount) {
253
+ return { kind, ok: false, count: 0, error: birdAccountError(kind) };
254
+ }
255
+ const result = await syncHomeTimeline({
256
+ account,
257
+ limit,
258
+ following: true,
259
+ refresh,
260
+ cacheTtlMs,
261
+ });
262
+ return {
263
+ kind,
264
+ ok: true,
265
+ count: readNumber(result, "count"),
266
+ source: readString(result, "source"),
267
+ };
268
+ }
269
+ if (kind === "mentions") {
270
+ if (!allowBirdAccount) {
271
+ return { kind, ok: false, count: 0, error: birdAccountError(kind) };
272
+ }
273
+ const result = await syncMentions({
274
+ account,
275
+ mode: "bird",
276
+ limit,
277
+ maxPages,
278
+ refresh,
279
+ cacheTtlMs,
280
+ });
281
+ return {
282
+ kind,
283
+ ok: true,
284
+ count: readNumber(result, "count"),
285
+ source: readString(result, "source"),
286
+ };
287
+ }
288
+ if (kind === "mention-threads") {
289
+ const result = await syncMentionThreads({
290
+ account,
291
+ mode: "xurl",
292
+ limit: Math.min(30, limit),
293
+ delayMs: 1500,
294
+ timeoutMs: 15000,
295
+ });
296
+ return {
297
+ kind,
298
+ ok: true,
299
+ count: readNumber(result, "mergedTweets"),
300
+ source: readString(result, "source"),
301
+ };
302
+ }
303
+ if (kind === "dms") {
304
+ const dmMode = allowBirdAccount
305
+ ? mode
306
+ : mode === "bird"
307
+ ? undefined
308
+ : "xurl";
309
+ if (!dmMode) {
310
+ return { kind, ok: false, count: 0, error: birdAccountError(kind) };
311
+ }
312
+ const result = await syncDirectMessagesViaCachedBird({
313
+ account,
314
+ mode: dmMode,
315
+ limit: Math.min(50, limit),
316
+ refresh,
317
+ cacheTtlMs,
318
+ });
319
+ return {
320
+ kind,
321
+ ok: true,
322
+ count: readNumber(result, "messages"),
323
+ source: readString(result, "source"),
324
+ };
325
+ }
326
+
327
+ const collectionKind = kind as TimelineCollectionKind;
328
+ const collectionMode = resolveCollectionModeForAccount({
329
+ mode,
330
+ allowBirdAccount,
331
+ });
332
+ if (!collectionMode) {
333
+ return { kind, ok: false, count: 0, error: birdAccountError(kind) };
334
+ }
335
+
336
+ const result = await syncTimelineCollection({
337
+ kind: collectionKind,
338
+ account,
339
+ mode: collectionMode,
340
+ limit,
341
+ all: true,
342
+ maxPages,
343
+ refresh,
344
+ cacheTtlMs,
345
+ earlyStop: true,
346
+ });
347
+ return {
348
+ kind,
349
+ ok: true,
350
+ count: readNumber(result, "count"),
351
+ source: readString(result, "source"),
352
+ };
353
+ } catch (error) {
354
+ return {
355
+ kind,
356
+ ok: false,
357
+ count: 0,
358
+ error: messageFromError(error),
359
+ };
360
+ }
361
+ }
362
+
363
+ export async function runAccountSyncJob({
364
+ account,
365
+ steps = DEFAULT_STEPS,
366
+ mode = "auto",
367
+ limit = DEFAULT_ACCOUNT_SYNC_LIMIT,
368
+ maxPages = DEFAULT_ACCOUNT_SYNC_MAX_PAGES,
369
+ refresh = true,
370
+ cacheTtlMs,
371
+ allowBirdAccount,
372
+ logPath,
373
+ lockPath,
374
+ db,
375
+ }: AccountSyncJobOptions = {}): Promise<AccountSyncAuditEntry> {
376
+ ensureBirdclawDirs();
377
+ const database = db ?? getNativeDb({ seedDemoData: false });
378
+ const resolvedLogPath = resolvePath(
379
+ logPath ?? getDefaultAccountSyncAuditLogPath(),
380
+ );
381
+ const resolvedLockPath = resolvePath(
382
+ lockPath ?? getDefaultAccountSyncLockPath(),
383
+ );
384
+ const started = Date.now();
385
+ const startedAt = new Date(started).toISOString();
386
+ const options = {
387
+ ...(account ? { account } : {}),
388
+ steps,
389
+ mode,
390
+ limit,
391
+ maxPages,
392
+ refresh,
393
+ ...(cacheTtlMs === undefined ? {} : { cacheTtlMs }),
394
+ ...(allowBirdAccount ? { allowBirdAccount } : {}),
395
+ };
396
+ const birdAccountAllowed =
397
+ !isExplicitNonDefaultAccount(database, account) ||
398
+ Boolean(allowBirdAccount);
399
+
400
+ const releaseLock = await acquireLock(resolvedLockPath);
401
+ if (!releaseLock) {
402
+ const finished = Date.now();
403
+ const entry: AccountSyncAuditEntry = {
404
+ job: "account-sync",
405
+ ok: true,
406
+ startedAt,
407
+ finishedAt: new Date(finished).toISOString(),
408
+ durationMs: finished - started,
409
+ host: os.hostname(),
410
+ pid: process.pid,
411
+ options,
412
+ steps: [],
413
+ skipped: "already-running",
414
+ };
415
+ await appendAuditEntry(resolvedLogPath, entry);
416
+ return entry;
417
+ }
418
+
419
+ const stepResults: AccountSyncAuditStep[] = [];
420
+ try {
421
+ for (const kind of steps) {
422
+ stepResults.push(
423
+ await runStep({
424
+ kind,
425
+ account,
426
+ mode,
427
+ limit,
428
+ maxPages,
429
+ refresh,
430
+ cacheTtlMs,
431
+ allowBirdAccount: birdAccountAllowed,
432
+ }),
433
+ );
434
+ }
435
+ const backup = await maybeAutoSyncBackup(database);
436
+ const finished = Date.now();
437
+ const entry: AccountSyncAuditEntry = {
438
+ job: "account-sync",
439
+ ok: stepResults.every((step) => step.ok),
440
+ startedAt,
441
+ finishedAt: new Date(finished).toISOString(),
442
+ durationMs: finished - started,
443
+ host: os.hostname(),
444
+ pid: process.pid,
445
+ options,
446
+ steps: stepResults,
447
+ backup,
448
+ };
449
+ await appendAuditEntry(resolvedLogPath, entry);
450
+ return entry;
451
+ } catch (error) {
452
+ const finished = Date.now();
453
+ const entry: AccountSyncAuditEntry = {
454
+ job: "account-sync",
455
+ ok: false,
456
+ startedAt,
457
+ finishedAt: new Date(finished).toISOString(),
458
+ durationMs: finished - started,
459
+ host: os.hostname(),
460
+ pid: process.pid,
461
+ options,
462
+ steps: stepResults,
463
+ error: messageFromError(error),
464
+ };
465
+ await appendAuditEntry(resolvedLogPath, entry);
466
+ return entry;
467
+ } finally {
468
+ await releaseLock();
469
+ }
470
+ }
471
+
472
+ function xmlEscape(value: string) {
473
+ return value
474
+ .replaceAll("&", "&amp;")
475
+ .replaceAll("<", "&lt;")
476
+ .replaceAll(">", "&gt;");
477
+ }
478
+
479
+ function stringEntry(value: string) {
480
+ return `<string>${xmlEscape(value)}</string>`;
481
+ }
482
+
483
+ function shellQuote(value: string) {
484
+ return `'${value.replaceAll("'", "'\\''")}'`;
485
+ }
486
+
487
+ function buildProgramArguments({
488
+ program = "birdclaw",
489
+ account,
490
+ steps,
491
+ mode = "auto",
492
+ limit = DEFAULT_ACCOUNT_SYNC_LIMIT,
493
+ maxPages = DEFAULT_ACCOUNT_SYNC_MAX_PAGES,
494
+ refresh = true,
495
+ allowBirdAccount,
496
+ cacheTtlSeconds,
497
+ logPath,
498
+ envFile,
499
+ }: AccountSyncLaunchAgentOptions) {
500
+ const args =
501
+ path.isAbsolute(program) || program.includes("/")
502
+ ? [program]
503
+ : ["/usr/bin/env", program];
504
+ args.push(
505
+ "--json",
506
+ "jobs",
507
+ "sync-account",
508
+ "--mode",
509
+ mode,
510
+ "--limit",
511
+ String(limit),
512
+ "--max-pages",
513
+ String(maxPages),
514
+ "--log",
515
+ resolvePath(logPath ?? getDefaultAccountSyncAuditLogPath()),
516
+ );
517
+ if (account) {
518
+ args.push("--account", account);
519
+ }
520
+ if (steps?.length) {
521
+ args.push("--steps", steps.join(","));
522
+ }
523
+ if (refresh) {
524
+ args.push("--refresh");
525
+ }
526
+ if (allowBirdAccount) {
527
+ args.push("--allow-bird-account");
528
+ }
529
+ if (cacheTtlSeconds !== undefined) {
530
+ args.push("--cache-ttl", String(cacheTtlSeconds));
531
+ }
532
+ if (envFile) {
533
+ const resolvedEnvFile = resolvePath(envFile);
534
+ return [
535
+ "/bin/bash",
536
+ "-lc",
537
+ [
538
+ "set -a",
539
+ `[ ! -f ${shellQuote(resolvedEnvFile)} ] || . ${shellQuote(resolvedEnvFile)}`,
540
+ "set +a",
541
+ `exec ${args.map(shellQuote).join(" ")}`,
542
+ ].join("; "),
543
+ ];
544
+ }
545
+ return args;
546
+ }
547
+
548
+ export function buildAccountSyncLaunchAgentPlist(
549
+ options: AccountSyncLaunchAgentOptions = {},
550
+ ) {
551
+ const label = options.label ?? DEFAULT_ACCOUNT_SYNC_LABEL;
552
+ const intervalSeconds =
553
+ options.intervalSeconds ?? DEFAULT_ACCOUNT_SYNC_INTERVAL_SECONDS;
554
+ const logPath = resolvePath(
555
+ options.logPath ?? getDefaultAccountSyncAuditLogPath(),
556
+ );
557
+ const stdoutPath = resolvePath(
558
+ options.stdoutPath ??
559
+ path.join(getBirdclawPaths().rootDir, "logs", "account-sync.out.log"),
560
+ );
561
+ const stderrPath = resolvePath(
562
+ options.stderrPath ??
563
+ path.join(getBirdclawPaths().rootDir, "logs", "account-sync.err.log"),
564
+ );
565
+ const programArguments = buildProgramArguments({ ...options, logPath });
566
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
567
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
568
+ <plist version="1.0">
569
+ <dict>
570
+ <key>Label</key>
571
+ ${stringEntry(label)}
572
+ <key>ProgramArguments</key>
573
+ <array>
574
+ ${programArguments.map(stringEntry).join("\n ")}
575
+ </array>
576
+ <key>StartInterval</key>
577
+ <integer>${String(intervalSeconds)}</integer>
578
+ <key>RunAtLoad</key>
579
+ <true/>
580
+ <key>StandardOutPath</key>
581
+ ${stringEntry(stdoutPath)}
582
+ <key>StandardErrorPath</key>
583
+ ${stringEntry(stderrPath)}
584
+ <key>EnvironmentVariables</key>
585
+ <dict>
586
+ <key>PATH</key>
587
+ ${stringEntry(DEFAULT_LAUNCHD_PATH)}
588
+ </dict>
589
+ </dict>
590
+ </plist>
591
+ `;
592
+ return {
593
+ label,
594
+ intervalSeconds,
595
+ logPath,
596
+ ...(options.envFile ? { envFile: resolvePath(options.envFile) } : {}),
597
+ stdoutPath,
598
+ stderrPath,
599
+ programArguments,
600
+ plist,
601
+ };
602
+ }
603
+
604
+ export async function installAccountSyncLaunchAgent(
605
+ options: AccountSyncLaunchAgentOptions = {},
606
+ ): Promise<AccountSyncLaunchAgentInstallResult> {
607
+ ensureBirdclawDirs();
608
+ const agent = buildAccountSyncLaunchAgentPlist(options);
609
+ const launchAgentsDir = resolvePath(
610
+ options.launchAgentsDir ?? "~/Library/LaunchAgents",
611
+ );
612
+ const plistPath = path.join(launchAgentsDir, `${agent.label}.plist`);
613
+ await fs.mkdir(launchAgentsDir, { recursive: true });
614
+ await fs.mkdir(path.dirname(agent.logPath), { recursive: true });
615
+ await fs.mkdir(path.dirname(agent.stdoutPath), { recursive: true });
616
+ await fs.mkdir(path.dirname(agent.stderrPath), { recursive: true });
617
+ await fs.writeFile(plistPath, agent.plist, "utf8");
618
+
619
+ let loaded = false;
620
+ if (options.load !== false) {
621
+ await execFileAsync("launchctl", ["unload", plistPath]).catch(() => {});
622
+ await execFileAsync("launchctl", ["load", "-w", plistPath]);
623
+ loaded = true;
624
+ }
625
+
626
+ return {
627
+ ok: true,
628
+ label: agent.label,
629
+ plistPath,
630
+ loaded,
631
+ programArguments: agent.programArguments,
632
+ logPath: agent.logPath,
633
+ stdoutPath: agent.stdoutPath,
634
+ stderrPath: agent.stderrPath,
635
+ intervalSeconds: agent.intervalSeconds,
636
+ ...(agent.envFile ? { envFile: agent.envFile } : {}),
637
+ };
638
+ }
639
+
640
+ export function parseAccountSyncSteps(value: string | undefined) {
641
+ if (!value) return undefined;
642
+ const valid = new Set<AccountSyncStepKind>(DEFAULT_STEPS);
643
+ const steps = value
644
+ .split(",")
645
+ .map((step) => step.trim())
646
+ .filter(Boolean);
647
+ if (steps.length === 0) return undefined;
648
+ for (const step of steps) {
649
+ if (!valid.has(step as AccountSyncStepKind)) {
650
+ throw new Error(`--steps must contain ${Array.from(valid).join(", ")}`);
651
+ }
652
+ }
653
+ return steps as AccountSyncStepKind[];
654
+ }