Skip to content
Merged
12 changes: 12 additions & 0 deletions .changeset/30119-studio-customization-build-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/cli': patch
'create-objectstack': patch
---

feat(cli,create-objectstack): `os build` says when the local dev database holds Studio customizations the artifact leaves behind; the starter's `AGENTS.md` and README say a Studio change lives in the database and name `os package export`

Clause-②: yes (widening) — the exported CompileCommand gains a protected reportsLocalCustomizations flag (default false); nothing removed or narrowed
Metadata-protocol: none

- **`@objectstack/cli`: `os build`** ends, on its text face, with one notice when the SQLite database `os dev` opens for the project (the same resolution, over the project's development `.env*` files) holds items of a `<base>.custom` package: how many, in which pair, the database file, and that `os package export` takes them out while `os dev` runs. A change saved in Studio is such an item (ADR-0146 D2), never part of `src/`, so neither the artifact nor the starter's `Dockerfile` carries it. The read is local and read-only; a database that is not a local SQLite file is not probed and prints nothing, and a file that cannot be read is said to be unchecked, never empty. `os compile`, which `os dev` runs on every boot and rebuild, and `os build --json` print nothing new.
- **`create-objectstack`: the generated `AGENTS.md` and the blank starter's README** say that Studio saves into the environment's database as an item of `<package id>.custom`, that `build` and the `Dockerfile` leave it behind, that app changes are made in `src/`, and how a customization moves as a package (`os login`, `os package export`, then `os package install` on the deployment, in the numbered order).
3 changes: 3 additions & 0 deletions packages/cli/src/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ import Compile from './compile.js';

export default class Build extends Compile {
static override description = 'Compile ObjectStack configuration to JSON artifact (alias for compile)';

/** `os build` is what a deployment runs, so it says what the artifact leaves behind. */
protected override reportsLocalCustomizations = true;
}
24 changes: 24 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import { findExtensionFieldSuffixRefusals } from '../utils/extension-field-suffi
// [#29051] An undeclared top-level flow cycle — the walk `os validate` step
// 3a-vii runs, over the automation engine's own judge.
import { findFlowCycleRefusals } from '../utils/flow-undeclared-cycle.js';
import { localCustomizationNotice, readLocalCustomizations } from '../utils/local-customizations.js';

export default class Compile extends Command {
static override description = 'Compile ObjectStack configuration to JSON artifact';
Expand Down Expand Up @@ -121,6 +122,13 @@ export default class Compile extends Command {
}),
};

/**
* Whether the text face ends with the local-customization notice
* (`utils/local-customizations.ts`). `os build` turns it on; `os compile`,
* which `os dev` runs on every boot and rebuild, keeps it off.
*/
protected reportsLocalCustomizations = false;

async run(): Promise<void> {
const { args, flags } = await this.parse(Compile);
const timer = createTimer();
Expand Down Expand Up @@ -1447,6 +1455,22 @@ export default class Compile extends Command {
}
console.log('');

// A Studio customization in the local dev database is left behind by
// this artifact (ADR-0146 D2: it lives in `<base>.custom`, in the
// database). Said once, on `os build` only — the command a deployment
// runs; `os dev` compiles through `os compile` on every boot and rebuild,
// where it would be noise.
if (this.reportsLocalCustomizations) {
const notice = localCustomizationNotice(
await readLocalCustomizations({ projectRoot: process.cwd(), artifactPath }),
);
if (notice.length > 0) {
printWarning(notice[0]);
for (const line of notice.slice(1)) console.log(chalk.dim(` ${line}`));
console.log('');
}
}

} catch (error: any) {
if (isExitSignal(error)) throw error;
if (flags.json) {
Expand Down
97 changes: 97 additions & 0 deletions packages/cli/src/utils/local-customizations.sqlite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// The integration half of `os build`'s local-customization notice: the read
// against a real SQLite file, the shape `os dev` leaves in
// `.objectstack/data/objectstack.db` (a `sys_metadata` row per stored item,
// keyed by `package_id`). The pure half is `local-customizations.test.ts`.

import { describe, it, expect, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
// Loaded here, at module top, so no clocked test pays the runtime graph's first
// load inside its window: `readLocalCustomizations` imports it lazily.
import '@objectstack/runtime';
import { readCustomizationsFromSqlite, readLocalCustomizations } from './local-customizations.js';

const dirs: string[] = [];
function tempProject(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'os-local-customizations-db-'));
dirs.push(dir);
return dir;
}
afterEach(() => {
for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true });
});

