From 30eba36bd6d77fd748c1922cae4544270ea19586 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 05:25:14 +0000 Subject: [PATCH 1/5] fix(cloud-connection,cli): install-local ledger follows OS_HOME, adopting the cwd ledger once The ledger resolved from the working directory alone, so os dev --fresh and a second instance under another OS_HOME rehydrated an earlier run's installs. With OS_HOME set it now lives at /installed-packages; unset, it stays in the project. The cwd ledger is copied once into a new directory (never under a --fresh home), and os doctor reads the same resolver. Claude-Session: https://claude.ai/code/session_01Utp6CY4LWqfjLCYqpZhNCs Co-authored-by: Claude --- .../30217-install-ledger-follows-os-home.md | 15 ++ .../commands/dev-fresh-home-channel.test.ts | 30 ++++ packages/cli/src/commands/dev.ts | 26 +++ .../doctor-ledger-dir-authority.test.ts | 27 +-- .../doctor-ledger-read-failure.test.ts | 32 +++- packages/cli/src/commands/doctor.ts | 77 +++++---- packages/cloud-connection/src/index.ts | 9 +- .../src/local-manifest-source.test.ts | 123 ++++++++++++- .../src/local-manifest-source.ts | 161 +++++++++++++++++- .../src/marketplace-install-local-plugin.ts | 40 ++++- ...ketplace-install-local-storage-dir.test.ts | 153 ++++++++++++++++- 11 files changed, 628 insertions(+), 65 deletions(-) create mode 100644 .changeset/30217-install-ledger-follows-os-home.md create mode 100644 packages/cli/src/commands/dev-fresh-home-channel.test.ts diff --git a/.changeset/30217-install-ledger-follows-os-home.md b/.changeset/30217-install-ledger-follows-os-home.md new file mode 100644 index 00000000000..765a1651711 --- /dev/null +++ b/.changeset/30217-install-ledger-follows-os-home.md @@ -0,0 +1,15 @@ +--- +'@objectstack/cloud-connection': patch +'@objectstack/cli': patch +--- + +fix(cloud-connection,cli): the install-local ledger follows `OS_HOME`, so `os dev --fresh` and a second instance under another `OS_HOME` start without an earlier run's installs + +Clause-②: yes (widening) +Metadata-protocol: none + +- **Now.** With no `storageDir`, the install-local ledger (`LocalManifestSource`, and so `MarketplaceInstallLocalPlugin`) lives at `/installed-packages` when `OS_HOME` is set, and at `/.objectstack/installed-packages` when it is not. Before, it was always the working directory, so `os dev --fresh` (a fresh `OS_HOME` and database) rehydrated a package an earlier run installed, and so did a second instance under another `OS_HOME`. +- **Unchanged.** `os dev` without `OS_HOME` keeps the ledger in the project, beside its database. `os start` in project mode sets `OS_HOME` to `/.objectstack`, which is the same directory as before. An explicit `storageDir` is used as given. +- **Existing installs.** When the ledger moved (an `OS_HOME` other than `/.objectstack`) and its new directory does not exist yet, the boot copies the `.json` files of the working-directory ledger into it once, and logs a warning naming both directories. The old files are left in place and are not read again; remove them. A copy that fails is logged as an error, and the next boot tries again. Under `os dev --fresh` nothing is copied. +- **`os doctor`** reads the same directory the runtime does, `OS_HOME` included. +- **The widening.** `@objectstack/cloud-connection` exports `resolveInstalledPackagesDir({ env?, cwd? })` (returns `{ dir, legacyDir }`), `INSTALLED_PACKAGES_HOME_SUBDIR`, and `LocalManifestSource.legacyDir` / `adoptLegacyEntries()`. `os doctor` takes the ledger directory from `resolveInstalledPackagesDir`; a `@objectstack/cloud-connection` without it is reported as a reader that does not declare the ledger directory. Align the two packages' versions. diff --git a/packages/cli/src/commands/dev-fresh-home-channel.test.ts b/packages/cli/src/commands/dev-fresh-home-channel.test.ts new file mode 100644 index 00000000000..ae2ac4c3529 --- /dev/null +++ b/packages/cli/src/commands/dev-fresh-home-channel.test.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { FRESH_HOME_CHANNEL_ENV as CONSUMER_SPELLING } from '@objectstack/cloud-connection'; +import { applyFreshHomeChannel, FRESH_HOME_CHANNEL_ENV } from './dev.js'; + +/** + * #30217 — `os dev --fresh` tells its `serve` child which `OS_HOME` is the + * run's ephemeral one, so the install ledger there adopts nothing from the + * project's working-directory ledger and the run starts without an earlier + * run's installs. The parent owns the variable: without `--fresh` it is + * removed, so an inherited copy never speaks for this run. + */ +describe('#30217 `os dev` owns the fresh-home channel on its serve child', () => { + it('is spelled exactly as @objectstack/cloud-connection reads it', () => { + expect(FRESH_HOME_CHANNEL_ENV).toBe(CONSUMER_SPELLING); + }); + + it('names the ephemeral home under --fresh', () => { + const env: NodeJS.ProcessEnv = { OS_HOME: '/tmp/objectstack-dev-abc' }; + applyFreshHomeChannel(env, '/tmp/objectstack-dev-abc'); + expect(env[FRESH_HOME_CHANNEL_ENV]).toBe('/tmp/objectstack-dev-abc'); + }); + + it('removes an inherited copy when the run is not --fresh', () => { + const env: NodeJS.ProcessEnv = { [FRESH_HOME_CHANNEL_ENV]: '/tmp/objectstack-dev-old' }; + applyFreshHomeChannel(env, undefined); + expect(FRESH_HOME_CHANNEL_ENV in env).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index c857029fc29..2130ac8e54f 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -251,6 +251,28 @@ export function forwardSeedSettledToParent(msg: unknown): boolean { return true; } +/** + * The internal channel `@objectstack/cloud-connection` reads as + * `FRESH_HOME_CHANNEL_ENV` (#30217): the ephemeral `OS_HOME` a `--fresh` run + * created. Spelled here rather than imported because oclif loads every command + * module on every invocation; `dev-fresh-home-channel.test.ts` holds the two + * spellings equal. + */ +export const FRESH_HOME_CHANNEL_ENV = 'OS_INTERNAL_FRESH_HOME'; + +/** + * Set or clear {@link FRESH_HOME_CHANNEL_ENV} on the serve child's env + * (#30217). Under `--fresh` it names the run's ephemeral `OS_HOME`, so the + * install ledger there adopts nothing from the project's working-directory + * ledger — a fresh run starts without an earlier run's installs. Otherwise it is + * removed: the parent owns the variable, so an inherited copy never speaks for + * a home this run did not create. Not an operator setting. + */ +export function applyFreshHomeChannel(childEnv: NodeJS.ProcessEnv, freshHome: string | undefined): void { + if (freshHome) childEnv[FRESH_HOME_CHANNEL_ENV] = freshHome; + else delete childEnv[FRESH_HOME_CHANNEL_ENV]; +} + /** * Whether this `os dev` boot runs the watch-recompile loop (#20681). * @@ -676,6 +698,9 @@ export default class Dev extends Command { // (OS_HOME → /data/objectstack.db, published as OS_DATABASE_URL), // - the storage-service uploads root, published on the settings // service's own env name OS_STORAGE_LOCAL_ROOT (#4968), + // - the install-local ledger, which follows OS_HOME (#30217); the + // child is told this home is ephemeral (applyFreshHomeChannel), so + // it adopts nothing from the project's working-directory ledger, // - any other state a plugin keys off OS_HOME. // In one sentence: framework-owned, OS_HOME-keyed state plus the env // channels this block publishes below. Auto-deleted on exit. @@ -810,6 +835,7 @@ export default class Dev extends Command { ...(flags['database-auth-token'] ? { OS_DATABASE_AUTH_TOKEN: flags['database-auth-token'] } : {}), ...(flags['auth-secret'] ? { OS_AUTH_SECRET: flags['auth-secret'] } : {}), }; + applyFreshHomeChannel(localEnv, freshHome); // The data key's file: the ONE answer `os start` takes too (#29934), so a // secret saved here reads back under `os start` over the same project — // and the file an earlier `os start` kept in the project is still read. diff --git a/packages/cli/src/commands/doctor-ledger-dir-authority.test.ts b/packages/cli/src/commands/doctor-ledger-dir-authority.test.ts index 8df658f4381..903690c82db 100644 --- a/packages/cli/src/commands/doctor-ledger-dir-authority.test.ts +++ b/packages/cli/src/commands/doctor-ledger-dir-authority.test.ts @@ -5,7 +5,8 @@ * * ── What this file pins, honestly ──────────────────────────────────────── * - * Not a live defect. `DEFAULT_INSTALLED_PACKAGES_DIR` is an export of + * Not a live defect. The directory authority (`DEFAULT_INSTALLED_PACKAGES_DIR`, + * `resolveInstalledPackagesDir` since #30217) is an export of * `@objectstack/cloud-connection` in every version ever shipped, so the `??` * fallback this change deletes had never fired. What is being hardened is a * DIAGNOSIS BOUNDARY — the last cell of the edge #5644 carved. That issue @@ -114,10 +115,10 @@ describe('installedPackageLedgerDirAuthorityMissingCheck — loaded but unrecogn expect(check.message).not.toContain('\n'); }); - it('names the received value when the export exists but is not a string', () => { + it('names the received value when the export exists but is not a function', () => { const check = installedPackageLedgerDirAuthorityMissingCheck(42); - expect(check.message).toContain('not a string'); + expect(check.message).toContain('not a function'); expect(check.message).toContain('got number: 42'); }); @@ -136,7 +137,7 @@ describe('installedPackageLedgerDirAuthorityMissingCheck — loaded but unrecogn // it from the #5644 row one cell over. Without both, the reader's first // move would be to rebuild a package that builds and loads fine. expect(fix).toContain('IS installed here and it DID load'); - expect(fix).toContain('authority on what the ledger directory is called'); + expect(fix).toContain('authority on where the ledger directory is'); // And it names the remedy: the two ends of this contract disagree. expect(fix).toContain('Align their versions'); }); @@ -145,7 +146,7 @@ describe('installedPackageLedgerDirAuthorityMissingCheck — loaded but unrecogn const check = installedPackageLedgerDirAuthorityMissingCheck(undefined); // The one name doctor had for the ledger is the export that came back - // non-string. A path in the row — the old `??` guess included — would be + // as no function. A path in the row — the old `??` guess included — would be // doctor asserting what it cannot know (#5644's option-B rejection, one // cell over). expect(check.message).not.toContain('.objectstack'); @@ -182,7 +183,7 @@ describe('installedPackageLedgerSkippedEntriesCheck — names the directory it a * * Simulated through the loader seam, like the #5644 cause case next door: the * mock hands doctor a module that LOADED (`state: 'loaded'`) and carries no - * `DEFAULT_INSTALLED_PACKAGES_DIR`. No preflight (`assertLedgerReaderIsBuilt`) + * `resolveInstalledPackagesDir`. No preflight (`assertLedgerReaderIsBuilt`) * in this describe, and that is not a rollback of #5612: nothing here reads * the real package at all — the seam is mocked — so there is no accident for * a preflight to catch. @@ -271,7 +272,7 @@ describe('os doctor, end to end, against a reader that declares no ledger direct it('reports the named row and reads NO directory — guessed or otherwise', async () => { const ctorCalls: string[] = []; const fakeModule = { - // Deliberately NO `DEFAULT_INSTALLED_PACKAGES_DIR` — the state under test. + // Deliberately NO `resolveInstalledPackagesDir` — the state under test. LocalManifestSource: class { constructor(dir: string) { ctorCalls.push(dir); @@ -312,13 +313,13 @@ describe('os doctor, end to end, against a reader that declares no ledger direct expect(verboseRun.exitCode).toBeUndefined(); }, 120_000); - it('a non-string export is the same fact — never a path.join over it', async () => { + it('a non-function export is the same fact — never a call of it', async () => { const ctorCalls: string[] = []; const fakeModule = { - // Present, and not a string: the shape whose `path.join` throw the old - // code mis-reported through the config `catch` (see the function's - // header in doctor.ts). - DEFAULT_INSTALLED_PACKAGES_DIR: 42, + // Present, and not a function: the shape a bare call would throw on, which + // the old `path.join` over the string authority mis-reported through the + // config `catch` (see the function's header in doctor.ts). + resolveInstalledPackagesDir: 42, LocalManifestSource: class { constructor(dir: string) { ctorCalls.push(dir); @@ -333,7 +334,7 @@ describe('os doctor, end to end, against a reader that declares no ledger direct expect(ctorCalls).toHaveLength(0); expect(run.out).toContain(AUTHORITY_HEADLINE); - expect(run.out).toContain('not a string'); + expect(run.out).toContain('not a function'); expect(run.out).toContain('got number: 42'); expect(run.out).not.toContain(CLEAN_BILL); expect(run.out).not.toContain('invoice.code'); diff --git a/packages/cli/src/commands/doctor-ledger-read-failure.test.ts b/packages/cli/src/commands/doctor-ledger-read-failure.test.ts index e4cfce7e985..9cb95adca74 100644 --- a/packages/cli/src/commands/doctor-ledger-read-failure.test.ts +++ b/packages/cli/src/commands/doctor-ledger-read-failure.test.ts @@ -479,10 +479,10 @@ describe('os doctor, end to end, against an unreadable installed-package ledger' // installed-packages` here would move the literal into the test and pin // the row against the guess a second time — the defect, relocated. // Re-imported rather than read off `LEDGER_REL` for the same reason. - const { DEFAULT_INSTALLED_PACKAGES_DIR } = await import('@objectstack/cloud-connection'); + const { resolveInstalledPackagesDir } = await import('@objectstack/cloud-connection'); // `mkdtemp` makes this unique per run, which is what makes "the resolved // dir, not the literal" assertable at all: no literal can contain it. - const resolved = path.join(tmp, DEFAULT_INSTALLED_PACKAGES_DIR); + const resolved = resolveInstalledPackagesDir({ cwd: tmp }).dir; const run = await runDoctor(['--verbose']); @@ -673,6 +673,34 @@ describe('os doctor, end to end, against an unreadable installed-package ledger' expect(verboseRun.out).toContain('Repair the JSON, or delete the file'); expect(verboseRun.exitCode).toBeUndefined(); }, 60_000); + + it('reads the ledger under OS_HOME when OS_HOME is set — the directory the runtime reads (#30217)', async () => { + // The runtime's ledger follows OS_HOME. Doctor reading `/.objectstack/ + // installed-packages` here would report on a directory the runtime no + // longer reads, and stay silent about the one it does. + writeConfig(); + const home = path.join(tmp, 'home'); + const homeLedger = path.join(home, 'installed-packages'); + fs.mkdirSync(homeLedger, { recursive: true }); + fs.writeFileSync(path.join(homeLedger, 'billing.json'), JSON.stringify(globalUniqueEntry('billing'))); + // A stale working-directory ledger beside it: not what the runtime reads. + fs.mkdirSync(ledgerPath(), { recursive: true }); + fs.writeFileSync(path.join(ledgerPath(), 'stale.json'), '{"manifestId":"stale"'); + + const savedHome = process.env.OS_HOME; + process.env.OS_HOME = home; + let run: { out: string; exitCode: number | undefined }; + try { + run = await runDoctor(['--verbose']); + } finally { + if (savedHome === undefined) delete process.env.OS_HOME; + else process.env.OS_HOME = savedHome; + } + + expect(run.out).toContain("installed package 'billing'"); + expect(run.out).not.toContain('stale.json'); + expect(run.out).not.toContain(SKIPPED_HEADLINE); + }, 60_000); }); /** diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 7d2cb9411fc..40f3db49b3a 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1140,7 +1140,9 @@ export interface InstalledPackageLedgerReading { readerFailure?: { cause: unknown }; /** * Present ONLY when the reader package LOADED and its - * `DEFAULT_INSTALLED_PACKAGES_DIR` export is not a string (#5996). + * `resolveInstalledPackagesDir` export is not a function (#5996; the + * authority was the `DEFAULT_INSTALLED_PACKAGES_DIR` string until the ledger + * followed `OS_HOME`, #30217). * * A FOURTH fact — the last cell of the edge #5644 carved. `readerFailure` is * "present but unloadable"; this is "loaded but unrecognizable". That export @@ -1152,9 +1154,10 @@ export interface InstalledPackageLedgerReading { */ dirAuthorityMissing?: { received: unknown }; /** - * The resolved ledger directory — `cwd` joined with the producer's - * `DEFAULT_INSTALLED_PACKAGES_DIR` export (#5996). Present exactly when that - * export was read successfully, whatever happened afterwards (a `failure` + * The resolved ledger directory — the producer's + * `resolveInstalledPackagesDir` answer for `cwd` (#5996, #30217): the same + * directory the runtime's default reads, `OS_HOME` included. Present exactly + * when that export was read successfully, whatever happened afterwards (a `failure` * reading still carries it); absent when the reader never loaded * (`readerFailure`) or loaded without declaring it (`dirAuthorityMissing`). * Rows that name the directory take it from HERE, never from a re-hardcoded @@ -1221,9 +1224,10 @@ interface SkippedLedgerEntry { * and the measurements behind it); only the genuinely-absent half stays silent. * * And a FIFTH — the cell the fourth's edge left uncovered (#5996). `loaded` is - * still not RECOGNIZED: the ledger directory's name is the module's own - * `DEFAULT_INSTALLED_PACKAGES_DIR` export — the single authority on that name — - * and a module that evaluates fine while declaring no such string is a reader + * still not RECOGNIZED: the ledger directory is the module's own + * `resolveInstalledPackagesDir` answer (the `DEFAULT_INSTALLED_PACKAGES_DIR` + * string until #30217) — the single authority on where it is — and a module + * that evaluates fine while declaring no such function is a reader * this doctor does not know how to follow. The consumer-side `??` that used to * sit on that export answered the state with a hard-coded guess, two lines * above the #5413 comment that forbids exactly that accommodation (Prime @@ -1237,8 +1241,8 @@ interface SkippedLedgerEntry { * every finding above is now reachable under every posture (which is the whole * point), and this function no longer sits inside a `try` belonging to somebody * else. It must therefore report rather than throw for anything it can hit, - * which is why the export is TYPE-CHECKED before `path.join()` ever sees it - * (#5996): the join over two known strings cannot throw. Before the check + * which is why the export is TYPE-CHECKED before it is called (#5996), and a + * resolver that throws anyway comes back as `readerFailure`. Before the check * existed, the same hazard was handled positionally — the join lived INSIDE the * guarded block, because a `path.join()` over a non-string export used to be * absorbed by the config `catch` and misreported as "Could not load config for @@ -1262,22 +1266,29 @@ async function readInstalledPackageEntries(cwd: string): Promise { dir: string })({ cwd }).dir; + } catch (cause) { + return { entries: [], skipped: [], readerFailure: { cause } }; } - // Outside the `try` on purpose: both operands are known strings, so this - // join cannot throw, and resolving it here keeps `dir` in scope for every - // return below — including the `failure` one. - const dir = path.join(cwd, declaredDir); try { // No directory = nothing was ever installed. Genuinely not a finding. @@ -1723,8 +1734,8 @@ const LEDGER_ROW_NAME = 'Installed packages'; * • **The cause is quoted, not paraphrased** (#5390 / #5403). `ENOTDIR: not * a directory, scandir '…'` names the file that is in the way; no sentence * doctor could invent would beat it. - * • **`dir` is the directory doctor actually read** (#6643) — resolved from - * `DEFAULT_INSTALLED_PACKAGES_DIR` and carried on the reading, exactly as + * • **`dir` is the directory doctor actually read** (#6643) — resolved by + * `resolveInstalledPackagesDir` and carried on the reading, exactly as * its `skipped` sibling takes it since #5996. It used to open with a * re-hardcoded ``.objectstack/installed-packages/`` literal "under the * project root": the consumer restating a value only the producer decides, @@ -1781,8 +1792,8 @@ export function installedPackageLedgerFailureCheck(err: unknown, dir: string): H * are different repairs. * • **The row quotes the FIRST cause; `fix` carries them all** (#5390 body). * One row is one line, the same bound every other finding here respects. - * • **`dir` is the directory doctor actually read** — the one resolved from - * `DEFAULT_INSTALLED_PACKAGES_DIR`, handed down through the reading + * • **`dir` is the directory doctor actually read** — the one resolved by + * `resolveInstalledPackagesDir`, handed down through the reading * (#5996). It used to be a re-hardcoded `.objectstack/installed-packages/` * literal: the same guess the deleted `??` fallback made, i.e. the * consumer restating what only the producer decides. A row that names @@ -1831,7 +1842,7 @@ export function installedPackageLedgerSkippedEntriesCheck( * * What is deliberately NOT a condition here: whether * `.objectstack/installed-packages/` exists. Doctor does not know that it does - * not — the directory's name is `DEFAULT_INSTALLED_PACKAGES_DIR`, an export of + * not — the directory is `resolveInstalledPackagesDir`'s answer, an export of * the very package that would not load, and answering from the hard-coded * fallback would be doctor claiming knowledge it just lost. Making the row * conditional on the directory was option B of #5644 and was rejected on @@ -1855,7 +1866,7 @@ export function installedPackageLedgerReaderFailureCheck(err: unknown): HealthCh + `${reportRowHeadline(cause)}`, fix: '`@objectstack/cloud-connection` IS installed here — its specifier resolves — and loading\n' - + ' it threw. That package is how `os doctor` reads `.objectstack/installed-packages/`,\n' + + ' it threw. That package is how `os doctor` reads the installed-package ledger,\n' + ' so nothing about the installed packages was read: whether any of them is\n' + ' unreadable — and, under the `isolated` posture, whether any declares an\n' + ' installation-wide `unique` — went unasked. Doctor cannot even tell you\n' @@ -1876,9 +1887,9 @@ export function installedPackageLedgerReaderFailureCheck(err: unknown): HealthCh */ function describeMissingDirAuthority(received: unknown): string { if (received === undefined) { - return 'DEFAULT_INSTALLED_PACKAGES_DIR is not among its exports'; + return 'resolveInstalledPackagesDir is not among its exports'; } - return `DEFAULT_INSTALLED_PACKAGES_DIR is not a string (got ${typeof received}: ${String(received)})`; + return `resolveInstalledPackagesDir is not a function (got ${typeof received}: ${String(received)})`; } /** @@ -1888,8 +1899,8 @@ function describeMissingDirAuthority(received: unknown): string { * The fourth sibling of `installedPackageLedgerFailureCheck`, and the last * cell of the edge #5644 carved. That issue split "present but unloadable" out * of absence's silence; this row is "loaded but unrecognizable": the module - * evaluated fine and its `DEFAULT_INSTALLED_PACKAGES_DIR` export — the single - * authority on what the ledger directory is called — is not a string. Like its + * evaluated fine and its `resolveInstalledPackagesDir` export — the single + * authority on where the ledger directory is — is not a function. Like its * three siblings the row takes the {@link LEDGER_ROW_NAME} name column, makes * the D5e advisory withhold its `✓ Unique scope` line, and stays a warning. * @@ -1903,8 +1914,8 @@ function describeMissingDirAuthority(received: unknown): string { * plausible path. * * What is deliberately NOT here, in the row or behind it: any directory path. - * The one name doctor had for the ledger is the export that just came back - * non-string, so naming a path — any path — would be doctor claiming the + * The one answer doctor had for the ledger is the export that just came back + * as no function, so naming a path — any path — would be doctor claiming the * knowledge whose loss this row reports. Same ground as #5644's option-B * rejection, one cell over: while this row shows, no directory is read at all. */ @@ -1918,8 +1929,8 @@ export function installedPackageLedgerDirAuthorityMissingCheck(received: unknown + reportRowHeadline(saw), fix: '`@objectstack/cloud-connection` IS installed here and it DID load — but it does not\n' - + ' export `DEFAULT_INSTALLED_PACKAGES_DIR` as a string, and that export is the single\n' - + ' authority on what the ledger directory is called. Doctor will not read a guessed\n' + + ' export `resolveInstalledPackagesDir` as a function, and that export is the single\n' + + ' authority on where the ledger directory is. Doctor will not read a guessed\n' + ' path in its place: the ledger’s location belongs to that package — the runtime\n' + ' reads wherever it decides — so a guess here could have doctor and the runtime\n' + ' reporting on two different directories with neither saying so. Nothing about the\n' diff --git a/packages/cloud-connection/src/index.ts b/packages/cloud-connection/src/index.ts index ac4215b49ca..33832a2da7e 100644 --- a/packages/cloud-connection/src/index.ts +++ b/packages/cloud-connection/src/index.ts @@ -38,7 +38,14 @@ export { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugi export type { MarketplaceInstallLocalPluginConfig } from './marketplace-install-local-plugin.js'; // cloud ADR-0007 step ⑤ — the local desired-state ledger, exported as a first-class // seam so hosts/reconcilers can read the same ledger without going through HTTP. -export { LocalManifestSource, DEFAULT_INSTALLED_PACKAGES_DIR } from './local-manifest-source.js'; +export { + LocalManifestSource, + DEFAULT_INSTALLED_PACKAGES_DIR, + INSTALLED_PACKAGES_HOME_SUBDIR, + FRESH_HOME_CHANNEL_ENV, + resolveInstalledPackagesDir, +} from './local-manifest-source.js'; +export type { InstalledPackagesLocation, LegacyLedgerAdoption } from './local-manifest-source.js'; // The RETURN CONTRACTS of both read paths are part of that seam. `list()` // reports what it could NOT read alongside what it could (#5413), so a consumer // cannot mistake half a ledger for a whole one; `read()` separates "never diff --git a/packages/cloud-connection/src/local-manifest-source.test.ts b/packages/cloud-connection/src/local-manifest-source.test.ts index 14daed10698..6ee52a25d84 100644 --- a/packages/cloud-connection/src/local-manifest-source.test.ts +++ b/packages/cloud-connection/src/local-manifest-source.test.ts @@ -7,11 +7,17 @@ * and `read()` (#5426) — and manifest-id sanitisation. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readdirSync, existsSync } from 'node:fs'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readdirSync, readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js'; +import { homedir, tmpdir } from 'node:os'; +import { + DEFAULT_INSTALLED_PACKAGES_DIR, + FRESH_HOME_CHANNEL_ENV, + LocalManifestSource, + resolveInstalledPackagesDir, + type InstalledManifestEntry, +} from './local-manifest-source.js'; let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'lms-')); }); @@ -217,3 +223,112 @@ describe('LocalManifestSource', () => { expect(src.has('../../etc/passwd')).toBe(true); }); }); + +describe('resolveInstalledPackagesDir — the ledger follows OS_HOME (#30217)', () => { + const cwd = '/srv/project'; + const legacy = join(cwd, DEFAULT_INSTALLED_PACKAGES_DIR); + + it('OS_HOME unset (or blank): the working-directory ledger, nothing to adopt', () => { + expect(resolveInstalledPackagesDir({ env: {}, cwd })).toEqual({ dir: legacy, legacyDir: null }); + expect(resolveInstalledPackagesDir({ env: { OS_HOME: ' ' }, cwd })).toEqual({ dir: legacy, legacyDir: null }); + }); + + it('OS_HOME set: /installed-packages, adopting from the working directory', () => { + expect(resolveInstalledPackagesDir({ env: { OS_HOME: '/data/os' }, cwd })).toEqual({ + dir: '/data/os/installed-packages', + legacyDir: legacy, + }); + }); + + it('expands a leading ~ the way the home resolver does', () => { + expect(resolveInstalledPackagesDir({ env: { OS_HOME: '~/os-home' }, cwd }).dir) + .toBe(join(homedir(), 'os-home', 'installed-packages')); + }); + + it('os start in project mode (OS_HOME = /.objectstack) keeps the directory it always had', () => { + expect(resolveInstalledPackagesDir({ env: { OS_HOME: join(cwd, '.objectstack') }, cwd })) + .toEqual({ dir: legacy, legacyDir: null }); + }); + + it('the --fresh channel stops the adoption only for the home it names', () => { + const fresh = '/tmp/objectstack-dev-abc'; + expect(resolveInstalledPackagesDir({ env: { OS_HOME: fresh, [FRESH_HOME_CHANNEL_ENV]: fresh }, cwd })) + .toEqual({ dir: join(fresh, 'installed-packages'), legacyDir: null }); + // An inherited copy naming another run's home speaks for nothing here. + expect(resolveInstalledPackagesDir({ env: { OS_HOME: '/data/os', [FRESH_HOME_CHANNEL_ENV]: fresh }, cwd }).legacyDir) + .toBe(legacy); + }); + + it('the default constructor reads the same answer; an explicit storageDir adopts nothing', () => { + vi.stubEnv('OS_HOME', '/data/os'); + try { + const src = new LocalManifestSource(); + expect(src.dir).toBe('/data/os/installed-packages'); + expect(src.legacyDir).toBe(join(process.cwd(), DEFAULT_INSTALLED_PACKAGES_DIR)); + expect(new LocalManifestSource('/elsewhere').legacyDir).toBeNull(); + } finally { + vi.unstubAllEnvs(); + } + }); +}); + +describe('LocalManifestSource.adoptLegacyEntries — one copy from the old place (#30217)', () => { + /** A source whose ledger is `/home/installed-packages`, adopting from `/legacy`. */ + function sources() { + const legacyDir = join(dir, 'legacy'); + const target = join(dir, 'home', 'installed-packages'); + const src = Object.assign(new LocalManifestSource(target), { legacyDir }) as LocalManifestSource; + return { src, legacyDir, target }; + } + + it('copies every .json file byte for byte — unreadable ones too — and leaves the old ones', () => { + const { src, legacyDir, target } = sources(); + new LocalManifestSource(legacyDir).write(entry('com.acme.crm')); + writeFileSync(join(legacyDir, 'broken.json'), '{"manifestId":"bro', 'utf8'); + writeFileSync(join(legacyDir, 'notes.txt'), 'not an entry', 'utf8'); + + const adoption = src.adoptLegacyEntries(); + + expect(adoption).toEqual({ from: legacyDir, to: target, files: expect.arrayContaining(['com.acme.crm.json', 'broken.json']) }); + expect(adoption!.files).toHaveLength(2); + expect(readdirSync(target).sort()).toEqual(['broken.json', 'com.acme.crm.json']); + expect(readFileSync(join(target, 'broken.json'), 'utf8')).toBe('{"manifestId":"bro'); + // The unreadable one is reported from its new place, as before. + expect(src.list().skipped.map((s) => s.file)).toEqual(['broken.json']); + expect(readdirSync(legacyDir).sort()).toEqual(['broken.json', 'com.acme.crm.json', 'notes.txt']); + // No staging directory is left beside the ledger. + expect(readdirSync(join(dir, 'home'))).toEqual(['installed-packages']); + }); + + it('adopts nothing once the ledger directory exists — an uninstall there sticks', () => { + const { src, legacyDir } = sources(); + new LocalManifestSource(legacyDir).write(entry('com.acme.crm')); + expect(src.adoptLegacyEntries()).not.toBeNull(); + + src.remove('com.acme.crm'); + + expect(src.adoptLegacyEntries()).toBeNull(); + expect(src.has('com.acme.crm')).toBe(false); + }); + + it('answers null with no legacy directory, an absent one, or one with no entry — and creates nothing', () => { + const { src, legacyDir, target } = sources(); + expect(new LocalManifestSource(target).adoptLegacyEntries()).toBeNull(); + expect(src.adoptLegacyEntries()).toBeNull(); + mkdirSync(legacyDir, { recursive: true }); + writeFileSync(join(legacyDir, 'notes.txt'), 'x', 'utf8'); + expect(src.adoptLegacyEntries()).toBeNull(); + expect(existsSync(target)).toBe(false); + }); + + it('a copy that fails throws and leaves no ledger directory, so the next boot adopts again', () => { + const { src, legacyDir, target } = sources(); + new LocalManifestSource(legacyDir).write(entry('com.acme.crm')); + // An unreadable "file": a directory named like an entry fails the copy. + mkdirSync(join(legacyDir, 'zz-dir.json')); + + expect(() => src.adoptLegacyEntries()).toThrow(); + expect(existsSync(target)).toBe(false); + expect(readdirSync(join(dir, 'home'))).toEqual([]); + }); +}); diff --git a/packages/cloud-connection/src/local-manifest-source.ts b/packages/cloud-connection/src/local-manifest-source.ts index eb118f29ac0..6cd1bd09566 100644 --- a/packages/cloud-connection/src/local-manifest-source.ts +++ b/packages/cloud-connection/src/local-manifest-source.ts @@ -7,8 +7,10 @@ * A self-hosted / single-environment runtime OWNS its desired state: the * answer to "which packages should this runtime load" lives on the runtime's * own disk, one JSON file per installed manifest under - * `/.objectstack/installed-packages/`. This class is that ledger, - * promoted to a first-class named seam. + * `/installed-packages/` when `OS_HOME` is set, and under + * `/.objectstack/installed-packages/` when it is not + * ({@link resolveInstalledPackagesDir}). This class is that ledger, promoted + * to a first-class named seam. * * It is the LOCAL isomorph of what a cloud control plane does for managed * environments (`sys_package_installation` desired rows → compiled @@ -28,8 +30,12 @@ * the same ledger without going through HTTP. */ -import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { + copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; import type { GlobalUniqueAttestation } from '@objectstack/types'; @@ -179,9 +185,98 @@ export interface InstalledManifestListing { skipped: SkippedManifestEntry[]; } -/** Default ledger location, relative to the runtime's working directory. */ +/** + * The ledger location relative to the runtime's working directory — where the + * ledger lives when `OS_HOME` is unset, and where every release before the + * ledger followed `OS_HOME` wrote it ({@link resolveInstalledPackagesDir}). + */ export const DEFAULT_INSTALLED_PACKAGES_DIR = '.objectstack/installed-packages'; +/** The ledger's directory name under `OS_HOME`. */ +export const INSTALLED_PACKAGES_HOME_SUBDIR = 'installed-packages'; + +/** + * The internal channel `os dev --fresh` sets on its `serve` child: the + * ephemeral `OS_HOME` that run created. A ledger under a home this channel + * names adopts nothing from the working directory — see + * {@link resolveInstalledPackagesDir}. The parent owns the variable, like the + * CLI's other `OS_INTERNAL_*` channels; it is not an operator setting. + */ +export const FRESH_HOME_CHANNEL_ENV = 'OS_INTERNAL_FRESH_HOME'; + +/** Where the ledger lives, and the earlier location it adopts from once. */ +export interface InstalledPackagesLocation { + /** The directory the ledger reads and writes. */ + dir: string; + /** + * The working-directory ledger a release before this one wrote, which + * {@link LocalManifestSource.adoptLegacyEntries} copies into {@link dir} + * once. `null` when it is {@link dir} itself, or when `dir` is under the + * ephemeral home of an `os dev --fresh` run. + */ + legacyDir: string | null; +} + +function expandHome(raw: string): string { + return raw.startsWith('~') ? resolve(homedir(), raw.slice(1).replace(/^[/\\]/, '')) : resolve(raw); +} + +/** + * The ONE answer to "where is this runtime's install ledger" — the runtime's + * own default and `os doctor` both read it, so the two never look at two + * directories. + * + * | `OS_HOME` | ledger | + * |------------|-------------------------------------------| + * | set | `/installed-packages` | + * | unset | `/.objectstack/installed-packages` | + * + * Set, the ledger follows the home the rest of the instance's state follows + * (the default database, the uploads root), so `os dev --fresh` — a fresh + * `OS_HOME` — and a second instance under another `OS_HOME` start without the + * packages an earlier run installed. The ledger used to resolve from the + * working directory alone, so both of those rehydrated a previous install. + * + * Unset, it stays in the project: an `os dev` that sets no `OS_HOME` keeps its + * database in the project too, and the ledger must sit beside the database + * whose sample-data rows its entries describe. `~/.objectstack` (the home's + * own default) would make one ledger serve every project on the machine. An + * `os start` in project mode sets `OS_HOME` to `/.objectstack`, so its + * ledger is the same directory it always was. + * + * `legacyDir` names the working-directory ledger whenever the answer moved + * away from it, so the installs an earlier release recorded there are adopted + * once instead of silently disappearing — except under the ephemeral home an + * `os dev --fresh` run announces on {@link FRESH_HOME_CHANNEL_ENV}, whose + * whole point is to start without them. + */ +export function resolveInstalledPackagesDir( + opts: { env?: NodeJS.ProcessEnv; cwd?: string } = {}, +): InstalledPackagesLocation { + const env = opts.env ?? process.env; + const legacy = resolve(opts.cwd ?? process.cwd(), DEFAULT_INSTALLED_PACKAGES_DIR); + const home = env.OS_HOME?.trim(); + if (!home) return { dir: legacy, legacyDir: null }; + const homeDir = expandHome(home); + const dir = join(homeDir, INSTALLED_PACKAGES_HOME_SUBDIR); + if (dir === legacy) return { dir, legacyDir: null }; + const fresh = env[FRESH_HOME_CHANNEL_ENV]?.trim(); + // The channel counts only for the home it names: an inherited copy never + // speaks for another run's home. + if (fresh && expandHome(fresh) === homeDir) return { dir, legacyDir: null }; + return { dir, legacyDir: legacy }; +} + +/** What {@link LocalManifestSource.adoptLegacyEntries} copied, and from where. */ +export interface LegacyLedgerAdoption { + /** The working-directory ledger the files were copied from (left in place). */ + from: string; + /** The ledger directory they were copied into. */ + to: string; + /** The basenames copied, byte for byte — unreadable ones included. */ + files: string[]; +} + function safeFilename(manifestId: string): string { return manifestId.replace(/[^a-zA-Z0-9._-]/g, '_') + '.json'; } @@ -189,11 +284,61 @@ function safeFilename(manifestId: string): string { export class LocalManifestSource { /** Resolved ledger directory. */ readonly dir: string; + /** + * The earlier ledger location {@link adoptLegacyEntries} copies from — + * set only on a default construction whose {@link resolveInstalledPackagesDir} + * answer moved away from the working directory. An explicit `storageDir` + * is the host's own choice and adopts nothing. + */ + readonly legacyDir: string | null; constructor(storageDir?: string) { - this.dir = storageDir - ? resolve(storageDir) - : resolve(process.cwd(), DEFAULT_INSTALLED_PACKAGES_DIR); + if (storageDir) { + this.dir = resolve(storageDir); + this.legacyDir = null; + } else { + const location = resolveInstalledPackagesDir(); + this.dir = location.dir; + this.legacyDir = location.legacyDir; + } + } + + /** + * Copy the working-directory ledger an earlier release wrote into this + * ledger, ONCE: only while this ledger's directory does not exist yet. + * + * Answers `null` when there is nothing to adopt — no {@link legacyDir}, the + * ledger directory already exists, or the legacy directory holds no + * `.json` file. The files are copied byte for byte, unreadable ones too, so + * `list()` reports those from their new place exactly as it would have + * from the old one. The legacy files are LEFT IN PLACE: an `OS_HOME` + * pointed at a throwaway directory must never take a project's installs + * away with it. They are not read again: from then on the ledger directory + * exists, so a package uninstalled here stays uninstalled. + * + * The copy lands in a sibling directory that is renamed into place, so a + * failure part-way leaves no ledger directory behind and the next boot + * adopts again. A failure throws — the caller reports it; nothing here + * decides how loud a boot should be (the same split as {@link list}). + * + * Not called by `list()` or `read()`: a reader such as `os doctor` must not + * write. The runtime calls it once, before it rehydrates. + */ + adoptLegacyEntries(): LegacyLedgerAdoption | null { + const from = this.legacyDir; + if (!from || existsSync(this.dir) || !existsSync(from)) return null; + const files = readdirSync(from).filter((name) => name.endsWith('.json')); + if (files.length === 0) return null; + mkdirSync(dirname(this.dir), { recursive: true }); + const staging = mkdtempSync(join(dirname(this.dir), `.${basename(this.dir)}.adopting-`)); + try { + for (const name of files) copyFileSync(join(from, name), join(staging, name)); + renameSync(staging, this.dir); + } catch (cause) { + rmSync(staging, { recursive: true, force: true }); + throw cause; + } + return { from, to: this.dir, files }; } /** diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 304abdb60d5..770ab76390e 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -233,6 +233,7 @@ import { LocalManifestSource, type InstalledManifestEntry, type InstalledManifestListing, + type LegacyLedgerAdoption, type SkippedManifestEntry, } from './local-manifest-source.js'; import { ConnectionCredentialStore } from './connection-credential-store.js'; @@ -1086,7 +1087,9 @@ export interface MarketplaceInstallLocalPluginConfig { * OS_CLOUD_URL=off to disable (the install endpoint then returns 503). */ controlPlaneUrl?: string; /** Override the on-disk cache directory. Defaults to - * `/.objectstack/installed-packages`. */ + * `/installed-packages` when `OS_HOME` is set, else + * `/.objectstack/installed-packages` (`resolveInstalledPackagesDir`). + * An explicit directory adopts nothing from the working directory. */ storageDir?: string; } @@ -1207,6 +1210,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin { private rehydrate = async (ctx: PluginContext): Promise => { // [#21822] The record describes THIS rehydrate, and nothing older. this.refusedAtRehydrate.clear(); + // [#30217] Before the read: the installs an earlier release ledgered in + // the working directory are copied into the ledger that now follows + // OS_HOME, once, so they rehydrate here instead of silently vanishing. + this.adoptLegacyLedger(ctx); const { entries, skipped } = this.readAll(); // #5413 — BEFORE the early return, not after. A ledger whose entries @@ -5515,6 +5522,37 @@ export class MarketplaceInstallLocalPlugin implements Plugin { ); }; + /** + * [#30217] Copy the working-directory ledger into this one, once + * ({@link LocalManifestSource.adoptLegacyEntries}), and say so. + * + * A copy that happened is a `warn`: the runtime now reads the new place, + * and the old directory, left in place, is stale state the operator should + * remove. A copy that failed is an `error` (AGENTS.md, "Degradation log + * levels"): the old ledger lists packages as installed, this runtime loads + * none of them, and the boot otherwise looks normal. Nothing was lost — the + * old files are untouched and the next boot copies again. + */ + private adoptLegacyLedger = (ctx: PluginContext): void => { + let adoption: LegacyLedgerAdoption | null; + try { + adoption = this.ledger.adoptLegacyEntries(); + } catch (cause) { + ctx.logger?.error?.( + `[MarketplaceInstallLocal] the packages ledgered in ${this.ledger.legacyDir} are NOT loaded into this ` + + `runtime: copying them into ${this.storageDir}, where the ledger now lives (it follows OS_HOME), ` + + `failed: ${describeLedgerCause(cause)}. Make ${this.storageDir} writable and restart, or copy the ` + + `files there yourself; the old files are untouched.`, + ); + return; + } + if (!adoption) return; + ctx.logger?.warn?.( + `[MarketplaceInstallLocal] copied ${adoption.files.length} installed package(s) from ${adoption.from} ` + + `into ${adoption.to}: the ledger now follows OS_HOME. ${adoption.from} is no longer read; remove it.`, + ); + }; + private warnSkippedLedgerEntries = (ctx: PluginContext, skipped: SkippedManifestEntry[], what: string): void => { for (const { file, cause } of skipped) { ctx.logger?.warn?.( diff --git a/packages/cloud-connection/src/marketplace-install-local-storage-dir.test.ts b/packages/cloud-connection/src/marketplace-install-local-storage-dir.test.ts index 67fc751a455..c8934b3f62c 100644 --- a/packages/cloud-connection/src/marketplace-install-local-storage-dir.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-storage-dir.test.ts @@ -24,7 +24,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; // [#21321] An install, and a rehydrate of a ledger entry, now bind the package's @@ -34,7 +34,7 @@ import { tmpdir } from 'node:os'; import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; -import { DEFAULT_INSTALLED_PACKAGES_DIR } from './local-manifest-source.js'; +import { DEFAULT_INSTALLED_PACKAGES_DIR, FRESH_HOME_CHANNEL_ENV, LocalManifestSource } from './local-manifest-source.js'; type Handler = (c: any) => Promise; @@ -102,7 +102,7 @@ const MANIFEST = { id: 'com.acme.crm', name: 'Acme CRM', version: '1.0.0', objec let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-storage-dir-')); }); -afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); }); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); vi.unstubAllEnvs(); }); describe('install-local POST response — storageDir', () => { it('carries the RESOLVED ledger directory of a host that configured a non-default storageDir', async () => { @@ -124,6 +124,8 @@ describe('install-local POST response — storageDir', () => { // file there. Without this the case would litter the repo checkout. const prevCwd = process.cwd(); process.chdir(dir); + // [#30217] The cwd default is the `OS_HOME`-unset answer. + vi.stubEnv('OS_HOME', ''); try { const { install } = await mount(undefined); @@ -147,3 +149,148 @@ describe('install-local POST response — storageDir', () => { expect(installed.payload.data.storageDir.length).toBeGreaterThan(0); }); }); + +/** + * #30217 — with no `storageDir`, the ledger follows `OS_HOME`. + * + * It used to resolve from the working directory alone, so `os dev --fresh` (a + * fresh `OS_HOME`) and a second instance under another `OS_HOME` rehydrated a + * package an earlier run installed. The installs an earlier release ledgered in + * the working directory are copied into the new place once, so they do not + * vanish on upgrade — except under the ephemeral home of a `--fresh` run. + */ +describe('install-local default ledger — follows OS_HOME (#30217)', () => { + let prevCwd: string; + let project: string; + let home: string; + beforeEach(() => { + prevCwd = process.cwd(); + project = join(dir, 'project'); + home = join(dir, 'home'); + mkdirSync(project, { recursive: true }); + process.chdir(project); + }); + afterEach(() => { process.chdir(prevCwd); }); + + const legacyDir = () => join(project, DEFAULT_INSTALLED_PACKAGES_DIR); + const homeLedger = () => join(home, 'installed-packages'); + + /** A ledger entry an earlier release wrote in the working directory. */ + function writeLegacyEntry(manifestId = 'com.acme.legacy') { + new LocalManifestSource(legacyDir()).write({ + packageId: 'pkg_legacy', + versionId: 'pkgv_legacy', + manifestId, + version: '1.0.0', + manifest: { id: manifestId, name: 'Legacy', version: '1.0.0', objects: [] }, + installedAt: '2026-01-01T00:00:00.000Z', + installedBy: 'admin', + }); + } + + async function boot() { + const rawApp = makeRawApp(); + const { ctx, fire } = makeCtx(rawApp); + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off' }); + await plugin.start(ctx as any); + await fire(); + const register = (ctx.getService('manifest') as any).register as ReturnType; + const registered = register.mock.calls.map((call: any[]) => call[0]?.id); + return { + ctx, + registered, + install: async (manifest: any) => + rawApp.routes.get('POST /api/v1/marketplace/install-local')!(makeC({ manifest })), + }; + } + + it('installs into /installed-packages, never the working directory', async () => { + vi.stubEnv('OS_HOME', home); + const { install } = await boot(); + + const res = await install(MANIFEST); + + expect(res.payload?.success).toBe(true); + expect(res.payload.data.storageDir).toBe(homeLedger()); + expect(readdirSync(homeLedger())).toEqual(['com.acme.crm.json']); + expect(existsSync(legacyDir())).toBe(false); + }); + + it('a second instance under another OS_HOME does not rehydrate the first one\'s install', async () => { + vi.stubEnv('OS_HOME', home); + expect((await (await boot()).install(MANIFEST)).payload?.success).toBe(true); + + vi.stubEnv('OS_HOME', join(dir, 'other-home')); + const second = await boot(); + + expect(second.registered).not.toContain(MANIFEST.id); + }); + + it('adopts the working-directory ledger once, leaving the old files in place', async () => { + writeLegacyEntry(); + vi.stubEnv('OS_HOME', home); + + const first = await boot(); + + // Rehydrated from its new place, and said so. + expect(first.registered).toContain('com.acme.legacy'); + expect(readdirSync(homeLedger())).toEqual(['com.acme.legacy.json']); + expect(first.ctx.logger.warn).toHaveBeenCalledWith(expect.stringContaining(`from ${legacyDir()} into ${homeLedger()}`)); + // Copied, not moved: an OS_HOME pointed at a throwaway directory must + // never take the project's installs with it. + expect(readdirSync(legacyDir())).toEqual(['com.acme.legacy.json']); + + // Once: the ledger directory exists now, so an uninstall there sticks — + // the old file is not read again. + new LocalManifestSource(homeLedger()).remove('com.acme.legacy'); + const second = await boot(); + expect(second.registered).not.toContain('com.acme.legacy'); + expect(second.ctx.logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('copied')); + }); + + it('a `--fresh` home adopts nothing — the run starts without earlier installs', async () => { + writeLegacyEntry(); + vi.stubEnv('OS_HOME', home); + vi.stubEnv(FRESH_HOME_CHANNEL_ENV, home); + + const { registered } = await boot(); + + expect(registered).not.toContain('com.acme.legacy'); + expect(existsSync(homeLedger())).toBe(false); + expect(readdirSync(legacyDir())).toEqual(['com.acme.legacy.json']); + }); + + it('a fresh-home channel naming ANOTHER home does not stop the adoption', async () => { + writeLegacyEntry(); + vi.stubEnv('OS_HOME', home); + vi.stubEnv(FRESH_HOME_CHANNEL_ENV, join(dir, 'some-earlier-fresh-home')); + + const { registered } = await boot(); + + expect(registered).toContain('com.acme.legacy'); + }); + + it('with OS_HOME unset the ledger stays in the working directory — nothing to adopt', async () => { + writeLegacyEntry(); + vi.stubEnv('OS_HOME', ''); + + const { registered, ctx } = await boot(); + + expect(registered).toContain('com.acme.legacy'); + expect(ctx.logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('copied')); + }); + + it('a copy that fails is an error naming both directories, and no half-made ledger is left', async () => { + writeLegacyEntry(); + // OS_HOME is a FILE: the ledger directory under it can never be made. + writeFileSync(home, 'not a directory'); + vi.stubEnv('OS_HOME', home); + + const { registered, ctx } = await boot(); + + expect(registered).not.toContain('com.acme.legacy'); + expect(ctx.logger.error).toHaveBeenCalledWith(expect.stringContaining(`ledgered in ${legacyDir()} are NOT loaded`)); + expect(ctx.logger.error).toHaveBeenCalledWith(expect.stringContaining(homeLedger())); + expect(readdirSync(legacyDir())).toEqual(['com.acme.legacy.json']); + }); +}); From fb95d7657da04fdacecf2d6e74150c64e82a90fd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 05:48:47 +0000 Subject: [PATCH 2/5] fix(cli): os doctor resolves the ledger's OS_HOME under the .env overlay and reports its provenance Claude-Session: https://claude.ai/code/session_01Utp6CY4LWqfjLCYqpZhNCs Co-authored-by: Claude --- packages/cli/src/commands/doctor.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 40f3db49b3a..2255d0810c7 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -120,7 +120,14 @@ function renderHealthCheckResult(result: HealthCheckResult, verbose: boolean): v * Reading `.env` into a check that reports no attribution is precisely the * defect #5387 closed, and it would otherwise creep back one variable at a time. */ -export const DOCTOR_ENV_INPUTS = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED', SCHEDULED_WORK_ENV] as const; +export const DOCTOR_ENV_INPUTS = [ + 'OS_TENANCY_POSTURE', + 'OS_MULTI_ORG_ENABLED', + SCHEDULED_WORK_ENV, + // [#30217] Where the installed-package ledger lives: `/installed-packages` + // when set (`readInstalledPackageEntries`). + 'OS_HOME', +] as const; /** Where one environment value actually came from. */ export interface EnvValueProvenance { @@ -1248,7 +1255,10 @@ interface SkippedLedgerEntry { * absorbed by the config `catch` and misreported as "Could not load config for * analysis". The named reading replaces both the guess and the throw. */ -async function readInstalledPackageEntries(cwd: string): Promise { +async function readInstalledPackageEntries( + cwd: string, + dotenvReading: DotenvReading, +): Promise { // Dynamic, like serve.ts's cloud-connection load: `os doctor` must still run // in a checkout where the optional package is not resolvable. const load = await loadOptionalPackage('@objectstack/cloud-connection'); @@ -1285,7 +1295,9 @@ async function readInstalledPackageEntries(cwd: string): Promise { dir: string })({ cwd }).dir; + // Under the `.env*` overlay, like every other env-derived read here: `os + // serve` loads that cascade before the ledger resolves `OS_HOME`. + dir = withDotenvOverlay(dotenvReading, () => (resolveDir as (opts: { cwd: string }) => { dir: string })({ cwd })).dir; } catch (cause) { return { entries: [], skipped: [], readerFailure: { cause } }; } @@ -2424,7 +2436,7 @@ export default class Doctor extends Command { // // The same reading is handed to the D5e advisory further down instead of // being read a second time, which is what keeps one bad ledger to one row. - const ledgerReading = await readInstalledPackageEntries(cwd); + const ledgerReading = await readInstalledPackageEntries(cwd, dotenvReading); results.push(...installedPackageLedgerChecks(ledgerReading)); // Display environment results From 0947c7af85a32a76ab3d1e671bfe5a5ec984357f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 06:25:01 +0000 Subject: [PATCH 3/5] test(cloud-connection): read the manifest service's register mock without an any cast Claude-Session: https://claude.ai/code/session_01Utp6CY4LWqfjLCYqpZhNCs Co-authored-by: Claude --- .../src/marketplace-install-local-storage-dir.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cloud-connection/src/marketplace-install-local-storage-dir.test.ts b/packages/cloud-connection/src/marketplace-install-local-storage-dir.test.ts index c8934b3f62c..152ce9900a9 100644 --- a/packages/cloud-connection/src/marketplace-install-local-storage-dir.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-storage-dir.test.ts @@ -194,7 +194,7 @@ describe('install-local default ledger — follows OS_HOME (#30217)', () => { const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off' }); await plugin.start(ctx as any); await fire(); - const register = (ctx.getService('manifest') as any).register as ReturnType; + const register = ctx.getService('manifest').register as ReturnType; const registered = register.mock.calls.map((call: any[]) => call[0]?.id); return { ctx, From 299ca6ddb2a8748be8191820b22fd21f306b31e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:04:00 +0000 Subject: [PATCH 4/5] docs(changeset): spell the ledger paths without angle brackets Claude-Session: https://claude.ai/code/session_01Utp6CY4LWqfjLCYqpZhNCs Co-authored-by: Claude --- .changeset/30217-install-ledger-follows-os-home.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/30217-install-ledger-follows-os-home.md b/.changeset/30217-install-ledger-follows-os-home.md index 765a1651711..abb3136d5a6 100644 --- a/.changeset/30217-install-ledger-follows-os-home.md +++ b/.changeset/30217-install-ledger-follows-os-home.md @@ -8,8 +8,8 @@ fix(cloud-connection,cli): the install-local ledger follows `OS_HOME`, so `os de Clause-②: yes (widening) Metadata-protocol: none -- **Now.** With no `storageDir`, the install-local ledger (`LocalManifestSource`, and so `MarketplaceInstallLocalPlugin`) lives at `/installed-packages` when `OS_HOME` is set, and at `/.objectstack/installed-packages` when it is not. Before, it was always the working directory, so `os dev --fresh` (a fresh `OS_HOME` and database) rehydrated a package an earlier run installed, and so did a second instance under another `OS_HOME`. -- **Unchanged.** `os dev` without `OS_HOME` keeps the ledger in the project, beside its database. `os start` in project mode sets `OS_HOME` to `/.objectstack`, which is the same directory as before. An explicit `storageDir` is used as given. -- **Existing installs.** When the ledger moved (an `OS_HOME` other than `/.objectstack`) and its new directory does not exist yet, the boot copies the `.json` files of the working-directory ledger into it once, and logs a warning naming both directories. The old files are left in place and are not read again; remove them. A copy that fails is logged as an error, and the next boot tries again. Under `os dev --fresh` nothing is copied. +- **Now.** With no `storageDir`, the install-local ledger (`LocalManifestSource`, and so `MarketplaceInstallLocalPlugin`) lives at `$OS_HOME/installed-packages` when `OS_HOME` is set, and at `CWD/.objectstack/installed-packages` when it is not. Before, it was always the working directory, so `os dev --fresh` (a fresh `OS_HOME` and database) rehydrated a package an earlier run installed, and so did a second instance under another `OS_HOME`. +- **Unchanged.** `os dev` without `OS_HOME` keeps the ledger in the project, beside its database. `os start` in project mode sets `OS_HOME` to `CWD/.objectstack`, which is the same directory as before. An explicit `storageDir` is used as given. +- **Existing installs.** When the ledger moved (an `OS_HOME` other than `CWD/.objectstack`) and its new directory does not exist yet, the boot copies the `.json` files of the working-directory ledger into it once, and logs a warning naming both directories. The old files are left in place and are not read again; remove them. A copy that fails is logged as an error, and the next boot tries again. Under `os dev --fresh` nothing is copied. - **`os doctor`** reads the same directory the runtime does, `OS_HOME` included. - **The widening.** `@objectstack/cloud-connection` exports `resolveInstalledPackagesDir({ env?, cwd? })` (returns `{ dir, legacyDir }`), `INSTALLED_PACKAGES_HOME_SUBDIR`, and `LocalManifestSource.legacyDir` / `adoptLegacyEntries()`. `os doctor` takes the ledger directory from `resolveInstalledPackagesDir`; a `@objectstack/cloud-connection` without it is reported as a reader that does not declare the ledger directory. Align the two packages' versions. From 2e2ebc4089991d0f1c833149f20d6385067fae2e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:29:06 +0000 Subject: [PATCH 5/5] test(cli): write the earlier build's ledger entry into the home's ledger The install-local ledger follows OS_HOME (#30217). The jobs-pull and hooks integration fixtures boot `os start --home HOME` and planted the earlier build's entry under the working directory, so boot 2 never read it (boot 1 had already created the home's ledger, so no one-time copy ran). They now write it into HOME/installed-packages; the intent of each test is unchanged. Claude-Session: https://claude.ai/code/session_01Utp6CY4LWqfjLCYqpZhNCs Co-authored-by: Claude --- .../test/package-install-local-hooks.integration.test.ts | 6 ++++-- .../package-install-local-jobs-pull.integration.test.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/cli/test/package-install-local-hooks.integration.test.ts b/packages/cli/test/package-install-local-hooks.integration.test.ts index 6a1638fa0b8..ecdef90f1df 100644 --- a/packages/cli/test/package-install-local-hooks.integration.test.ts +++ b/packages/cli/test/package-install-local-hooks.integration.test.ts @@ -412,8 +412,10 @@ beforeAll(async () => { readings.output.install = first.output(); await stopGroup(first.child); - // ── boot 2: same host, home and cwd, plus a ledger entry an earlier build wrote ── - const ledger = join(runtimeDir, '.objectstack', 'installed-packages'); + // ── boot 2: same host, home and cwd, plus an entry an earlier build wrote into this home's ledger ── + // The ledger follows OS_HOME (#30217): `--home` names it, so the entry goes + // under that home, not under the working directory. + const ledger = join(home, 'installed-packages'); mkdirSync(ledger, { recursive: true }); writeFileSync(join(ledger, `${LEGACY_APP_ID}.json`), JSON.stringify(LEGACY_ENTRY, null, 2), 'utf8'); const second = await bootStart(runtimeDir, home, ['--artifact', hostArtifact]); diff --git a/packages/cli/test/package-install-local-jobs-pull.integration.test.ts b/packages/cli/test/package-install-local-jobs-pull.integration.test.ts index 99418f23dff..4c13dc30006 100644 --- a/packages/cli/test/package-install-local-jobs-pull.integration.test.ts +++ b/packages/cli/test/package-install-local-jobs-pull.integration.test.ts @@ -385,8 +385,10 @@ beforeAll(async () => { readings.output.install = first.output(); await stopGroup(first.child); - // ── boot 2: same host, home and cwd, plus a ledger entry an earlier build wrote ── - const ledger = join(runtimeDir, '.objectstack', 'installed-packages'); + // ── boot 2: same host, home and cwd, plus an entry an earlier build wrote into this home's ledger ── + // The ledger follows OS_HOME (#30217): `--home` names it, so the entry goes + // under that home, not under the working directory. + const ledger = join(home, 'installed-packages'); mkdirSync(ledger, { recursive: true }); writeFileSync(join(ledger, `${LEGACY_APP_ID}.json`), JSON.stringify(LEGACY_ENTRY, null, 2), 'utf8'); const second = await bootStart(runtimeDir, home, ['--artifact', hostArtifact]);