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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,14 @@ its package set, reconciles every host and companion, and restarts its daemon
only after the Worker gate is clear. There is no second command owed after
`mise upgrade` on that path.

Each RedDB product has exactly one mise identity. Native binaries (`red-dev`,
`red`, `tq`, `redcode`, `dit` and the Zellij fork) come from attested GitHub
release assets. Node runtimes and package sets (`red-router` and RedSkills)
come from npm. The generated short alias is the installed identity; red-dev
never also writes the backend-qualified spec into the global config. Existing
machines are migrated only after the aliased replacement is present, with the
original config backed up before the redundant declaration is retired.

On the Ubuntu desktop, red-dev itself also has a tool-level postinstall: it
runs the newly installed binary's `desktop reconcile`. That updates only the
managed mise declaration, GNOME menu bar and shortcuts; it does not install
Expand Down
9 changes: 1 addition & 8 deletions src/agent-update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,14 +209,7 @@ describe("the update argv of each per-host mechanism", () => {
expect(p.mechanism).toBe("mise");
expect(p.step).toEqual({
kind: "command",
argv: [
"/usr/bin/mise",
"use",
"-g",
"--yes",
"--fuzzy",
"github:reddb-io/redcode@latest",
],
argv: ["/usr/bin/mise", "install", "redcode@latest"],
env: {},
});
});
Expand Down
21 changes: 10 additions & 11 deletions src/agent-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,14 +210,11 @@ export function planAgentUpdate(
}

