From 79b66321ad4b61c3804f814434270858f8cebf7b Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:26:25 -0500 Subject: [PATCH] feat(cli): aggregate Hermes profiles in service sync --- apps/cli/src/commands/service.test.ts | 208 +++++++++++++++++++++++++- apps/cli/src/commands/service.ts | 127 ++++++++++++++-- 2 files changed, 321 insertions(+), 14 deletions(-) diff --git a/apps/cli/src/commands/service.test.ts b/apps/cli/src/commands/service.test.ts index ae5c288..924ac08 100644 --- a/apps/cli/src/commands/service.test.ts +++ b/apps/cli/src/commands/service.test.ts @@ -1,5 +1,14 @@ import { createHash } from "node:crypto"; -import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { delimiter, dirname, join } from "node:path"; import { gzipSync } from "node:zlib"; @@ -25,6 +34,7 @@ import { capturedServiceEnv, deferredServiceRepairInvocation, detectAutoUpdateManager, + discoverHermesServiceEnv, deterministicServiceJitterMs, durableTokenmaxxingCommandPath, extractServiceRunnerFromTarball, @@ -41,6 +51,7 @@ import { resolveExecutableSiblingPackageJson, renderLaunchdPlist, renderServiceWrapper, + repairServiceProgram, renderSystemdTimer, runServiceAutoUpdate, scheduleDescription, @@ -482,6 +493,125 @@ describe("servicePaths", () => { }); describe("renderServiceWrapper", () => { + it("discovers the default Hermes root and profile roots for scheduled sync", async () => { + const home = await mkdtemp(join(tmpdir(), "tokenmaxxing-hermes-")); + try { + const hermesRoot = join(home, ".hermes"); + const alpha = join(hermesRoot, "profiles", "alpha"); + const beta = join(hermesRoot, "profiles", "beta"); + await mkdir(alpha, { recursive: true }); + await mkdir(beta, { recursive: true }); + await writeFile(join(hermesRoot, "state.db"), "default"); + await writeFile(join(alpha, "state.db"), "alpha"); + await writeFile(join(beta, "state.db"), "beta"); + + await expect(discoverHermesServiceEnv({ HOME: home }, "linux")).resolves.toEqual({ + HOME: home, + HERMES_HOME: [hermesRoot, alpha, beta].join(","), + }); + } finally { + await rm(home, { force: true, recursive: true }); + } + }); + + it("keeps an explicit Hermes home unchanged", async () => { + const env = { HERMES_HOME: "/custom/one,/custom/two", HOME: "/home/alex" }; + await expect(discoverHermesServiceEnv(env, "linux")).resolves.toBe(env); + }); + + it("sets only the default root when no profiles contain state", async () => { + const home = await mkdtemp(join(tmpdir(), "tokenmaxxing-hermes-root-")); + try { + const hermesRoot = join(home, ".hermes"); + await mkdir(join(hermesRoot, "profiles", "empty"), { recursive: true }); + await writeFile(join(hermesRoot, "state.db"), "default"); + await expect(discoverHermesServiceEnv({ HOME: home }, "darwin")).resolves.toEqual({ + HOME: home, + HERMES_HOME: hermesRoot, + }); + } finally { + await rm(home, { force: true, recursive: true }); + } + }); + + it("leaves Hermes home unset when no readable state exists", async () => { + const home = await mkdtemp(join(tmpdir(), "tokenmaxxing-hermes-empty-")); + try { + await mkdir(join(home, ".hermes", "profiles", "empty"), { recursive: true }); + await expect(discoverHermesServiceEnv({ HOME: home }, "linux")).resolves.toEqual({ + HOME: home, + }); + } finally { + await rm(home, { force: true, recursive: true }); + } + }); + + it("deduplicates profile aliases and ignores symlink escapes", async () => { + const home = await mkdtemp(join(tmpdir(), "tokenmaxxing-hermes-links-")); + const outside = await mkdtemp(join(tmpdir(), "tokenmaxxing-hermes-outside-")); + try { + const hermesRoot = join(home, ".hermes"); + const profile = join(hermesRoot, "profiles", "real"); + const linkedStateProfile = join(hermesRoot, "profiles", "linked-state"); + await mkdir(profile, { recursive: true }); + await mkdir(linkedStateProfile, { recursive: true }); + await writeFile(join(profile, "state.db"), "real"); + await writeFile(join(outside, "state.db"), "outside"); + await symlink(join(outside, "state.db"), join(linkedStateProfile, "state.db")); + await symlink(profile, join(hermesRoot, "profiles", "alias")); + await symlink(outside, join(hermesRoot, "profiles", "escape")); + expect((await discoverHermesServiceEnv({ HOME: home }, "linux"))["HERMES_HOME"]).toBe( + profile, + ); + } finally { + await rm(home, { force: true, recursive: true }); + await rm(outside, { force: true, recursive: true }); + } + }); + + it("uses Windows path semantics and ignores unreadable profile state", async () => { + const fs = { + access: async (path: string) => { + if (path.includes("blocked")) throw new Error("EACCES"); + }, + readdir: async () => ["blocked", "alpha"], + realpath: async (path: string) => path, + }; + await expect( + discoverHermesServiceEnv({ USERPROFILE: "C:\\Users\\alex" }, "win32", fs), + ).resolves.toEqual({ + HERMES_HOME: "C:\\Users\\alex\\.hermes,C:\\Users\\alex\\.hermes\\profiles\\alpha", + USERPROFILE: "C:\\Users\\alex", + }); + }); + + it("uses case-insensitive containment for Windows paths", async () => { + const fs = { + access: async () => undefined, + readdir: async () => ["alpha"], + realpath: async (path: string) => path.replace("C:\\Users\\alex", "c:\\users\\ALEX"), + }; + await expect( + discoverHermesServiceEnv({ USERPROFILE: "C:\\Users\\alex" }, "win32", fs), + ).resolves.toEqual({ + HERMES_HOME: "c:\\users\\ALEX\\.hermes,c:\\users\\ALEX\\.hermes\\profiles\\alpha", + USERPROFILE: "C:\\Users\\alex", + }); + }); + + it("sorts profile names by code unit and skips comma-delimited paths", async () => { + const fs = { + access: async () => undefined, + readdir: async () => ["zeta", "a,comma", "Beta", "alpha"], + realpath: async (path: string) => path, + }; + await expect(discoverHermesServiceEnv({ HOME: "/home/alex" }, "linux", fs)).resolves.toEqual({ + HOME: "/home/alex", + HERMES_HOME: + "/home/alex/.hermes,/home/alex/.hermes/profiles/Beta,/home/alex/.hermes/profiles/alpha,/home/alex/.hermes/profiles/zeta", + }); + }); + it("runs sync with a durable command without embedding package-manager updates", () => { const env = capturedServiceEnv({ HERMES_HOME: "/data/hermes", @@ -694,6 +824,52 @@ describe("serviceStateJson", () => { }); describe("service repair helpers", () => { + it("preserves discovered Hermes roots during deferred repair", async () => { + const dir = await mkdtemp(join(tmpdir(), "tokenmaxxing-hermes-repair-")); + try { + const home = join(dir, "home"); + const configDir = join(dir, "config"); + const hermesRoot = join(home, ".hermes"); + const profile = join(hermesRoot, "profiles", "alpha"); + await mkdir(profile, { recursive: true }); + await writeFile(join(hermesRoot, "state.db"), "default"); + await writeFile(join(profile, "state.db"), "alpha"); + const wrappers: string[] = []; + const { layer } = makeTestLayer({ + initialConfig: { + apiUrl: "https://api.tokenmaxxing.example", + wwwUrl: "https://tokenmaxxing.example", + }, + }); + + const exit = await Effect.runPromiseExit( + repairServiceProgram( + { deferred: true, json: true, reason: "reload-required" }, + { + env: { HOME: home, TOKENMAXXING_CONFIG_DIR: configDir }, + installServiceRunner: () => + Effect.succeed({ + packageName: "@851-labs/tokenmaxxing-linux-x64", + path: join(configDir, "runner"), + target: "linux-x64", + version: "0.6.0", + }), + platform: "linux", + readNativeStatus: () => Effect.succeed({ active: true, detail: "active" }), + writeFiles: (_paths, wrapper) => Effect.sync(() => wrappers.push(wrapper)), + writeRunnerPointer: () => Effect.void, + }, + ).pipe(Effect.provide(layer)), + ); + + expect(exit._tag).toBe("Success"); + expect(wrappers).toHaveLength(1); + expect(wrappers[0]).toContain(`export HERMES_HOME='${hermesRoot},${profile}'`); + } finally { + await rm(dir, { force: true, recursive: true }); + } + }); + it("prioritizes the reason that should drive automatic repair", () => { expect( serviceRepairReason({ @@ -2117,6 +2293,36 @@ describe("formatServiceStatusAutoUpdate", () => { }); describe("serviceInstallProgram", () => { + it("writes discovered Hermes roots into the installed service wrapper", async () => { + const home = await mkdtemp(join(tmpdir(), "tokenmaxxing-hermes-install-")); + try { + const hermesRoot = join(home, ".hermes"); + const profile = join(hermesRoot, "profiles", "alpha"); + await mkdir(profile, { recursive: true }); + await writeFile(join(hermesRoot, "state.db"), "default"); + await writeFile(join(profile, "state.db"), "alpha"); + const { layer } = makeTestLayer({ + initialConfig: { + apiUrl: "https://api.tokenmaxxing.example", + token: "tmx_existing", + wwwUrl: "https://tokenmaxxing.example", + }, + }); + const { runtime, written } = makeInstallRuntime({ env: { HOME: home } }); + + const exit = await Effect.runPromiseExit( + serviceInstallProgram({ force: false, refresh: false }, runtime).pipe( + Effect.provide(layer), + ), + ); + + expect(exit._tag).toBe("Success"); + expect(written[0]?.wrapper).toContain(`export HERMES_HOME='${hermesRoot},${profile}'`); + } finally { + await rm(home, { force: true, recursive: true }); + } + }); + it("starts browser login and installs the service when no stored token exists", async () => { const { layer, state } = makeTestLayer({ initialConfig: { diff --git a/apps/cli/src/commands/service.ts b/apps/cli/src/commands/service.ts index 84cce4e..f5e2a81 100644 --- a/apps/cli/src/commands/service.ts +++ b/apps/cli/src/commands/service.ts @@ -15,7 +15,7 @@ import { } from "node:fs/promises"; import { createRequire } from "node:module"; import { arch, homedir, hostname } from "node:os"; -import { basename, delimiter, dirname, join } from "node:path"; +import { basename, delimiter, dirname, join, posix, win32 } from "node:path"; import { promisify } from "node:util"; import { gunzip } from "node:zlib"; @@ -576,7 +576,9 @@ function serviceInstallProgram( ), Effect.mapError((cause) => new ServiceInstallError({ cause })), ); - const serviceEnv = capturedServiceEnv(env); + const serviceEnv = yield* Effect.promise(() => + discoverHermesServiceEnv(capturedServiceEnv(env), platform), + ); const wrapper = renderServiceWrapper({ env: serviceEnv, logPath: paths.logPath, @@ -688,14 +690,33 @@ function serviceRepairEffect(options: ServiceRepairOptions = {}) { return options.deferred ? program : humanFrame("Repair automatic sync", options, program); } -function repairServiceProgram(options: ServiceRepairOptions = {}) { +function repairServiceProgram( + options: ServiceRepairOptions = {}, + runtime: { + env?: Record; + installScheduler?: (paths: ServicePaths) => Effect.Effect; + installServiceRunner?: (paths: ServicePaths) => Effect.Effect; + platform?: NodeJS.Platform; + readNativeStatus?: ( + paths: ServicePaths, + ) => Effect.Effect<{ active: boolean; detail: string }, unknown>; + writeFiles?: ( + paths: ServicePaths, + wrapper: string, + metadata: ServiceMetadata, + ) => Effect.Effect; + writeRunnerPointer?: (paths: ServicePaths, runnerPath: string) => Effect.Effect; + } = {}, +) { return Effect.gen(function* () { - const env = process.env; - const platform = process.platform; + const env = runtime.env ?? process.env; + const platform = runtime.platform ?? process.platform; const paths = yield* servicePathsEffect(env, undefined, platform); const currentState = (yield* readServiceState(paths.statePath)) ?? { version: 1 as const }; const existingMetadata = yield* readServiceMetadata(paths.metadataPath); - const initialNativeStatus = yield* readNativeSchedulerStatus(paths); + const initialNativeStatus = yield* (runtime.readNativeStatus ?? readNativeSchedulerStatus)( + paths, + ); const reloadRequired = serviceReloadRequired(existingMetadata, currentState); const repairReason = parseServiceRepairReason(options.reason) ?? @@ -732,7 +753,10 @@ function repairServiceProgram(options: ServiceRepairOptions = {}) { return yield* Effect.gen(function* () { const runnerSpinner = yield* humanSpinner("Installing service runner", options); - const runner = yield* installServiceRunnerForRepair(paths, { updatePointer: false }).pipe( + const runner = yield* ( + runtime.installServiceRunner ?? + ((servicePaths) => installServiceRunnerForRepair(servicePaths, { updatePointer: false })) + )(paths).pipe( Effect.tap((installedRunner) => Effect.sync(() => runnerSpinner.stop( @@ -746,8 +770,11 @@ function repairServiceProgram(options: ServiceRepairOptions = {}) { Effect.mapError((cause) => new ServiceRepairError({ cause })), ); + const serviceEnv = yield* Effect.promise(() => + discoverHermesServiceEnv(capturedServiceEnv(env), platform), + ); const wrapper = renderServiceWrapper({ - env: capturedServiceEnv(env), + env: serviceEnv, logPath: paths.logPath, platform, runnerPointerPath: paths.runnerPointerPath, @@ -767,8 +794,10 @@ function repairServiceProgram(options: ServiceRepairOptions = {}) { }; const filesSpinner = yield* humanSpinner("Writing service files", options); - yield* writeServiceFiles(paths, wrapper, metadata).pipe( - Effect.flatMap(() => writeServiceRunnerPointer(paths, runner.path)), + yield* (runtime.writeFiles ?? writeServiceFiles)(paths, wrapper, metadata).pipe( + Effect.flatMap(() => + (runtime.writeRunnerPointer ?? writeServiceRunnerPointer)(paths, runner.path), + ), Effect.tap(() => Effect.sync(() => filesSpinner.stop("Service files written"))), Effect.tapError(() => Effect.sync(() => filesSpinner.error("Failed writing service files")), @@ -776,7 +805,7 @@ function repairServiceProgram(options: ServiceRepairOptions = {}) { Effect.mapError((cause) => new ServiceRepairError({ cause })), ); - const nativeStatus = yield* readNativeSchedulerStatus(paths); + const nativeStatus = yield* (runtime.readNativeStatus ?? readNativeSchedulerStatus)(paths); const needsSchedulerInstall = serviceRepairNeedsSchedulerInstall({ reason: repairReason, reloadRequired, @@ -799,7 +828,7 @@ function repairServiceProgram(options: ServiceRepairOptions = {}) { } const schedulerSpinner = yield* humanSpinner("Repairing scheduler", options); - yield* installNativeScheduler(paths).pipe( + yield* (runtime.installScheduler ?? installNativeScheduler)(paths).pipe( Effect.tap(() => Effect.sync(() => schedulerSpinner.stop("Scheduler repaired"))), Effect.tapError(() => Effect.sync(() => schedulerSpinner.error("Failed repairing scheduler")), @@ -807,7 +836,9 @@ function repairServiceProgram(options: ServiceRepairOptions = {}) { Effect.mapError((cause) => new ServiceRepairError({ cause })), ); - const repairedNativeStatus = yield* readNativeSchedulerStatus(paths); + const repairedNativeStatus = yield* (runtime.readNativeStatus ?? readNativeSchedulerStatus)( + paths, + ); if (!repairedNativeStatus.active) { return yield* Effect.fail(new ServiceRepairError({ cause: repairedNativeStatus.detail })); } @@ -4219,6 +4250,74 @@ function capturedServiceEnv( return captured; } +interface HermesDiscoveryFs { + access: (path: string, mode: number) => Promise; + readdir: (path: string) => Promise; + realpath: (path: string) => Promise; +} + +async function discoverHermesServiceEnv( + env: Record, + platform: NodeJS.Platform = process.platform, + fs: HermesDiscoveryFs = { + access, + readdir: (path) => readdir(path), + realpath, + }, +): Promise> { + if (env["HERMES_HOME"] !== undefined && env["HERMES_HOME"] !== "") { + return env; + } + + const home = platform === "win32" ? env["USERPROFILE"] : env["HOME"]; + if (home === undefined || home === "") { + return env; + } + + const path = platform === "win32" ? win32 : posix; + const hermesRoot = path.join(home, ".hermes"); + + try { + const realRoot = await fs.realpath(hermesRoot); + const containmentKey = (value: string) => (platform === "win32" ? value.toLowerCase() : value); + const realRootKey = containmentKey(realRoot); + const roots: string[] = []; + const seen = new Set(); + const addRootWithReadableState = async (candidate: string) => { + const resolved = await fs.realpath(candidate); + const resolvedKey = containmentKey(resolved); + if (resolvedKey !== realRootKey && !resolvedKey.startsWith(`${realRootKey}${path.sep}`)) { + return; + } + const resolvedState = await fs.realpath(path.join(resolved, "state.db")); + if (!containmentKey(resolvedState).startsWith(`${realRootKey}${path.sep}`)) { + return; + } + await fs.access(resolvedState, constants.R_OK); + if (resolved.includes(",")) { + return; + } + if (!seen.has(resolvedKey)) { + seen.add(resolvedKey); + roots.push(resolved); + } + }; + + await addRootWithReadableState(hermesRoot).catch(() => undefined); + const profilesRoot = path.join(hermesRoot, "profiles"); + const profileNames = await fs.readdir(profilesRoot).catch(() => []); + for (const profileName of profileNames.sort((left, right) => + left < right ? -1 : left > right ? 1 : 0, + )) { + await addRootWithReadableState(path.join(profilesRoot, profileName)).catch(() => undefined); + } + + return roots.length === 0 ? env : { ...env, HERMES_HOME: roots.join(",") }; + } catch { + return env; + } +} + function defaultPath(): string { return process.platform === "win32" ? "C:\\Windows\\System32;C:\\Windows" @@ -4472,6 +4571,7 @@ export { deferredServiceRepairInvocation, durableTokenmaxxingCommandPath, detectAutoUpdateManager, + discoverHermesServiceEnv, findCommandOnPath, findTokenmaxxingCommandInstall, formatServiceLockStatus, @@ -4487,6 +4587,7 @@ export { resolveServiceRunnerPackageJson, renderLaunchdPlist, renderServiceWrapper, + repairServiceProgram, renderSystemdTimer, refreshServiceAfterUpdate, installServiceRunner,