Skip to content
Merged
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
137 changes: 124 additions & 13 deletions commands/__tests__/home.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test";
import {
DEFAULT_USER_REPO_URL,
claudePluginsPointerMessage,
defaultAgeKeyInputSeam,
defaultMaterializeEnv,
Expand All @@ -13,13 +12,15 @@ import {
InvalidUrlArgError,
probeDeckHealthy,
readStdinTrimmed,
resolveHomeUrl,
type AgeKeyInputSeam,
type DeckHealthProbe,
type HomeDaemonSeam,
type HomeProbes,
type MachineProfilePickerSeam,
type SopsYamlSeam,
} from "../home.ts";
import type { SetupIntent } from "../../lib/setup/intent.ts";
import { setSetting } from "../../lib/settings/write.ts";
import { Readable } from "stream";
import { EventEmitter } from "events";
Expand All @@ -32,14 +33,15 @@ import { readOwners } from "../../lib/home/snapshot-owners.ts";
import type { DaemonResponse } from "../../lib/daemon-client.ts";
import type { SnapshotResult, SnapshotStatus } from "../../lib/daemon/home-snapshot.ts";
import type { MaterializeEnv, MaterializeExecResult, MaterializeExecSeam } from "../../lib/home/materialize.ts";
import { mkdtempSync, realpathSync, rmSync } from "fs";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { dirname, join } from "path";

const FAKE_PUBLIC_KEY = "age1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq";
const FAKE_PRIVATE_KEY = "AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ";
const SOPS_YAML_PATH = join(mattstackHome(), "user", ".sops.yaml");
const KEY = "mbp-14";
const TEST_URL = "https://github.com/example/mattstack-home.git";

/** In-memory .sops.yaml — never touches the real filesystem. */
class FakeSopsYamlSeam implements SopsYamlSeam {
Expand Down Expand Up @@ -236,6 +238,8 @@ async function runHomeInit(
isInteractive: () => boolean = () => false,
materializeEnv: () => Promise<MaterializeEnv> = async () => NOOP_MATERIALIZE_ENV,
materializeExec: MaterializeExecSeam = new FakeMaterializeExecSeam(),
env: Record<string, string | undefined> = {},
readIntent: (() => SetupIntent | null) | "real-disk-read" = () => null,
): Promise<{ exitCode: number | undefined; logs: string[]; errors: string[] }> {
const exitSpy = spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
Expand All @@ -249,7 +253,12 @@ async function runHomeInit(
errors.push(parts.map(String).join(" "));
});
try {
await homeInit(args, {}, { probes, exec, ageKeySeam, sopsYamlSeam, key, pickerSeam, isInteractive, materializeEnv, materializeExec });
// Omitting `readIntent` entirely is what makes homeInit fall through to
// its real ~/.mattstack/rt/setup-intent.json read; the default above
// keeps every other case off disk, so nothing a preload or a later test
// writes there can move an assertion.
const intentSeam = readIntent === "real-disk-read" ? {} : { readIntent };
await homeInit(args, {}, { probes, exec, ageKeySeam, sopsYamlSeam, key, pickerSeam, isInteractive, materializeEnv, materializeExec, env, ...intentSeam });
return { exitCode: undefined, logs, errors };
} catch {
const code = exitSpy.mock.calls.at(-1)?.[0] as number | undefined;
Expand Down Expand Up @@ -376,15 +385,15 @@ describe("homeInit", () => {
expect(sopsYamlSeam.writes).toEqual([]);
});

test("a fresh, fully successful init clones with the default URL, mints the age key, and writes .sops.yaml after adoption", async () => {
test("a fresh, fully successful init clones the given URL, mints the age key, and writes .sops.yaml after adoption", async () => {
const seam = new FakeSeam();
const ageKeySeam = new FakeAgeKeySeam();
const sopsYamlSeam = new FakeSopsYamlSeam();
const { exitCode, logs } = await runHomeInit(fakeProbes({}), seam, ageKeySeam, [], sopsYamlSeam);
const { exitCode, logs } = await runHomeInit(fakeProbes({}), seam, ageKeySeam, ["--url", TEST_URL], sopsYamlSeam);

expect(exitCode).toBeUndefined();
const cloneCall = seam.calls.find((c) => c.kind === "run") as { kind: string; arg: string[] } | undefined;
expect(cloneCall?.arg).toEqual(["git", "clone", DEFAULT_USER_REPO_URL, "user"]);
expect(cloneCall?.arg).toEqual(["git", "clone", TEST_URL, "user"]);
expect(ageKeySeam.calls.some((c) => c[1] === "find-generic-password")).toBe(true);
expect(ageKeySeam.calls.some((c) => c[0] === "age-keygen")).toBe(true);
expect(sopsYamlSeam.files.get(SOPS_YAML_PATH)).toBe(renderSopsYaml(FAKE_PUBLIC_KEY));
Expand All @@ -397,13 +406,35 @@ describe("homeInit", () => {
expect(successIdx).toBeGreaterThan(readyIdx);
});

test("--url overrides the default clone URL", async () => {
test("--url is used to clone", async () => {
const seam = new FakeSeam();
const customUrl = "https://github.com/example/mattstack-home.git";
await runHomeInit(fakeProbes({}), seam, new FakeAgeKeySeam(), ["--url", customUrl]);
await runHomeInit(fakeProbes({}), seam, new FakeAgeKeySeam(), ["--url", TEST_URL]);

const cloneCall = seam.calls.find((c) => c.kind === "run") as { kind: string; arg: string[] } | undefined;
expect(cloneCall?.arg).toEqual(["git", "clone", customUrl, "user"]);
expect(cloneCall?.arg).toEqual(["git", "clone", TEST_URL, "user"]);
});

test("no url anywhere: git-inits a local-only repo and commits its initial tree, never silently substituting a built-in default repo", async () => {
const seam = new FakeSeam();
await runHomeInit(
fakeProbes({}),
seam,
new FakeAgeKeySeam(),
[],
new FakeSopsYamlSeam(),
KEY,
new UnreachablePickerSeam(),
() => false,
async () => NOOP_MATERIALIZE_ENV,
new FakeMaterializeExecSeam(),
{}, // no RT_HOME_URL — proves the "no url resolved" path, independent of the ambient shell's actual env
);

const runCalls = seam.calls.filter((c) => c.kind === "run").map((c) => c.arg as string[]);
expect(runCalls).toContainEqual(["git", "init", "-b", "main", "user"]);
expect(runCalls).toContainEqual(["git", "-C", "user", "add", "-A"]);
expect(runCalls).toContainEqual(["git", "-c", "commit.gpgsign=false", "-C", "user", "commit", "-m", "initial home repo"]);
expect(runCalls.some((arg) => arg[1] === "clone")).toBe(false);
});

test("--dry-run never touches the age key or runs any step, even on a fresh (not-yet-provisioned) home", async () => {
Expand Down Expand Up @@ -774,14 +805,14 @@ describe("homeInit", () => {
probes,
cloneAwareSeam,
new FakeAgeKeySeam(),
["--profile", "desktop"],
["--url", TEST_URL, "--profile", "desktop"],
new FakeSopsYamlSeam(),
KEY,
);

expect(exitCode).toBeUndefined();
const cloneCall = cloneAwareSeam.calls.find((c) => c.kind === "run") as { kind: string; arg: string[] } | undefined;
expect(cloneCall?.arg).toEqual(["git", "clone", DEFAULT_USER_REPO_URL, "user"]);
expect(cloneCall?.arg).toEqual(["git", "clone", TEST_URL, "user"]);
expect(cloneAwareSeam.calls).toContainEqual({ kind: "writeFile", arg: { path: "machine-key", content: "desktop" } });
});

Expand Down Expand Up @@ -1363,6 +1394,86 @@ describe("homeInit", () => {
});
});

describe("resolveHomeUrl", () => {
test("--url wins over intent and env", () => {
const url = resolveHomeUrl(["--url", "https://x/a.git"], {
readIntent: () => ({ v: 1, at: "", mode: "create", homeRepo: "https://x/b.git" }) as SetupIntent,
env: { RT_HOME_URL: "https://x/c.git" },
});
expect(url).toBe("https://x/a.git");
});

test("intent homeRepo beats RT_HOME_URL", () => {
const url = resolveHomeUrl([], {
readIntent: () => ({ v: 1, at: "", mode: "create", homeRepo: "https://x/b.git" }) as SetupIntent,
env: { RT_HOME_URL: "https://x/c.git" },
});
expect(url).toBe("https://x/b.git");
});

test("RT_HOME_URL is used when nothing else supplies one", () => {
expect(resolveHomeUrl([], { readIntent: () => null, env: { RT_HOME_URL: "https://x/c.git" } })).toBe("https://x/c.git");
});

test("no url anywhere resolves to null — local-only, never a built-in default", () => {
expect(resolveHomeUrl([], { readIntent: () => null, env: {} })).toBeNull();
});

test("--url with no value still throws rather than falling through to local-only", () => {
expect(() => resolveHomeUrl(["--url"], { readIntent: () => null, env: {} })).toThrow(InvalidUrlArgError);
});

test("an exported-but-empty RT_HOME_URL is unset, never a clone of \"\"", () => {
expect(resolveHomeUrl([], { readIntent: () => null, env: { RT_HOME_URL: "" } })).toBeNull();
});

test("restore.homeRepo is honoured — otherwise a local-only repo squats the path home.restore needs", () => {
const url = resolveHomeUrl([], {
readIntent: () => ({ v: 1, at: "", mode: "restore", restore: { homeRepo: "https://x/restore.git" } }) as SetupIntent,
env: { RT_HOME_URL: "https://x/c.git" },
});
expect(url).toBe("https://x/restore.git");
});
});

/**
* Deliberately NOT seamed: the seamed tests above pass `readIntent` in
* directly and so cannot catch a default wired at the wrong path.
*/
describe("homeInit — the real intent read", () => {
test("reads the setup-intent.json that setup actually writes, so intent still outranks RT_HOME_URL", async () => {
const origHome = process.env.HOME;
const isolatedHome = realpathSync(mkdtempSync(join(tmpdir(), "rt-home-intent-")));
process.env.HOME = isolatedHome;
try {
const intentFile = join(isolatedHome, ".mattstack", "rt", "setup-intent.json");
mkdirSync(dirname(intentFile), { recursive: true });
writeFileSync(intentFile, JSON.stringify({ v: 1, at: "", mode: "create", homeRepo: "https://x/from-intent.git" }));

const probes = fakeProbes({ exists: (path) => path.endsWith("/machine-key") });
const { logs } = await runHomeInit(
probes,
new FakeSeam(),
new FakeAgeKeySeam(),
["--dry-run", "--no-materialize"],
new FakeSopsYamlSeam(),
KEY,
new UnreachablePickerSeam(),
() => false,
async () => NOOP_MATERIALIZE_ENV,
new FakeMaterializeExecSeam(),
{ RT_HOME_URL: "https://x/from-env.git" },
"real-disk-read",
);

expect(logs.join("\n")).toContain("clone https://x/from-intent.git into user/");
} finally {
process.env.HOME = origHome;
rmSync(isolatedHome, { recursive: true, force: true });
}
});
});

describe("claudePluginsPointerMessage", () => {
test("neither key resolved: no message", () => {
expect(claudePluginsPointerMessage(undefined, undefined)).toBeNull();
Expand Down
69 changes: 58 additions & 11 deletions commands/home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
*/

import { existsSync, readdirSync, readFileSync, readlinkSync, statSync, writeFileSync } from "fs";
import { homedir } from "os";
import { join } from "path";
import type { CommandContext } from "../lib/command-tree.ts";
import { bold, dim, green, red, reset, yellow } from "../lib/ansi.ts";
Expand Down Expand Up @@ -80,8 +81,7 @@ import { loadRepoIndex } from "../lib/daemon/repo-index.ts";
import { loadMachineRepoTracking } from "../lib/repo-tracking.ts";
import { isDaemonInstalled } from "../lib/daemon-config.ts";
import { getSetting } from "../lib/settings/resolve.ts";

export const DEFAULT_USER_REPO_URL = "https://github.com/m4ttheweric/mattstack-home";
import { readIntent as readIntentFromDisk, type SetupIntent } from "../lib/setup/intent.ts";

export interface HomeProbes {
isGitRepo(dir: string): boolean;
Expand Down Expand Up @@ -174,6 +174,10 @@ function describeStep(step: InitStep): string {
return `create missing state dirs: ${step.dirs.join(", ")}`;
case "cloneUserRepo":
return `clone ${step.url} into user/`;
case "initUserRepo":
return "git init a local-only user/ repo (no remote)";
case "commitInitialUserRepo":
return "commit the initial user/ tree";
case "writeGitignore":
return "write the user repo's .gitignore";
case "writeOwners":
Expand All @@ -187,12 +191,12 @@ function describeStep(step: InitStep): string {
}
}

/** Thrown by parseUrlArg for a `--url` with no usable value — never silently absorbed into the default or into the next flag. */
/** Thrown by parseUrlArg for a `--url` with no usable value — never silently absorbed into a default or into the next flag. */
export class InvalidUrlArgError extends Error {}

function parseUrlArg(args: string[]): string {
function parseUrlArg(args: string[]): string | null {
const idx = args.indexOf("--url");
if (idx === -1) return DEFAULT_USER_REPO_URL;
if (idx === -1) return null;

const value = args[idx + 1];
if (value === undefined || value.startsWith("--")) {
Expand All @@ -201,6 +205,29 @@ function parseUrlArg(args: string[]): string {
return value;
}

/**
* The precedence chain for which repo `rt home init` provisions: an explicit
* `--url` beats the setup intent's `homeRepo` (set once, ahead of time, by
* `create`/`join`, or under `restore.homeRepo` in restore mode — ignoring the
* restore rung would provision a local-only repo that then squats the path
* `home.restore` needs, unrecoverably), which beats `RT_HOME_URL` (a
* per-invocation override).
* `null` means no rung supplied one — a deliberate, first-class outcome, not
* a fallback to any repo this operator never chose.
*/
export function resolveHomeUrl(
args: string[],
seams: { readIntent: () => SetupIntent | null; env: Record<string, string | undefined> },
): string | null {
const fromFlag = parseUrlArg(args);
if (fromFlag !== null) return fromFlag;
const intent = seams.readIntent();
const fromIntent = intent?.homeRepo ?? intent?.restore?.homeRepo;
if (fromIntent) return fromIntent;
// An exported-but-empty RT_HOME_URL is "unset", never a clone of "".
return seams.env.RT_HOME_URL || null;
}

/** Thrown by parseProfileArg for a `--profile` with no usable value. */
export class InvalidProfileArgError extends Error {}

Expand Down Expand Up @@ -324,7 +351,7 @@ async function ensureHomeAgeKey(
}

/** buildInitPlan's only checked failure (InvalidMachineKeyError) turned into the CLI's print-and-exit(1) — shared by every one of homeInit's three plan builds so the three don't drift. */
function planOrExit(state: HomeState, config: { url: string; machineKey: string }): InitPlan {
function planOrExit(state: HomeState, config: { url: string | null; machineKey: string }): InitPlan {
try {
return buildInitPlan(state, config);
} catch (err) {
Expand Down Expand Up @@ -511,6 +538,10 @@ export interface HomeInitSeams {
materializeEnv?: () => Promise<MaterializeEnv>;
/** Runs each materialize step's subprocess. Defaults to a real `runCapture` wrap; tests inject a fake that never touches a real binary. */
materializeExec?: MaterializeExecSeam;
/** Defaults to a real read of ~/.mattstack/rt/setup-intent.json; tests inject a fixed value instead of writing that file for real. */
readIntent?: () => SetupIntent | null;
/** Defaults to `process.env` — resolveHomeUrl's RT_HOME_URL rung; tests inject a fixed value instead of depending on the ambient shell's environment. */
env?: Record<string, string | undefined>;
}

export async function homeInit(args: string[], _ctx: CommandContext = {}, seams: HomeInitSeams = {}): Promise<void> {
Expand All @@ -523,15 +554,31 @@ export async function homeInit(args: string[], _ctx: CommandContext = {}, seams:
const isInteractive = seams.isInteractive ?? (() => Boolean(process.stdin.isTTY));
const materializeExec = seams.materializeExec ?? defaultMaterializeExec();
const materializeEnv = seams.materializeEnv ?? (() => defaultMaterializeEnv(materializeExec));
const readIntent =
seams.readIntent ??
(() =>
readIntentFromDisk({
readFile: (path) => {
try {
return readFileSync(path, "utf8");
} catch {
return null;
}
},
// The OS home, not mattstackHome(): intentPath() appends `.mattstack`
// itself, and every writer (setup, team create/join) passes Probes.home.
home: process.env.HOME ?? homedir(),
}));
const env = seams.env ?? process.env;

const dryRun = args.includes("--dry-run");
const noMaterialize = args.includes("--no-materialize");
const home = mattstackHome();

let url: string;
let resolvedUrl: string | null;
let profileFlag: string | undefined;
try {
url = parseUrlArg(args);
resolvedUrl = resolveHomeUrl(args, { readIntent, env });
profileFlag = parseProfileArg(args);
} catch (err) {
if (err instanceof InvalidUrlArgError || err instanceof InvalidProfileArgError) {
Expand All @@ -553,7 +600,7 @@ export async function homeInit(args: string[], _ctx: CommandContext = {}, seams:
if (!state.machineKeyFilePresent) {
if (!state.userRepoPresent) {
if (dryRun) {
const previewPlan = planOrExit(state, { url, machineKey: key });
const previewPlan = planOrExit(state, { url: resolvedUrl, machineKey: key });
printPlan(home, previewPlan.steps);
if (previewPlan.blocked === "skills-symlink-real-file") printSkillsSymlinkBlocked(home);
console.log(
Expand Down Expand Up @@ -584,7 +631,7 @@ export async function homeInit(args: string[], _ctx: CommandContext = {}, seams:
skillsSymlinkPresent: true,
skillsSymlinkBlocked: false,
};
const clonePlan = planOrExit(cloneOnlyState, { url, machineKey: key });
const clonePlan = planOrExit(cloneOnlyState, { url: resolvedUrl, machineKey: key });
printPlan(home, clonePlan.steps);

const cloneResult = await executeInitPlan(clonePlan.steps, exec, (message) => console.log(` ${message}`));
Expand Down Expand Up @@ -658,7 +705,7 @@ export async function homeInit(args: string[], _ctx: CommandContext = {}, seams:
process.exit(1);
}

const plan = planOrExit(state, { url, machineKey: chosenKey });
const plan = planOrExit(state, { url: resolvedUrl, machineKey: chosenKey });

// Env gathering is read-only (which deck, the repo index, rt.repoTracking,
// the daemon-install marker) — safe to run under --dry-run, so the preview
Expand Down
Loading
Loading