case "mise": {
// The same mechanism that moves the portable workstation suite.
// `use -g ...@latest` is intentional rather than `upgrade`. An
// agent may predate red-dev's mise ownership and still live in
// ~/.local/bin; in that case `mise upgrade herdr` exits zero while
// updating nothing because herdr is not declared yet. `use` adopts
// that installed host into mise as well as advancing one mise
// already owns, and leaves the unpinned selector behind for every
// later bare `mise upgrade`.
// A suite host is already declared under its short alias in red-dev's
// fragment. Installing that alias advances the one managed identity;
// `use -g <qualified spec>` would create a second global declaration
// and a second physical install of the same release. Third-party hosts
// still need `use -g`: it both adopts and declares them.
const mise = res.locate("mise");
if (!mise) {
return {
Expand All @@ -230,9 +227,11 @@ export function planAgentUpdate(
}
return ready({
kind: "command",
// --fuzzy keeps `latest` in config even on a machine whose
// MISE_PIN=1 would otherwise turn this into today's number.
argv: [mise, "use", "-g", "--yes", "--fuzzy", `${a.mise as string}@latest`],
argv: a.miseSuite
? [mise, "install", `${a.cmd}@latest`]
// --fuzzy keeps `latest` in config even on a machine whose
// MISE_PIN=1 would otherwise turn this into today's number.
: [mise, "use", "-g", "--yes", "--fuzzy", `${a.mise as string}@latest`],
env: {},
});
}
Expand Down
18 changes: 13 additions & 5 deletions src/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,12 +672,20 @@ export async function installAgent(a: AgentSpec, p: Platform): Promise<void> {
const method = agentInstallMethod(a, p);

if (method === "mise" && a.mise) {
if (a.miseSuite) {
// The suite fragment already maps this command to its one canonical
// backend. Writing the qualified spec with `mise use -g` as well makes
// mise treat it as another tool identity and downloads the same release
// into a second install root.
const { miseInstallDeclared } = await import("./providers.ts");
await miseInstallDeclared(a.cmd, p);
return;
}

// `use -g`, the same verb every other tool in the suite is placed
// with: it installs, writes the pin into the global config and
// leaves a shim on PATH, so the next `mise upgrade` knows this host
// exists. `ghInstallExactArchive` below places a file and leaves
// nothing that knows how to move it — which is the whole reason
// this branch is here.
// with for third-party hosts: it installs, writes the pin into the
// global config and leaves a shim on PATH. Suite hosts returned above;
// their generated declaration is already the durable pin.
const { useRuntimes } = await import("./runtimes.ts");
await useRuntimes([`${a.mise}@latest`]);
return;
Expand Down
27 changes: 27 additions & 0 deletions src/migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { join } from "node:path";
import {
MIGRATIONS,
globalMiseConfigPath,
migrateMiseSuiteToSingleIdentity,
migrateMiseToolsToLatest,
migrationLedgerPath,
readMigrationLedger,
Expand All @@ -42,6 +43,32 @@ describe("legacy mise selectors", () => {
});
});

describe("one mise identity per RedDB product", () => {
const entries = [
{ spec: "github:reddb-io/redcode", alias: "redcode" },
{ spec: "npm:@reddb-io/red-router", alias: "red-router" },
{ spec: "github:someone/else", alias: "else" },
];

test("retires only first-party qualified rows superseded by aliases", () => {
const source = `[tools]\n# written by an older red-dev\n"github:reddb-io/redcode" = "latest"\n"npm:@reddb-io/red-router" = {\n version = "latest"\n}\n"github:someone/else" = "latest"\nnode = "latest"\n`;
const first = migrateMiseSuiteToSingleIdentity(source, entries);

expect(first.removed).toEqual([
"github:reddb-io/redcode -> redcode",
"npm:@reddb-io/red-router -> red-router",
]);
expect(first.text).toBe(`[tools]\n# written by an older red-dev\n\n\n\n\n"github:someone/else" = "latest"\nnode = "latest"\n`);
expect(Bun.TOML.parse(first.text)).toEqual({
tools: { "github:someone/else": "latest", node: "latest" },
});
expect(migrateMiseSuiteToSingleIdentity(first.text, entries)).toEqual({
text: first.text,
removed: [],
});
});
});

describe("the ledger", () => {
test("ids are unique, because the ledger is keyed on them", () => {
const ids = MIGRATIONS.map((m) => m.id);
Expand Down
93 changes: 91 additions & 2 deletions src/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@
*
* - Idempotent anyway. The ledger is a promise, not a guarantee; a
* preferences file can be deleted.
* - Never destructive. A migration runs unattended during install, so
* it may repair and must not remove.
* - Never remove user data. A machine-owned declaration may be retired
* only after its replacement is present, with the original file backed
* up first.
* - Skip loudly when it does not apply, so `install` on a fresh
* machine does not look like it silently did something.
*/
Expand Down Expand Up @@ -67,6 +68,49 @@ export interface MiseLatestMigrationResult {
changed: string[];
}

export interface MiseIdentityMigrationResult {
text: string;
removed: string[];
}

/**
* Remove backend-qualified suite declarations superseded by managed aliases.
*
* `github:reddb-io/redcode` and `redcode` resolve the same publisher release,
* but mise stores them under different identities and downloads both. Only
* first-party entries are eligible: third-party declarations in the person's
* config remain theirs even when red-dev happens to offer the same tool.
*/
export function migrateMiseSuiteToSingleIdentity(
source: string,
entries: readonly { spec: string; alias?: string }[],
): MiseIdentityMigrationResult {
const aliases = new Map(
entries
.filter((entry): entry is { spec: string; alias: string } =>
!!entry.alias && entry.alias !== entry.spec && /^(github|npm):@?reddb-io[/-]/.test(entry.spec))
.map((entry) => [entry.spec, entry.alias]),
);
const lines = splitLines(source);
const removed: string[] = [];

for (const statement of statements(lines)) {
if (statement.kind !== "assignment" || statement.table !== "tools") continue;
const alias = aliases.get(statement.key);
if (!alias) continue;

for (let i = statement.line; i <= statement.end; i++) {
const line = lines[i]!;
lines[i] = { text: "", eol: line.eol };
}
removed.push(`${statement.key} -> ${alias}`);
}

const text = joinLines(lines);
if (removed.length > 0) Bun.TOML.parse(text);
return { text, removed };
}

/**
* Rewrite only known red-dev tool rows in `[tools]`, preserving the person's
* comments, ordering, quoting and inline-table options.
Expand Down Expand Up @@ -571,6 +615,51 @@ return {}
log.plain(` ${result.changed.join(", ")} -> latest`);
},
},
{
id: "2026-09-22-single-mise-identity",
describe: "retire duplicate qualified identities for the managed RedDB suite",
applies: async (p) => {
const path = globalMiseConfigPath(process.env, p.os === "windows" ? "win32" : "linux");
if (!path || !existsSync(path)) return false;
const { miseEntries } = await import("./mise-config.ts");
return migrateMiseSuiteToSingleIdentity(readFileSync(path, "utf8"), miseEntries(p)).removed.length > 0;
},
run: async (p) => {
const path = globalMiseConfigPath(process.env, p.os === "windows" ? "win32" : "linux");
if (!path || !existsSync(path)) return;

const { convergeMiseConfig, miseEntries } = await import("./mise-config.ts");
const entries = miseEntries(p);
const source = readFileSync(path, "utf8");
const result = migrateMiseSuiteToSingleIdentity(source, entries);
if (result.removed.length === 0) return;

// Declare the aliases first, then prove every replacement has an
// installed tree. A machine with no replacement keeps its old row and
// retries after the ordinary converge has installed the suite.
convergeMiseConfig(p);
const mise = Bun.which("mise");
if (!mise) throw new Error("mise is not installed yet — duplicate declarations left intact");
const bySpec = new Map(entries.filter((entry) => entry.alias).map((entry) => [entry.spec, entry.alias!]));
for (const item of result.removed) {
const spec = item.slice(0, item.indexOf(" -> "));
const alias = bySpec.get(spec);
if (!alias) continue;
const where = Bun.spawnSync([mise, "where", alias], { stdout: "ignore", stderr: "ignore" });
if (where.exitCode !== 0) {
throw new Error(`${alias} has no managed replacement yet — ${spec} left intact`);
}
}

const backup = `${path}.bak-red-dev-single-identity`;
if (!existsSync(backup)) writeFileSync(backup, source);
const temporary = `${path}.red-dev-single-identity.tmp`;
writeFileSync(temporary, result.text);
renameSync(temporary, path);
log.plain(` ${result.removed.join(", ")}`);
log.plain(` original config backed up at ${backup}`);
},
},
];

/**
Expand Down
22 changes: 22 additions & 0 deletions src/mise-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,28 @@ describe("miseEntries", () => {
expect(releaseAgeExcludes(entries)).toContain("github:reddb-io/redcode");
});

test("each RedDB product has one canonical publication source", () => {
const ours = Object.fromEntries(
miseEntries(UBUNTU)
.filter((entry) => entry.alias && /^(github|npm):@?reddb-io[/-]/.test(entry.spec))
.map((entry) => [entry.alias!, entry.spec]),
);

expect(ours).toEqual({
dit: "github:reddb-io/dit",
red: "github:reddb-io/reddb",
"red-dev": "github:reddb-io/red-dev",
"red-router": "npm:@reddb-io/red-router",
"red-skills": "npm:@reddb-io/red-skills",
"red-skills-brain": "npm:@reddb-io/red-skills-brain",
"red-skills-dev": "npm:@reddb-io/red-skills-dev",
"red-skills-memory": "npm:@reddb-io/red-skills-memory",
redcode: "github:reddb-io/redcode",
tq: "github:reddb-io/toon",
zellij: "github:reddb-io/zellij",
});
});

test("the suite CLIs are there, under the names people type", () => {
const aliases = miseEntries(UBUNTU).map((e) => e.alias);
expect(aliases).toContain("red");
Expand Down
19 changes: 19 additions & 0 deletions src/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,25 @@ async function miseInstall(
}
}

/**
* Install one tool already declared by red-dev's mise fragment.
*
* Suite agent hosts use this instead of `mise use -g <spec>`. The latter
* writes the backend-qualified spec into the person's global config while
* the fragment already declares the short alias, making mise install the
* same release once under each identity.
*/
export async function miseInstallDeclared(name: string, platform: Platform): Promise<void> {
const mise = Bun.which("mise");
if (!mise) throw new RedError("mise is not on PATH — run `red-dev install core` first");

convergeMiseConfig(platform);
const selector = `${name}@latest`;
log.step(`mise: ${selector}`);
const code = await runMise([mise, "install", selector], platform);
if (code !== 0) throw new RedError(`mise could not install ${selector} (exit ${code})`);
}

/**
* Install everything the fragment declares, in one pass.
*
Expand Down