From 3318b2ddff6f7714df5ff9111f5856e2c34d58cd Mon Sep 17 00:00:00 2001 From: Filipe Forattini Date: Tue, 22 Sep 2026 13:21:25 -0300 Subject: [PATCH] fix: keep one mise identity per suite tool --- README.md | 8 ++++ src/agent-update.test.ts | 9 +--- src/agent-update.ts | 21 +++++---- src/agents.ts | 18 +++++--- src/migrations.test.ts | 27 ++++++++++++ src/migrations.ts | 93 +++++++++++++++++++++++++++++++++++++++- src/mise-config.test.ts | 22 ++++++++++ src/providers.ts | 19 ++++++++ 8 files changed, 191 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 2a72f31..0026ae0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/agent-update.test.ts b/src/agent-update.test.ts index ea2ed10..7fd697f 100644 --- a/src/agent-update.test.ts +++ b/src/agent-update.test.ts @@ -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: {}, }); }); diff --git a/src/agent-update.ts b/src/agent-update.ts index dfaadd8..441f4b5 100644 --- a/src/agent-update.ts +++ b/src/agent-update.ts @@ -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 ` 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 { @@ -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: {}, }); } diff --git a/src/agents.ts b/src/agents.ts index 93b6e3d..e11ed91 100644 --- a/src/agents.ts +++ b/src/agents.ts @@ -672,12 +672,20 @@ export async function installAgent(a: AgentSpec, p: Platform): Promise { 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; diff --git a/src/migrations.test.ts b/src/migrations.test.ts index 898e911..c0f17a4 100644 --- a/src/migrations.test.ts +++ b/src/migrations.test.ts @@ -17,6 +17,7 @@ import { join } from "node:path"; import { MIGRATIONS, globalMiseConfigPath, + migrateMiseSuiteToSingleIdentity, migrateMiseToolsToLatest, migrationLedgerPath, readMigrationLedger, @@ -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); diff --git a/src/migrations.ts b/src/migrations.ts index 1c38010..41c1768 100644 --- a/src/migrations.ts +++ b/src/migrations.ts @@ -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. */ @@ -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. @@ -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}`); + }, + }, ]; /** diff --git a/src/mise-config.test.ts b/src/mise-config.test.ts index fd9984d..b8cbb3a 100644 --- a/src/mise-config.test.ts +++ b/src/mise-config.test.ts @@ -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"); diff --git a/src/providers.ts b/src/providers.ts index 7543528..1f2486c 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -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 `. 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 { + 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. *