Skip to content
Closed
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
164 changes: 126 additions & 38 deletions LifeOS/install/LIFEOS/TOOLS/InstallWorkSweep.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
#!/usr/bin/env bun
/**
* InstallWorkSweep.ts — Materialize com.lifeos.worksweep.plist.template and bootstrap it.
* InstallWorkSweep.ts — Materialize the WorkSweep scheduler unit(s) and bootstrap them.
*
* bun ~/.claude/LIFEOS/TOOLS/InstallWorkSweep.ts # install
* bun ~/.claude/LIFEOS/TOOLS/InstallWorkSweep.ts --uninstall # remove
* bun ~/.claude/LIFEOS/TOOLS/InstallWorkSweep.ts --status # check
*
* Reads $HOME, substitutes {{HOME}} in the template, writes
* ~/Library/LaunchAgents/com.lifeos.worksweep.plist, and runs `launchctl bootstrap`.
* Two backends, chosen by `process.platform` (same pattern as
* LIFEOS/PULSE/manage.sh — macOS uses launchd, Linux uses systemd --user):
*
* Idempotent. Re-running install bootouts the prior load before bootstrapping
* the fresh plist.
* - darwin: materializes com.lifeos.worksweep.plist.template into
* ~/Library/LaunchAgents/ and bootstraps it with launchctl.
* - linux: materializes com.lifeos.worksweep.{service,timer}.template into
* ~/.config/systemd/user/ and enables the timer with systemctl --user.
* Requires `loginctl enable-linger $USER` for the timer to survive logout
* (same requirement PULSE/manage.sh documents for com.lifeos.pulse).
*
* Both back ends read $HOME + `which bun` at install time, substitute
* {{HOME}}/{{BUN}}/{{BUN_DIR}} placeholders, and are idempotent — re-running
* install tears down the prior unit before installing the fresh one.
*/

import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "fs";
Expand All @@ -19,100 +27,180 @@ import { join } from "path";
declare const Bun: { spawn: (cmd: string[], opts?: any) => any };

const HOME = process.env.HOME || "";
const TEMPLATE_PATH = join(HOME, ".claude", "LIFEOS", "TOOLS", "com.lifeos.worksweep.plist.template");
const LAUNCH_AGENTS_DIR = join(HOME, "Library", "LaunchAgents");
const TARGET_PLIST = join(LAUNCH_AGENTS_DIR, "com.lifeos.worksweep.plist");
const LABEL = "com.lifeos.worksweep";
const IS_LINUX = process.platform === "linux";

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();
}
// ── darwin (launchd) paths ──
const PLIST_TEMPLATE_PATH = join(HOME, ".claude", "LIFEOS", "TOOLS", "com.lifeos.worksweep.plist.template");
const LAUNCH_AGENTS_DIR = join(HOME, "Library", "LaunchAgents");
const TARGET_PLIST = join(LAUNCH_AGENTS_DIR, `${LABEL}.plist`);

