Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion LifeOS/install/LIFEOS/ATLAS/Atlas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ import { github } from "./collectors/Github";
import { projects } from "./collectors/Projects";
import { infraInventory } from "./collectors/InfraInventory";
import { launchd } from "./collectors/Launchd";
import { systemd } from "./collectors/Systemd";
import { gear } from "./collectors/Gear";
import { secrets } from "./collectors/Secrets";

const COLLECTORS: Record<string, Collector> = Object.fromEntries(
[cloudflare, github, projects, infraInventory, launchd, gear, secrets].map((c) => [c.name, c]),
[cloudflare, github, projects, infraInventory, launchd, systemd, gear, secrets].map((c) => [c.name, c]),
);

const FULL_SYNC_INTERVAL_MS = 60 * 60 * 1000;
Expand Down
253 changes: 253 additions & 0 deletions LifeOS/install/LIFEOS/ATLAS/InstallAtlas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
#!/usr/bin/env bun
/**
* InstallAtlas.ts — Materialize the Atlas tick unit(s) and bootstrap them.
*
* bun ~/.claude/LIFEOS/ATLAS/InstallAtlas.ts # install
* bun ~/.claude/LIFEOS/ATLAS/InstallAtlas.ts --uninstall # remove
* bun ~/.claude/LIFEOS/ATLAS/InstallAtlas.ts --status # check
*
* Same two-backend pattern as LIFEOS/TOOLS/InstallWorkSweep.ts (macOS launchd,
* Linux systemd --user, `id -un` + loginctl enable-linger so the Linux timer
* survives logout — the gap found in InstallWorkSweep.ts on 2026-08-03).
*
* macOS: materializes com.lifeos.atlas.plist.template into ~/Library/LaunchAgents/.
* Linux: materializes com.lifeos.atlas.{service,timer}.template into
* ~/.config/systemd/user/ — the timer fires `atlas tick` every 15 minutes;
* Atlas.ts's own lastFullSyncAt() gate means most ticks just process hint
* events cheaply and only run a real full sync once an hour is due.
* Other platforms: unsupported, exits loud rather than silently no-op.
*
* Idempotent. Re-running install bootouts/stops the prior load before
* bootstrapping/starting the fresh unit(s).
*/

import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "fs";
import { join } from "path";

declare const Bun: { spawn: (cmd: string[], opts?: any) => any };

const HOME = process.env.HOME || "";
const LABEL = "com.lifeos.atlas";
const IS_LINUX = process.platform === "linux";
const IS_MACOS = process.platform === "darwin";

// macOS (launchd)
const TEMPLATE_PATH = join(HOME, ".claude", "LIFEOS", "ATLAS", "com.lifeos.atlas.plist.template");
const LAUNCH_AGENTS_DIR = join(HOME, "Library", "LaunchAgents");
const TARGET_PLIST = join(LAUNCH_AGENTS_DIR, "com.lifeos.atlas.plist");

// Linux (systemd --user)
const SYSTEMD_USER_DIR = join(HOME, ".config", "systemd", "user");
const SERVICE_TEMPLATE_PATH = join(HOME, ".claude", "LIFEOS", "ATLAS", "com.lifeos.atlas.service.template");
const TIMER_TEMPLATE_PATH = join(HOME, ".claude", "LIFEOS", "ATLAS", "com.lifeos.atlas.timer.template");
const TARGET_SERVICE = join(SYSTEMD_USER_DIR, "com.lifeos.atlas.service");
const TARGET_TIMER = join(SYSTEMD_USER_DIR, "com.lifeos.atlas.timer");

async function uid(): Promise<string> {
const proc = Bun.spawn(["id", "-u"], { stdout: "pipe", stderr: "ignore" });
const out = await new Response(proc.stdout).text();
await proc.exited;
return out.trim();
}

async function username(): Promise<string> {
// `id -un` over process.env.USER — see InstallWorkSweep.ts's 2026-08-03 fix.
const proc = Bun.spawn(["id", "-un"], { stdout: "pipe", stderr: "ignore" });
const out = await new Response(proc.stdout).text();
await proc.exited;
return out.trim();
}

async function launchctl(args: string[]): Promise<{ ok: boolean; out: string; err: string }> {
const proc = Bun.spawn(["launchctl", ...args], { stdout: "pipe", stderr: "pipe" });
const out = await new Response(proc.stdout).text();
const err = await new Response(proc.stderr).text();
const exit = await proc.exited;
return { ok: exit === 0, out, err };
}

