@objectstack/cli 4.0.5 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/dist/commands/cloud/login.d.ts +16 -0
- package/dist/commands/cloud/login.d.ts.map +1 -0
- package/dist/commands/cloud/login.js +166 -0
- package/dist/commands/cloud/login.js.map +1 -0
- package/dist/commands/cloud/logout.d.ts +15 -0
- package/dist/commands/cloud/logout.d.ts.map +1 -0
- package/dist/commands/cloud/logout.js +51 -0
- package/dist/commands/cloud/logout.js.map +1 -0
- package/dist/commands/cloud/whoami.d.ts +15 -0
- package/dist/commands/cloud/whoami.d.ts.map +1 -0
- package/dist/commands/cloud/whoami.js +81 -0
- package/dist/commands/cloud/whoami.js.map +1 -0
- package/dist/commands/dev.d.ts +6 -0
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +79 -14
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/login.js +2 -2
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/package/publish.d.ts +32 -0
- package/dist/commands/package/publish.d.ts.map +1 -0
- package/dist/commands/package/publish.js +324 -0
- package/dist/commands/package/publish.js.map +1 -0
- package/dist/commands/publish.d.ts +3 -0
- package/dist/commands/publish.d.ts.map +1 -1
- package/dist/commands/publish.js +55 -11
- package/dist/commands/publish.js.map +1 -1
- package/dist/commands/rollback.d.ts +13 -0
- package/dist/commands/rollback.d.ts.map +1 -0
- package/dist/commands/rollback.js +77 -0
- package/dist/commands/rollback.js.map +1 -0
- package/dist/commands/serve.d.ts +14 -0
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +583 -30
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/start.d.ts +8 -1
- package/dist/commands/start.d.ts.map +1 -1
- package/dist/commands/start.js +80 -11
- package/dist/commands/start.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/auth-flows.d.ts +31 -0
- package/dist/utils/auth-flows.d.ts.map +1 -0
- package/dist/utils/auth-flows.js +151 -0
- package/dist/utils/auth-flows.js.map +1 -0
- package/dist/utils/cloud-config.d.ts +24 -0
- package/dist/utils/cloud-config.d.ts.map +1 -0
- package/dist/utils/cloud-config.js +75 -0
- package/dist/utils/cloud-config.js.map +1 -0
- package/dist/utils/console.d.ts +1 -0
- package/dist/utils/console.d.ts.map +1 -1
- package/dist/utils/console.js +7 -4
- package/dist/utils/console.js.map +1 -1
- package/dist/utils/studio.d.ts.map +1 -1
- package/dist/utils/studio.js +5 -6
- package/dist/utils/studio.js.map +1 -1
- package/package.json +40 -36
package/dist/commands/serve.js
CHANGED
|
@@ -35,7 +35,7 @@ const getAvailablePort = async (startPort) => {
|
|
|
35
35
|
return port;
|
|
36
36
|
};
|
|
37
37
|
export default class Serve extends Command {
|
|
38
|
-
static description = 'Start ObjectStack server
|
|
38
|
+
static description = 'Start ObjectStack server. Reads `objectstack.config.ts` if present; otherwise falls back to `dist/objectstack.json` (or OS_ARTIFACT_PATH, including http(s):// URLs) as a portable artifact.';
|
|
39
39
|
static args = {
|
|
40
40
|
config: Args.string({ description: 'Configuration file path', required: false, default: 'objectstack.config.ts' }),
|
|
41
41
|
};
|
|
@@ -43,6 +43,11 @@ export default class Serve extends Command {
|
|
|
43
43
|
port: Flags.string({ char: 'p', description: 'Server port', default: process.env.PORT ?? '3000' }),
|
|
44
44
|
dev: Flags.boolean({ description: 'Run in development mode (load devPlugins)' }),
|
|
45
45
|
ui: Flags.boolean({ description: 'Enable Studio UI at /_studio/ (default: true)', default: true, allowNo: true }),
|
|
46
|
+
console: Flags.boolean({
|
|
47
|
+
description: 'Mount the Console UI at /_console/ when the package is installed (default: true). When disabled, Studio claims the root redirect.',
|
|
48
|
+
default: true,
|
|
49
|
+
allowNo: true,
|
|
50
|
+
}),
|
|
46
51
|
server: Flags.boolean({ description: 'Start HTTP server plugin', default: true, allowNo: true }),
|
|
47
52
|
prebuilt: Flags.boolean({ description: 'Skip esbuild/bundle-require — load config as native ESM (production mode)', default: false }),
|
|
48
53
|
preset: Flags.string({
|
|
@@ -50,6 +55,21 @@ export default class Serve extends Command {
|
|
|
50
55
|
options: ['minimal', 'default', 'full'],
|
|
51
56
|
}),
|
|
52
57
|
};
|
|
58
|
+
/**
|
|
59
|
+
* Capabilities auto-added to every app's `requires` for every preset
|
|
60
|
+
* EXCEPT `minimal`. These form the foundation that every server-side
|
|
61
|
+
* runtime expects to exist (background work, settings persistence,
|
|
62
|
+
* transactional mail, file uploads). Apps may still list these in
|
|
63
|
+
* `requires:` explicitly — duplicates are de-duped.
|
|
64
|
+
*
|
|
65
|
+
* Opt out: `objectstack serve --preset minimal`.
|
|
66
|
+
*
|
|
67
|
+
* Mirrored on hosted objectos per-project kernels by
|
|
68
|
+
* `mountDefaultProjectPlugins()` in `@objectstack/service-cloud`.
|
|
69
|
+
*/
|
|
70
|
+
static ALWAYS_ON_CAPABILITIES = Object.freeze([
|
|
71
|
+
'queue', 'job', 'cache', 'settings', 'email', 'storage',
|
|
72
|
+
]);
|
|
53
73
|
/**
|
|
54
74
|
* Auto-registered plugin tiers. Plugins explicitly listed in
|
|
55
75
|
* `config.plugins` are always loaded — tiers only gate the optional
|
|
@@ -62,6 +82,14 @@ export default class Serve extends Command {
|
|
|
62
82
|
};
|
|
63
83
|
async run() {
|
|
64
84
|
const { args, flags } = await this.parse(Serve);
|
|
85
|
+
// When --dev is passed, set NODE_ENV early so any runtime modules
|
|
86
|
+
// imported below (and any deps that branch on NODE_ENV at import
|
|
87
|
+
// time) see development mode. We deliberately do NOT inherit
|
|
88
|
+
// NODE_ENV from the parent `os dev` spawn — see the note in
|
|
89
|
+
// commands/dev.ts for why.
|
|
90
|
+
if (flags.dev && !process.env.NODE_ENV) {
|
|
91
|
+
process.env.NODE_ENV = 'development';
|
|
92
|
+
}
|
|
65
93
|
let port = parseInt(flags.port);
|
|
66
94
|
try {
|
|
67
95
|
const availablePort = await getAvailablePort(port);
|
|
@@ -80,14 +108,36 @@ export default class Serve extends Command {
|
|
|
80
108
|
const isDev = flags.dev || process.env.NODE_ENV === 'development';
|
|
81
109
|
const absolutePath = path.resolve(process.cwd(), args.config);
|
|
82
110
|
const relativeConfig = path.relative(process.cwd(), absolutePath);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
111
|
+
// ── Artifact-first fallback ──────────────────────────────────────
|
|
112
|
+
// If the user did not author an `objectstack.config.ts`, but a
|
|
113
|
+
// compiled artifact is reachable (explicit OS_ARTIFACT_PATH —
|
|
114
|
+
// including http(s):// URLs — or the canonical
|
|
115
|
+
// `<cwd>/dist/objectstack.json`), boot from that artifact alone.
|
|
116
|
+
// This is the same capability previously hard-coded in
|
|
117
|
+
// `apps/objectos/objectstack.config.ts`, lifted into the framework
|
|
118
|
+
// so any project can `objectstack start` against just a
|
|
119
|
+
// `dist/objectstack.json`.
|
|
120
|
+
const configMissing = !fs.existsSync(absolutePath);
|
|
121
|
+
let useArtifactFallback = false;
|
|
122
|
+
if (configMissing) {
|
|
123
|
+
const { resolveDefaultArtifactPath } = await import('@objectstack/runtime');
|
|
124
|
+
const artifactSource = resolveDefaultArtifactPath();
|
|
125
|
+
if (!artifactSource) {
|
|
126
|
+
printError(`Configuration file not found: ${absolutePath}`);
|
|
127
|
+
console.log(chalk.dim(' Hint: Run `objectstack init` to create a new project,'));
|
|
128
|
+
console.log(chalk.dim(' or run `objectstack build` first, or set OS_ARTIFACT_PATH.'));
|
|
129
|
+
this.exit(1);
|
|
130
|
+
}
|
|
131
|
+
useArtifactFallback = true;
|
|
87
132
|
}
|
|
88
133
|
// Quiet loading — only show a single spinner line
|
|
89
134
|
console.log('');
|
|
90
|
-
|
|
135
|
+
if (useArtifactFallback) {
|
|
136
|
+
console.log(chalk.dim(' No objectstack.config.ts found — booting from artifact (default host)...'));
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
console.log(chalk.dim(` Loading ${relativeConfig}...`));
|
|
140
|
+
}
|
|
91
141
|
// Track loaded plugins for summary
|
|
92
142
|
const loadedPlugins = [];
|
|
93
143
|
const shortPluginName = (raw) => {
|
|
@@ -149,11 +199,15 @@ export default class Serve extends Command {
|
|
|
149
199
|
// Load configuration
|
|
150
200
|
// --prebuilt: load as native ESM (no esbuild, no bundle-require) —
|
|
151
201
|
// intended for production where the config has been compiled to dist/.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
202
|
+
// --artifact-fallback: skip config loading entirely; the default-host
|
|
203
|
+
// helper will synthesize a stack from the artifact JSON below.
|
|
204
|
+
const { mod } = useArtifactFallback
|
|
205
|
+
? { mod: { default: {} } }
|
|
206
|
+
: flags.prebuilt
|
|
207
|
+
? { mod: await import(absolutePath.startsWith('/') ? `file://${absolutePath}` : absolutePath) }
|
|
208
|
+
: await bundleRequire({ filepath: absolutePath });
|
|
155
209
|
let config = mod.default || mod;
|
|
156
|
-
if (!config) {
|
|
210
|
+
if (!useArtifactFallback && !config) {
|
|
157
211
|
throw new Error(`No default export found in ${args.config}`);
|
|
158
212
|
}
|
|
159
213
|
// Preserve module-level named exports (e.g. `onEnable`, `onDisable`
|
|
@@ -173,19 +227,39 @@ export default class Serve extends Command {
|
|
|
173
227
|
// Boot-mode dispatch: standalone goes directly through
|
|
174
228
|
// `@objectstack/runtime` (no cloud dependencies). runtime/cloud
|
|
175
229
|
// modes go through `@objectstack/service-cloud`.
|
|
176
|
-
if (shouldBootWithLibrary(config)) {
|
|
230
|
+
if (useArtifactFallback || shouldBootWithLibrary(config)) {
|
|
177
231
|
// The boot stack returns only `{plugins, api}` — preserve the
|
|
178
232
|
// original stack metadata (notably `requires`, `analyticsCubes`,
|
|
179
233
|
// `tiers`) so the capability resolver further down can read it.
|
|
180
234
|
const originalConfig = config;
|
|
181
235
|
const resolvedMode = config.bootMode ?? process.env.OS_MODE ?? 'standalone';
|
|
182
|
-
if (
|
|
236
|
+
if (useArtifactFallback) {
|
|
237
|
+
// Artifact-only boot — no objectstack.config.ts authored.
|
|
238
|
+
// Always use the default-host helper which is standalone-only
|
|
239
|
+
// and never depends on @objectstack/service-cloud.
|
|
240
|
+
const { createDefaultHostConfig } = await import('@objectstack/runtime');
|
|
241
|
+
const bootResult = await createDefaultHostConfig();
|
|
242
|
+
config = { ...originalConfig, ...bootResult };
|
|
243
|
+
}
|
|
244
|
+
else if (resolvedMode === 'standalone') {
|
|
183
245
|
const { createStandaloneStack } = await import('@objectstack/runtime');
|
|
184
246
|
const bootResult = await createStandaloneStack(config.standalone);
|
|
185
247
|
config = { ...originalConfig, ...bootResult };
|
|
186
248
|
}
|
|
187
249
|
else {
|
|
188
|
-
|
|
250
|
+
// Cloud / multi-project boot modes require @objectstack/service-cloud.
|
|
251
|
+
// When the package is unavailable (e.g. someone vendored only the
|
|
252
|
+
// public framework), fail with a clear, actionable error instead of
|
|
253
|
+
// an opaque module-not-found stack trace.
|
|
254
|
+
let createBootStack;
|
|
255
|
+
try {
|
|
256
|
+
({ createBootStack } = await import('@objectstack/service-cloud'));
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
throw new Error(`Boot mode '${resolvedMode}' requires @objectstack/service-cloud, which is not installed.\n`
|
|
260
|
+
+ `Either install it (\`pnpm add @objectstack/service-cloud\`) or switch to bootMode='standalone'.\n`
|
|
261
|
+
+ `Underlying error: ${err?.message ?? String(err)}`);
|
|
262
|
+
}
|
|
189
263
|
const bootResult = await createBootStack({
|
|
190
264
|
mode: config.bootMode,
|
|
191
265
|
runtime: config.runtime ?? config.project,
|
|
@@ -210,6 +284,38 @@ export default class Serve extends Command {
|
|
|
210
284
|
const requires = Array.isArray(config.requires)
|
|
211
285
|
? config.requires.filter((c) => typeof c === 'string')
|
|
212
286
|
: [];
|
|
287
|
+
// Auth callbacks (password-reset, email-verification, magic-link,
|
|
288
|
+
// invitation) depend on the email service. Auto-pull `email` when
|
|
289
|
+
// `auth` is required so transactional mail works out of the box
|
|
290
|
+
// (LogTransport fallback when no provider is configured).
|
|
291
|
+
if (requires.includes('auth') && !requires.includes('email')) {
|
|
292
|
+
requires.push('email');
|
|
293
|
+
}
|
|
294
|
+
// Default capability slate — every preset except `minimal` gets the
|
|
295
|
+
// foundational services (queue + job + cache + settings + email +
|
|
296
|
+
// storage). Opt out with `objectstack serve --preset minimal`.
|
|
297
|
+
// Keeping `auth → email` above as a defensive rule for users who
|
|
298
|
+
// explicitly opt into `minimal` but still enable auth.
|
|
299
|
+
const ALWAYS_CAPS = Serve.ALWAYS_ON_CAPABILITIES;
|
|
300
|
+
if (presetName !== 'minimal') {
|
|
301
|
+
for (const cap of ALWAYS_CAPS) {
|
|
302
|
+
if (!requires.includes(cap))
|
|
303
|
+
requires.push(cap);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// The email + approvals + reports services schedule background work
|
|
307
|
+
// (durable retries, SLA escalation, scheduled digests). Auto-pull
|
|
308
|
+
// 'job' and 'queue' so plugins can opt into durable scheduling.
|
|
309
|
+
// IMPORTANT: prepend, so their plugins load (and their kernel:ready
|
|
310
|
+
// hooks fire) BEFORE consumers like email/approvals that subscribe
|
|
311
|
+
// to queues during their own kernel:ready phase.
|
|
312
|
+
const NEEDS_JOB_OR_QUEUE = ['email', 'approvals', 'reports', 'auth'];
|
|
313
|
+
if (NEEDS_JOB_OR_QUEUE.some((c) => requires.includes(c))) {
|
|
314
|
+
if (!requires.includes('queue'))
|
|
315
|
+
requires.unshift('queue');
|
|
316
|
+
if (!requires.includes('job'))
|
|
317
|
+
requires.unshift('job');
|
|
318
|
+
}
|
|
213
319
|
// Capability → tier: any capability that is gated by a tier
|
|
214
320
|
// here automatically opens that tier when listed in `requires`.
|
|
215
321
|
// Capabilities NOT in this map (e.g. `automation`, `analytics`,
|
|
@@ -447,6 +553,193 @@ export default class Serve extends Command {
|
|
|
447
553
|
console.warn(chalk.yellow(` ⚠ HTTP server plugin not available: ${e.message}`));
|
|
448
554
|
}
|
|
449
555
|
}
|
|
556
|
+
// Unknown-environment hostname guard.
|
|
557
|
+
//
|
|
558
|
+
// In multi-tenant cloud deployments (e.g. *.objectos.app), every
|
|
559
|
+
// public hostname is expected to map to a `sys_environment` row
|
|
560
|
+
// whose `hostname` column matches the request `Host`. Without this
|
|
561
|
+
// guard, an unknown subdomain like `demo-xxx.objectos.app` happily
|
|
562
|
+
// renders the control-plane Console SPA (served statically by
|
|
563
|
+
// createConsoleStaticPlugin), making the deployment look like an
|
|
564
|
+
// empty env rather than a missing one. We respond with a clear
|
|
565
|
+
// 404 instead.
|
|
566
|
+
//
|
|
567
|
+
// Activation: only when OS_ROOT_DOMAIN is set (e.g. "objectos.app").
|
|
568
|
+
// Reserved subdomains (cloud/www/api/docs/admin/app and the apex)
|
|
569
|
+
// bypass the check so platform surfaces keep working. Non-root
|
|
570
|
+
// hostnames (custom domains, localhost, *.workers.dev) pass through
|
|
571
|
+
// unchanged. Infra paths under /_admin or /.well-known are always
|
|
572
|
+
// allowed so health checks / cert flows aren't broken.
|
|
573
|
+
//
|
|
574
|
+
// Implemented as a Plugin so the middleware is wired during init
|
|
575
|
+
// (when http.server is available) and BEFORE start() runs on the
|
|
576
|
+
// Console static plugin / route-registering plugins. Hono's
|
|
577
|
+
// `app.use('*')` is order-independent for matching, so as long as
|
|
578
|
+
// the middleware is added before kernel:listening fires, it
|
|
579
|
+
// intercepts every request regardless of which plugin registered
|
|
580
|
+
// its handler.
|
|
581
|
+
const __rootDomain = (process.env.OS_ROOT_DOMAIN || '').trim().toLowerCase();
|
|
582
|
+
if (__rootDomain) {
|
|
583
|
+
const RESERVED = new Set(['', 'cloud', 'www', 'api', 'docs', 'admin', 'app']);
|
|
584
|
+
const guardPlugin = {
|
|
585
|
+
name: 'com.objectstack.cli.unknown-hostname-guard',
|
|
586
|
+
version: '1.0.0',
|
|
587
|
+
init: async (ctx) => {
|
|
588
|
+
try {
|
|
589
|
+
const httpServer = ctx.getService?.('http.server') ?? ctx.getService?.('http-server');
|
|
590
|
+
const rawApp = httpServer?.getRawApp?.();
|
|
591
|
+
if (!rawApp || typeof rawApp.use !== 'function') {
|
|
592
|
+
ctx.logger?.warn?.('[unknown-hostname-guard] http.server unavailable; guard not installed');
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const getEnvRegistry = () => {
|
|
596
|
+
try {
|
|
597
|
+
return ctx.getService?.('env-registry') ?? null;
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
rawApp.use('*', async (c, next) => {
|
|
604
|
+
const rawHost = c.req.header('host') || '';
|
|
605
|
+
const host = rawHost.split(':')[0].toLowerCase();
|
|
606
|
+
if (!host)
|
|
607
|
+
return next();
|
|
608
|
+
const isPlatformHost = host === __rootDomain || host.endsWith('.' + __rootDomain);
|
|
609
|
+
if (!isPlatformHost)
|
|
610
|
+
return next();
|
|
611
|
+
const sub = host === __rootDomain ? '' : host.slice(0, -(__rootDomain.length + 1));
|
|
612
|
+
const head = sub.split('.').pop() || '';
|
|
613
|
+
if (RESERVED.has(sub) || RESERVED.has(head))
|
|
614
|
+
return next();
|
|
615
|
+
const p = c.req.path;
|
|
616
|
+
if (p.startsWith('/_admin/') || p === '/_admin' || p.startsWith('/.well-known/')) {
|
|
617
|
+
return next();
|
|
618
|
+
}
|
|
619
|
+
// Health and readiness endpoints must always answer 200
|
|
620
|
+
// regardless of whether the requested hostname maps to
|
|
621
|
+
// an env — Cloudflare's container probe (and any
|
|
622
|
+
// upstream load balancer) hits whatever Host header is
|
|
623
|
+
// currently bound to the worker. Returning 404 here on
|
|
624
|
+
// an unmapped hostname would kill the container.
|
|
625
|
+
if (p === '/api/v1/health' || p === '/api/v1/ready' || p === '/health') {
|
|
626
|
+
return next();
|
|
627
|
+
}
|
|
628
|
+
// Resolve env-registry lazily on each request — it may
|
|
629
|
+
// not be registered yet at init() time (registered by
|
|
630
|
+
// ObjectOSProjectPlugin's init which runs in plugin
|
|
631
|
+
// dependency order; we don't want to rely on ordering).
|
|
632
|
+
const registry = getEnvRegistry();
|
|
633
|
+
if (!registry || typeof registry.resolveByHostname !== 'function') {
|
|
634
|
+
return next();
|
|
635
|
+
}
|
|
636
|
+
try {
|
|
637
|
+
const hit = await registry.resolveByHostname(host);
|
|
638
|
+
if (hit)
|
|
639
|
+
return next();
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
return next();
|
|
643
|
+
}
|
|
644
|
+
// Content negotiation: browsers (Accept: text/html) get
|
|
645
|
+
// a clean 404 page; API clients (curl/fetch with JSON
|
|
646
|
+
// accept) get a structured error body.
|
|
647
|
+
const accept = (c.req.header('accept') || '').toLowerCase();
|
|
648
|
+
const wantsHtml = accept.includes('text/html');
|
|
649
|
+
if (wantsHtml) {
|
|
650
|
+
const safeHost = host.replace(/[<>&"']/g, (ch) => (({
|
|
651
|
+
'<': '<', '>': '>', '&': '&', '"': '"', "'": ''',
|
|
652
|
+
}[ch]) ?? ch));
|
|
653
|
+
const html = `<!doctype html>
|
|
654
|
+
<html lang="en">
|
|
655
|
+
<head>
|
|
656
|
+
<meta charset="utf-8" />
|
|
657
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
658
|
+
<title>404 — Environment not found</title>
|
|
659
|
+
<style>
|
|
660
|
+
:root { color-scheme: light dark; }
|
|
661
|
+
* { box-sizing: border-box; }
|
|
662
|
+
html, body { height: 100%; margin: 0; }
|
|
663
|
+
body {
|
|
664
|
+
font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
665
|
+
background: #fafafa;
|
|
666
|
+
color: #111;
|
|
667
|
+
display: grid;
|
|
668
|
+
place-items: center;
|
|
669
|
+
padding: 24px;
|
|
670
|
+
}
|
|
671
|
+
@media (prefers-color-scheme: dark) {
|
|
672
|
+
body { background: #0b0b0c; color: #e8e8e8; }
|
|
673
|
+
.card { background: #141417; border-color: #26262b; }
|
|
674
|
+
.host { background: #1c1c20; border-color: #2d2d33; color: #d0d0d0; }
|
|
675
|
+
.muted { color: #8b8b94; }
|
|
676
|
+
a { color: #6ea8fe; }
|
|
677
|
+
}
|
|
678
|
+
.card {
|
|
679
|
+
max-width: 520px;
|
|
680
|
+
width: 100%;
|
|
681
|
+
background: #fff;
|
|
682
|
+
border: 1px solid #e6e6e6;
|
|
683
|
+
border-radius: 12px;
|
|
684
|
+
padding: 32px;
|
|
685
|
+
box-shadow: 0 1px 2px rgba(0,0,0,.04);
|
|
686
|
+
text-align: center;
|
|
687
|
+
}
|
|
688
|
+
.code { font: 600 64px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; margin: 0; letter-spacing: -2px; }
|
|
689
|
+
h1 { font-size: 20px; margin: 16px 0 8px; font-weight: 600; }
|
|
690
|
+
p { margin: 8px 0; }
|
|
691
|
+
.muted { color: #666; font-size: 14px; }
|
|
692
|
+
.host {
|
|
693
|
+
display: inline-block;
|
|
694
|
+
margin-top: 16px;
|
|
695
|
+
padding: 6px 12px;
|
|
696
|
+
background: #f4f4f5;
|
|
697
|
+
border: 1px solid #e4e4e7;
|
|
698
|
+
border-radius: 6px;
|
|
699
|
+
font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
700
|
+
color: #444;
|
|
701
|
+
word-break: break-all;
|
|
702
|
+
}
|
|
703
|
+
a { color: #2563eb; text-decoration: none; }
|
|
704
|
+
a:hover { text-decoration: underline; }
|
|
705
|
+
</style>
|
|
706
|
+
</head>
|
|
707
|
+
<body>
|
|
708
|
+
<main class="card">
|
|
709
|
+
<p class="code">404</p>
|
|
710
|
+
<h1>Environment not found</h1>
|
|
711
|
+
<p class="muted">No ObjectStack environment is bound to this hostname.</p>
|
|
712
|
+
<div class="host">${safeHost}</div>
|
|
713
|
+
<p class="muted" style="margin-top:24px">
|
|
714
|
+
If you own this domain, bind it to an environment in the
|
|
715
|
+
<a href="https://cloud.objectos.app/">ObjectStack Cloud console</a>.
|
|
716
|
+
</p>
|
|
717
|
+
</main>
|
|
718
|
+
</body>
|
|
719
|
+
</html>`;
|
|
720
|
+
return c.html(html, 404);
|
|
721
|
+
}
|
|
722
|
+
return c.json({
|
|
723
|
+
error: 'environment_not_found',
|
|
724
|
+
message: `No environment is bound to hostname '${host}'.`,
|
|
725
|
+
hostname: host,
|
|
726
|
+
}, 404);
|
|
727
|
+
});
|
|
728
|
+
ctx.logger?.info?.('[unknown-hostname-guard] installed', { rootDomain: __rootDomain });
|
|
729
|
+
}
|
|
730
|
+
catch (err) {
|
|
731
|
+
ctx.logger?.warn?.('[unknown-hostname-guard] install failed', { error: err?.message ?? err });
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
};
|
|
735
|
+
try {
|
|
736
|
+
await kernel.use(guardPlugin);
|
|
737
|
+
trackPlugin('UnknownHostnameGuard');
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
// Best-effort.
|
|
741
|
+
}
|
|
742
|
+
}
|
|
450
743
|
// 5. Auto-register Studio single-project signal in dev mode.
|
|
451
744
|
//
|
|
452
745
|
// `objectstack dev` runs a vanilla user stack (e.g. examples/app-crm)
|
|
@@ -498,7 +791,21 @@ export default class Serve extends Command {
|
|
|
498
791
|
const secret = process.env.AUTH_SECRET
|
|
499
792
|
?? process.env.OS_AUTH_SECRET
|
|
500
793
|
?? (isDev ? 'dev-only-insecure-secret-change-me-in-production' : undefined);
|
|
501
|
-
|
|
794
|
+
// Guard: in cloud-connected runtime mode (e.g. objectos worker)
|
|
795
|
+
// the host kernel is a pure routing shell. Auth is owned by each
|
|
796
|
+
// per-project kernel (`ArtifactKernelFactory` injects an
|
|
797
|
+
// `AuthPlugin` per project against the project's own DB so users
|
|
798
|
+
// persist and stay isolated per subdomain). Injecting a host-level
|
|
799
|
+
// AuthPlugin here would compete with the per-project one — its
|
|
800
|
+
// shared OS_AUTH_SECRET would erroneously validate cookies across
|
|
801
|
+
// unrelated projects. Refuse to inject in runtime mode.
|
|
802
|
+
const cloudUrl = process.env.OS_CLOUD_URL?.trim();
|
|
803
|
+
const isRuntimeMode = !!cloudUrl && cloudUrl.toLowerCase() !== 'local' && cloudUrl.toLowerCase() !== 'off';
|
|
804
|
+
if (isRuntimeMode) {
|
|
805
|
+
console.warn(chalk.yellow(' ⚠ AuthPlugin skipped on host kernel — runtime mode (OS_CLOUD_URL set).\n' +
|
|
806
|
+
' Auth is owned per-project by ArtifactKernelFactory (see service-cloud).'));
|
|
807
|
+
}
|
|
808
|
+
else if (!secret) {
|
|
502
809
|
console.warn(chalk.yellow(' ⚠ AuthPlugin skipped — set AUTH_SECRET to enable authentication in production'));
|
|
503
810
|
}
|
|
504
811
|
else {
|
|
@@ -510,10 +817,127 @@ export default class Serve extends Command {
|
|
|
510
817
|
socialProviders.google = { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET };
|
|
511
818
|
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET)
|
|
512
819
|
socialProviders.github = { clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET };
|
|
820
|
+
// Trusted origins (CSRF). better-auth uses a `*` glob that
|
|
821
|
+
// does NOT cross dot-separators, so `http://localhost:*` does
|
|
822
|
+
// not cover `http://<sub>.localhost:*`. Build the allow-list
|
|
823
|
+
// explicitly:
|
|
824
|
+
// - explicit `OS_TRUSTED_ORIGINS` (comma-separated) wins
|
|
825
|
+
// - else dev / preview defaults below
|
|
826
|
+
const trustedOrigins = [];
|
|
827
|
+
const explicitTrusted = process.env.OS_TRUSTED_ORIGINS?.trim();
|
|
828
|
+
if (explicitTrusted) {
|
|
829
|
+
explicitTrusted.split(',').map(s => s.trim()).filter(Boolean).forEach(o => {
|
|
830
|
+
if (!trustedOrigins.includes(o))
|
|
831
|
+
trustedOrigins.push(o);
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
// Always add the configured baseUrl so first-party redirects work.
|
|
835
|
+
try {
|
|
836
|
+
const u = new URL(baseUrl);
|
|
837
|
+
const baseOrigin = `${u.protocol}//${u.host}`;
|
|
838
|
+
if (!trustedOrigins.includes(baseOrigin))
|
|
839
|
+
trustedOrigins.push(baseOrigin);
|
|
840
|
+
}
|
|
841
|
+
catch { /* ignore malformed baseUrl */ }
|
|
842
|
+
// Preview-mode subdomain wildcards (`<commit>--<pid>.<base>`).
|
|
843
|
+
// Honour `OS_PREVIEW_BASE_DOMAINS` (used by service-cloud's
|
|
844
|
+
// preview routing) and add `http://*.<base>:*` patterns.
|
|
845
|
+
const previewMode = (process.env.OS_PREVIEW_MODE ?? '').trim().toLowerCase();
|
|
846
|
+
const isPreviewMode = previewMode === '1' || previewMode === 'true' || previewMode === 'yes';
|
|
847
|
+
if (isPreviewMode) {
|
|
848
|
+
const baseDomains = (process.env.OS_PREVIEW_BASE_DOMAINS
|
|
849
|
+
?? 'preview.objectstack.ai,localhost')
|
|
850
|
+
.split(',').map(s => s.trim()).filter(Boolean);
|
|
851
|
+
for (const dom of baseDomains) {
|
|
852
|
+
const isLoopback = dom === 'localhost' || dom.endsWith('.localhost');
|
|
853
|
+
const scheme = isLoopback ? 'http' : 'https';
|
|
854
|
+
const portSuffix = isLoopback ? ':*' : '';
|
|
855
|
+
const wildcard = `${scheme}://*.${dom}${portSuffix}`;
|
|
856
|
+
if (!trustedOrigins.includes(wildcard))
|
|
857
|
+
trustedOrigins.push(wildcard);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
// Dev convenience: keep `http://localhost:*` so plain
|
|
861
|
+
// `localhost:<port>` still works for non-preview Studio/Console.
|
|
862
|
+
if (isDev && !trustedOrigins.includes('http://localhost:*')) {
|
|
863
|
+
trustedOrigins.push('http://localhost:*');
|
|
864
|
+
}
|
|
865
|
+
// Per-project subdomains: when OS_ROOT_DOMAIN is set (multi-
|
|
866
|
+
// project hosting under `*.<root>`), every project hostname
|
|
867
|
+
// must be trusted by better-auth or sign-up/sign-in is
|
|
868
|
+
// rejected with "Invalid origin". Mirrors the OS_COOKIE_DOMAIN
|
|
869
|
+
// wildcard semantics — they are always set together.
|
|
870
|
+
const rootDomain = (process.env.OS_ROOT_DOMAIN ?? process.env.ROOT_DOMAIN)?.trim();
|
|
871
|
+
if (rootDomain) {
|
|
872
|
+
const wildcard = `https://*.${rootDomain}`;
|
|
873
|
+
if (!trustedOrigins.includes(wildcard))
|
|
874
|
+
trustedOrigins.push(wildcard);
|
|
875
|
+
}
|
|
876
|
+
// Collect application-defined org roles from the stack so
|
|
877
|
+
// Better-Auth's organization plugin accepts invitations to
|
|
878
|
+
// those roles (otherwise it 400s with `ROLE_NOT_FOUND`).
|
|
879
|
+
// Sources:
|
|
880
|
+
// - top-level `roles[]` (role hierarchy entries)
|
|
881
|
+
// - `permissions[]` PermissionSets where `isProfile === true`
|
|
882
|
+
// (these double as role identifiers; e.g. CRM Profiles)
|
|
883
|
+
// Real RBAC enforcement is still owned by SecurityPlugin, which
|
|
884
|
+
// matches these names against `permission` metadata entries.
|
|
885
|
+
const additionalOrgRoles = new Set();
|
|
886
|
+
try {
|
|
887
|
+
const stackAny = config ?? {};
|
|
888
|
+
const collect = (arr) => {
|
|
889
|
+
if (!Array.isArray(arr))
|
|
890
|
+
return;
|
|
891
|
+
for (const r of arr) {
|
|
892
|
+
const n = typeof r === 'string' ? r : (r && typeof r.name === 'string' ? r.name : null);
|
|
893
|
+
if (n && n !== 'owner' && n !== 'admin' && n !== 'member')
|
|
894
|
+
additionalOrgRoles.add(n);
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
collect(stackAny.roles);
|
|
898
|
+
if (Array.isArray(stackAny.permissions)) {
|
|
899
|
+
for (const p of stackAny.permissions) {
|
|
900
|
+
if (p && typeof p.name === 'string' && p.isProfile !== false) {
|
|
901
|
+
if (p.name !== 'owner' && p.name !== 'admin' && p.name !== 'member')
|
|
902
|
+
additionalOrgRoles.add(p.name);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
catch {
|
|
908
|
+
// best-effort
|
|
909
|
+
}
|
|
513
910
|
await kernel.use(new AuthPlugin({
|
|
514
911
|
secret,
|
|
515
912
|
baseUrl,
|
|
516
913
|
socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
|
|
914
|
+
trustedOrigins: trustedOrigins.length ? trustedOrigins : undefined,
|
|
915
|
+
...(additionalOrgRoles.size > 0 ? { additionalOrgRoles: Array.from(additionalOrgRoles) } : {}),
|
|
916
|
+
// Enable the admin plugin by default so the Setup app's
|
|
917
|
+
// ban/unban/set-password/impersonate/set-role row actions
|
|
918
|
+
// resolve to real endpoints. The plugin self-gates by role
|
|
919
|
+
// (only users whose `role` column is `admin` can hit
|
|
920
|
+
// /admin/* endpoints), so leaving it on for everyone is
|
|
921
|
+
// safe. Opt-out via OS_AUTH_ADMIN=false.
|
|
922
|
+
//
|
|
923
|
+
// Similarly enable twoFactor by default — it powers the
|
|
924
|
+
// Setup app's `sys_two_factor` toolbar actions (Enable 2FA,
|
|
925
|
+
// Disable 2FA). Opt-out via OS_AUTH_TWO_FACTOR=false.
|
|
926
|
+
//
|
|
927
|
+
// (api-key plugin: not yet shipped by better-auth — generic
|
|
928
|
+
// CRUD on `sys_api_key` handles row creation in the meantime.)
|
|
929
|
+
plugins: {
|
|
930
|
+
admin: String(process.env.OS_AUTH_ADMIN ?? 'true').toLowerCase() !== 'false',
|
|
931
|
+
twoFactor: String(process.env.OS_AUTH_TWO_FACTOR ?? 'true').toLowerCase() !== 'false',
|
|
932
|
+
},
|
|
933
|
+
advanced: process.env.OS_COOKIE_DOMAIN
|
|
934
|
+
? {
|
|
935
|
+
crossSubDomainCookies: {
|
|
936
|
+
enabled: true,
|
|
937
|
+
domain: process.env.OS_COOKIE_DOMAIN,
|
|
938
|
+
},
|
|
939
|
+
}
|
|
940
|
+
: undefined,
|
|
517
941
|
}));
|
|
518
942
|
trackPlugin('Auth');
|
|
519
943
|
// Pair: SecurityPlugin (RBAC) — optional
|
|
@@ -594,9 +1018,15 @@ export default class Serve extends Command {
|
|
|
594
1018
|
const apiConfig = config.api ?? {};
|
|
595
1019
|
const enableProjectScoping = apiConfig.enableProjectScoping ?? false;
|
|
596
1020
|
const projectResolution = apiConfig.projectResolution ?? 'auto';
|
|
1021
|
+
// `requireAuth: true` rejects anonymous requests on `/api/v1/data/*`
|
|
1022
|
+
// with HTTP 401 before they reach ObjectQL. Default-on when the
|
|
1023
|
+
// stack opts in OR when the resolved tier set includes `auth`
|
|
1024
|
+
// (because anonymous data access is almost never desirable when
|
|
1025
|
+
// auth is enabled). Apps can override via stack `api.requireAuth`.
|
|
1026
|
+
const requireAuth = apiConfig.requireAuth ?? (tierEnabled('auth') ? true : false);
|
|
597
1027
|
try {
|
|
598
1028
|
const { createRestApiPlugin } = await import('@objectstack/rest');
|
|
599
|
-
await kernel.use(createRestApiPlugin({ api: { api: { enableProjectScoping, projectResolution } } }));
|
|
1029
|
+
await kernel.use(createRestApiPlugin({ api: { api: { enableProjectScoping, projectResolution, requireAuth } } }));
|
|
600
1030
|
trackPlugin('RestAPI');
|
|
601
1031
|
}
|
|
602
1032
|
catch (e) {
|
|
@@ -702,6 +1132,31 @@ export default class Serve extends Command {
|
|
|
702
1132
|
export: 'PackageServicePlugin',
|
|
703
1133
|
nameMatch: ['service-package', 'PackageServicePlugin'],
|
|
704
1134
|
},
|
|
1135
|
+
email: {
|
|
1136
|
+
pkg: '@objectstack/plugin-email',
|
|
1137
|
+
export: 'EmailServicePlugin',
|
|
1138
|
+
nameMatch: ['plugin-email', 'EmailServicePlugin'],
|
|
1139
|
+
},
|
|
1140
|
+
sharing: {
|
|
1141
|
+
pkg: '@objectstack/plugin-sharing',
|
|
1142
|
+
export: 'SharingServicePlugin',
|
|
1143
|
+
nameMatch: ['plugin-sharing', 'SharingServicePlugin', 'SharingPlugin'],
|
|
1144
|
+
},
|
|
1145
|
+
reports: {
|
|
1146
|
+
pkg: '@objectstack/plugin-reports',
|
|
1147
|
+
export: 'ReportsServicePlugin',
|
|
1148
|
+
nameMatch: ['plugin-reports', 'ReportsServicePlugin'],
|
|
1149
|
+
},
|
|
1150
|
+
approvals: {
|
|
1151
|
+
pkg: '@objectstack/plugin-approvals',
|
|
1152
|
+
export: 'ApprovalsServicePlugin',
|
|
1153
|
+
nameMatch: ['plugin-approvals', 'ApprovalsServicePlugin'],
|
|
1154
|
+
},
|
|
1155
|
+
settings: {
|
|
1156
|
+
pkg: '@objectstack/service-settings',
|
|
1157
|
+
export: 'SettingsServicePlugin',
|
|
1158
|
+
nameMatch: ['service-settings', 'SettingsServicePlugin'],
|
|
1159
|
+
},
|
|
705
1160
|
};
|
|
706
1161
|
const hasPluginMatching = (fragments) => plugins.some((p) => {
|
|
707
1162
|
const n = String(p?.name ?? '');
|
|
@@ -727,6 +1182,74 @@ export default class Serve extends Command {
|
|
|
727
1182
|
const cubes = config.analyticsCubes ?? config.cubes ?? [];
|
|
728
1183
|
arg = { cubes };
|
|
729
1184
|
}
|
|
1185
|
+
else if (cap === 'email') {
|
|
1186
|
+
// Compose EmailServicePlugin options from config.email + OS_EMAIL_* env.
|
|
1187
|
+
// Env precedence: env beats config so operators can override per-environment.
|
|
1188
|
+
const cfgEmail = config.email ?? {};
|
|
1189
|
+
const envProvider = process.env.OS_EMAIL_PROVIDER;
|
|
1190
|
+
const provider = (envProvider || cfgEmail.provider || 'log').toLowerCase();
|
|
1191
|
+
const apiKey = process.env.OS_EMAIL_API_KEY || cfgEmail.apiKey;
|
|
1192
|
+
const envFrom = process.env.OS_EMAIL_FROM;
|
|
1193
|
+
// OS_EMAIL_FROM supports either "addr@x" or "Name <addr@x>".
|
|
1194
|
+
let defaultFrom = cfgEmail.defaultFrom;
|
|
1195
|
+
if (envFrom) {
|
|
1196
|
+
const m = envFrom.match(/^\s*(?:"?([^"<]*?)"?\s*<\s*([^>]+)\s*>|(\S+))\s*$/);
|
|
1197
|
+
if (m) {
|
|
1198
|
+
const name = (m[1] ?? '').trim();
|
|
1199
|
+
const address = (m[2] ?? m[3] ?? '').trim();
|
|
1200
|
+
if (address)
|
|
1201
|
+
defaultFrom = name ? { name, address } : { address };
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
const retries = process.env.OS_EMAIL_RETRIES
|
|
1205
|
+
? Number(process.env.OS_EMAIL_RETRIES)
|
|
1206
|
+
: cfgEmail.retries;
|
|
1207
|
+
const defaultTemplateContext = {
|
|
1208
|
+
appName: process.env.OS_APP_NAME || cfgEmail.appName || config.appName || 'ObjectStack',
|
|
1209
|
+
...(cfgEmail.defaultTemplateContext || {}),
|
|
1210
|
+
};
|
|
1211
|
+
// Provide a sensible fallback `from` so templates can render
|
|
1212
|
+
// even before operators configure SMTP/SaaS. The log transport
|
|
1213
|
+
// simply prints to stdout; the address never leaves the box.
|
|
1214
|
+
if (!defaultFrom) {
|
|
1215
|
+
const slug = String(defaultTemplateContext.appName || 'objectstack')
|
|
1216
|
+
.toLowerCase()
|
|
1217
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
1218
|
+
.replace(/^-+|-+$/g, '') || 'objectstack';
|
|
1219
|
+
defaultFrom = { name: defaultTemplateContext.appName, address: `no-reply@${slug}.local` };
|
|
1220
|
+
}
|
|
1221
|
+
arg = {
|
|
1222
|
+
provider,
|
|
1223
|
+
...(apiKey ? { apiKey } : {}),
|
|
1224
|
+
defaultFrom,
|
|
1225
|
+
...(retries != null && !Number.isNaN(retries) ? { retries } : {}),
|
|
1226
|
+
defaultTemplateContext,
|
|
1227
|
+
};
|
|
1228
|
+
if (provider !== 'log' && !apiKey) {
|
|
1229
|
+
console.warn(chalk.yellow(` ⚠ Capability "email": provider='${provider}' but no apiKey found (set OS_EMAIL_API_KEY or config.email.apiKey). Falling back to LogTransport.`));
|
|
1230
|
+
arg.provider = 'log';
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
else if (cap === 'storage') {
|
|
1234
|
+
// Storage is now in the default capability slate. If the host
|
|
1235
|
+
// hasn't configured a backend explicitly we fall back to the
|
|
1236
|
+
// local-disk driver under `.objectstack/data/uploads/` so
|
|
1237
|
+
// avatars / attachments / report files work out of the box.
|
|
1238
|
+
// In production mode we emit a single loud warning so the
|
|
1239
|
+
// operator knows to point storage at S3 / GCS / Azure before
|
|
1240
|
+
// shipping (data on a single pod is volatile / non-replicated).
|
|
1241
|
+
const cfgStorage = config.storage;
|
|
1242
|
+
if (cfgStorage && (cfgStorage.driver || cfgStorage.adapter)) {
|
|
1243
|
+
arg = cfgStorage;
|
|
1244
|
+
}
|
|
1245
|
+
else {
|
|
1246
|
+
const root = process.env.OS_STORAGE_ROOT || '.objectstack/data/uploads';
|
|
1247
|
+
arg = { driver: 'local', root };
|
|
1248
|
+
if (!isDev) {
|
|
1249
|
+
console.warn(chalk.yellow(` ⚠ StorageServicePlugin using local driver (${root}) — switch to S3/GCS/Azure for production (set config.storage or OS_STORAGE_*).`));
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
730
1253
|
await kernel.use(arg !== undefined ? new Ctor(arg) : new Ctor());
|
|
731
1254
|
trackPlugin(spec.export);
|
|
732
1255
|
if (spec.extras) {
|
|
@@ -764,22 +1287,34 @@ export default class Serve extends Command {
|
|
|
764
1287
|
if (enableUI) {
|
|
765
1288
|
// Pre-detect Console availability so we can demote Studio's root
|
|
766
1289
|
// redirect when the Console is going to claim `/`.
|
|
767
|
-
|
|
1290
|
+
// The `--no-console` flag (or OS_DISABLE_CONSOLE=1 env var) lets a
|
|
1291
|
+
// host (e.g. apps/cloud) opt out of the Console entirely so Studio
|
|
1292
|
+
// owns `/` — useful for control-plane deployments where the
|
|
1293
|
+
// runtime Console is meaningless.
|
|
1294
|
+
const consoleEnabled = flags.console && process.env.OS_DISABLE_CONSOLE !== '1';
|
|
1295
|
+
const consolePath = consoleEnabled ? resolveConsolePath() : null;
|
|
768
1296
|
const consoleWillMount = !!(consolePath && hasConsoleDist(consolePath));
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
1297
|
+
// The `OS_DISABLE_STUDIO=1` env var lets a host (e.g. apps/cloud,
|
|
1298
|
+
// which is a pure control plane) opt out of the Studio designer
|
|
1299
|
+
// entirely while keeping Account/Console. Studio is meaningless
|
|
1300
|
+
// when there are no per-project kernels in the same process.
|
|
1301
|
+
const studioEnabled = process.env.OS_DISABLE_STUDIO !== '1';
|
|
1302
|
+
if (studioEnabled) {
|
|
1303
|
+
const studioPath = resolveStudioPath();
|
|
1304
|
+
if (!studioPath) {
|
|
1305
|
+
console.warn(chalk.yellow(` ⚠ @objectstack/studio not found — skipping UI`));
|
|
1306
|
+
}
|
|
1307
|
+
else if (hasStudioDist(studioPath)) {
|
|
1308
|
+
const distPath = path.join(studioPath, 'dist');
|
|
1309
|
+
await kernel.use(createStudioStaticPlugin(distPath, {
|
|
1310
|
+
isDev,
|
|
1311
|
+
rootRedirect: !consoleWillMount,
|
|
1312
|
+
}));
|
|
1313
|
+
trackPlugin('StudioUI');
|
|
1314
|
+
}
|
|
1315
|
+
else {
|
|
1316
|
+
console.warn(chalk.yellow(` ⚠ Studio dist not found — run "pnpm --filter @objectstack/studio build" first`));
|
|
1317
|
+
}
|
|
783
1318
|
}
|
|
784
1319
|
// ── Account portal ─────────────────────────────────────────
|
|
785
1320
|
// The account portal sits next to Studio under `/_account/` and
|
|
@@ -819,6 +1354,24 @@ export default class Serve extends Command {
|
|
|
819
1354
|
// Brief delay to allow logger writes to flush before restoring stdout
|
|
820
1355
|
await new Promise(r => setTimeout(r, 100));
|
|
821
1356
|
restoreOutput();
|
|
1357
|
+
// ── Migrate-and-exit short-circuit ─────────────────────────────
|
|
1358
|
+
// Out-of-band migration mode: the caller (e.g.
|
|
1359
|
+
// `apps/cloud/scripts/migrate.ts`) just wants the kernel
|
|
1360
|
+
// bootstrap (ObjectQLPlugin → schema sync → metadata hydration)
|
|
1361
|
+
// to run once against the configured database, then exit. The
|
|
1362
|
+
// HTTP server has already bound `port` at this point but we
|
|
1363
|
+
// never accept a request — shutdown immediately so the deploy
|
|
1364
|
+
// pipeline can move on.
|
|
1365
|
+
if (process.env.OS_MIGRATE_AND_EXIT === '1') {
|
|
1366
|
+
console.log(chalk.green(`✓ Migration complete (${loadedPlugins.length} plugins started against ${redactDbUrl(resolvedDatabaseUrl) || 'configured DB'})`));
|
|
1367
|
+
try {
|
|
1368
|
+
await kernel.shutdown();
|
|
1369
|
+
}
|
|
1370
|
+
catch (err) {
|
|
1371
|
+
console.warn(chalk.yellow(` ⚠ shutdown warning: ${err?.message ?? err}`));
|
|
1372
|
+
}
|
|
1373
|
+
process.exit(0);
|
|
1374
|
+
}
|
|
822
1375
|
// ── Driver introspection ──────────────────────────────────────
|
|
823
1376
|
// When the driver was registered by an app preset / per-project
|
|
824
1377
|
// factory (ProjectKernelFactory) instead of serve.ts's own
|