async function launchctl(args: string[]): Promise<{ ok: boolean; out: string; err: string }> {
const proc = Bun.spawn(["launchctl", ...args], { stdout: "pipe", stderr: "pipe" });
// ── linux (systemd --user) paths ──
const SERVICE_TEMPLATE_PATH = join(HOME, ".claude", "LIFEOS", "TOOLS", "com.lifeos.worksweep.service.template");
const TIMER_TEMPLATE_PATH = join(HOME, ".claude", "LIFEOS", "TOOLS", "com.lifeos.worksweep.timer.template");
const SYSTEMD_USER_DIR = join(HOME, ".config", "systemd", "user");
const TARGET_SERVICE = join(SYSTEMD_USER_DIR, `${LABEL}.service`);
const TARGET_TIMER = join(SYSTEMD_USER_DIR, `${LABEL}.timer`);
const TIMER_UNIT = `${LABEL}.timer`;

async function run(cmd: string[]): Promise<{ ok: boolean; out: string; err: string }> {
const proc = Bun.spawn(cmd, { 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 uid(): Promise<string> {
const r = await run(["id", "-u"]);
return r.out.trim();
}

async function username(): Promise<string> {
// Prefer `id -un` over process.env.USER — USER isn't guaranteed to be set
// in every invoking environment (e.g. some non-interactive shells), and an
// empty string would make `loginctl enable-linger ""` fail silently.
const r = await run(["id", "-un"]);
return r.out.trim();
}

async function detectBun(): Promise<string> {
// Detect bun via `which bun` — survives different install paths (homebrew, ~/.bun, asdf).
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();
const r = await run(["which", "bun"]);
const path = r.out.trim();
if (!path) throw new Error("bun not found in PATH — install bun first");
return path;
}

async function install(): Promise<void> {
if (!existsSync(TEMPLATE_PATH)) {
console.error(`[InstallWorkSweep] template missing at ${TEMPLATE_PATH}`);
function materialize(templatePath: string, bunPath: string, bunDir: string): string {
const template = readFileSync(templatePath, "utf-8");
return template
.replace(/\{\{HOME\}\}/g, HOME)
.replace(/\{\{BUN\}\}/g, bunPath)
.replace(/\{\{BUN_DIR\}\}/g, bunDir);
}

// ── darwin (launchd) ──

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

// Idempotent bootout (ignore failures — first install has nothing to remove)
const u = await uid();
if (existsSync(TARGET_PLIST)) {
await launchctl(["bootout", `gui/${u}`, TARGET_PLIST]);
await run(["launchctl", "bootout", `gui/${u}`, TARGET_PLIST]);
}

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

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

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

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

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

// ── linux (systemd --user) ──

async function installLinux(): Promise<void> {
if (!existsSync(SERVICE_TEMPLATE_PATH) || !existsSync(TIMER_TEMPLATE_PATH)) {
console.error(`[InstallWorkSweep] template missing — expected ${SERVICE_TEMPLATE_PATH} and ${TIMER_TEMPLATE_PATH}`);
process.exit(1);
}
const bunPath = await detectBun();
const bunDir = bunPath.replace(/\/bun$/, "");
console.log(`[InstallWorkSweep] detected bun at ${bunPath}`);

if (!existsSync(SYSTEMD_USER_DIR)) mkdirSync(SYSTEMD_USER_DIR, { recursive: true });

// Idempotent teardown (ignore failures — first install has nothing to remove)
await run(["systemctl", "--user", "disable", "--now", TIMER_UNIT]);

writeFileSync(TARGET_SERVICE, materialize(SERVICE_TEMPLATE_PATH, bunPath, bunDir));
writeFileSync(TARGET_TIMER, materialize(TIMER_TEMPLATE_PATH, bunPath, bunDir));
console.log(`[InstallWorkSweep] wrote ${TARGET_SERVICE}`);
console.log(`[InstallWorkSweep] wrote ${TARGET_TIMER}`);

await run(["systemctl", "--user", "daemon-reload"]);

// Survive logout/reboot, same requirement PULSE/manage.sh documents for com.lifeos.pulse.
await run(["loginctl", "enable-linger", await username()]);

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

const list = await run(["systemctl", "--user", "list-timers", TIMER_UNIT, "--no-pager"]);
if (list.ok) console.log(list.out.trim());
}

async function uninstallLinux(): Promise<void> {
await run(["systemctl", "--user", "disable", "--now", TIMER_UNIT]);
let removed = false;
for (const f of [TARGET_SERVICE, TARGET_TIMER]) {
if (existsSync(f)) {
try { unlinkSync(f); console.log(`[InstallWorkSweep] removed ${f}`); removed = true; } catch {}
}
}
if (!removed) console.log(`[InstallWorkSweep] no unit files found — nothing to do`);
await run(["systemctl", "--user", "daemon-reload"]);
}

async function statusLinux(): Promise<void> {
const r = await run(["systemctl", "--user", "status", TIMER_UNIT, "--no-pager"]);
console.log(r.out || r.err);
const list = await run(["systemctl", "--user", "list-timers", TIMER_UNIT, "--no-pager"]);
if (list.ok) console.log(list.out.trim());
if (!r.ok) process.exit(1);
}

// ── dispatch ──

async function main(): Promise<void> {
const arg = process.argv[2];
if (arg === "--uninstall") return uninstall();
if (arg === "--status") return status();
return install();
if (arg === "--uninstall") return IS_LINUX ? uninstallLinux() : uninstallDarwin();
if (arg === "--status") return IS_LINUX ? statusLinux() : statusDarwin();
return IS_LINUX ? installLinux() : installDarwin();
}

if (import.meta.main) {
Expand Down
26 changes: 26 additions & 0 deletions LifeOS/install/LIFEOS/TOOLS/com.lifeos.worksweep.service.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# com.lifeos.worksweep.service.template — Source template (Linux/systemd --user).
# Install via:
# bun ~/.claude/LIFEOS/TOOLS/InstallWorkSweep.ts
#
# The installer reads $HOME at install time, substitutes {{HOME}}/{{BUN}}/{{BUN_DIR}}
# placeholders, writes the materialized unit to ~/.config/systemd/user/, and
# enables it via systemctl --user. This template stays clean of principal-specific
# paths so it ships in the public LifeOS release, mirroring the macOS
# com.lifeos.worksweep.plist.template (same cadence, same log target).
#
# Behavior: one-shot run of WorkSweep.ts, triggered by the paired .timer unit
# (com.lifeos.worksweep.timer — every 60 minutes, once ~2min after boot).
# stdout/stderr captured to MEMORY/STATE/com.lifeos.worksweep.log, same as the
# launchd StandardOutPath/StandardErrorPath.

[Unit]
Description=LifeOS Work Sweep — periodic Work System capture (session catch-up, stale flagging, project checks, TELOS goals)

[Service]
Type=oneshot
ExecStart={{BUN}} {{HOME}}/.claude/LIFEOS/TOOLS/WorkSweep.ts
WorkingDirectory={{HOME}}/.claude
Environment=HOME={{HOME}}
Environment=PATH={{BUN_DIR}}:/usr/local/bin:/usr/bin:/bin
StandardOutput=append:{{HOME}}/.claude/LIFEOS/MEMORY/STATE/com.lifeos.worksweep.log
StandardError=append:{{HOME}}/.claude/LIFEOS/MEMORY/STATE/com.lifeos.worksweep.log
16 changes: 16 additions & 0 deletions LifeOS/install/LIFEOS/TOOLS/com.lifeos.worksweep.timer.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# com.lifeos.worksweep.timer.template — pairs with com.lifeos.worksweep.service.template.
# Equivalent to the launchd plist's RunAtLoad + StartInterval 3600 + ThrottleInterval 60:
# OnBootSec fires once ~2min after boot, OnUnitActiveSec repeats hourly from the
# last run, and systemd's own crash-loop handling covers the throttle case.

[Unit]
Description=LifeOS Work Sweep timer — hourly

[Timer]
OnBootSec=2min
OnUnitActiveSec=60min
Persistent=true
Unit=com.lifeos.worksweep.service

[Install]
WantedBy=timers.target