async function detectBun(): Promise<string> {
const proc = Bun.spawn(["which", "bun"], { stdout: "pipe", stderr: "ignore" });
const out = await new Response(proc.stdout).text();
await proc.exited;
const path = out.trim();
if (!path) throw new Error("bun not found in PATH — install bun first");
return path;
}

async function detectGh(): Promise<string> {
// The github collector shells out to `gh`; widen the unit's PATH the same
// way InstallWorkSweep.ts does for WorkSweep.ts's own gh calls.
const proc = Bun.spawn(["which", "gh"], { stdout: "pipe", stderr: "ignore" });
const out = await new Response(proc.stdout).text();
await proc.exited;
const path = out.trim();
if (!path) throw new Error("gh not found in PATH — install the GitHub CLI first");
return path;
}

async function systemctl(args: string[]): Promise<{ ok: boolean; out: string; err: string }> {
const proc = Bun.spawn(["systemctl", "--user", ...args], { stdout: "pipe", stderr: "pipe" });
const out = await new Response(proc.stdout).text();
const err = await new Response(proc.stderr).text();
const exit = await proc.exited;
return { ok: exit === 0, out, err };
}

async function installLaunchd(): Promise<void> {
if (!existsSync(TEMPLATE_PATH)) {
console.error(`[InstallAtlas] template missing at ${TEMPLATE_PATH}`);
process.exit(1);
}
const bunPath = await detectBun();
const bunDir = bunPath.replace(/\/bun$/, "");
const ghPath = await detectGh();
const ghDir = ghPath.replace(/\/gh$/, "");
console.log(`[InstallAtlas] detected bun at ${bunPath}, gh at ${ghPath}`);
const template = readFileSync(TEMPLATE_PATH, "utf-8");
const materialized = template
.replace(/\{\{HOME\}\}/g, HOME)
.replace(/\{\{BUN\}\}/g, bunPath)
.replace(/\{\{BUN_DIR\}\}/g, bunDir)
.replace(/\{\{GH_DIR\}\}/g, ghDir);
if (!existsSync(LAUNCH_AGENTS_DIR)) mkdirSync(LAUNCH_AGENTS_DIR, { recursive: true });

const u = await uid();
if (existsSync(TARGET_PLIST)) {
await launchctl(["bootout", `gui/${u}`, TARGET_PLIST]);
}

writeFileSync(TARGET_PLIST, materialized);
console.log(`[InstallAtlas] wrote ${TARGET_PLIST}`);

const r = await launchctl(["bootstrap", `gui/${u}`, TARGET_PLIST]);
if (!r.ok) {
console.error(`[InstallAtlas] bootstrap failed: ${r.err.trim()}`);
process.exit(1);
}
console.log(`[InstallAtlas] launchd bootstrap OK — ${LABEL} active`);

const status = await launchctl(["print", `gui/${u}/${LABEL}`]);
if (status.ok) {
const stateLine = status.out.split("\n").find((l) => l.includes("state ="));
console.log(`[InstallAtlas] ${stateLine?.trim() ?? "state unknown"}`);
}
}

async function installSystemd(): Promise<void> {
if (!existsSync(SERVICE_TEMPLATE_PATH) || !existsSync(TIMER_TEMPLATE_PATH)) {
console.error(`[InstallAtlas] template(s) missing at ${SERVICE_TEMPLATE_PATH} / ${TIMER_TEMPLATE_PATH}`);
process.exit(1);
}
const bunPath = await detectBun();
const bunDir = bunPath.replace(/\/bun$/, "");
const ghPath = await detectGh();
const ghDir = ghPath.replace(/\/gh$/, "");
console.log(`[InstallAtlas] detected bun at ${bunPath}, gh at ${ghPath}`);
const sub = (s: string) => s.replace(/\{\{HOME\}\}/g, HOME).replace(/\{\{BUN\}\}/g, bunPath).replace(/\{\{BUN_DIR\}\}/g, bunDir).replace(/\{\{GH_DIR\}\}/g, ghDir);
const service = sub(readFileSync(SERVICE_TEMPLATE_PATH, "utf-8"));
const timer = sub(readFileSync(TIMER_TEMPLATE_PATH, "utf-8"));
if (!existsSync(SYSTEMD_USER_DIR)) mkdirSync(SYSTEMD_USER_DIR, { recursive: true });

await systemctl(["stop", `${LABEL}.timer`]);

writeFileSync(TARGET_SERVICE, service);
writeFileSync(TARGET_TIMER, timer);
console.log(`[InstallAtlas] wrote ${TARGET_SERVICE} and ${TARGET_TIMER}`);

await systemctl(["daemon-reload"]);

// Survive logout/reboot — see InstallWorkSweep.ts's 2026-08-03 fix for why.
const lingerUser = await username();
if (lingerUser) {
const linger = Bun.spawn(["loginctl", "enable-linger", lingerUser], { stdout: "ignore", stderr: "pipe" });
await linger.exited;
} else {
console.error(`[InstallAtlas] could not resolve username via 'id -un' — skipping loginctl enable-linger (timer may not survive logout)`);
}

const r = await systemctl(["enable", "--now", `${LABEL}.timer`]);
if (!r.ok) {
console.error(`[InstallAtlas] systemctl enable failed: ${r.err.trim()}`);
process.exit(1);
}
console.log(`[InstallAtlas] systemd timer enabled — ${LABEL}.timer active`);

const status = await systemctl(["is-active", `${LABEL}.timer`]);
console.log(`[InstallAtlas] ${LABEL}.timer state: ${status.out.trim() || "unknown"}`);
}

