From e3b472904187c0789e16a90656d41c5fb3c6c561 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 19:53:45 +0000 Subject: [PATCH] =?UTF-8?q?fix(cli):=20=E6=8A=8A=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=AD=98=E5=82=A8=E6=A0=B9=E5=86=99=E6=88=90=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E8=AF=BB=E7=9A=84=E9=82=A3=E4=B8=AA=20env=20?= =?UTF-8?q?=E5=90=8D=20(#4968)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI 与设置服务对同一个值用了两个拼写。CLI 自造了 `OS_STORAGE_ROOT`; 设置服务从它自己拥有的命名空间派生 env 名 —— `envKeyOf('storage','local_root')` = `OS_STORAGE_LOCAL_ROOT` —— 而全仓 没有任何地方设置过它。于是两条通道从未相遇:`os serve` 按运维给的根构造 了本地 adapter,`StorageServicePlugin` 在 `kernel:ready` 从 settings 重新 解析,只看到 manifest 的 schema 默认值,就把 adapter 换成了 `./.objectstack/data/uploads`。 所以 `OS_STORAGE_ROOT` 只对一个值生效 —— 恰好等于该默认值的那个,这正是 普通 `pnpm dev` 从没暴露它的原因。其余任何值都是构造完就被丢弃:生产 `/srv/uploads` 被忽略、运维按 backup-restore.mdx 备份到空目录;`dev --fresh` 承诺 tempdir 独占本次运行的全部状态,上传实际落在项目 cwd 且退出后不清理; 每次干净启动都响一条数据丢失级 swap 警告 —— 那条警告是**准确的**,swap 真的发生了,本 commit 不动它,它随 swap 消失而不再响。 修在生产者侧,不在消费者侧加容忍读:`dev.ts` 发布 `OS_STORAGE_LOCAL_ROOT`, `serve.ts` 经单一通道 `resolveStorageLocalRootEnv` 解析根,并与 `os migrate` 的 storage 引导共用,使 CLI 落字节的位置与 server 完全一致。 `OS_STORAGE_ROOT` 经 `readEnvWithDeprecation('OS_STORAGE_LOCAL_ROOT', 'OS_STORAGE_ROOT')` 保留一个 release,每进程 warn 一次,随后移除。旧名供值 时同时回写到新名 —— 设置服务只查 `OS_STORAGE_LOCAL_ROOT`,没有这一步,旧名 部署会原样保留本单要修的缺陷。 不动 `packages/services/service-storage`:swap 谓词是对的(#4096 已修正), 消费缝归 #5536。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh --- .changeset/storage-local-root-env-channel.md | 44 +++++++ content/docs/deployment/backup-restore.mdx | 20 ++- .../docs/deployment/environment-variables.mdx | 3 +- packages/cli/src/commands/dev.ts | 12 +- .../commands/serve-storage-capability.test.ts | 114 +++++++++++++++++- packages/cli/src/commands/serve.ts | 64 +++++++++- .../cli/src/utils/data-migration-plugins.ts | 11 +- 7 files changed, 253 insertions(+), 15 deletions(-) create mode 100644 .changeset/storage-local-root-env-channel.md diff --git a/.changeset/storage-local-root-env-channel.md b/.changeset/storage-local-root-env-channel.md new file mode 100644 index 0000000000..b15ca26ef5 --- /dev/null +++ b/.changeset/storage-local-root-env-channel.md @@ -0,0 +1,44 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): `OS_STORAGE_ROOT` now actually takes effect — renamed to `OS_STORAGE_LOCAL_ROOT`, the name the settings service reads (#4968) + +The CLI and the settings service spelled the local storage root differently. +The CLI wrote its own invented name, `OS_STORAGE_ROOT`; the settings service +derives the env name for the same value from the namespace it owns — +`envKeyOf('storage', 'local_root')` = `OS_STORAGE_LOCAL_ROOT` — and nothing in +the repo ever set that. So the two channels never met: `os serve` constructed a +local adapter at the root the operator named, `StorageServicePlugin` re-resolved +from settings at `kernel:ready`, found only the manifest's **schema default**, +and swapped the adapter to `./.objectstack/data/uploads`. + +`OS_STORAGE_ROOT` therefore took effect for exactly one value — the one that +happens to equal that default — which is why plain `pnpm dev` never showed it. +Every other value was constructed and then discarded: + +- **Production**: `OS_STORAGE_ROOT=/srv/uploads` was ignored and uploads landed + under the process cwd. An operator following `backup-restore.mdx` backed up an + empty directory. +- **`dev --fresh`**: the tempdir was documented to own all state for the run; + uploads actually went to the project cwd and survived process exit. +- Every clean boot logged a data-loss-grade "adapter swapped … existing files + were NOT migrated" warning. That warning was **accurate** — the swap really + happened — and is untouched here. It stops firing because the swap stops. + +The fix is at the producer, not as a tolerant read in the consumer: `dev.ts` +publishes `OS_STORAGE_LOCAL_ROOT`, and `serve.ts` resolves the root through one +channel (`resolveStorageLocalRootEnv`), shared with `os migrate`'s storage +bootstrap so the CLI materialises bytes exactly where the server would. + +`OS_STORAGE_ROOT` keeps working for **one release** via +`readEnvWithDeprecation('OS_STORAGE_LOCAL_ROOT', 'OS_STORAGE_ROOT')`, warning +once per process, and is then removed. When the legacy name supplies the value +it is also stamped onto the canonical name, because the settings service only +ever looks up `OS_STORAGE_LOCAL_ROOT` — without the stamp a deployment on the +old spelling would keep the original defect in full. + +Storage settings now resolve `source: 'env'` at the value the adapter was built +with, so Setup → Settings → File Storage shows the directory actually in use. +No change to `packages/services/service-storage` — the swap predicate is correct +and stays as is. diff --git a/content/docs/deployment/backup-restore.mdx b/content/docs/deployment/backup-restore.mdx index ec2254194b..3b1c0e356e 100644 --- a/content/docs/deployment/backup-restore.mdx +++ b/content/docs/deployment/backup-restore.mdx @@ -76,13 +76,27 @@ Cloud-managed environments — the open framework does not ship that driver.) ## Backing up uploaded files -The local storage adapter keeps uploads under `OS_STORAGE_ROOT` (default +The local storage adapter keeps uploads under `OS_STORAGE_LOCAL_ROOT` (default `./.objectstack/data/uploads`). On single-host deployments, include that directory in the same schedule as the database so records and their attachments restore to the same point in time. Deployments using an external storage service (S3-compatible, etc.) inherit that service's durability and versioning instead. + +**Verify the directory you are backing up actually holds the files.** This +variable was previously named `OS_STORAGE_ROOT`, and on releases before the +rename the CLI and the settings service spelled it differently — so any value +other than the default was discarded at startup and uploads landed in +`./.objectstack/data/uploads` instead of where the variable pointed. A backup +job aimed at the configured path copied an **empty directory**. The old name +still works for one release (with a startup warning), but it now resolves to +the same place the adapter writes. Confirm with **Setup → Settings → File +Storage → Root directory**: it shows the path in effect, and shows it as +locked-by-env when the variable is set. If they disagree, trust the Setup page +and back that path up too. + + ## The restore drill Rehearse this on a scratch host **before** go-live, and again after any @@ -106,8 +120,8 @@ pg_restore --clean --if-exists -d "$OS_DATABASE_URL" backup-2026-07-14.dump ### Provide the original secrets Set `OS_SECRET_KEY` and `OS_AUTH_SECRET` to the **escrowed originals** — not -freshly generated values. Restore `OS_STORAGE_ROOT` contents if you use local -file storage. +freshly generated values. Restore `OS_STORAGE_LOCAL_ROOT` contents if you use +local file storage. ### Boot from the artifact and verify diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index ad567d94bd..d7f846fc4d 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -51,7 +51,8 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false | `OS_DATABASE_DRIVER` | enum | inferred | Force a specific driver when the URL is ambiguous. `memory` \| `sqlite` \| `sqlite-wasm` \| `postgres` \| `mongodb`. | | `OS_DATABASE_SQLITE_JOURNAL_MODE` | enum | `wal` | Journal mode for **file-backed** SQLite. `wal` (default) lets a dev server and CLI commands share one file without blocking each other, and is what makes the `os migrate` occupancy check reliable. Set to `delete` for SQLite's rollback journal — required when the database lives on a **network filesystem** (NFS/SMB), where WAL cannot work. The setting is applied, not merely skipped: `delete` converts a database that already adopted WAL back. Ignored for `:memory:`, for the WASM SQLite driver, and for non-SQLite drivers. A per-datasource `sqliteJournalMode` in driver config outranks it. See [Journal mode](/docs/data-modeling/drivers#journal-mode-wal-and-cross-process-access). | | `OS_ALLOW_DRIVER_CONNECT_FAILURE` | boolean | `false` | Escape hatch for the driver-connect boot guard. By default a data driver that fails to connect at startup **refuses the boot** — a server that cannot reach its database must not report itself started and then fail every request. The same guard covers a **declared datasource** that objects bind to via `datasource: '…'`, or an `external` one with `validation.onMismatch: 'fail'`: those objects have no fallback datasource, so an unconnected one means they are all dead. Set to `1` to boot anyway, in an explicitly degraded state logged loudly at startup. There is **no reconnection**: whatever failed stays dead for the process lifetime and every query and schema sync routed to it fails. | -| `OS_STORAGE_ROOT` | path | `./.objectstack/data/uploads` | Root directory for the local file storage adapter, relative to the process cwd (used by `os serve`'s default `storage` capability wiring). | +| `OS_STORAGE_LOCAL_ROOT` | path | `./.objectstack/data/uploads` | Root directory for the local file storage adapter, relative to the process cwd (used by `os serve`'s default `storage` capability wiring). This is the same value as **Setup → Settings → File Storage → Root directory**; setting it here pins that field (it shows as locked-by-env). Renamed from `OS_STORAGE_ROOT` — see below. | +| `OS_STORAGE_ROOT` | path | — | **Deprecated alias for `OS_STORAGE_LOCAL_ROOT`.** Still read for one release, with a startup warning; it will be removed in a future major. Rename it now. Before the rename the two halves of the platform spelled this value differently — the CLI wrote `OS_STORAGE_ROOT` while the settings service read `OS_STORAGE_LOCAL_ROOT` — so **any value other than the default was silently discarded** at startup and uploads landed in `./.objectstack/data/uploads` regardless. If you set `OS_STORAGE_ROOT` on an older release, check where your uploads actually are before assuming a backup covered them. | | `OS_ARTIFACT_PATH` | path | — | Path or `http(s)://` URL to a compiled `objectstack.json` artifact to boot the kernel from. | --- diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index d8e87573b0..ef84961288 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -182,8 +182,16 @@ export default class Dev extends Command { // ── --fresh: ephemeral OS_HOME under the OS tempdir ───────────── // Creates a unique scratch dir that owns ALL persistent state for // this run: the SQLite DB (via OS_HOME → /data/...), the - // storage-service uploads root (OS_STORAGE_ROOT), and any other + // storage-service uploads root (OS_STORAGE_LOCAL_ROOT), and any other // state plugins keyed off OS_HOME. Auto-deleted on exit. + // + // The uploads root MUST be published under the name the settings + // service derives for it — `envKeyOf('storage','local_root')` (#4968). + // Under the CLI's old private spelling (`OS_STORAGE_ROOT`) the settings + // side saw no env value, fell back to the manifest default, and swapped + // the adapter to `./.objectstack/data/uploads` at kernel:ready — so + // `--fresh` uploads landed in the PROJECT CWD and outlived the run, + // which is the opposite of what this block promises. let freshHome: string | undefined; let freshDbUrl: string | undefined; let freshStorageRoot: string | undefined; @@ -245,7 +253,7 @@ export default class Dev extends Command { ...(seedAdmin && flags['admin-email'] ? { OS_SEED_ADMIN_EMAIL: flags['admin-email'] } : {}), ...(seedAdmin && flags['admin-password'] ? { OS_SEED_ADMIN_PASSWORD: flags['admin-password'] } : {}), ...(freshHome ? { OS_HOME: freshHome } : {}), - ...(freshStorageRoot ? { OS_STORAGE_ROOT: freshStorageRoot } : {}), + ...(freshStorageRoot ? { OS_STORAGE_LOCAL_ROOT: freshStorageRoot } : {}), ...(effectiveDb ? { OS_DATABASE_URL: effectiveDb } : {}), ...(flags['database-driver'] ? { OS_DATABASE_DRIVER: flags['database-driver'] } : {}), ...(flags['database-auth-token'] ? { OS_DATABASE_AUTH_TOKEN: flags['database-auth-token'] } : {}), diff --git a/packages/cli/src/commands/serve-storage-capability.test.ts b/packages/cli/src/commands/serve-storage-capability.test.ts index 57a5e55b4c..387677ff6f 100644 --- a/packages/cli/src/commands/serve-storage-capability.test.ts +++ b/packages/cli/src/commands/serve-storage-capability.test.ts @@ -25,8 +25,9 @@ * write it are now told, by `lintUnknownAuthoringKeys`. */ -import { describe, it, expect } from 'vitest'; -import { resolveStorageCapabilityArg } from './serve.js'; +import { afterEach, describe, it, expect, vi } from 'vitest'; +import { _resetEnvDeprecationWarnings } from '@objectstack/types'; +import { resolveStorageCapabilityArg, resolveStorageLocalRootEnv } from './serve.js'; describe('resolveStorageCapabilityArg', () => { it('builds options StorageServicePlugin actually reads', () => { @@ -45,7 +46,10 @@ describe('resolveStorageCapabilityArg', () => { expect(options).not.toHaveProperty('root'); }); - it('honours OS_STORAGE_ROOT, which the old shape discarded', () => { + // Renamed from "honours OS_STORAGE_ROOT" (#4968): this case never read env, + // it passes the root as an argument. Naming it after a variable it does not + // touch is how the env channel went unexamined while the shape looked pinned. + it('honours an explicit root, which the old shape discarded', () => { const { options, localRoot } = resolveStorageCapabilityArg('/srv/uploads'); expect(options).toEqual({ adapter: 'local', local: { rootDir: '/srv/uploads' } }); expect(localRoot).toBe('/srv/uploads'); @@ -77,3 +81,107 @@ describe('resolveStorageCapabilityArg', () => { }); }); + +/** + * #4968 — the env CHANNEL, which #4096 left split. + * + * #4096 pinned the option shape and the tests above went green, but the value + * still could not reach the settings service: the CLI wrote `OS_STORAGE_ROOT` + * and the settings service reads `envKeyOf('storage','local_root')` = + * `OS_STORAGE_LOCAL_ROOT`, which nothing in the repo ever set. So settings saw + * only the manifest's schema default and swapped the adapter at `kernel:ready` + * — `OS_STORAGE_ROOT` took effect for exactly one value (the one equal to that + * default) and `dev --fresh` wrote uploads into the project cwd. + * + * The stamp assertion is the load-bearing one. Returning the legacy value is + * only half the migration; if the canonical name is not also SET, a deployment + * on the old spelling keeps the original bug in full, silently. + */ +describe('resolveStorageLocalRootEnv (#4968)', () => { + const CANONICAL = 'OS_STORAGE_LOCAL_ROOT'; + const LEGACY = 'OS_STORAGE_ROOT'; + const originalCanonical = process.env[CANONICAL]; + const originalLegacy = process.env[LEGACY]; + + afterEach(() => { + if (originalCanonical === undefined) delete process.env[CANONICAL]; + else process.env[CANONICAL] = originalCanonical; + if (originalLegacy === undefined) delete process.env[LEGACY]; + else process.env[LEGACY] = originalLegacy; + _resetEnvDeprecationWarnings(); + vi.restoreAllMocks(); + }); + + it('reads the canonical name the settings service derives, quietly', () => { + delete process.env[LEGACY]; + process.env[CANONICAL] = '/srv/uploads'; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(resolveStorageLocalRootEnv()).toBe('/srv/uploads'); + expect(warn).not.toHaveBeenCalled(); + // The whole point: this is the name `envKeyOf('storage','local_root')` + // produces, so the settings service resolves source:'env' at this value. + expect(process.env[CANONICAL]).toBe('/srv/uploads'); + }); + + it('still reads the legacy name AND stamps it onto the canonical one', () => { + delete process.env[CANONICAL]; + process.env[LEGACY] = '/srv/legacy-uploads'; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(resolveStorageLocalRootEnv()).toBe('/srv/legacy-uploads'); + // Without this line a legacy deployment keeps the exact defect #4968 + // describes: adapter built at /srv/legacy-uploads, settings still on its + // schema default, adapter swapped away at kernel:ready. + expect(process.env[CANONICAL]).toBe('/srv/legacy-uploads'); + + expect(warn).toHaveBeenCalledTimes(1); + const msg = String(warn.mock.calls[0][0]); + expect(msg).toContain(LEGACY); + expect(msg).toContain(CANONICAL); + expect(msg).toContain('deprecated'); + }); + + it('feeds the capability arg from the legacy name end to end', () => { + delete process.env[CANONICAL]; + process.env[LEGACY] = '/srv/legacy-uploads'; + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const { options, localRoot } = resolveStorageCapabilityArg(resolveStorageLocalRootEnv()); + expect(options).toEqual({ adapter: 'local', local: { rootDir: '/srv/legacy-uploads' } }); + expect(localRoot).toBe('/srv/legacy-uploads'); + // Constructor side and settings side now name the same directory, which is + // what makes `needsStorageSwap` answer false instead of swapping + warning. + expect(process.env[CANONICAL]).toBe(localRoot); + }); + + it('lets the canonical name win when both are set, without warning', () => { + process.env[CANONICAL] = '/srv/canonical'; + process.env[LEGACY] = '/srv/legacy'; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(resolveStorageLocalRootEnv()).toBe('/srv/canonical'); + expect(process.env[CANONICAL]).toBe('/srv/canonical'); + // The stamp must never overwrite an explicitly-set canonical value, and the + // legacy variable is left exactly as the operator wrote it. + expect(process.env[LEGACY]).toBe('/srv/legacy'); + expect(warn).not.toHaveBeenCalled(); + }); + + it('sets nothing when neither name is set, so the default still applies', () => { + delete process.env[CANONICAL]; + delete process.env[LEGACY]; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(resolveStorageLocalRootEnv()).toBeUndefined(); + // Must NOT stamp a default: an env-locked value would show up in Setup as + // locked-by-env and take the root out of the admin's hands for no reason. + expect(process.env[CANONICAL]).toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + + // Unset falls through to the resolver default, which equals the manifest + // default — this is exactly why plain `pnpm dev` never showed the bug. + expect(resolveStorageCapabilityArg(resolveStorageLocalRootEnv()).localRoot) + .toBe('.objectstack/data/uploads'); + }); +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 6bf0e06694..a1a513af90 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -2357,7 +2357,7 @@ export default class Serve extends Command { // In production mode we emit a single loud warning so the // operator knows to point storage at S3 / GCS / Azure before // shipping (data on a single pod is volatile / non-replicated). - const storageArg = resolveStorageCapabilityArg(process.env.OS_STORAGE_ROOT); + const storageArg = resolveStorageCapabilityArg(resolveStorageLocalRootEnv()); arg = storageArg.options; if (storageArg.localRoot && !isDev) { // Names only the channels that actually work — `config.storage` @@ -2933,12 +2933,18 @@ export interface StorageCapabilityArg { * * The fallback used to be `{ driver: 'local', root }` — neither of which * `StorageServicePluginOptions` declares. Both were dropped on the floor, so the - * plugin applied its OWN default (`./storage`), `OS_STORAGE_ROOT` changed + * plugin applied its OWN default (`./storage`), the storage-root env var changed * nothing, and uploads landed somewhere the operator never named. The `storage` * settings namespace then corrected the root on its first read (its manifest * default IS `./.objectstack/data/uploads`), which swapped the adapter and * warned about stranded files — on every boot of a healthy server. * + * #4096 fixed the option SHAPE; the value still could not reach the settings + * side, because the CLI and the settings service spelled the env var + * differently. {@link resolveStorageLocalRootEnv} is the channel that closes + * that gap (#4968) — read the root through it, never off `process.env` + * directly. + * * `config.storage` is deliberately NOT read (framework#4167). It was never a * stack key: `ObjectStackDefinitionSchema` does not declare it, and the schema * is not `.strict()`, so `defineStack` — which every documented authoring path @@ -2960,6 +2966,60 @@ export function resolveStorageCapabilityArg(envRoot?: string): StorageCapability return { options: { adapter: 'local', local: { rootDir } }, localRoot: rootDir }; } +/** + * The ONE env channel for the local storage root (#4968). + * + * The CLI used to invent its own name, `OS_STORAGE_ROOT`, while the settings + * service derives the env name for the same value from the namespace it owns: + * `envKeyOf('storage', 'local_root')` = `OS_STORAGE_LOCAL_ROOT`. Nothing in the + * repo ever set that name, so the two channels never met — the CLI constructed + * an adapter at the root the operator asked for, and `StorageServicePlugin` + * then re-resolved from settings at `kernel:ready`, found nothing but the + * manifest's schema DEFAULT, and swapped the adapter to + * `./.objectstack/data/uploads`. + * + * The consequences were not log noise: + * + * - `OS_STORAGE_ROOT` took effect for exactly one value — the one that happens + * to equal the manifest default. Every other value (`/srv/uploads`, a + * `--fresh` tempdir) was constructed and then discarded, so an operator + * following `backup-restore.mdx` backed up an empty directory. + * - `dev --fresh` promised the tempdir "owns ALL persistent state for this + * run"; uploads actually landed under the project cwd and survived exit. + * - The "adapter swapped … may be unreachable" warning on every clean boot was + * ACCURATE — the swap really happened. It is not touched here, and it stops + * firing because the swap stops happening. + * + * So the fix is at the producer, not in a tolerant consumer: write the name the + * settings service already declares. The legacy name is read for one more + * release via {@link readEnvWithDeprecation} and, when it is the one that + * supplied the value, STAMPED onto the canonical name — the settings service + * reads `process.env` live through its own `env` reference and only ever looks + * up `OS_STORAGE_LOCAL_ROOT`, so without the stamp a legacy deployment would + * keep the exact bug this fixes. With it, settings resolves + * `source: 'env'`/`locked: true` at the value the adapter was built with, + * `needsStorageSwap` answers false, and the two channels agree by construction. + * + * Side-effecting on purpose, and idempotent: `readEnvWithDeprecation` + * deduplicates its warning process-wide, and once stamped the canonical branch + * wins on every later call. + * + * @returns The resolved root, or `undefined` when neither name is set (the + * caller then falls through to `resolveStorageCapabilityArg`'s + * built-in default, which matches the manifest default). + */ +export function resolveStorageLocalRootEnv(): string | undefined { + const value = readEnvWithDeprecation('OS_STORAGE_LOCAL_ROOT', 'OS_STORAGE_ROOT'); + if (value === undefined) return undefined; + // Bridge the legacy spelling onto the canonical one the settings service + // reads. Guarded so we never rewrite a canonical value with itself. + if (typeof process !== 'undefined' && process.env + && process.env.OS_STORAGE_LOCAL_ROOT === undefined) { + process.env.OS_STORAGE_LOCAL_ROOT = value; + } + return value; +} + /** * Constructor options for `EmailServicePlugin`. * diff --git a/packages/cli/src/utils/data-migration-plugins.ts b/packages/cli/src/utils/data-migration-plugins.ts index 3d9ba98b61..ec229bbecc 100644 --- a/packages/cli/src/utils/data-migration-plugins.ts +++ b/packages/cli/src/utils/data-migration-plugins.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { resolveStorageCapabilityArg } from '../commands/serve.js'; +import { resolveStorageCapabilityArg, resolveStorageLocalRootEnv } from '../commands/serve.js'; /** * The plugins a gated data migration boots with. @@ -20,8 +20,11 @@ import { resolveStorageCapabilityArg } from '../commands/serve.js'; * S3-configured deployment's backfill uploads land in S3 rather than on * this machine. * - Storage config through the SAME resolver `os serve` uses - * (`resolveStorageCapabilityArg`), so the CLI materialises bytes exactly - * where the server would. + * (`resolveStorageCapabilityArg`), fed by the SAME env channel + * (`resolveStorageLocalRootEnv`, #4968), so the CLI materialises bytes + * exactly where the server would. Reading `process.env.OS_STORAGE_ROOT` + * here instead would reintroduce the split that made the settings service + * swap the adapter out from under the root the operator named. */ export async function buildDataMigrationPlugins( opts: { storage?: boolean; automation?: boolean } = {}, @@ -53,7 +56,7 @@ export async function buildDataMigrationPlugins( // optional — without it, constructor/env-driven storage config still applies } const { StorageServicePlugin } = await import('@objectstack/service-storage'); - const { options } = resolveStorageCapabilityArg(process.env.OS_STORAGE_ROOT); + const { options } = resolveStorageCapabilityArg(resolveStorageLocalRootEnv()); plugins.push(new StorageServicePlugin({ ...options, registerRoutes: false })); } return plugins;