function devDatabase(root: string, rows: Array<[type: string, name: string, packageId: string | null]>): string {
const file = path.join(root, '.objectstack', 'data', 'objectstack.db');
fs.mkdirSync(path.dirname(file), { recursive: true });
const db = new Database(file);
db.exec('CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, type TEXT, name TEXT, package_id TEXT, state TEXT)');
const insert = db.prepare('INSERT INTO sys_metadata (id, type, name, package_id, state) VALUES (?, ?, ?, ?, ?)');
rows.forEach(([type, name, packageId], i) => insert.run(`m${i}`, type, name, packageId, 'active'));
db.close();
return file;
}

describe('reading the stored `.custom` items of the dev database', () => {
it('counts the items of each `.custom` package, and nothing else', async () => {
const root = tempProject();
const file = devDatabase(root, [
['view', 'asset_list', 'com.example.asset-repair.custom'],
['dashboard', 'ops', 'com.example.asset-repair.custom'],
['view', 'lead_list', 'com.vendor.crm.custom'],
['view', 'authored', 'com.example.studio-app'],
['view', 'packageless', null],
// A package whose id merely contains the suffix is not a pair.
['view', 'decoy', 'com.custom.tools'],
// The suffix ends the id; `.customs` is another package.
['view', 'decoy2', 'com.example.customs'],
]);

const reading = await readLocalCustomizations({ projectRoot: root, env: {} });
expect(reading).toEqual({
status: 'found',
filename: file,
packages: [
{ id: 'com.example.asset-repair.custom', items: 2 },
{ id: 'com.vendor.crm.custom', items: 1 },
],
});
});

it('answers none for a database with no `.custom` item, or not yet booted into tables', async () => {
const root = tempProject();
const file = devDatabase(root, [['view', 'authored', 'com.example.studio-app']]);
expect(await readCustomizationsFromSqlite(file)).toEqual({ status: 'none', filename: file });

const empty = path.join(tempProject(), 'empty.db');
new Database(empty).close();
expect(await readCustomizationsFromSqlite(empty)).toEqual({ status: 'none', filename: empty });
});

it('opens the file read-only: it is byte-identical after the read', async () => {
const root = tempProject();
const file = devDatabase(root, [['view', 'asset_list', 'com.example.app.custom']]);
const before = fs.readFileSync(file);
await readCustomizationsFromSqlite(file);
expect(fs.readFileSync(file).equals(before)).toBe(true);
});

it('answers unknown, with the reason, for a file that is not a database', async () => {
const file = path.join(tempProject(), 'objectstack.db');
fs.writeFileSync(file, 'not a sqlite database, just text that is long enough to have a header'.repeat(10));
const reading = await readCustomizationsFromSqlite(file);
expect(reading.status).toBe('unknown');
expect(reading).toMatchObject({ filename: file });
});

it('follows OS_DATABASE_URL to the file `os dev` would open', async () => {
const root = tempProject();
const elsewhere = devDatabase(tempProject(), [['view', 'v', 'com.example.app.custom']]);
const reading = await readLocalCustomizations({ projectRoot: root, env: { OS_DATABASE_URL: `file:${elsewhere}` } });
expect(reading).toMatchObject({ status: 'found', filename: elsewhere });
});
});
111 changes: 111 additions & 0 deletions packages/cli/src/utils/local-customizations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// The pure half of `os build`'s local-customization notice: which database it
// reads, which file a URL names, what it says for each reading, and that only
// `os build` says it. Reading a real SQLite file is the integration half
// (`local-customizations.sqlite.test.ts`).

import { describe, it, expect, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { sqliteFilenameFromUrl } from '@objectstack/runtime';
import {
devDatabaseEnv,
localSqliteFile,
localCustomizationNotice,
readCustomizationsFromSqlite,
readLocalCustomizations,
} from './local-customizations.js';
import Build from '../commands/build.js';
import Compile from '../commands/compile.js';

const dirs: string[] = [];
function tempProject(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'os-local-customizations-'));
dirs.push(dir);
return dir;
}
afterEach(() => {
for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true });
});

