Skip to content

Commit bc254be

Browse files
fix(cloud-connection,cli): the install-local ledger follows OS_HOME, so os dev --fresh starts without earlier installs (#30351)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg8wW3DTJphDWvE6eMmCmuPe)_ domain:server#3 · os-bill · session_01Utp6CY4LWqfjLCYqpZhNCs Fixes #30217 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么:** 本地安装账本(install-local 缓存目录)不再固定在工作目录:设了 `OS_HOME` 就放 `$OS_HOME/installed-packages`,没设仍在 `CWD/.objectstack/installed-packages`。旧位置的已有安装在新目录第一次出现时复制一次过去(旧文件不删),`os dev --fresh` 不复制;`os doctor` 读运行时同一个目录。 - **为什么改:** `os dev --fresh` 或换一个 `OS_HOME` 的第二个实例,库是新的,却把上一次装过的包又加载回来,测试互相污染。 - **风险与回滚:** 不设 `OS_HOME` 的 `os dev`、项目模式的 `os start`(`OS_HOME=CWD/.objectstack`)路径不变;设了别的 `OS_HOME` 的部署首次启动会复制旧账本并打一行警告。复制失败打 error,下次启动重试,旧文件始终不动。回滚即撤 PR。 - **先看:** `packages/cloud-connection/src/local-manifest-source.ts`(`resolveInstalledPackagesDir`、`adoptLegacyEntries`),`marketplace-install-local-plugin.ts` 的 `adoptLegacyLedger`,`packages/cli/src/commands/doctor.ts` 的 `readInstalledPackageEntries`。 - **两分钟验证:** `pnpm --filter @objectstack/cloud-connection exec vitest run src/marketplace-install-local-storage-dir.test.ts`,预期 10 个测试全过(含 `--fresh` 不复制、设 `OS_HOME` 复制一次、卸载后不复活)。 - **需要你做的:** 无,席位落地。 ## What changes **`@objectstack/cloud-connection`** - `resolveInstalledPackagesDir({ env?, cwd? })` is the one answer to where the ledger lives: `$OS_HOME/installed-packages` when `OS_HOME` is set (a leading `~` expanded the way the home resolver does), `CWD/.objectstack/installed-packages` when it is not. It also returns `legacyDir`: the working-directory ledger, whenever the answer moved away from it, else `null`. - Unset stays in the project on purpose. `os dev` without `OS_HOME` keeps its database in the project, and the ledger has to sit beside the database whose sample-data rows its entries describe; `~/.objectstack` would make one ledger serve every project on the machine. `os start` in project mode sets `OS_HOME` to `CWD/.objectstack`, so its ledger is the same directory as before. - `LocalManifestSource()` with no `storageDir` uses that answer and keeps `legacyDir`; an explicit `storageDir` adopts nothing. - `LocalManifestSource.adoptLegacyEntries()` copies the legacy directory's `.json` files byte for byte into the ledger directory, once: only while the ledger directory does not exist yet. The copy goes to a sibling staging directory that is renamed into place, so a failure leaves no half-made ledger and the next boot copies again. The legacy files are left in place, so an `OS_HOME` pointed at a throwaway directory never takes a project's installs with it, and they are not read again, so an uninstall in the new place sticks. `list()` and `read()` never adopt: a reader such as `os doctor` must not write. - `MarketplaceInstallLocalPlugin.rehydrate` calls it before the ledger read. A copy is a `warn` naming both directories and asking for the old one to be removed; a failed copy is an `error` (the old ledger lists packages this runtime does not load, and the boot looks normal otherwise), naming both directories and the fix. - Under `os dev --fresh` nothing is adopted: the dev parent names its ephemeral home on the internal channel `OS_INTERNAL_FRESH_HOME`, and the resolver skips the adoption only when that value is the current `OS_HOME`, so an inherited copy speaks for no other run. The channel is between `os dev` and its serve child only; it is not an operator setting and is not documented as one. - Only the constructor/`storageDir` doc and the adoption before `rehydrate`'s read change in `marketplace-install-local-plugin.ts`; `runInlineSeed` and the install/uninstall doors are untouched. **`@objectstack/cli`** - `os dev` sets `OS_INTERNAL_FRESH_HOME` on its serve child under `--fresh` and removes an inherited copy otherwise (`applyFreshHomeChannel`). - `os doctor` takes the ledger directory from `resolveInstalledPackagesDir` (under its `.env*` overlay, as `os serve` loads that cascade before the ledger resolves `OS_HOME`), instead of joining `cwd` with `DEFAULT_INSTALLED_PACKAGES_DIR`. Without this, with `OS_HOME` set, doctor and the runtime would read two directories and neither would say so. `OS_HOME` joins `DOCTOR_ENV_INPUTS`, so its provenance is reported. A `@objectstack/cloud-connection` without the resolver is the existing "does not declare the ledger directory" row, now naming `resolveInstalledPackagesDir`. - `serve.ts` is unchanged: its two `new MarketplaceInstallLocalPlugin(...)` sites pass no `storageDir`, and the default now resolves the right directory. **The widening.** New exports `resolveInstalledPackagesDir`, `INSTALLED_PACKAGES_HOME_SUBDIR`, `FRESH_HOME_CHANNEL_ENV` and the types `InstalledPackagesLocation` / `LegacyLedgerAdoption`; new members `LocalManifestSource.legacyDir` and `adoptLegacyEntries()`. Nothing is removed or narrowed; nothing an author writes changes. ## Tests - `local-manifest-source.test.ts`: two new describes. The resolver's table (unset and blank, set, `~`, project-mode `os start`, the fresh channel for its own home and for another), the default constructor; `adoptLegacyEntries` copies `.json` byte for byte (an unreadable entry too, reported by `list()` from its new place), leaves the legacy files, leaves no staging directory, adopts nothing once the directory exists, answers `null` without touching the disk, and on a failed copy throws and leaves no ledger directory. - `marketplace-install-local-storage-dir.test.ts`: a new describe booting the plugin with no `storageDir` — installs land under `$OS_HOME/installed-packages`; a second instance under another `OS_HOME` does not rehydrate the first one's install; the cwd ledger is adopted once and rehydrated, the old file kept, and an uninstall in the new place is not undone on the next boot; a fresh home adopts nothing; a fresh channel naming another home does not stop the adoption; with `OS_HOME` unset the cwd ledger is read with no adoption; a failed copy logs the error naming both directories. The existing cwd-default case now pins `OS_HOME` unset. - `doctor-ledger-read-failure.test.ts`: with `OS_HOME` set doctor reports the entry under `$OS_HOME/installed-packages` and nothing from a stale cwd ledger; the `#6643` case computes its expectation from the resolver. - `doctor-ledger-dir-authority.test.ts`: the authority is the resolver function (`not a function`). - `dev-fresh-home-channel.test.ts` (new): the CLI's spelling equals the package's; set under `--fresh`, removed otherwise. ## Acceptance notes - **A brand-new non-fresh home adopts the pre-upgrade ledger once.** Two instances started under two new `OS_HOME`s (neither `--fresh`) after this upgrade each copy the working-directory entries that existed before it. Installs made after the upgrade never leak; the old entries stop leaking once the old directory is removed, which the boot warning asks for. - **Copy, not move.** A move would converge sooner, but any `OS_HOME` pointed at a temporary directory (a test harness, a one-off run) would then take the project's installs into a directory that gets deleted. - **`os doctor` before the first boot.** With `OS_HOME` set and the new directory not created yet, doctor reads the new (absent) directory and reports nothing installed; the next boot adopts the old entries. Doctor does not write, by design. ## Verification Real boots of `examples/app-todo` through `os dev --no-watch --log-level info` with one pre-seeded working-directory ledger entry (`com.probe.ledger`, standing for an earlier install), reading the plugin's boot lines and the ledger directories afterwards. Before = `origin/main` sources of the five changed source files restored and both packages rebuilt; after = this branch; ablation = this branch with the fresh-channel return in `resolveInstalledPackagesDir` disabled through `scripts/ablation-replace.mjs`, `ablation-dist-preflight.mjs` confirming the marker in 2 built files, then restored (blob equal to HEAD, `git diff HEAD` empty, `--absent` over 6 built files). <details><summary>Before / after / ablation, three scenarios</summary> | Scenario | Before (`main`) | After (this PR) | Ablation (fresh guard off) | |---|---|---|---| | `OS_HOME` unset | `rehydrated com.probe.ledger@1.0.0`, storage `CWD/.objectstack/installed-packages` | same | same | | `os dev --fresh` | `rehydrated com.probe.ledger@1.0.0`, storage `CWD/.objectstack/installed-packages` — the defect | no rehydrate, storage `/tmp/objectstack-dev-…/installed-packages`, cwd ledger untouched | `copied 1 installed package(s) … into /tmp/objectstack-dev-…/installed-packages`, `rehydrated com.probe.ledger@1.0.0` — the defect is back | | `OS_HOME=/tmp/os30217-home-…` | `rehydrated`, storage `CWD/…`, nothing under the home | `copied 1 installed package(s) from CWD/… into HOME_DIR/installed-packages`, `rehydrated`, storage `HOME_DIR/installed-packages`; cwd file kept | same as after | </details> Tests and gates, on head `0947c7af` (after merging `origin/main` at `c1240a38`; the closure rebuilt after the merge). The final head `299ca6dd` changes only the changeset's wording (path spellings without angle brackets); the nine changeset- and text-reading families were re-run there, all exit 0: `check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`, `check:changeset-gate-self-tests`, `check:doc-authoring`, `check:issue-citations` (both forms), `check:nul-bytes`, `check-closing-keyword-parity`. - `pnpm --filter @objectstack/cloud-connection test`: Test Files 70 passed (70), Tests 863 passed (863); `typecheck` (both programs) exit 0. `marketplace-install-local-storage-dir.test.ts` re-run after the last commit: 10/10. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: 382 of 385 files passed on the first head; the two `published-subpath-*` pins failed only because `packages/cli` was not built yet (their own preflight says so) and pass after the build, and `doctor-env-provenance` named `OS_HOME` as an undeclared doctor input, which the second commit declares. Re-run after the fixes: every `doctor*` file, 14 files / 212 tests, plus `dev-fresh-home-channel` and both `published-subpath` pins, all passed; `pnpm --filter @objectstack/cli typecheck` exit 0. The `integration` tier is CI's. - `eslint --no-inline-config` over the ten changed source and test files: 0 problems. - `node scripts/pm/dispatch-gates.mjs --commands` derives 63 commands; each was run with its exit code captured before any pipe, and `--ran` reconciles 63 derived, 61 run, 2 NOT MEASURED, 0 unrun. <details><summary>The 63 derived commands and their exit codes</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 3 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:i18n-coverage :: exit 3 pnpm check:i18n-walk-parity :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:route-envelope :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` NOT MEASURED, both prerequisite refusals (exit 3), left to CI's `Lint & Repo Gates`: `check:dual-build-cjs-loads` (packages outside this card's closure have no `dist/` in this worktree, e.g. `@objectstack/studio`, `@objectstack/client-react`) and `check:i18n-coverage` (`examples/app-showcase`'s `@objectstack/connector-slack` has no build output here). </details> Dogfood: n/a — no `!` in the title and no `(narrowing)` arm. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CY4LWqfjLCYqpZhNCs --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CY4LWqfjLCYqpZhNCs)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fdad178 commit bc254be

13 files changed

Lines changed: 651 additions & 72 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/cloud-connection': patch
3+
'@objectstack/cli': patch
4+
---
5+
6+
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
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
- **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`.
12+
- **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.
13+
- **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.
14+
- **`os doctor`** reads the same directory the runtime does, `OS_HOME` included.
15+
- **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.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { FRESH_HOME_CHANNEL_ENV as CONSUMER_SPELLING } from '@objectstack/cloud-connection';
5+
import { applyFreshHomeChannel, FRESH_HOME_CHANNEL_ENV } from './dev.js';
6+
7+
/**
8+
* #30217 — `os dev --fresh` tells its `serve` child which `OS_HOME` is the
9+
* run's ephemeral one, so the install ledger there adopts nothing from the
10+
* project's working-directory ledger and the run starts without an earlier
11+
* run's installs. The parent owns the variable: without `--fresh` it is
12+
* removed, so an inherited copy never speaks for this run.
13+
*/
14+
describe('#30217 `os dev` owns the fresh-home channel on its serve child', () => {
15+
it('is spelled exactly as @objectstack/cloud-connection reads it', () => {
16+
expect(FRESH_HOME_CHANNEL_ENV).toBe(CONSUMER_SPELLING);
17+
});
18+
19+
it('names the ephemeral home under --fresh', () => {
20+
const env: NodeJS.ProcessEnv = { OS_HOME: '/tmp/objectstack-dev-abc' };
21+
applyFreshHomeChannel(env, '/tmp/objectstack-dev-abc');
22+
expect(env[FRESH_HOME_CHANNEL_ENV]).toBe('/tmp/objectstack-dev-abc');
23+
});
24+
25+
it('removes an inherited copy when the run is not --fresh', () => {
26+
const env: NodeJS.ProcessEnv = { [FRESH_HOME_CHANNEL_ENV]: '/tmp/objectstack-dev-old' };
27+
applyFreshHomeChannel(env, undefined);
28+
expect(FRESH_HOME_CHANNEL_ENV in env).toBe(false);
29+
});
30+
});

‎packages/cli/src/commands/dev.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,28 @@ export function forwardSeedSettledToParent(msg: unknown): boolean {
251251
return true;
252252
}
253253

254+
/**
255+
* The internal channel `@objectstack/cloud-connection` reads as
256+
* `FRESH_HOME_CHANNEL_ENV` (#30217): the ephemeral `OS_HOME` a `--fresh` run
257+
* created. Spelled here rather than imported because oclif loads every command
258+
* module on every invocation; `dev-fresh-home-channel.test.ts` holds the two
259+
* spellings equal.
260+
*/
261+
export const FRESH_HOME_CHANNEL_ENV = 'OS_INTERNAL_FRESH_HOME';
262+
263+
/**
264+
* Set or clear {@link FRESH_HOME_CHANNEL_ENV} on the serve child's env
265+
* (#30217). Under `--fresh` it names the run's ephemeral `OS_HOME`, so the
266+
* install ledger there adopts nothing from the project's working-directory
267+
* ledger — a fresh run starts without an earlier run's installs. Otherwise it is
268+
* removed: the parent owns the variable, so an inherited copy never speaks for
269+
* a home this run did not create. Not an operator setting.
270+
*/
271+
export function applyFreshHomeChannel(childEnv: NodeJS.ProcessEnv, freshHome: string | undefined): void {
272+
if (freshHome) childEnv[FRESH_HOME_CHANNEL_ENV] = freshHome;
273+
else delete childEnv[FRESH_HOME_CHANNEL_ENV];
274+
}
275+
254276
/**
255277
* Whether this `os dev` boot runs the watch-recompile loop (#20681).
256278
*
@@ -676,6 +698,9 @@ export default class Dev extends Command {
676698
// (OS_HOME → <home>/data/objectstack.db, published as OS_DATABASE_URL),
677699
// - the storage-service uploads root, published on the settings
678700
// service's own env name OS_STORAGE_LOCAL_ROOT (#4968),
701+
// - the install-local ledger, which follows OS_HOME (#30217); the
702+
// child is told this home is ephemeral (applyFreshHomeChannel), so
703+
// it adopts nothing from the project's working-directory ledger,
679704
// - any other state a plugin keys off OS_HOME.
680705
// In one sentence: framework-owned, OS_HOME-keyed state plus the env
681706
// channels this block publishes below. Auto-deleted on exit.
@@ -810,6 +835,7 @@ export default class Dev extends Command {
810835
...(flags['database-auth-token'] ? { OS_DATABASE_AUTH_TOKEN: flags['database-auth-token'] } : {}),
811836
...(flags['auth-secret'] ? { OS_AUTH_SECRET: flags['auth-secret'] } : {}),
812837
};
838+
applyFreshHomeChannel(localEnv, freshHome);
813839
// The data key's file: the ONE answer `os start` takes too (#29934), so a
814840
// secret saved here reads back under `os start` over the same project —
815841
// and the file an earlier `os start` kept in the project is still read.

‎packages/cli/src/commands/doctor-ledger-dir-authority.test.ts‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
*
66
* ── What this file pins, honestly ────────────────────────────────────────
77
*
8-
* Not a live defect. `DEFAULT_INSTALLED_PACKAGES_DIR` is an export of
8+
* Not a live defect. The directory authority (`DEFAULT_INSTALLED_PACKAGES_DIR`,
9+
* `resolveInstalledPackagesDir` since #30217) is an export of
910
* `@objectstack/cloud-connection` in every version ever shipped, so the `??`
1011
* fallback this change deletes had never fired. What is being hardened is a
1112
* DIAGNOSIS BOUNDARY — the last cell of the edge #5644 carved. That issue
@@ -114,10 +115,10 @@ describe('installedPackageLedgerDirAuthorityMissingCheck — loaded but unrecogn
114115
expect(check.message).not.toContain('\n');
115116
});
116117

117-
it('names the received value when the export exists but is not a string', () => {
118+
it('names the received value when the export exists but is not a function', () => {
118119
const check = installedPackageLedgerDirAuthorityMissingCheck(42);
119120

120-
expect(check.message).toContain('not a string');
121+
expect(check.message).toContain('not a function');
121122
expect(check.message).toContain('got number: 42');
122123
});
123124

@@ -136,7 +137,7 @@ describe('installedPackageLedgerDirAuthorityMissingCheck — loaded but unrecogn
136137
// it from the #5644 row one cell over. Without both, the reader's first
137138
// move would be to rebuild a package that builds and loads fine.
138139
expect(fix).toContain('IS installed here and it DID load');
139-
expect(fix).toContain('authority on what the ledger directory is called');
140+
expect(fix).toContain('authority on where the ledger directory is');
140141
// And it names the remedy: the two ends of this contract disagree.
141142
expect(fix).toContain('Align their versions');
142143
});
@@ -145,7 +146,7 @@ describe('installedPackageLedgerDirAuthorityMissingCheck — loaded but unrecogn
145146
const check = installedPackageLedgerDirAuthorityMissingCheck(undefined);
146147

147148
// The one name doctor had for the ledger is the export that came back
148-
// non-string. A path in the row — the old `??` guess included — would be
149+
// as no function. A path in the row — the old `??` guess included — would be
149150
// doctor asserting what it cannot know (#5644's option-B rejection, one
150151
// cell over).
151152
expect(check.message).not.toContain('.objectstack');
@@ -182,7 +183,7 @@ describe('installedPackageLedgerSkippedEntriesCheck — names the directory it a
182183
*
183184
* Simulated through the loader seam, like the #5644 cause case next door: the
184185
* mock hands doctor a module that LOADED (`state: 'loaded'`) and carries no
185-
* `DEFAULT_INSTALLED_PACKAGES_DIR`. No preflight (`assertLedgerReaderIsBuilt`)
186+
* `resolveInstalledPackagesDir`. No preflight (`assertLedgerReaderIsBuilt`)
186187
* in this describe, and that is not a rollback of #5612: nothing here reads
187188
* the real package at all — the seam is mocked — so there is no accident for
188189
* a preflight to catch.
@@ -271,7 +272,7 @@ describe('os doctor, end to end, against a reader that declares no ledger direct
271272
it('reports the named row and reads NO directory — guessed or otherwise', async () => {
272273
const ctorCalls: string[] = [];
273274
const fakeModule = {
274-
// Deliberately NO `DEFAULT_INSTALLED_PACKAGES_DIR` — the state under test.
275+
// Deliberately NO `resolveInstalledPackagesDir` — the state under test.
275276
LocalManifestSource: class {
276277
constructor(dir: string) {
277278
ctorCalls.push(dir);
@@ -312,13 +313,13 @@ describe('os doctor, end to end, against a reader that declares no ledger direct
312313
expect(verboseRun.exitCode).toBeUndefined();
313314
}, 120_000);
314315

315-
it('a non-string export is the same fact — never a path.join over it', async () => {
316+
it('a non-function export is the same fact — never a call of it', async () => {
316317
const ctorCalls: string[] = [];
317318
const fakeModule = {
318-
// Present, and not a string: the shape whose `path.join` throw the old
319-
// code mis-reported through the config `catch` (see the function's
320-
// header in doctor.ts).
321-
DEFAULT_INSTALLED_PACKAGES_DIR: 42,
319+
// Present, and not a function: the shape a bare call would throw on, which
320+
// the old `path.join` over the string authority mis-reported through the
321+
// config `catch` (see the function's header in doctor.ts).
322+
resolveInstalledPackagesDir: 42,
322323
LocalManifestSource: class {
323324
constructor(dir: string) {
324325
ctorCalls.push(dir);
@@ -333,7 +334,7 @@ describe('os doctor, end to end, against a reader that declares no ledger direct
333334

334335
expect(ctorCalls).toHaveLength(0);
335336
expect(run.out).toContain(AUTHORITY_HEADLINE);
336-
expect(run.out).toContain('not a string');
337+
expect(run.out).toContain('not a function');
337338
expect(run.out).toContain('got number: 42');
338339
expect(run.out).not.toContain(CLEAN_BILL);
339340
expect(run.out).not.toContain('invoice.code');

‎packages/cli/src/commands/doctor-ledger-read-failure.test.ts‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,10 +479,10 @@ describe('os doctor, end to end, against an unreadable installed-package ledger'
479479
// installed-packages` here would move the literal into the test and pin
480480
// the row against the guess a second time — the defect, relocated.
481481
// Re-imported rather than read off `LEDGER_REL` for the same reason.
482-
const { DEFAULT_INSTALLED_PACKAGES_DIR } = await import('@objectstack/cloud-connection');
482+
const { resolveInstalledPackagesDir } = await import('@objectstack/cloud-connection');
483483
// `mkdtemp` makes this unique per run, which is what makes "the resolved
484484
// dir, not the literal" assertable at all: no literal can contain it.
485-
const resolved = path.join(tmp, DEFAULT_INSTALLED_PACKAGES_DIR);
485+
const resolved = resolveInstalledPackagesDir({ cwd: tmp }).dir;
486486

487487
const run = await runDoctor(['--verbose']);
488488

@@ -673,6 +673,34 @@ describe('os doctor, end to end, against an unreadable installed-package ledger'
673673
expect(verboseRun.out).toContain('Repair the JSON, or delete the file');
674674
expect(verboseRun.exitCode).toBeUndefined();
675675
}, 60_000);
676+
677+
it('reads the ledger under OS_HOME when OS_HOME is set — the directory the runtime reads (#30217)', async () => {
678+
// The runtime's ledger follows OS_HOME. Doctor reading `<cwd>/.objectstack/
679+
// installed-packages` here would report on a directory the runtime no
680+
// longer reads, and stay silent about the one it does.
681+
writeConfig();
682+
const home = path.join(tmp, 'home');
683+
const homeLedger = path.join(home, 'installed-packages');
684+
fs.mkdirSync(homeLedger, { recursive: true });
685+
fs.writeFileSync(path.join(homeLedger, 'billing.json'), JSON.stringify(globalUniqueEntry('billing')));
686+
// A stale working-directory ledger beside it: not what the runtime reads.
687+
fs.mkdirSync(ledgerPath(), { recursive: true });
688+
fs.writeFileSync(path.join(ledgerPath(), 'stale.json'), '{"manifestId":"stale"');
689+
690+
const savedHome = process.env.OS_HOME;
691+
process.env.OS_HOME = home;
692+
let run: { out: string; exitCode: number | undefined };
693+
try {
694+
run = await runDoctor(['--verbose']);
695+
} finally {
696+
if (savedHome === undefined) delete process.env.OS_HOME;
697+
else process.env.OS_HOME = savedHome;
698+
}
699+
700+
expect(run.out).toContain("installed package 'billing'");
701+
expect(run.out).not.toContain('stale.json');
702+
expect(run.out).not.toContain(SKIPPED_HEADLINE);
703+
}, 60_000);
676704
});
677705

678706
/**

0 commit comments

Comments
 (0)