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
15 changes: 15 additions & 0 deletions .changeset/30217-install-ledger-follows-os-home.md
Original file line number Diff line number Diff line change
@@ -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 `$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.
30 changes: 30 additions & 0 deletions packages/cli/src/commands/dev-fresh-home-channel.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
26 changes: 26 additions & 0 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand Down Expand Up @@ -676,6 +698,9 @@ export default class Dev extends Command {
// (OS_HOME → <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.
Expand Down Expand Up @@ -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.
Expand Down
27 changes: 14 additions & 13 deletions packages/cli/src/commands/doctor-ledger-dir-authority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
});

Expand All @@ -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');
});
Expand All @@ -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');
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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');
Expand Down
32 changes: 30 additions & 2 deletions packages/cli/src/commands/doctor-ledger-read-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

Expand Down Expand Up @@ -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 `<cwd>/.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);
});

/**
Expand Down
Loading
Loading