describe('the database the notice reads is the one `os dev` opens', () => {
it('layers the development .env files under the process environment, without loading them', () => {
const root = tempProject();
fs.writeFileSync(path.join(root, '.env'), 'OS_DATABASE_URL=file:./from-env-file.db\nOS_ONLY_IN_FILE=1\n');
fs.writeFileSync(path.join(root, '.env.development'), 'OS_DATABASE_URL=file:./from-development.db\n');

const fromFiles = devDatabaseEnv(root, {});
expect(fromFiles.OS_DATABASE_URL).toBe('file:./from-development.db');
expect(fromFiles.OS_ONLY_IN_FILE).toBe('1');

// dotenv-flow never overrides a variable the process already has.
expect(devDatabaseEnv(root, { OS_DATABASE_URL: 'file:/from-shell.db' }).OS_DATABASE_URL).toBe('file:/from-shell.db');
expect(process.env.OS_ONLY_IN_FILE).toBeUndefined();
});

it('names a file only for a local SQLite URL', () => {
expect(localSqliteFile('file:/tmp/x/objectstack.db', sqliteFilenameFromUrl)).toBe('/tmp/x/objectstack.db');
expect(localSqliteFile('wasm-sqlite:///tmp/x/objectstack.db', sqliteFilenameFromUrl)).toBe('/tmp/x/objectstack.db');
expect(localSqliteFile('file::memory:', sqliteFilenameFromUrl)).toBeNull();
expect(localSqliteFile(':memory:', sqliteFilenameFromUrl)).toBeNull();
expect(localSqliteFile('postgres://u:p@db:5432/app', sqliteFilenameFromUrl)).toBeNull();
expect(localSqliteFile('libsql://app.turso.io', sqliteFilenameFromUrl)).toBeNull();
});

it('reads nothing when the resolved database is remote or not created yet', async () => {
const root = tempProject();
expect(await readLocalCustomizations({ projectRoot: root, env: { OS_DATABASE_URL: 'postgres://u:p@db:5432/app' } }))
.toEqual({ status: 'not_applicable' });
// The unified default `<root>/.objectstack/data/objectstack.db`, never booted.
expect(await readLocalCustomizations({ projectRoot: root, env: {} })).toEqual({ status: 'not_applicable' });
expect(await readCustomizationsFromSqlite(path.join(root, 'absent.db'))).toEqual({ status: 'not_applicable' });
});

it('reads nothing, and never throws, for a selection the dev boot refuses', async () => {
const root = tempProject();
expect(await readLocalCustomizations({ projectRoot: root, env: { OS_DATABASE_DRIVER: 'memory' } }))
.toEqual({ status: 'not_applicable' });
});
});

describe('what the notice says', () => {
it('names each pair, the count, the file and `os package export`', () => {
const lines = localCustomizationNotice({
status: 'found',
filename: '/p/.objectstack/data/objectstack.db',
packages: [
{ id: 'com.example.app.custom', items: 2 },
{ id: 'com.vendor.crm.custom', items: 1 },
],
});
const text = lines.join('\n');
expect(text).toContain('3 Studio customizations');
expect(text).toContain('com.example.app.custom (2)');
expect(text).toContain('com.vendor.crm.custom (1)');
expect(text).toContain('/p/.objectstack/data/objectstack.db');
expect(text).toContain('os package export');
});

it('says it could not check, never that there is nothing, when the read failed', () => {
const text = localCustomizationNotice({ status: 'unknown', filename: '/p/x.db', detail: 'file is not a database' }).join('\n');
expect(text).toContain('Could not check /p/x.db');
expect(text).toContain('file is not a database');
expect(text).toContain('os package export');
});

it('says nothing when there is nothing to leave behind', () => {
expect(localCustomizationNotice({ status: 'none', filename: '/p/x.db' })).toEqual([]);
expect(localCustomizationNotice({ status: 'not_applicable' })).toEqual([]);
});
});

describe('only `os build` says it', () => {
it('turns the notice on for build and leaves it off for compile, which `os dev` runs', () => {
const flag = (C: typeof Compile) => (Reflect.construct(C, [[], {}]) as unknown as { reportsLocalCustomizations: boolean })
.reportsLocalCustomizations;
expect(flag(Build)).toBe(true);
expect(flag(Compile)).toBe(false);
});
});
Loading
Loading