facult 2.3.1 → 2.5.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/src/autosync.ts CHANGED
@@ -5,6 +5,7 @@ import { basename, dirname, join } from "node:path";
5
5
  import { parseCliContextArgs, resolveCliContextRoot } from "./cli-context";
6
6
  import { syncManagedTools } from "./manage";
7
7
  import {
8
+ facultMachineStateDir,
8
9
  facultRootDir,
9
10
  facultStateDir,
10
11
  legacyFacultStateDirForRoot,
@@ -84,6 +85,10 @@ interface GitSyncOutcome {
84
85
  message?: string;
85
86
  }
86
87
 
88
+ let launchctlRunnerForTests:
89
+ | ((args: string[]) => Promise<CommandResult>)
90
+ | null = null;
91
+
87
92
  function nowIso(): string {
88
93
  return new Date().toISOString();
89
94
  }
@@ -100,6 +105,10 @@ function runDetached(context: string, promise: Promise<void>) {
100
105
  }
101
106
 
102
107
  function autosyncDir(home: string, rootDir?: string): string {
108
+ return join(facultMachineStateDir(home, rootDir), "autosync");
109
+ }
110
+
111
+ function canonicalAutosyncDir(home: string, rootDir?: string): string {
103
112
  return join(facultStateDir(home, rootDir), "autosync");
104
113
  }
105
114
 
@@ -155,11 +164,21 @@ function autosyncServiceName(
155
164
  }
156
165
 
157
166
  function autosyncLabel(serviceName: string): string {
167
+ return serviceName === "all"
168
+ ? "com.fclt.autosync"
169
+ : `com.fclt.autosync.${serviceName}`;
170
+ }
171
+
172
+ function legacyAutosyncLabel(serviceName: string): string {
158
173
  return serviceName === "all"
159
174
  ? "com.facult.autosync"
160
175
  : `com.facult.autosync.${serviceName}`;
161
176
  }
162
177
 
178
+ function autosyncLabelCandidates(serviceName: string): string[] {
179
+ return [autosyncLabel(serviceName), legacyAutosyncLabel(serviceName)];
180
+ }
181
+
163
182
  function autosyncPlistPath(home: string, serviceName: string): string {
164
183
  return join(
165
184
  home,
@@ -331,6 +350,11 @@ export async function loadAutosyncConfig(
331
350
  ): Promise<AutosyncServiceConfig | null> {
332
351
  const candidates = [
333
352
  autosyncConfigPath(homeDir, serviceName, rootDir),
353
+ join(
354
+ canonicalAutosyncDir(homeDir, rootDir),
355
+ "services",
356
+ `${serviceName}.json`
357
+ ),
334
358
  legacyAutosyncConfigPath(homeDir, serviceName, rootDir),
335
359
  ];
336
360
  for (const candidate of candidates) {
@@ -359,6 +383,11 @@ export async function loadAutosyncRuntimeState(
359
383
  ): Promise<AutosyncRuntimeState | null> {
360
384
  const candidates = [
361
385
  autosyncRuntimeStatePath(homeDir, serviceName, rootDir),
386
+ join(
387
+ canonicalAutosyncDir(homeDir, rootDir),
388
+ "state",
389
+ `${serviceName}.json`
390
+ ),
362
391
  legacyAutosyncRuntimeStatePath(homeDir, serviceName, rootDir),
363
392
  ];
364
393
  for (const candidate of candidates) {
@@ -399,9 +428,18 @@ async function runCommand(
399
428
  }
400
429
 
401
430
  async function runLaunchctl(args: string[]): Promise<CommandResult> {
431
+ if (launchctlRunnerForTests) {
432
+ return await launchctlRunnerForTests(args);
433
+ }
402
434
  return await runCommand(["launchctl", ...args]);
403
435
  }
404
436
 
437
+ export function setLaunchctlRunnerForTests(
438
+ runner: ((args: string[]) => Promise<CommandResult>) | null
439
+ ) {
440
+ launchctlRunnerForTests = runner;
441
+ }
442
+
405
443
  function launchdDomain(): string {
406
444
  return `gui/${process.getuid?.() ?? process.geteuid?.() ?? 0}`;
407
445
  }
@@ -470,6 +508,122 @@ async function ensureGitRepo(repoDir: string): Promise<boolean> {
470
508
  return await pathExists(join(repoDir, ".git"));
471
509
  }
472
510
 
511
+ async function cleanupAutosyncLaunchAgentArtifacts(args: {
512
+ homeDir: string;
513
+ serviceName: string;
514
+ }) {
515
+ const domain = launchdDomain();
516
+ for (const label of autosyncLabelCandidates(args.serviceName)) {
517
+ await runLaunchctl(["bootout", `${domain}/${label}`]).catch(() => null);
518
+ }
519
+
520
+ const legacyPlistPath = join(
521
+ args.homeDir,
522
+ "Library",
523
+ "LaunchAgents",
524
+ `${legacyAutosyncLabel(args.serviceName)}.plist`
525
+ );
526
+ if (legacyPlistPath !== autosyncPlistPath(args.homeDir, args.serviceName)) {
527
+ await rm(legacyPlistPath, { force: true });
528
+ }
529
+ }
530
+
531
+ async function cleanupLegacyAutosyncFiles(args: {
532
+ homeDir: string;
533
+ serviceName: string;
534
+ rootDir: string;
535
+ }) {
536
+ const legacyPaths = [
537
+ join(
538
+ canonicalAutosyncDir(args.homeDir, args.rootDir),
539
+ "services",
540
+ `${args.serviceName}.json`
541
+ ),
542
+ join(
543
+ canonicalAutosyncDir(args.homeDir, args.rootDir),
544
+ "state",
545
+ `${args.serviceName}.json`
546
+ ),
547
+ join(
548
+ canonicalAutosyncDir(args.homeDir, args.rootDir),
549
+ "logs",
550
+ `${args.serviceName}.log`
551
+ ),
552
+ join(
553
+ canonicalAutosyncDir(args.homeDir, args.rootDir),
554
+ "logs",
555
+ `${args.serviceName}.err.log`
556
+ ),
557
+ legacyAutosyncConfigPath(args.homeDir, args.serviceName, args.rootDir),
558
+ legacyAutosyncRuntimeStatePath(
559
+ args.homeDir,
560
+ args.serviceName,
561
+ args.rootDir
562
+ ),
563
+ ];
564
+ for (const candidate of legacyPaths) {
565
+ await rm(candidate, { force: true }).catch(() => null);
566
+ }
567
+ }
568
+
569
+ const AUTOSYNC_REBUILDABLE_PATHS = [
570
+ ".facult/ai/index.json",
571
+ ".facult/ai/graph.json",
572
+ ];
573
+
574
+ const AUTOSYNC_MACHINE_LOCAL_LEGACY_PATHS = [
575
+ ".facult/managed.json",
576
+ ".facult/install.json",
577
+ ".facult/autosync",
578
+ ".facult/runtime",
579
+ ];
580
+
581
+ async function gitListTrackedPaths(
582
+ repoDir: string,
583
+ pathValue: string
584
+ ): Promise<string[]> {
585
+ const result = await runCommand(["git", "ls-files", "-z", "--", pathValue], {
586
+ cwd: repoDir,
587
+ });
588
+ if (result.exitCode !== 0 || !result.stdout) {
589
+ return [];
590
+ }
591
+ return result.stdout
592
+ .split("\0")
593
+ .map((value) => value.trim())
594
+ .filter(Boolean);
595
+ }
596
+
597
+ async function cleanupAutosyncProtectedPaths(repoDir: string): Promise<void> {
598
+ const tracked = new Set<string>();
599
+ for (const pathValue of [
600
+ ...AUTOSYNC_REBUILDABLE_PATHS,
601
+ ...AUTOSYNC_MACHINE_LOCAL_LEGACY_PATHS,
602
+ ]) {
603
+ for (const entry of await gitListTrackedPaths(repoDir, pathValue)) {
604
+ tracked.add(entry);
605
+ }
606
+ await rm(join(repoDir, pathValue), { force: true, recursive: true }).catch(
607
+ () => null
608
+ );
609
+ }
610
+
611
+ if (tracked.size > 0) {
612
+ await runCommand(
613
+ [
614
+ "git",
615
+ "restore",
616
+ "--staged",
617
+ "--worktree",
618
+ "--source=HEAD",
619
+ "--",
620
+ ...tracked,
621
+ ],
622
+ { cwd: repoDir }
623
+ );
624
+ }
625
+ }
626
+
473
627
  export async function runGitAutosyncOnce(args: {
474
628
  config: AutosyncServiceConfig;
475
629
  }): Promise<GitSyncOutcome> {
@@ -503,6 +657,8 @@ export async function runGitAutosyncOnce(args: {
503
657
  };
504
658
  }
505
659
 
660
+ await cleanupAutosyncProtectedPaths(repoDir);
661
+
506
662
  const fetch = await runCommand(
507
663
  ["git", "fetch", config.git.remote, config.git.branch],
508
664
  { cwd: repoDir }
@@ -849,10 +1005,18 @@ export async function installAutosyncService(args: {
849
1005
  await mkdir(dirname(spec.plistPath), { recursive: true });
850
1006
  await mkdir(autosyncLogsDir(home, rootDir), { recursive: true });
851
1007
  await saveAutosyncConfig(config, home);
1008
+ await cleanupLegacyAutosyncFiles({
1009
+ homeDir: home,
1010
+ serviceName,
1011
+ rootDir: config.rootDir,
1012
+ });
1013
+ await cleanupAutosyncLaunchAgentArtifacts({
1014
+ homeDir: home,
1015
+ serviceName,
1016
+ });
852
1017
  await writeFile(spec.plistPath, plist, "utf8");
853
1018
 
854
1019
  const domain = launchdDomain();
855
- await runLaunchctl(["bootout", `${domain}/${spec.label}`]).catch(() => null);
856
1020
  await runLaunchctl(["bootstrap", domain, spec.plistPath]);
857
1021
  await runLaunchctl(["kickstart", "-k", `${domain}/${spec.label}`]);
858
1022
  return config;
@@ -868,10 +1032,16 @@ export async function uninstallAutosyncService(args: {
868
1032
  args.rootDir ??
869
1033
  resolveCliContextRoot({ homeDir: home, cwd: process.cwd() });
870
1034
  const serviceName = autosyncServiceName(args.tool, rootDir, home);
871
- const label = autosyncLabel(serviceName);
872
- const domain = launchdDomain();
873
1035
 
874
- await runLaunchctl(["bootout", `${domain}/${label}`]).catch(() => null);
1036
+ await cleanupAutosyncLaunchAgentArtifacts({
1037
+ homeDir: home,
1038
+ serviceName,
1039
+ });
1040
+ await cleanupLegacyAutosyncFiles({
1041
+ homeDir: home,
1042
+ serviceName,
1043
+ rootDir,
1044
+ });
875
1045
  await rm(autosyncPlistPath(home, serviceName), { force: true });
876
1046
  await rm(autosyncConfigPath(home, serviceName, rootDir), { force: true });
877
1047
  }
@@ -883,6 +1053,7 @@ export async function repairAutosyncServices(
883
1053
  const activeRoot = rootDir ?? facultRootDir(homeDir);
884
1054
  const serviceDirs = [
885
1055
  autosyncServicesDir(homeDir, activeRoot),
1056
+ join(canonicalAutosyncDir(homeDir, activeRoot), "services"),
886
1057
  legacyAutosyncServicesDir(homeDir, activeRoot),
887
1058
  ];
888
1059
  const seen = new Set<string>();
@@ -916,6 +1087,11 @@ export async function repairAutosyncServices(
916
1087
  await saveAutosyncConfig(config, homeDir);
917
1088
  changed = true;
918
1089
  }
1090
+ await cleanupLegacyAutosyncFiles({
1091
+ homeDir,
1092
+ serviceName,
1093
+ rootDir: config.rootDir,
1094
+ });
919
1095
 
920
1096
  const spec = buildLaunchAgentSpec({
921
1097
  homeDir,
@@ -923,19 +1099,27 @@ export async function repairAutosyncServices(
923
1099
  rootDir: config.rootDir,
924
1100
  });
925
1101
  const desired = buildLaunchAgentPlist(spec);
1102
+ const legacyPlistPath = join(
1103
+ homeDir,
1104
+ "Library",
1105
+ "LaunchAgents",
1106
+ `${legacyAutosyncLabel(serviceName)}.plist`
1107
+ );
926
1108
  const currentText = await readFile(spec.plistPath, "utf8").catch(
927
1109
  () => null
928
1110
  );
929
- if (currentText !== desired) {
1111
+ const legacyExists = await pathExists(legacyPlistPath);
1112
+ if (currentText !== desired || legacyExists) {
930
1113
  await mkdir(dirname(spec.plistPath), { recursive: true });
931
1114
  await mkdir(autosyncLogsDir(homeDir, config.rootDir), {
932
1115
  recursive: true,
933
1116
  });
1117
+ await cleanupAutosyncLaunchAgentArtifacts({
1118
+ homeDir,
1119
+ serviceName,
1120
+ });
934
1121
  await writeFile(spec.plistPath, desired, "utf8");
935
1122
  const domain = launchdDomain();
936
- await runLaunchctl(["bootout", `${domain}/${spec.label}`]).catch(
937
- () => null
938
- );
939
1123
  await runLaunchctl(["bootstrap", domain, spec.plistPath]).catch(
940
1124
  () => null
941
1125
  );
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readdir } from "node:fs/promises";
2
2
  import { basename, dirname, join, relative } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { getAdapter } from "./adapters";
4
5
  import { parseCliContextArgs, resolveCliContextRoot } from "./cli-context";
5
6
  import {
6
7
  type AssetScope,
@@ -14,7 +15,7 @@ import {
14
15
  import {
15
16
  facultAiGraphPath,
16
17
  facultAiIndexPath,
17
- facultGeneratedStateDir,
18
+ facultMachineStateDir,
18
19
  facultRootDir,
19
20
  projectRootFromAiRoot,
20
21
  projectSlugFromAiRoot,
@@ -31,6 +32,10 @@ interface AssetEntryBase {
31
32
  shadow?: boolean;
32
33
  }
33
34
 
35
+ function managedAgentFileExtension(tool: string): string {
36
+ return getAdapter(tool)?.agentFileExtension ?? ".toml";
37
+ }
38
+
34
39
  export interface SkillEntry {
35
40
  name: string;
36
41
  path: string;
@@ -987,7 +992,7 @@ async function readManagedState(
987
992
  rootDir: string
988
993
  ): Promise<ManagedStateLite | null> {
989
994
  const statePath = join(
990
- facultGeneratedStateDir({ home: homeDir, rootDir }),
995
+ facultMachineStateDir(homeDir, rootDir),
991
996
  "managed.json"
992
997
  );
993
998
  try {
@@ -1213,6 +1218,7 @@ function registerManagedRenderedTargets(args: {
1213
1218
  const nodes = args.graph.nodes;
1214
1219
  for (const toolState of toolStates) {
1215
1220
  if (toolState.agentsDir) {
1221
+ const extension = managedAgentFileExtension(toolState.tool);
1216
1222
  for (const entry of Object.values(args.index.agents)) {
1217
1223
  const sourceNodeId = sourceNodeIdForEntry({
1218
1224
  kind: "agent",
@@ -1221,7 +1227,10 @@ function registerManagedRenderedTargets(args: {
1221
1227
  if (!nodes[sourceNodeId]) {
1222
1228
  continue;
1223
1229
  }
1224
- const targetPath = join(toolState.agentsDir, `${entry.name}.toml`);
1230
+ const targetPath = join(
1231
+ toolState.agentsDir,
1232
+ `${entry.name}${extension}`
1233
+ );
1225
1234
  registerRenderedTargetNode({
1226
1235
  graph: args.graph,
1227
1236
  currentScope: args.currentScope,