facult 1.0.3 → 1.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.
@@ -0,0 +1,1028 @@
1
+ import { watch as fsWatch } from "node:fs";
2
+ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { homedir, hostname } from "node:os";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { parseCliContextArgs, resolveCliContextRoot } from "./cli-context";
6
+ import { syncManagedTools } from "./manage";
7
+ import { facultRootDir, facultStateDir, projectRootFromAiRoot } from "./paths";
8
+
9
+ const AUTOSYNC_VERSION = 1 as const;
10
+ const DEFAULT_DEBOUNCE_MS = 1500;
11
+ const DEFAULT_GIT_INTERVAL_MINUTES = 60;
12
+
13
+ export interface AutosyncGitConfig {
14
+ enabled: boolean;
15
+ remote: string;
16
+ branch: string;
17
+ intervalMinutes: number;
18
+ autoCommit: boolean;
19
+ commitPrefix: string;
20
+ source: string;
21
+ }
22
+
23
+ export interface AutosyncServiceConfig {
24
+ version: 1;
25
+ name: string;
26
+ tool?: string;
27
+ rootDir: string;
28
+ debounceMs: number;
29
+ git: AutosyncGitConfig;
30
+ }
31
+
32
+ export interface AutosyncRuntimeState {
33
+ version: 1;
34
+ service: string;
35
+ label: string;
36
+ tool?: string;
37
+ dirty: boolean;
38
+ rootDir: string;
39
+ lastEventAt?: string;
40
+ lastLocalSyncAt?: string;
41
+ lastGitSyncAt?: string;
42
+ lastError?: string;
43
+ remoteBlocked?: boolean;
44
+ remoteBlockReason?: string;
45
+ }
46
+
47
+ export interface LaunchAgentSpec {
48
+ label: string;
49
+ plistPath: string;
50
+ stdoutPath: string;
51
+ stderrPath: string;
52
+ programArguments: string[];
53
+ workingDirectory?: string;
54
+ }
55
+
56
+ export interface AutosyncStatus {
57
+ config: AutosyncServiceConfig | null;
58
+ state: AutosyncRuntimeState | null;
59
+ plistPath: string;
60
+ plistExists: boolean;
61
+ loaded: boolean;
62
+ launchctlSummary?: string;
63
+ }
64
+
65
+ interface CommandResult {
66
+ exitCode: number;
67
+ stdout: string;
68
+ stderr: string;
69
+ }
70
+
71
+ interface RunnerOptions {
72
+ homeDir?: string;
73
+ once?: boolean;
74
+ }
75
+
76
+ interface GitSyncOutcome {
77
+ changed: boolean;
78
+ blocked: boolean;
79
+ message?: string;
80
+ }
81
+
82
+ function nowIso(): string {
83
+ return new Date().toISOString();
84
+ }
85
+
86
+ function logAutosyncError(context: string, error: unknown) {
87
+ const detail = error instanceof Error ? error.message : String(error);
88
+ console.error(`facult autosync: ${context}: ${detail}`);
89
+ }
90
+
91
+ function runDetached(context: string, promise: Promise<void>) {
92
+ promise.catch((error) => {
93
+ logAutosyncError(context, error);
94
+ });
95
+ }
96
+
97
+ function autosyncDir(home: string): string {
98
+ return join(facultStateDir(home), "autosync");
99
+ }
100
+
101
+ function autosyncServicesDir(home: string): string {
102
+ return join(autosyncDir(home), "services");
103
+ }
104
+
105
+ function autosyncStateDir(home: string): string {
106
+ return join(autosyncDir(home), "state");
107
+ }
108
+
109
+ function autosyncLogsDir(home: string): string {
110
+ return join(autosyncDir(home), "logs");
111
+ }
112
+
113
+ function serviceSuffix(
114
+ rootDir: string | undefined,
115
+ home: string
116
+ ): string | null {
117
+ if (!rootDir) {
118
+ return null;
119
+ }
120
+ const projectRoot = projectRootFromAiRoot(rootDir, home);
121
+ if (!projectRoot) {
122
+ return null;
123
+ }
124
+ const base = basename(projectRoot).trim().toLowerCase();
125
+ const slug = base.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
126
+ return slug || "project";
127
+ }
128
+
129
+ function autosyncServiceName(
130
+ tool?: string,
131
+ rootDir?: string,
132
+ home: string = homedir()
133
+ ): string {
134
+ const base = tool?.trim() ? tool.trim() : "all";
135
+ const suffix = serviceSuffix(rootDir, home);
136
+ return suffix ? `${base}-${suffix}` : base;
137
+ }
138
+
139
+ function autosyncLabel(serviceName: string): string {
140
+ return serviceName === "all"
141
+ ? "com.facult.autosync"
142
+ : `com.facult.autosync.${serviceName}`;
143
+ }
144
+
145
+ function autosyncPlistPath(home: string, serviceName: string): string {
146
+ return join(
147
+ home,
148
+ "Library",
149
+ "LaunchAgents",
150
+ `${autosyncLabel(serviceName)}.plist`
151
+ );
152
+ }
153
+
154
+ function autosyncConfigPath(home: string, serviceName: string): string {
155
+ return join(autosyncServicesDir(home), `${serviceName}.json`);
156
+ }
157
+
158
+ function autosyncRuntimeStatePath(home: string, serviceName: string): string {
159
+ return join(autosyncStateDir(home), `${serviceName}.json`);
160
+ }
161
+
162
+ function escapeXml(value: string): string {
163
+ return value
164
+ .replace(/&/g, "&amp;")
165
+ .replace(/</g, "&lt;")
166
+ .replace(/>/g, "&gt;")
167
+ .replace(/"/g, "&quot;")
168
+ .replace(/'/g, "&apos;");
169
+ }
170
+
171
+ function plistArray(values: string[]): string {
172
+ return values
173
+ .map((value) => ` <string>${escapeXml(value)}</string>`)
174
+ .join("\n");
175
+ }
176
+
177
+ export function resolveAutosyncInvocation(
178
+ argv: string[] = process.argv
179
+ ): string[] {
180
+ const exec = process.execPath;
181
+ const script = argv[1];
182
+
183
+ if (script?.endsWith(".ts")) {
184
+ return [exec, "run", script];
185
+ }
186
+
187
+ if (basename(exec).startsWith("facult")) {
188
+ return [exec];
189
+ }
190
+
191
+ if (script) {
192
+ return [exec, script];
193
+ }
194
+
195
+ return [exec];
196
+ }
197
+
198
+ export function buildLaunchAgentSpec(args: {
199
+ homeDir: string;
200
+ serviceName: string;
201
+ rootDir: string;
202
+ invocation?: string[];
203
+ }): LaunchAgentSpec {
204
+ const { homeDir, rootDir, serviceName } = args;
205
+ const label = autosyncLabel(serviceName);
206
+ const invocation = args.invocation ?? resolveAutosyncInvocation();
207
+ const logsDir = autosyncLogsDir(homeDir);
208
+
209
+ return {
210
+ label,
211
+ plistPath: autosyncPlistPath(homeDir, serviceName),
212
+ stdoutPath: join(logsDir, `${serviceName}.log`),
213
+ stderrPath: join(logsDir, `${serviceName}.err.log`),
214
+ programArguments: [
215
+ ...invocation,
216
+ "autosync",
217
+ "run",
218
+ ...(serviceName === "all" ? [] : [serviceName]),
219
+ "--service",
220
+ serviceName,
221
+ ],
222
+ workingDirectory: rootDir,
223
+ };
224
+ }
225
+
226
+ export function buildLaunchAgentPlist(spec: LaunchAgentSpec): string {
227
+ const workingDirectory = spec.workingDirectory
228
+ ? ` <key>WorkingDirectory</key>\n <string>${escapeXml(spec.workingDirectory)}</string>\n`
229
+ : "";
230
+
231
+ return [
232
+ `<?xml version="1.0" encoding="UTF-8"?>`,
233
+ `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
234
+ `<plist version="1.0">`,
235
+ "<dict>",
236
+ " <key>Label</key>",
237
+ ` <string>${escapeXml(spec.label)}</string>`,
238
+ " <key>ProgramArguments</key>",
239
+ " <array>",
240
+ plistArray(spec.programArguments),
241
+ " </array>",
242
+ " <key>RunAtLoad</key>",
243
+ " <true/>",
244
+ " <key>KeepAlive</key>",
245
+ " <true/>",
246
+ workingDirectory.trimEnd(),
247
+ " <key>StandardOutPath</key>",
248
+ ` <string>${escapeXml(spec.stdoutPath)}</string>`,
249
+ " <key>StandardErrorPath</key>",
250
+ ` <string>${escapeXml(spec.stderrPath)}</string>`,
251
+ "</dict>",
252
+ "</plist>",
253
+ "",
254
+ ]
255
+ .filter(Boolean)
256
+ .join("\n");
257
+ }
258
+
259
+ async function pathExists(pathValue: string): Promise<boolean> {
260
+ try {
261
+ await Bun.file(pathValue).stat();
262
+ return true;
263
+ } catch {
264
+ return false;
265
+ }
266
+ }
267
+
268
+ async function readJsonFile<T>(pathValue: string): Promise<T | null> {
269
+ try {
270
+ const text = await readFile(pathValue, "utf8");
271
+ return JSON.parse(text) as T;
272
+ } catch {
273
+ return null;
274
+ }
275
+ }
276
+
277
+ async function writeJsonFile(pathValue: string, data: unknown): Promise<void> {
278
+ await mkdir(dirname(pathValue), { recursive: true });
279
+ await writeFile(pathValue, `${JSON.stringify(data, null, 2)}\n`, "utf8");
280
+ }
281
+
282
+ export async function loadAutosyncConfig(
283
+ serviceName: string,
284
+ homeDir: string = homedir()
285
+ ): Promise<AutosyncServiceConfig | null> {
286
+ return await readJsonFile<AutosyncServiceConfig>(
287
+ autosyncConfigPath(homeDir, serviceName)
288
+ );
289
+ }
290
+
291
+ async function saveAutosyncConfig(
292
+ config: AutosyncServiceConfig,
293
+ homeDir: string
294
+ ): Promise<void> {
295
+ await writeJsonFile(autosyncConfigPath(homeDir, config.name), config);
296
+ }
297
+
298
+ export async function loadAutosyncRuntimeState(
299
+ serviceName: string,
300
+ homeDir: string = homedir()
301
+ ): Promise<AutosyncRuntimeState | null> {
302
+ return await readJsonFile<AutosyncRuntimeState>(
303
+ autosyncRuntimeStatePath(homeDir, serviceName)
304
+ );
305
+ }
306
+
307
+ async function saveAutosyncRuntimeState(
308
+ state: AutosyncRuntimeState,
309
+ homeDir: string
310
+ ): Promise<void> {
311
+ await writeJsonFile(autosyncRuntimeStatePath(homeDir, state.service), state);
312
+ }
313
+
314
+ async function runCommand(
315
+ argv: string[],
316
+ opts?: { cwd?: string }
317
+ ): Promise<CommandResult> {
318
+ const proc = Bun.spawn({
319
+ cmd: argv,
320
+ cwd: opts?.cwd,
321
+ stdout: "pipe",
322
+ stderr: "pipe",
323
+ });
324
+ const [exitCode, stdout, stderr] = await Promise.all([
325
+ proc.exited,
326
+ new Response(proc.stdout).text(),
327
+ new Response(proc.stderr).text(),
328
+ ]);
329
+ return { exitCode, stdout, stderr };
330
+ }
331
+
332
+ async function runLaunchctl(args: string[]): Promise<CommandResult> {
333
+ return await runCommand(["launchctl", ...args]);
334
+ }
335
+
336
+ function launchdDomain(): string {
337
+ return `gui/${process.getuid?.() ?? process.geteuid?.() ?? 0}`;
338
+ }
339
+
340
+ function defaultAutosyncConfig(args: {
341
+ serviceName: string;
342
+ tool?: string;
343
+ homeDir: string;
344
+ rootDir?: string;
345
+ remote?: string;
346
+ branch?: string;
347
+ intervalMinutes?: number;
348
+ gitEnabled?: boolean;
349
+ }): AutosyncServiceConfig {
350
+ const source = hostname();
351
+ return {
352
+ version: AUTOSYNC_VERSION,
353
+ name: args.serviceName,
354
+ tool: args.tool,
355
+ rootDir: args.rootDir ?? facultRootDir(args.homeDir),
356
+ debounceMs: DEFAULT_DEBOUNCE_MS,
357
+ git: {
358
+ enabled: args.gitEnabled ?? true,
359
+ remote: args.remote ?? "origin",
360
+ branch: args.branch ?? "main",
361
+ intervalMinutes: args.intervalMinutes ?? DEFAULT_GIT_INTERVAL_MINUTES,
362
+ autoCommit: true,
363
+ commitPrefix: "chore(facult-autosync)",
364
+ source,
365
+ },
366
+ };
367
+ }
368
+
369
+ function gitCommitMessage(config: AutosyncServiceConfig): string {
370
+ const source = config.git.source || hostname();
371
+ return `${config.git.commitPrefix}: sync canonical ai changes from ${source} [service:${config.name}]`;
372
+ }
373
+
374
+ async function gitHasWorktreeChanges(repoDir: string): Promise<boolean> {
375
+ const result = await runCommand(["git", "status", "--porcelain"], {
376
+ cwd: repoDir,
377
+ });
378
+ return result.exitCode === 0 && result.stdout.trim().length > 0;
379
+ }
380
+
381
+ async function gitCurrentBranch(repoDir: string): Promise<string | null> {
382
+ const result = await runCommand(
383
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
384
+ { cwd: repoDir }
385
+ );
386
+ if (result.exitCode !== 0) {
387
+ return null;
388
+ }
389
+ const branch = result.stdout.trim();
390
+ return branch || null;
391
+ }
392
+
393
+ async function gitHead(repoDir: string): Promise<string | null> {
394
+ const result = await runCommand(["git", "rev-parse", "HEAD"], {
395
+ cwd: repoDir,
396
+ });
397
+ return result.exitCode === 0 ? result.stdout.trim() : null;
398
+ }
399
+
400
+ async function ensureGitRepo(repoDir: string): Promise<boolean> {
401
+ return await pathExists(join(repoDir, ".git"));
402
+ }
403
+
404
+ export async function runGitAutosyncOnce(args: {
405
+ config: AutosyncServiceConfig;
406
+ }): Promise<GitSyncOutcome> {
407
+ const { config } = args;
408
+ const repoDir = config.rootDir;
409
+
410
+ if (!config.git.enabled) {
411
+ return { changed: false, blocked: false };
412
+ }
413
+ if (!(await ensureGitRepo(repoDir))) {
414
+ return {
415
+ changed: false,
416
+ blocked: true,
417
+ message: `Canonical root is not a git repo: ${repoDir}`,
418
+ };
419
+ }
420
+
421
+ const branch = await gitCurrentBranch(repoDir);
422
+ if (!branch) {
423
+ return {
424
+ changed: false,
425
+ blocked: true,
426
+ message: "Unable to determine current git branch.",
427
+ };
428
+ }
429
+ if (branch !== config.git.branch) {
430
+ return {
431
+ changed: false,
432
+ blocked: true,
433
+ message: `Autosync expects branch ${config.git.branch} but repo is on ${branch}.`,
434
+ };
435
+ }
436
+
437
+ const fetch = await runCommand(
438
+ ["git", "fetch", config.git.remote, config.git.branch],
439
+ { cwd: repoDir }
440
+ );
441
+ if (fetch.exitCode !== 0) {
442
+ return {
443
+ changed: false,
444
+ blocked: false,
445
+ message: fetch.stderr.trim() || fetch.stdout.trim() || "git fetch failed",
446
+ };
447
+ }
448
+
449
+ const beforeHead = await gitHead(repoDir);
450
+ const hadChanges = await gitHasWorktreeChanges(repoDir);
451
+
452
+ if (hadChanges && config.git.autoCommit) {
453
+ await runCommand(["git", "add", "-A"], { cwd: repoDir });
454
+ const commit = await runCommand(
455
+ ["git", "commit", "-m", gitCommitMessage(config)],
456
+ { cwd: repoDir }
457
+ );
458
+ if (
459
+ commit.exitCode !== 0 &&
460
+ !commit.stdout.includes("nothing to commit") &&
461
+ !commit.stderr.includes("nothing to commit")
462
+ ) {
463
+ return {
464
+ changed: false,
465
+ blocked: false,
466
+ message:
467
+ commit.stderr.trim() || commit.stdout.trim() || "git commit failed",
468
+ };
469
+ }
470
+ }
471
+
472
+ const pull = await runCommand(
473
+ ["git", "pull", "--rebase", config.git.remote, config.git.branch],
474
+ { cwd: repoDir }
475
+ );
476
+ if (pull.exitCode !== 0) {
477
+ await runCommand(["git", "rebase", "--abort"], { cwd: repoDir });
478
+ return {
479
+ changed: false,
480
+ blocked: true,
481
+ message:
482
+ pull.stderr.trim() ||
483
+ pull.stdout.trim() ||
484
+ "git pull --rebase reported conflicts",
485
+ };
486
+ }
487
+
488
+ const push = await runCommand(["git", "push", config.git.remote, branch], {
489
+ cwd: repoDir,
490
+ });
491
+ if (push.exitCode !== 0) {
492
+ return {
493
+ changed: false,
494
+ blocked: false,
495
+ message: push.stderr.trim() || push.stdout.trim() || "git push failed",
496
+ };
497
+ }
498
+
499
+ const afterHead = await gitHead(repoDir);
500
+ return {
501
+ changed: beforeHead !== afterHead || hadChanges,
502
+ blocked: false,
503
+ };
504
+ }
505
+
506
+ async function runLocalAutosync(
507
+ config: AutosyncServiceConfig,
508
+ homeDir: string
509
+ ): Promise<void> {
510
+ await syncManagedTools({
511
+ homeDir,
512
+ rootDir: config.rootDir,
513
+ tool: config.tool,
514
+ });
515
+ }
516
+
517
+ function isIgnoredRootEvent(fileName: string | Buffer | null): boolean {
518
+ if (!fileName) {
519
+ return false;
520
+ }
521
+ const text = typeof fileName === "string" ? fileName : fileName.toString();
522
+ return text === ".git" || text.startsWith(".git/");
523
+ }
524
+
525
+ export async function runAutosyncService(
526
+ config: AutosyncServiceConfig,
527
+ opts: RunnerOptions = {}
528
+ ): Promise<void> {
529
+ const home = opts.homeDir ?? homedir();
530
+ const label = autosyncLabel(config.name);
531
+ let dirty = false;
532
+ let stopped = false;
533
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
534
+ let running = Promise.resolve();
535
+
536
+ const persistState = async (patch: Partial<AutosyncRuntimeState>) => {
537
+ const current =
538
+ (await loadAutosyncRuntimeState(config.name, home)) ??
539
+ ({
540
+ version: AUTOSYNC_VERSION,
541
+ service: config.name,
542
+ label,
543
+ tool: config.tool,
544
+ dirty,
545
+ rootDir: config.rootDir,
546
+ } satisfies AutosyncRuntimeState);
547
+ const next: AutosyncRuntimeState = {
548
+ ...current,
549
+ ...patch,
550
+ version: AUTOSYNC_VERSION,
551
+ service: config.name,
552
+ label,
553
+ tool: config.tool,
554
+ dirty: patch.dirty ?? dirty,
555
+ rootDir: config.rootDir,
556
+ };
557
+ await saveAutosyncRuntimeState(next, home);
558
+ };
559
+
560
+ const queue = async (fn: () => Promise<void>) => {
561
+ running = running.then(fn, fn);
562
+ await running;
563
+ };
564
+
565
+ const syncLocal = async () => {
566
+ await queue(async () => {
567
+ await runLocalAutosync(config, home);
568
+ dirty = false;
569
+ await persistState({
570
+ dirty,
571
+ lastLocalSyncAt: nowIso(),
572
+ lastError: undefined,
573
+ });
574
+ });
575
+ };
576
+
577
+ const syncRemote = async () => {
578
+ if (!config.git.enabled) {
579
+ return;
580
+ }
581
+ await queue(async () => {
582
+ const outcome = await runGitAutosyncOnce({ config });
583
+ await persistState({
584
+ lastGitSyncAt: nowIso(),
585
+ remoteBlocked: outcome.blocked,
586
+ remoteBlockReason: outcome.message,
587
+ lastError: outcome.message,
588
+ });
589
+ if (outcome.changed && !outcome.blocked) {
590
+ await runLocalAutosync(config, home);
591
+ dirty = false;
592
+ await persistState({
593
+ dirty,
594
+ lastLocalSyncAt: nowIso(),
595
+ lastError: undefined,
596
+ remoteBlocked: false,
597
+ remoteBlockReason: undefined,
598
+ });
599
+ }
600
+ });
601
+ };
602
+
603
+ await persistState({
604
+ dirty,
605
+ remoteBlocked: false,
606
+ remoteBlockReason: undefined,
607
+ });
608
+ await syncLocal();
609
+
610
+ if (opts.once) {
611
+ await syncRemote();
612
+ return;
613
+ }
614
+
615
+ const watcher = fsWatch(config.rootDir, { recursive: true });
616
+ watcher.on("change", (_eventType, fileName) => {
617
+ if (stopped || isIgnoredRootEvent(fileName)) {
618
+ return;
619
+ }
620
+ dirty = true;
621
+ runDetached(
622
+ "persisting runtime state after file change",
623
+ persistState({ dirty, lastEventAt: nowIso() })
624
+ );
625
+ if (debounceTimer) {
626
+ clearTimeout(debounceTimer);
627
+ }
628
+ debounceTimer = setTimeout(() => {
629
+ runDetached("running local sync", syncLocal());
630
+ }, config.debounceMs);
631
+ });
632
+
633
+ const gitInterval = Math.max(1, config.git.intervalMinutes) * 60_000;
634
+ const remoteTimer = setInterval(() => {
635
+ if (dirty || config.git.enabled) {
636
+ runDetached("running remote sync", syncRemote());
637
+ }
638
+ }, gitInterval);
639
+
640
+ await new Promise<void>((resolvePromise) => {
641
+ const stop = async () => {
642
+ if (stopped) {
643
+ return;
644
+ }
645
+ stopped = true;
646
+ if (debounceTimer) {
647
+ clearTimeout(debounceTimer);
648
+ }
649
+ clearInterval(remoteTimer);
650
+ watcher.close();
651
+ await persistState({ dirty });
652
+ resolvePromise();
653
+ };
654
+
655
+ process.once("SIGINT", () => {
656
+ runDetached("stopping after SIGINT", stop());
657
+ });
658
+ process.once("SIGTERM", () => {
659
+ runDetached("stopping after SIGTERM", stop());
660
+ });
661
+ });
662
+ }
663
+
664
+ function parseAutosyncIntFlag(
665
+ argv: string[],
666
+ flag: string
667
+ ): number | undefined {
668
+ const exact = argv.indexOf(flag);
669
+ if (exact >= 0) {
670
+ const raw = argv[exact + 1];
671
+ if (!raw) {
672
+ throw new Error(`${flag} requires a value.`);
673
+ }
674
+ return Number.parseInt(raw, 10);
675
+ }
676
+ const inline = argv.find((arg) => arg.startsWith(`${flag}=`));
677
+ if (!inline) {
678
+ return undefined;
679
+ }
680
+ return Number.parseInt(inline.slice(flag.length + 1), 10);
681
+ }
682
+
683
+ function parseAutosyncStringFlag(
684
+ argv: string[],
685
+ flag: string
686
+ ): string | undefined {
687
+ const exact = argv.indexOf(flag);
688
+ if (exact >= 0) {
689
+ const raw = argv[exact + 1];
690
+ if (!raw) {
691
+ throw new Error(`${flag} requires a value.`);
692
+ }
693
+ return raw.trim();
694
+ }
695
+ const inline = argv.find((arg) => arg.startsWith(`${flag}=`));
696
+ return inline ? inline.slice(flag.length + 1).trim() : undefined;
697
+ }
698
+
699
+ function parseAutosyncPositionals(
700
+ argv: string[],
701
+ flagsWithValues: string[]
702
+ ): string[] {
703
+ const valueFlags = new Set(flagsWithValues);
704
+ const positionals: string[] = [];
705
+
706
+ for (let i = 0; i < argv.length; i += 1) {
707
+ const arg = argv[i];
708
+ if (!arg) {
709
+ continue;
710
+ }
711
+ if (valueFlags.has(arg)) {
712
+ i += 1;
713
+ continue;
714
+ }
715
+ if (valueFlags.has(arg.split("=")[0] ?? "")) {
716
+ continue;
717
+ }
718
+ if (arg.startsWith("-")) {
719
+ continue;
720
+ }
721
+ positionals.push(arg);
722
+ }
723
+
724
+ return positionals;
725
+ }
726
+
727
+ function autosyncHelp(): string {
728
+ return `facult autosync — background autosync for managed tools
729
+
730
+ Usage:
731
+ facult autosync install [tool] [--git-remote <name>] [--git-branch <name>] [--git-interval-minutes <n>] [--git-disable]
732
+ facult autosync uninstall [tool]
733
+ facult autosync status [tool]
734
+ facult autosync restart [tool]
735
+ facult autosync run [tool] [--service <name>] [--once]
736
+
737
+ Options:
738
+ --git-remote <name> Git remote for canonical repo sync (default: origin)
739
+ --git-branch <name> Git branch for canonical repo sync (default: main)
740
+ --git-interval-minutes <n> Remote git sync interval in minutes (default: 60)
741
+ --git-disable Disable remote git sync for this service
742
+ --root <path> Select a canonical .ai root explicitly
743
+ --global Force the global canonical root
744
+ --project Force the nearest repo-local .ai root
745
+ --once Run one local+remote sync cycle and exit
746
+ `;
747
+ }
748
+
749
+ export async function installAutosyncService(args: {
750
+ tool?: string;
751
+ homeDir?: string;
752
+ rootDir?: string;
753
+ gitRemote?: string;
754
+ gitBranch?: string;
755
+ gitIntervalMinutes?: number;
756
+ gitEnabled?: boolean;
757
+ }): Promise<AutosyncServiceConfig> {
758
+ const home = args.homeDir ?? homedir();
759
+ const rootDir =
760
+ args.rootDir ??
761
+ resolveCliContextRoot({ homeDir: home, cwd: process.cwd() });
762
+ const serviceName = autosyncServiceName(args.tool, rootDir, home);
763
+ const config = defaultAutosyncConfig({
764
+ serviceName,
765
+ tool: args.tool,
766
+ homeDir: home,
767
+ rootDir,
768
+ remote: args.gitRemote,
769
+ branch: args.gitBranch,
770
+ intervalMinutes: args.gitIntervalMinutes,
771
+ gitEnabled: args.gitEnabled,
772
+ });
773
+ const spec = buildLaunchAgentSpec({
774
+ homeDir: home,
775
+ serviceName,
776
+ rootDir: config.rootDir,
777
+ });
778
+ const plist = buildLaunchAgentPlist(spec);
779
+
780
+ await mkdir(dirname(spec.plistPath), { recursive: true });
781
+ await mkdir(autosyncLogsDir(home), { recursive: true });
782
+ await saveAutosyncConfig(config, home);
783
+ await writeFile(spec.plistPath, plist, "utf8");
784
+
785
+ const domain = launchdDomain();
786
+ await runLaunchctl(["bootout", `${domain}/${spec.label}`]).catch(() => null);
787
+ await runLaunchctl(["bootstrap", domain, spec.plistPath]);
788
+ await runLaunchctl(["kickstart", "-k", `${domain}/${spec.label}`]);
789
+ return config;
790
+ }
791
+
792
+ export async function uninstallAutosyncService(args: {
793
+ tool?: string;
794
+ homeDir?: string;
795
+ rootDir?: string;
796
+ }): Promise<void> {
797
+ const home = args.homeDir ?? homedir();
798
+ const rootDir =
799
+ args.rootDir ??
800
+ resolveCliContextRoot({ homeDir: home, cwd: process.cwd() });
801
+ const serviceName = autosyncServiceName(args.tool, rootDir, home);
802
+ const label = autosyncLabel(serviceName);
803
+ const domain = launchdDomain();
804
+
805
+ await runLaunchctl(["bootout", `${domain}/${label}`]).catch(() => null);
806
+ await rm(autosyncPlistPath(home, serviceName), { force: true });
807
+ await rm(autosyncConfigPath(home, serviceName), { force: true });
808
+ }
809
+
810
+ export async function repairAutosyncServices(
811
+ homeDir: string = homedir()
812
+ ): Promise<boolean> {
813
+ const servicesDir = autosyncServicesDir(homeDir);
814
+ const files = await readdir(servicesDir).catch(() => [] as string[]);
815
+ let changed = false;
816
+
817
+ for (const entry of files) {
818
+ if (!entry.endsWith(".json")) {
819
+ continue;
820
+ }
821
+ const serviceName = basename(entry, ".json");
822
+ const config = await loadAutosyncConfig(serviceName, homeDir);
823
+ if (!config) {
824
+ continue;
825
+ }
826
+ const desiredRoot = projectRootFromAiRoot(config.rootDir, homeDir)
827
+ ? config.rootDir
828
+ : facultRootDir(homeDir);
829
+ if (config.rootDir !== desiredRoot) {
830
+ config.rootDir = desiredRoot;
831
+ await saveAutosyncConfig(config, homeDir);
832
+ changed = true;
833
+ }
834
+
835
+ const spec = buildLaunchAgentSpec({
836
+ homeDir,
837
+ serviceName,
838
+ rootDir: config.rootDir,
839
+ });
840
+ const desired = buildLaunchAgentPlist(spec);
841
+ const currentText = await readFile(spec.plistPath, "utf8").catch(
842
+ () => null
843
+ );
844
+ if (currentText !== desired) {
845
+ await mkdir(dirname(spec.plistPath), { recursive: true });
846
+ await mkdir(autosyncLogsDir(homeDir), { recursive: true });
847
+ await writeFile(spec.plistPath, desired, "utf8");
848
+ const domain = launchdDomain();
849
+ await runLaunchctl(["bootout", `${domain}/${spec.label}`]).catch(
850
+ () => null
851
+ );
852
+ await runLaunchctl(["bootstrap", domain, spec.plistPath]).catch(
853
+ () => null
854
+ );
855
+ await runLaunchctl(["kickstart", "-k", `${domain}/${spec.label}`]).catch(
856
+ () => null
857
+ );
858
+ changed = true;
859
+ }
860
+ }
861
+
862
+ return changed;
863
+ }
864
+
865
+ export async function autosyncStatus(args: {
866
+ tool?: string;
867
+ homeDir?: string;
868
+ rootDir?: string;
869
+ }): Promise<AutosyncStatus> {
870
+ const home = args.homeDir ?? homedir();
871
+ const rootDir =
872
+ args.rootDir ??
873
+ resolveCliContextRoot({ homeDir: home, cwd: process.cwd() });
874
+ const serviceName = autosyncServiceName(args.tool, rootDir, home);
875
+ const config = await loadAutosyncConfig(serviceName, home);
876
+ const state = await loadAutosyncRuntimeState(serviceName, home);
877
+ const plistPath = autosyncPlistPath(home, serviceName);
878
+ const plistExists = await pathExists(plistPath);
879
+ const label = autosyncLabel(serviceName);
880
+ const domain = launchdDomain();
881
+ const launchctl = await runLaunchctl(["print", `${domain}/${label}`]);
882
+
883
+ return {
884
+ config,
885
+ state,
886
+ plistPath,
887
+ plistExists,
888
+ loaded: launchctl.exitCode === 0,
889
+ launchctlSummary:
890
+ launchctl.exitCode === 0
891
+ ? launchctl.stdout.trim()
892
+ : launchctl.stderr.trim() || launchctl.stdout.trim() || undefined,
893
+ };
894
+ }
895
+
896
+ export async function restartAutosyncService(args: {
897
+ tool?: string;
898
+ rootDir?: string;
899
+ }): Promise<void> {
900
+ const home = homedir();
901
+ const rootDir =
902
+ args.rootDir ??
903
+ resolveCliContextRoot({ homeDir: home, cwd: process.cwd() });
904
+ const serviceName = autosyncServiceName(args.tool, rootDir, home);
905
+ const label = autosyncLabel(serviceName);
906
+ await runLaunchctl(["kickstart", "-k", `${launchdDomain()}/${label}`]);
907
+ }
908
+
909
+ export async function autosyncCommand(argv: string[]) {
910
+ const [sub, ...rest] = argv;
911
+ if (!sub || sub === "--help" || sub === "-h" || sub === "help") {
912
+ console.log(autosyncHelp());
913
+ return;
914
+ }
915
+
916
+ try {
917
+ const parsed = parseCliContextArgs(rest);
918
+ if (
919
+ parsed.argv.includes("--help") ||
920
+ parsed.argv.includes("-h") ||
921
+ parsed.argv[0] === "help"
922
+ ) {
923
+ console.log(autosyncHelp());
924
+ return;
925
+ }
926
+ const rootDir = resolveCliContextRoot({
927
+ rootArg: parsed.rootArg,
928
+ scope: parsed.scope,
929
+ cwd: process.cwd(),
930
+ });
931
+
932
+ if (sub === "install") {
933
+ const tool = parseAutosyncPositionals(parsed.argv, [
934
+ "--git-remote",
935
+ "--git-branch",
936
+ "--git-interval-minutes",
937
+ ])[0];
938
+ const gitRemote = parseAutosyncStringFlag(parsed.argv, "--git-remote");
939
+ const gitBranch = parseAutosyncStringFlag(parsed.argv, "--git-branch");
940
+ const gitIntervalMinutes = parseAutosyncIntFlag(
941
+ parsed.argv,
942
+ "--git-interval-minutes"
943
+ );
944
+ const gitEnabled = !parsed.argv.includes("--git-disable");
945
+ const config = await installAutosyncService({
946
+ tool,
947
+ rootDir,
948
+ gitRemote,
949
+ gitBranch,
950
+ gitIntervalMinutes,
951
+ gitEnabled,
952
+ });
953
+ console.log(`Installed autosync service: ${config.name}`);
954
+ console.log(`Label: ${autosyncLabel(config.name)}`);
955
+ return;
956
+ }
957
+
958
+ if (sub === "uninstall") {
959
+ const tool = parseAutosyncPositionals(parsed.argv, [])[0];
960
+ await uninstallAutosyncService({ tool, rootDir });
961
+ console.log(
962
+ `Removed autosync service: ${autosyncServiceName(tool, rootDir)}`
963
+ );
964
+ return;
965
+ }
966
+
967
+ if (sub === "status") {
968
+ const tool = parseAutosyncPositionals(parsed.argv, [])[0];
969
+ const status = await autosyncStatus({ tool, rootDir });
970
+ console.log(`Service: ${autosyncServiceName(tool, rootDir)}`);
971
+ console.log(`Plist: ${status.plistPath}`);
972
+ console.log(`Installed: ${status.plistExists ? "yes" : "no"}`);
973
+ console.log(`Loaded: ${status.loaded ? "yes" : "no"}`);
974
+ if (status.config) {
975
+ console.log(`Root: ${status.config.rootDir}`);
976
+ console.log(
977
+ `Remote sync: ${status.config.git.enabled ? "enabled" : "disabled"}`
978
+ );
979
+ if (status.config.git.enabled) {
980
+ console.log(
981
+ `Git remote: ${status.config.git.remote}/${status.config.git.branch}`
982
+ );
983
+ console.log(`Git interval: ${status.config.git.intervalMinutes}m`);
984
+ }
985
+ }
986
+ if (status.state?.lastLocalSyncAt) {
987
+ console.log(`Last local sync: ${status.state.lastLocalSyncAt}`);
988
+ }
989
+ if (status.state?.lastGitSyncAt) {
990
+ console.log(`Last git sync: ${status.state.lastGitSyncAt}`);
991
+ }
992
+ if (status.state?.remoteBlocked) {
993
+ console.log(
994
+ `Remote blocked: ${status.state.remoteBlockReason ?? "yes"}`
995
+ );
996
+ }
997
+ return;
998
+ }
999
+
1000
+ if (sub === "restart") {
1001
+ const tool = parseAutosyncPositionals(parsed.argv, [])[0];
1002
+ await restartAutosyncService({ tool, rootDir });
1003
+ console.log(
1004
+ `Restarted autosync service: ${autosyncServiceName(tool, rootDir)}`
1005
+ );
1006
+ return;
1007
+ }
1008
+
1009
+ if (sub === "run") {
1010
+ const service = parseAutosyncStringFlag(parsed.argv, "--service");
1011
+ const tool = parseAutosyncPositionals(parsed.argv, ["--service"])[0];
1012
+ const serviceName = service ?? autosyncServiceName(tool, rootDir);
1013
+ const config = await loadAutosyncConfig(serviceName);
1014
+ if (!config) {
1015
+ throw new Error(`Autosync service not configured: ${serviceName}`);
1016
+ }
1017
+ await runAutosyncService(config, {
1018
+ once: parsed.argv.includes("--once"),
1019
+ });
1020
+ return;
1021
+ }
1022
+
1023
+ throw new Error(`Unknown autosync command: ${sub}`);
1024
+ } catch (err) {
1025
+ console.error(err instanceof Error ? err.message : String(err));
1026
+ process.exitCode = 1;
1027
+ }
1028
+ }