async function uninstallLaunchd(): Promise<void> {
const u = await uid();
if (existsSync(TARGET_PLIST)) {
const r = await launchctl(["bootout", `gui/${u}`, TARGET_PLIST]);
console.log(`[InstallAtlas] bootout ${r.ok ? "OK" : "FAILED: " + r.err.trim()}`);
try { unlinkSync(TARGET_PLIST); console.log(`[InstallAtlas] removed ${TARGET_PLIST}`); } catch {}
} else {
console.log(`[InstallAtlas] no plist at ${TARGET_PLIST} — nothing to do`);
}
}

async function uninstallSystemd(): Promise<void> {
if (existsSync(TARGET_TIMER) || existsSync(TARGET_SERVICE)) {
const r = await systemctl(["disable", "--now", `${LABEL}.timer`]);
console.log(`[InstallAtlas] disable ${r.ok ? "OK" : "FAILED: " + r.err.trim()}`);
try { unlinkSync(TARGET_TIMER); } catch {}
try { unlinkSync(TARGET_SERVICE); } catch {}
await systemctl(["daemon-reload"]);
console.log(`[InstallAtlas] removed ${TARGET_SERVICE} and ${TARGET_TIMER}`);
} else {
console.log(`[InstallAtlas] no unit at ${TARGET_TIMER} — nothing to do`);
}
}

async function statusLaunchd(): Promise<void> {
const u = await uid();
const r = await launchctl(["print", `gui/${u}/${LABEL}`]);
if (!r.ok) {
console.log(`[InstallAtlas] ${LABEL} not loaded`);
process.exit(1);
}
console.log(r.out);
}

async function statusSystemd(): Promise<void> {
const r = await systemctl(["status", `${LABEL}.timer`, "--no-pager"]);
if (!r.out.trim() && !r.err.trim()) {
console.log(`[InstallAtlas] ${LABEL}.timer not found`);
process.exit(1);
}
console.log(r.out || r.err);
}

async function install(): Promise<void> {
if (IS_LINUX) return installSystemd();
if (IS_MACOS) return installLaunchd();
console.error(`[InstallAtlas] unsupported platform: ${process.platform} (macOS and Linux only)`);
process.exit(1);
}

async function uninstall(): Promise<void> {
if (IS_LINUX) return uninstallSystemd();
if (IS_MACOS) return uninstallLaunchd();
console.error(`[InstallAtlas] unsupported platform: ${process.platform} (macOS and Linux only)`);
process.exit(1);
}

async function status(): Promise<void> {
if (IS_LINUX) return statusSystemd();
if (IS_MACOS) return statusLaunchd();
console.error(`[InstallAtlas] unsupported platform: ${process.platform} (macOS and Linux only)`);
process.exit(1);
}

async function main(): Promise<void> {
const arg = process.argv[2];
if (arg === "--uninstall") return uninstall();
if (arg === "--status") return status();
return install();
}

if (import.meta.main) {
main().catch((err) => { console.error(`[InstallAtlas] Fatal: ${err}`); process.exit(1); });
}
14 changes: 11 additions & 3 deletions LifeOS/install/LIFEOS/ATLAS/collectors/Launchd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,17 @@ import type { AssetObs, CollectResult, Collector, EdgeObs } from "../Store";
const AGENTS_DIR = join(homedir(), "Library/LaunchAgents");

