Skip to content

Commit 7d0802b

Browse files
committed
feat: add empty-boot mode and OS_HOME to objectstack start
1 parent fe71931 commit 7d0802b

5 files changed

Lines changed: 221 additions & 54 deletions

File tree

packages/cli/src/commands/serve.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -241,21 +241,33 @@ export default class Serve extends Command {
241241
// `dist/objectstack.json`.
242242
const configMissing = !fs.existsSync(absolutePath);
243243
let useArtifactFallback = false;
244+
let useEmptyBoot = false;
244245
if (configMissing) {
245246
const { resolveDefaultArtifactPath } = await import('@objectstack/runtime');
246247
const artifactSource = resolveDefaultArtifactPath();
247248
if (!artifactSource) {
248-
printError(`Configuration file not found: ${absolutePath}`);
249-
console.log(chalk.dim(' Hint: Run `objectstack init` to create a new project,'));
250-
console.log(chalk.dim(' or run `objectstack build` first, or set OS_ARTIFACT_PATH.'));
251-
this.exit(1);
249+
// Quick-start mode: `objectstack start` lets the user boot an
250+
// empty kernel with no config and no artifact, then install apps
251+
// from the marketplace via Studio. The CLI signals this by
252+
// setting OS_BOOT_EMPTY=1 in the child env.
253+
if (process.env.OS_BOOT_EMPTY === '1') {
254+
useEmptyBoot = true;
255+
} else {
256+
printError(`Configuration file not found: ${absolutePath}`);
257+
console.log(chalk.dim(' Hint: Run `objectstack init` to create a new project,'));
258+
console.log(chalk.dim(' `objectstack start` to boot an empty kernel against your marketplace,'));
259+
console.log(chalk.dim(' or run `objectstack build` first / set OS_ARTIFACT_PATH.'));
260+
this.exit(1);
261+
}
252262
}
253263
useArtifactFallback = true;
254264
}
255265

256266
// Quiet loading — only show a single spinner line
257267
console.log('');
258-
if (useArtifactFallback) {
268+
if (useEmptyBoot) {
269+
console.log(chalk.dim(' No objectstack.config.ts or artifact found — booting empty kernel...'));
270+
} else if (useArtifactFallback) {
259271
console.log(chalk.dim(' No objectstack.config.ts found — booting from artifact (default host)...'));
260272
} else {
261273
console.log(chalk.dim(` Loading ${relativeConfig}...`));
@@ -360,8 +372,13 @@ export default class Serve extends Command {
360372
// Artifact-only boot — no objectstack.config.ts authored.
361373
// Always use the default-host helper which is standalone-only
362374
// and never depends on @objectstack/service-cloud.
375+
//
376+
// When `useEmptyBoot` is set the user asked for a quick-start
377+
// ("objectstack start" with nothing to load); skip the
378+
// "missing artifact" error and assemble a bare kernel that
379+
// can later install marketplace apps at runtime.
363380
const { createDefaultHostConfig } = await import('@objectstack/runtime');
364-
const bootResult = await createDefaultHostConfig();
381+
const bootResult = await createDefaultHostConfig({ requireArtifact: !useEmptyBoot });
365382
config = { ...originalConfig, ...bootResult } as any;
366383
} else if (resolvedMode === 'standalone') {
367384
const { createStandaloneStack } = await import('@objectstack/runtime');

packages/cli/src/commands/start.ts

Lines changed: 136 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,45 +3,75 @@
33
import { Command, Flags } from '@oclif/core';
44
import { spawn } from 'child_process';
55
import fs from 'fs';
6+
import os from 'os';
67
import path from 'path';
78
import { printHeader, printKV, printStep, printError } from '../utils/format.js';
89

10+
/**
11+
* `objectstack start` — zero-config quick boot.
12+
*
13+
* Three escalating modes, picked automatically:
14+
*
15+
* 1. **Empty boot** (no artifact, no config in cwd)
16+
* Boots a bare kernel with Studio mounted at `/_studio/`. The user
17+
* can then browse the marketplace and install apps into the home
18+
* directory at runtime. Perfect for "I just want to try it".
19+
*
20+
* 2. **Artifact boot** (an `objectstack.json` is reachable)
21+
* Boots from the compiled artifact, same as today.
22+
*
23+
* 3. **Explicit overrides** (`--artifact`, `--database`, ...)
24+
* Highest precedence — the user is in control.
25+
*
26+
* The HOME directory (default `~/.objectstack`) is where the default
27+
* sqlite database, downloaded marketplace apps and plugin cache all live
28+
* — independent of the current working directory, so the same `os start`
29+
* gives you the same instance no matter where you run it.
30+
*/
931
export default class Start extends Command {
10-
static override description = 'Serve the pre-compiled artifact in production mode (no objectstack.config.ts required)';
32+
static override description = 'Quick-start an ObjectStack server (auto-falls back to an empty kernel with Studio + marketplace when no artifact is present)';
1133

1234
static override examples = [
1335
'<%= config.bin %> start',
36+
'<%= config.bin %> start --home ~/my-objectstack',
1437
'<%= config.bin %> start --artifact ./build/myapp.json',
1538
'<%= config.bin %> start --artifact https://cdn.example.com/app.json --port 8080',
1639
'<%= config.bin %> start --database file:./data/prod.db',
1740
'<%= config.bin %> start --database postgres://user:pass@host:5432/mydb',
1841
'<%= config.bin %> start --database libsql://my-db.turso.io --database-auth-token $TURSO_TOKEN',
19-
'<%= config.bin %> start --auth-secret $(openssl rand -hex 32) --ui',
42+
'<%= config.bin %> start --no-ui',
2043
];
2144

2245
static override flags = {
2346
// Server
2447
port: Flags.integer({ char: 'p', description: 'Port to listen on (overrides $PORT, default 3000)' }),
2548
ui: Flags.boolean({
26-
description: 'Mount Studio / Account / Console portals at /_studio/, /_account/, /_console/ (off by default in production)',
49+
description: 'Mount Studio / Account / Console portals at /_studio/, /_account/, /_console/ (default: true so you can install marketplace apps)',
50+
default: true,
51+
allowNo: true,
2752
}),
2853
verbose: Flags.boolean({ char: 'v', description: 'Verbose output' }),
2954

55+
// Home directory — where persistent runtime state lives.
56+
home: Flags.string({
57+
description: 'Home directory for persistent state (default ~/.objectstack; overrides $OS_HOME)',
58+
}),
59+
3060
// Artifact source
3161
artifact: Flags.string({
3262
char: 'a',
33-
description: 'Path or http(s):// URL to the compiled objectstack.json (overrides $OS_ARTIFACT_PATH; defaults to ./dist/objectstack.json)',
63+
description: 'Path or http(s):// URL to the compiled objectstack.json (overrides $OS_ARTIFACT_PATH; auto-detected from ./dist/objectstack.json or <home>/dist/objectstack.json)',
3464
}),
3565

3666
// Project identity
37-
'project-id': Flags.string({
38-
description: 'Project identifier (overrides $OS_ENVIRONMENT_ID, default proj_local)',
67+
'environment-id': Flags.string({
68+
description: 'Environment identifier (overrides $OS_ENVIRONMENT_ID, default env_local)',
3969
}),
4070

4171
// Storage
4272
database: Flags.string({
4373
char: 'd',
44-
description: 'Database URL: file:./db.sqlite | libsql://... | postgres://... | mongodb://... | memory:// (overrides $OS_DATABASE_URL)',
74+
description: 'Database URL: file:./db.sqlite | libsql://... | postgres://... | mongodb://... | memory:// (overrides $OS_DATABASE_URL; defaults to file:<home>/data/objectstack.db)',
4575
}),
4676
'database-driver': Flags.string({
4777
description: 'Force driver kind when URL is ambiguous: sqlite | turso | postgres | mongodb | memory (overrides $OS_DATABASE_DRIVER)',
@@ -60,69 +90,132 @@ export default class Start extends Command {
6090
async run(): Promise<void> {
6191
const { flags } = await this.parse(Start);
6292

63-
printHeader('Production Mode');
93+
printHeader('ObjectStack');
6494

65-
// ── Artifact resolution ────────────────────────────────────────
66-
// Priority: --artifact flag > $OS_ARTIFACT_PATH > ./dist/objectstack.json
67-
const artifactPathInput = flags.artifact
68-
?? process.env.OS_ARTIFACT_PATH
69-
?? path.resolve(process.cwd(), 'dist/objectstack.json');
70-
71-
// `--artifact` / `OS_ARTIFACT_PATH` is allowed to be an
72-
// `http(s)://` URL — the runtime's loadArtifactBundle() can fetch
73-
// JSON over the network. Skip the existence check for URLs
74-
// (validated lazily by the loader).
75-
const isUrl = /^https?:\/\//i.test(artifactPathInput);
76-
const artifactPath = isUrl ? artifactPathInput : path.resolve(process.cwd(), artifactPathInput);
77-
78-
if (!isUrl && !fs.existsSync(artifactPath)) {
79-
printError(`Artifact not found: ${path.relative(process.cwd(), artifactPath)}`);
80-
console.error(' Run \x1b[33mobjectstack build\x1b[0m to compile your configuration first,');
81-
console.error(' pass \x1b[33m--artifact <path|url>\x1b[0m, or set \x1b[33mOS_ARTIFACT_PATH\x1b[0m.');
95+
// ── Home directory ──────────────────────────────────────────────
96+
// Priority: --home > $OS_HOME > ~/.objectstack
97+
const homeDir = resolveHome(flags.home);
98+
try {
99+
fs.mkdirSync(path.join(homeDir, 'data'), { recursive: true });
100+
} catch (err: any) {
101+
printError(`Cannot create home directory at ${homeDir}: ${err?.message ?? err}`);
82102
process.exit(1);
83103
}
84104

85-
const displayPath = isUrl ? artifactPath : path.relative(process.cwd(), artifactPath);
86-
printKV('Artifact', displayPath, '📦');
87-
printStep('Starting server (production mode)...');
105+
// ── Artifact resolution ────────────────────────────────────────
106+
// Priority: --artifact > $OS_ARTIFACT_PATH > ./dist/objectstack.json
107+
// > <home>/dist/objectstack.json > none (empty boot)
108+
const artifactSource = resolveArtifactSource(flags.artifact, homeDir);
109+
110+
// ── Database resolution ─────────────────────────────────────────
111+
// Priority: --database > $OS_DATABASE_URL > file:<home>/data/objectstack.db
112+
const databaseUrl = flags.database
113+
?? process.env.OS_DATABASE_URL
114+
?? `file:${path.join(homeDir, 'data', 'objectstack.db')}`;
115+
116+
const environmentId = flags['environment-id']
117+
?? process.env.OS_ENVIRONMENT_ID
118+
?? 'env_local';
119+
120+
// ── Banner ──────────────────────────────────────────────────────
121+
printKV('Home', homeDir, '🏠');
122+
if (artifactSource) {
123+
printKV('Artifact', artifactSource.display, '📦');
124+
} else {
125+
printKV('Artifact', 'none (empty kernel — install apps via Studio marketplace)', '📦');
126+
}
127+
printKV('Database', redactDbUrl(databaseUrl), '🗄️');
128+
printKV('Environment', environmentId, '🎯');
129+
if (flags.ui) printKV('Studio', `http://localhost:${flags.port ?? 3000}/_studio/`, '🎨');
88130

89-
const environmentId = flags['project-id'] ?? process.env.OS_ENVIRONMENT_ID ?? 'proj_local';
131+
printStep('Starting server...');
90132

91-
// Build the env handed to `serve`. Flags win over inherited env so
92-
// `--database file:foo.db` overrides any pre-existing OS_DATABASE_URL.
133+
// ── Child env ───────────────────────────────────────────────────
134+
// Flags win over inherited env. When no artifact was located, signal
135+
// serve.ts to boot an empty kernel via OS_BOOT_EMPTY=1.
93136
const localEnv: NodeJS.ProcessEnv = {
94137
...process.env,
95-
NODE_ENV: 'production',
138+
OS_HOME: homeDir,
96139
OS_ENVIRONMENT_ID: environmentId,
97-
OS_ARTIFACT_PATH: artifactPath,
140+
OS_DATABASE_URL: databaseUrl,
98141
...(flags.port ? { PORT: String(flags.port) } : {}),
99-
...(flags.database ? { OS_DATABASE_URL: flags.database } : {}),
100142
...(flags['database-driver'] ? { OS_DATABASE_DRIVER: flags['database-driver'] } : {}),
101143
...(flags['database-auth-token'] ? { OS_DATABASE_AUTH_TOKEN: flags['database-auth-token'] } : {}),
102144
...(flags['auth-secret'] ? { AUTH_SECRET: flags['auth-secret'] } : {}),
145+
...(artifactSource ? { OS_ARTIFACT_PATH: artifactSource.path } : { OS_BOOT_EMPTY: '1' }),
103146
};
104-
105-
printKV('Project ID', environmentId, '🎯');
106-
if (flags.database) printKV('Database', redactDbUrl(flags.database), '🗄️');
147+
// NODE_ENV is only forced to production when the user has not set it.
148+
// Allows `NODE_ENV=development objectstack start` to work for debugging.
149+
if (!localEnv.NODE_ENV) localEnv.NODE_ENV = 'production';
107150

108151
const binPath = process.argv[1];
109-
spawn(
152+
const child = spawn(
110153
process.execPath,
111154
[
112155
binPath,
113156
'serve',
114-
// Production runtime: Studio is opt-in. The `serve` command
115-
// defaults `--ui` to true (because it's also driven by `os dev`
116-
// where Studio is the expected DX), so we have to explicitly
117-
// pass `--no-ui` when the user hasn't asked for `--ui`.
118-
...(flags.ui ? ['--ui'] : ['--no-ui']),
157+
flags.ui ? '--ui' : '--no-ui',
119158
...(flags.verbose ? ['--verbose'] : []),
120159
],
121160
{ stdio: 'inherit', env: localEnv },
122161
);
162+
child.on('exit', (code) => process.exit(code ?? 0));
123163
}
124164
}
125165

166+
function resolveHome(flagValue?: string): string {
167+
const raw = flagValue ?? process.env.OS_HOME;
168+
if (raw && raw.trim().length > 0) {
169+
const v = raw.trim();
170+
if (v.startsWith('~')) return path.resolve(os.homedir(), v.slice(1).replace(/^[/\\]/, ''));
171+
return path.resolve(v);
172+
}
173+
return path.resolve(os.homedir(), '.objectstack');
174+
}
175+
176+
interface ResolvedArtifact {
177+
/** Absolute path or URL passed to OS_ARTIFACT_PATH. */
178+
path: string;
179+
/** Human-friendly form for the banner. */
180+
display: string;
181+
}
182+
183+
function resolveArtifactSource(flagValue: string | undefined, homeDir: string): ResolvedArtifact | undefined {
184+
const cwd = process.cwd();
185+
186+
// Explicit flag wins, including URLs.
187+
if (flagValue) {
188+
if (/^https?:\/\//i.test(flagValue)) return { path: flagValue, display: flagValue };
189+
const abs = path.resolve(cwd, flagValue);
190+
if (!fs.existsSync(abs)) {
191+
// We don't exit here — the user asked for this file. Defer to
192+
// serve.ts which already prints a precise error.
193+
return { path: abs, display: path.relative(cwd, abs) };
194+
}
195+
return { path: abs, display: path.relative(cwd, abs) };
196+
}
197+
198+
// Explicit env var wins next.
199+
const envPath = process.env.OS_ARTIFACT_PATH;
200+
if (envPath) {
201+
if (/^https?:\/\//i.test(envPath)) return { path: envPath, display: envPath };
202+
const abs = path.resolve(cwd, envPath);
203+
return { path: abs, display: path.relative(cwd, abs) };
204+
}
205+
206+
// Auto-detect — cwd first, then home.
207+
const cwdCandidate = path.resolve(cwd, 'dist/objectstack.json');
208+
if (fs.existsSync(cwdCandidate)) {
209+
return { path: cwdCandidate, display: path.relative(cwd, cwdCandidate) };
210+
}
211+
const homeCandidate = path.resolve(homeDir, 'dist/objectstack.json');
212+
if (fs.existsSync(homeCandidate)) {
213+
return { path: homeCandidate, display: homeCandidate };
214+
}
215+
216+
return undefined;
217+
}
218+
126219
function redactDbUrl(url: string): string {
127220
try {
128221
return url.replace(/(\/\/[^/@:]+):[^/@]+@/, '$1:****@');

packages/runtime/src/default-host.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@
2626
*/
2727

2828
import { resolve as resolvePath } from 'node:path';
29-
import { existsSync } from 'node:fs';
30-
import { createStandaloneStack, type StandaloneStackConfig, type StandaloneStackResult } from './standalone-stack.js';
29+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
30+
import { createStandaloneStack, resolveObjectStackHome, type StandaloneStackConfig, type StandaloneStackResult } from './standalone-stack.js';
3131
import { isHttpUrl } from './load-artifact-bundle.js';
3232

3333
export interface DefaultHostConfigOptions extends StandaloneStackConfig {
@@ -82,7 +82,7 @@ export async function createDefaultHostConfig(
8282
): Promise<DefaultHostConfigResult> {
8383
const { requireArtifact = true, ...standaloneOpts } = options;
8484

85-
const resolvedArtifact = resolveDefaultArtifactPath(standaloneOpts.artifactPath);
85+
let resolvedArtifact = resolveDefaultArtifactPath(standaloneOpts.artifactPath);
8686
if (!resolvedArtifact && requireArtifact) {
8787
throw new Error(
8888
'[createDefaultHostConfig] No artifact source available. ' +
@@ -93,6 +93,40 @@ export async function createDefaultHostConfig(
9393
);
9494
}
9595

96+
// Empty-boot path: synthesize a minimal artifact stub inside the
97+
// ObjectStack home directory so MetadataPlugin has a real file to
98+
// read (and to watch for marketplace installs that land later).
99+
if (!resolvedArtifact && !requireArtifact) {
100+
const home = resolveObjectStackHome();
101+
const stubPath = resolvePath(home, 'dist/objectstack.json');
102+
if (!existsSync(stubPath)) {
103+
mkdirSync(resolvePath(stubPath, '..'), { recursive: true });
104+
writeFileSync(
105+
stubPath,
106+
JSON.stringify(
107+
{
108+
manifest: {
109+
id: 'com.objectstack.empty',
110+
name: 'empty',
111+
version: '0.0.0',
112+
type: 'app',
113+
description: 'Empty starter kernel — install apps via the Studio marketplace.',
114+
},
115+
objects: [],
116+
views: [],
117+
apps: [],
118+
flows: [],
119+
requires: [],
120+
},
121+
null,
122+
2,
123+
),
124+
'utf8',
125+
);
126+
}
127+
resolvedArtifact = stubPath;
128+
}
129+
96130
return createStandaloneStack({
97131
...standaloneOpts,
98132
artifactPath: resolvedArtifact,

packages/runtime/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export { Runtime } from './runtime.js';
88
export type { RuntimeConfig } from './runtime.js';
99

1010
// Export Standalone Stack
11-
export { createStandaloneStack } from './standalone-stack.js';
11+
export { createStandaloneStack, resolveObjectStackHome } from './standalone-stack.js';
1212
export type { StandaloneStackConfig, StandaloneStackResult } from './standalone-stack.js';
1313

1414
// Export Default Host (artifact-first, no objectstack.config.ts required)

0 commit comments

Comments
 (0)