async function plutilRaw(keypath: string, plist: string): Promise<string | null> {
const proc = Bun.spawn(["plutil", "-extract", keypath, "raw", "-o", "-", plist], { stdout: "pipe", stderr: "pipe" });
const out = await new Response(proc.stdout).text();
return (await proc.exited) === 0 ? out.trim() : null;
try {
const proc = Bun.spawn(["plutil", "-extract", keypath, "raw", "-o", "-", plist], { stdout: "pipe", stderr: "pipe" });
const out = await new Response(proc.stdout).text();
return (await proc.exited) === 0 ? out.trim() : null;
} catch {
// ENOENT — plutil itself missing. AGENTS_DIR existing doesn't imply the
// platform's own tool does (e.g. a Linux box with stray com.lifeos.*.plist
// files but no plutil binary). Same "absent tool is a normal state, not a
// fault" class Github.ts already treats a missing `gh` as.
return null;
}
}

export const launchd: Collector = {
Expand Down
87 changes: 87 additions & 0 deletions LifeOS/install/LIFEOS/ATLAS/collectors/Systemd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Systemd collector — LifeOS background services (com.lifeos.* / com.pai.*) on this
* machine, Linux sibling of Launchd.ts (which degrades harmlessly here since
* ~/Library/LaunchAgents never exists on Linux). Reads unit files directly —
* no `systemctl show` shelling per unit, since OnUnitActiveSec/OnBootSec are
* plain text in the .timer file and this stays a pure fs read like Launchd's
* plutil calls, just without the subprocess.
*/

import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { homedir, hostname } from "node:os";
import type { AssetObs, CollectResult, Collector, EdgeObs } from "../Store";

const UNITS_DIR = join(homedir(), ".config/systemd/user");

/** First `Key=value` match for `key` inside `[section]`, or null. Plain unit-file INI, no subprocess. */
function unitValue(text: string, section: string, key: string): string | null {
const secMatch = text.match(new RegExp(`\\[${section}\\][^\\[]*`));
if (!secMatch) return null;
const m = secMatch[0].match(new RegExp(`^${key}=(.*)$`, "m"));
return m ? m[1].trim() : null;
}

export const systemd: Collector = {
name: "systemd",
async collect(): Promise<CollectResult> {
const host = hostname();
const machineKey = `machine:${host}`;
const assets: AssetObs[] = [{ kind: "machine", key: machineKey, name: host }];
const edges: EdgeObs[] = [];

// Absent source ⇒ degrade, never throw (ratchet gate 3, same as Launchd.ts) —
// a machine without this dir (macOS, or a fresh Linux box pre-install) reports
// incompleteness rather than a permanent sync failure.
if (!existsSync(UNITS_DIR)) return { complete: false, assets: [], edges: [] };
const files = readdirSync(UNITS_DIR).filter((f) => /^com\.(lifeos|pai)\..*\.(service|timer)$/.test(f));
// Group by label (strip .service/.timer) so a paired unit reports one asset
// with both its schedule (from the .timer) and its exec (from the .service).
const labels = new Map<string, { service?: string; timer?: string }>();
for (const f of files) {
const isTimer = f.endsWith(".timer");
const label = f.replace(/\.(service|timer)$/, "");
const entry = labels.get(label) ?? {};
if (isTimer) entry.timer = f; else entry.service = f;
labels.set(label, entry);
}

for (const [label, { service, timer }] of labels) {
let onUnitActiveSec: number | null = null;
let onBootSec: string | null = null;
let persistent = false;
if (timer) {
const text = readFileSync(join(UNITS_DIR, timer), "utf8");
const active = unitValue(text, "Timer", "OnUnitActiveSec");
onUnitActiveSec = active ? parseDuration(active) : null;
onBootSec = unitValue(text, "Timer", "OnBootSec");
persistent = unitValue(text, "Timer", "Persistent") === "true";
}
assets.push({
kind: "service",
key: `service:${label}`,
name: label,
attrs: {
service_unit: service ?? null,
timer_unit: timer ?? null,
on_unit_active_sec: onUnitActiveSec,
on_boot_sec: onBootSec,
persistent,
backend: "systemd",
},
});
edges.push({ kind: "RUNS_ON", srcKey: `service:${label}`, dstKey: machineKey, srcKind: "service", dstKind: "machine" });
}
return { complete: true, assets, edges };
},
};

/** systemd time-span → seconds. Handles the shapes InstallWorkSweep-style templates
* actually emit ("3600", "60min", "1h", "30s") — not the full grammar. */
function parseDuration(s: string): number | null {
if (/^\d+$/.test(s)) return Number(s);
const m = s.match(/^(\d+)\s*(s|min|h)$/);
if (!m) return null;
const n = Number(m[1]);
return m[2] === "h" ? n * 3600 : m[2] === "min" ? n * 60 : n;
}
Loading