@objectstack/cli 4.0.4 → 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 +44 -25
- package/dist/commands/build.d.ts +5 -0
- package/dist/commands/build.d.ts.map +1 -0
- package/dist/commands/build.js +6 -0
- package/dist/commands/build.js.map +1 -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/compile.d.ts +3 -0
- package/dist/commands/compile.d.ts.map +1 -1
- package/dist/commands/compile.js +128 -6
- package/dist/commands/compile.js.map +1 -1
- package/dist/commands/create.js +1 -1
- package/dist/commands/data/create.js +2 -2
- package/dist/commands/data/create.js.map +1 -1
- package/dist/commands/data/delete.js +2 -2
- package/dist/commands/data/delete.js.map +1 -1
- package/dist/commands/data/get.js +2 -2
- package/dist/commands/data/get.js.map +1 -1
- package/dist/commands/data/query.js +2 -2
- package/dist/commands/data/query.js.map +1 -1
- package/dist/commands/data/update.js +2 -2
- package/dist/commands/data/update.js.map +1 -1
- package/dist/commands/dev.d.ts +9 -0
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +116 -22
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/generate.js +9 -9
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/i18n/check.d.ts +18 -0
- package/dist/commands/i18n/check.d.ts.map +1 -0
- package/dist/commands/i18n/check.js +153 -0
- package/dist/commands/i18n/check.js.map +1 -0
- package/dist/commands/init.js +2 -2
- package/dist/commands/lint.d.ts +3 -0
- package/dist/commands/lint.d.ts.map +1 -1
- package/dist/commands/lint.js +24 -0
- package/dist/commands/lint.js.map +1 -1
- package/dist/commands/login.d.ts +17 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +313 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/{auth/logout.js → logout.js} +14 -2
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/meta/delete.js +2 -2
- package/dist/commands/meta/delete.js.map +1 -1
- package/dist/commands/meta/get.js +2 -2
- package/dist/commands/meta/get.js.map +1 -1
- package/dist/commands/meta/list.js +2 -2
- package/dist/commands/meta/list.js.map +1 -1
- package/dist/commands/meta/register.js +2 -2
- package/dist/commands/meta/register.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/projects/bind.d.ts +30 -0
- package/dist/commands/projects/bind.d.ts.map +1 -0
- package/dist/commands/projects/bind.js +132 -0
- package/dist/commands/projects/bind.js.map +1 -0
- package/dist/commands/projects/create.d.ts +28 -0
- package/dist/commands/projects/create.d.ts.map +1 -0
- package/dist/commands/projects/create.js +120 -0
- package/dist/commands/projects/create.js.map +1 -0
- package/dist/commands/projects/list.d.ts +21 -0
- package/dist/commands/projects/list.d.ts.map +1 -0
- package/dist/commands/projects/list.js +79 -0
- package/dist/commands/projects/list.js.map +1 -0
- package/dist/commands/projects/projects.test.d.ts +2 -0
- package/dist/commands/projects/projects.test.d.ts.map +1 -0
- package/dist/commands/projects/projects.test.js +56 -0
- package/dist/commands/projects/projects.test.js.map +1 -0
- package/dist/commands/projects/show.d.ts +21 -0
- package/dist/commands/projects/show.d.ts.map +1 -0
- package/dist/commands/projects/show.js +72 -0
- package/dist/commands/projects/show.js.map +1 -0
- package/dist/commands/projects/switch.d.ts +24 -0
- package/dist/commands/projects/switch.d.ts.map +1 -0
- package/dist/commands/projects/switch.js +64 -0
- package/dist/commands/projects/switch.js.map +1 -0
- package/dist/commands/publish.d.ts +17 -0
- package/dist/commands/publish.d.ts.map +1 -0
- package/dist/commands/publish.js +135 -0
- package/dist/commands/publish.js.map +1 -0
- package/dist/commands/{auth/login.d.ts → register.d.ts} +3 -2
- package/dist/commands/register.d.ts.map +1 -0
- package/dist/commands/{auth/login.js → register.js} +44 -61
- package/dist/commands/register.js.map +1 -0
- 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 +22 -0
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +1173 -58
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/start.d.ts +18 -0
- package/dist/commands/start.d.ts.map +1 -0
- package/dist/commands/start.js +112 -0
- package/dist/commands/start.js.map +1 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/{auth/whoami.js → whoami.js} +5 -5
- package/dist/commands/whoami.js.map +1 -0
- package/dist/index.d.ts +11 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -5
- package/dist/index.js.map +1 -1
- package/dist/utils/account.d.ts +31 -0
- package/dist/utils/account.d.ts.map +1 -0
- package/dist/utils/account.js +154 -0
- package/dist/utils/account.js.map +1 -0
- package/dist/utils/api-client.d.ts +10 -4
- package/dist/utils/api-client.d.ts.map +1 -1
- package/dist/utils/api-client.js +13 -7
- package/dist/utils/api-client.js.map +1 -1
- package/dist/utils/auth-config.d.ts +6 -0
- package/dist/utils/auth-config.d.ts.map +1 -1
- package/dist/utils/auth-config.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/build-runtime.d.ts +45 -0
- package/dist/utils/build-runtime.d.ts.map +1 -0
- package/dist/utils/build-runtime.js +154 -0
- package/dist/utils/build-runtime.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/config.d.ts.map +1 -1
- package/dist/utils/config.js +17 -2
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/console.d.ts +33 -0
- package/dist/utils/console.d.ts.map +1 -0
- package/dist/utils/console.js +172 -0
- package/dist/utils/console.js.map +1 -0
- package/dist/utils/extract-hook-body.d.ts +13 -0
- package/dist/utils/extract-hook-body.d.ts.map +1 -0
- package/dist/utils/extract-hook-body.js +175 -0
- package/dist/utils/extract-hook-body.js.map +1 -0
- package/dist/utils/format.d.ts +8 -0
- package/dist/utils/format.d.ts.map +1 -1
- package/dist/utils/format.js +15 -2
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/i18n-coverage.d.ts +61 -0
- package/dist/utils/i18n-coverage.d.ts.map +1 -0
- package/dist/utils/i18n-coverage.js +176 -0
- package/dist/utils/i18n-coverage.js.map +1 -0
- package/dist/utils/lower-callables.d.ts +17 -0
- package/dist/utils/lower-callables.d.ts.map +1 -0
- package/dist/utils/lower-callables.js +181 -0
- package/dist/utils/lower-callables.js.map +1 -0
- package/dist/utils/plugin-detection.d.ts +1 -0
- package/dist/utils/plugin-detection.d.ts.map +1 -1
- package/dist/utils/plugin-detection.js +41 -0
- package/dist/utils/plugin-detection.js.map +1 -1
- package/dist/utils/studio.d.ts +1 -0
- package/dist/utils/studio.d.ts.map +1 -1
- package/dist/utils/studio.js +24 -9
- package/dist/utils/studio.js.map +1 -1
- package/package.json +60 -22
- package/.turbo/turbo-build.log +0 -4
- package/CHANGELOG.md +0 -821
- package/bin/run-dev.js +0 -5
- package/dist/commands/auth/login.d.ts.map +0 -1
- package/dist/commands/auth/login.js.map +0 -1
- package/dist/commands/auth/logout.d.ts.map +0 -1
- package/dist/commands/auth/logout.js.map +0 -1
- package/dist/commands/auth/whoami.d.ts.map +0 -1
- package/dist/commands/auth/whoami.js.map +0 -1
- package/dist/commands/codemod/v2-to-v3.d.ts +0 -10
- package/dist/commands/codemod/v2-to-v3.d.ts.map +0 -1
- package/dist/commands/codemod/v2-to-v3.js +0 -145
- package/dist/commands/codemod/v2-to-v3.js.map +0 -1
- package/dist/commands/plugin/add.d.ts +0 -22
- package/dist/commands/plugin/add.d.ts.map +0 -1
- package/dist/commands/plugin/add.js +0 -93
- package/dist/commands/plugin/add.js.map +0 -1
- package/dist/commands/plugin/build.d.ts +0 -29
- package/dist/commands/plugin/build.d.ts.map +0 -1
- package/dist/commands/plugin/build.js +0 -170
- package/dist/commands/plugin/build.js.map +0 -1
- package/dist/commands/plugin/info.d.ts +0 -10
- package/dist/commands/plugin/info.d.ts.map +0 -1
- package/dist/commands/plugin/info.js +0 -65
- package/dist/commands/plugin/info.js.map +0 -1
- package/dist/commands/plugin/list.d.ts +0 -13
- package/dist/commands/plugin/list.d.ts.map +0 -1
- package/dist/commands/plugin/list.js +0 -78
- package/dist/commands/plugin/list.js.map +0 -1
- package/dist/commands/plugin/publish.d.ts +0 -27
- package/dist/commands/plugin/publish.d.ts.map +0 -1
- package/dist/commands/plugin/publish.js +0 -152
- package/dist/commands/plugin/publish.js.map +0 -1
- package/dist/commands/plugin/remove.d.ts +0 -20
- package/dist/commands/plugin/remove.d.ts.map +0 -1
- package/dist/commands/plugin/remove.js +0 -79
- package/dist/commands/plugin/remove.js.map +0 -1
- package/dist/commands/plugin/validate.d.ts +0 -23
- package/dist/commands/plugin/validate.d.ts.map +0 -1
- package/dist/commands/plugin/validate.js +0 -251
- package/dist/commands/plugin/validate.js.map +0 -1
- package/src/bin.ts +0 -13
- package/src/commands/auth/login.ts +0 -188
- package/src/commands/auth/logout.ts +0 -51
- package/src/commands/auth/whoami.ts +0 -85
- package/src/commands/codemod/v2-to-v3.ts +0 -171
- package/src/commands/compile.ts +0 -114
- package/src/commands/create.ts +0 -281
- package/src/commands/data/create.ts +0 -110
- package/src/commands/data/delete.ts +0 -84
- package/src/commands/data/get.ts +0 -84
- package/src/commands/data/query.ts +0 -127
- package/src/commands/data/update.ts +0 -114
- package/src/commands/dev.ts +0 -83
- package/src/commands/diff.ts +0 -294
- package/src/commands/doctor.ts +0 -572
- package/src/commands/explain.ts +0 -412
- package/src/commands/generate.ts +0 -924
- package/src/commands/info.ts +0 -124
- package/src/commands/init.ts +0 -327
- package/src/commands/lint.ts +0 -315
- package/src/commands/meta/delete.ts +0 -79
- package/src/commands/meta/get.ts +0 -73
- package/src/commands/meta/list.ts +0 -105
- package/src/commands/meta/register.ts +0 -97
- package/src/commands/plugin/add.ts +0 -112
- package/src/commands/plugin/build.ts +0 -193
- package/src/commands/plugin/info.ts +0 -79
- package/src/commands/plugin/list.ts +0 -93
- package/src/commands/plugin/publish.ts +0 -176
- package/src/commands/plugin/remove.ts +0 -97
- package/src/commands/plugin/validate.ts +0 -268
- package/src/commands/serve.ts +0 -411
- package/src/commands/studio.ts +0 -52
- package/src/commands/test.ts +0 -135
- package/src/commands/validate.ts +0 -143
- package/src/index.ts +0 -22
- package/src/utils/api-client.ts +0 -88
- package/src/utils/auth-config.ts +0 -107
- package/src/utils/config.ts +0 -80
- package/src/utils/format.ts +0 -267
- package/src/utils/output-formatter.ts +0 -91
- package/src/utils/plugin-detection.ts +0 -16
- package/src/utils/plugin-helpers.ts +0 -37
- package/src/utils/studio.ts +0 -350
- package/test/commands.test.ts +0 -128
- package/test/create.test.ts +0 -25
- package/test/plugin-commands.test.ts +0 -44
- package/test/plugin.test.ts +0 -169
- package/test/remote-api-commands.test.ts +0 -188
- package/test/remote-api-utils.test.ts +0 -196
- package/test/serve-host-config.test.ts +0 -77
- package/tsconfig.build.json +0 -20
- package/tsconfig.json +0 -25
- package/tsup.config.ts +0 -23
- /package/dist/commands/{auth/logout.d.ts → logout.d.ts} +0 -0
- /package/dist/commands/{auth/whoami.d.ts → whoami.d.ts} +0 -0
package/dist/commands/serve.js
CHANGED
|
@@ -5,9 +5,11 @@ import fs from 'fs';
|
|
|
5
5
|
import net from 'net';
|
|
6
6
|
import chalk from 'chalk';
|
|
7
7
|
import { bundleRequire } from 'bundle-require';
|
|
8
|
-
import {
|
|
8
|
+
import { shouldBootWithLibrary } from '../utils/plugin-detection.js';
|
|
9
9
|
import { printError, printServerReady, } from '../utils/format.js';
|
|
10
10
|
import { STUDIO_PATH, resolveStudioPath, hasStudioDist, createStudioStaticPlugin, } from '../utils/studio.js';
|
|
11
|
+
import { ACCOUNT_PATH, resolveAccountPath, hasAccountDist, createAccountStaticPlugin, } from '../utils/account.js';
|
|
12
|
+
import { CONSOLE_PATH, resolveConsolePath, hasConsoleDist, createConsoleStaticPlugin, } from '../utils/console.js';
|
|
11
13
|
import dotenvFlow from 'dotenv-flow';
|
|
12
14
|
// Helper to find available port
|
|
13
15
|
const getAvailablePort = async (startPort) => {
|
|
@@ -33,18 +35,61 @@ const getAvailablePort = async (startPort) => {
|
|
|
33
35
|
return port;
|
|
34
36
|
};
|
|
35
37
|
export default class Serve extends Command {
|
|
36
|
-
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.';
|
|
37
39
|
static args = {
|
|
38
40
|
config: Args.string({ description: 'Configuration file path', required: false, default: 'objectstack.config.ts' }),
|
|
39
41
|
};
|
|
40
42
|
static flags = {
|
|
41
|
-
port: Flags.string({ char: 'p', description: 'Server port', default: '3000' }),
|
|
43
|
+
port: Flags.string({ char: 'p', description: 'Server port', default: process.env.PORT ?? '3000' }),
|
|
42
44
|
dev: Flags.boolean({ description: 'Run in development mode (load devPlugins)' }),
|
|
43
|
-
ui: Flags.boolean({ description: 'Enable Studio UI at /_studio/ (default: true
|
|
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
|
+
}),
|
|
44
51
|
server: Flags.boolean({ description: 'Start HTTP server plugin', default: true, allowNo: true }),
|
|
52
|
+
prebuilt: Flags.boolean({ description: 'Skip esbuild/bundle-require — load config as native ESM (production mode)', default: false }),
|
|
53
|
+
preset: Flags.string({
|
|
54
|
+
description: 'Plugin tier preset: minimal | default | full (overridden by config.tiers if set)',
|
|
55
|
+
options: ['minimal', 'default', 'full'],
|
|
56
|
+
}),
|
|
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
|
+
]);
|
|
73
|
+
/**
|
|
74
|
+
* Auto-registered plugin tiers. Plugins explicitly listed in
|
|
75
|
+
* `config.plugins` are always loaded — tiers only gate the optional
|
|
76
|
+
* auto-registration blocks below (AIService, I18n, Studio UI, etc.).
|
|
77
|
+
*/
|
|
78
|
+
static TIER_PRESETS = {
|
|
79
|
+
minimal: ['core'],
|
|
80
|
+
default: ['core', 'i18n', 'ui', 'auth'],
|
|
81
|
+
full: ['core', 'i18n', 'ui', 'ai', 'auth'],
|
|
45
82
|
};
|
|
46
83
|
async run() {
|
|
47
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
|
+
}
|
|
48
93
|
let port = parseInt(flags.port);
|
|
49
94
|
try {
|
|
50
95
|
const availablePort = await getAvailablePort(port);
|
|
@@ -63,14 +108,36 @@ export default class Serve extends Command {
|
|
|
63
108
|
const isDev = flags.dev || process.env.NODE_ENV === 'development';
|
|
64
109
|
const absolutePath = path.resolve(process.cwd(), args.config);
|
|
65
110
|
const relativeConfig = path.relative(process.cwd(), absolutePath);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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;
|
|
70
132
|
}
|
|
71
133
|
// Quiet loading — only show a single spinner line
|
|
72
134
|
console.log('');
|
|
73
|
-
|
|
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
|
+
}
|
|
74
141
|
// Track loaded plugins for summary
|
|
75
142
|
const loadedPlugins = [];
|
|
76
143
|
const shortPluginName = (raw) => {
|
|
@@ -86,6 +153,20 @@ export default class Serve extends Command {
|
|
|
86
153
|
return raw;
|
|
87
154
|
};
|
|
88
155
|
const trackPlugin = (name) => { loadedPlugins.push(shortPluginName(name)); };
|
|
156
|
+
// Track resolved storage driver + redacted URL for the startup banner.
|
|
157
|
+
let resolvedDriverLabel;
|
|
158
|
+
let resolvedDatabaseUrl;
|
|
159
|
+
const redactDbUrl = (url) => {
|
|
160
|
+
if (!url)
|
|
161
|
+
return undefined;
|
|
162
|
+
try {
|
|
163
|
+
// Redact passwords inside connection URLs: protocol://user:****@host/db
|
|
164
|
+
return url.replace(/(\/\/[^/@:]+):[^/@]+@/, '$1:****@');
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return url;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
89
170
|
// Save original console/stdout methods — we'll suppress noise during boot
|
|
90
171
|
const originalConsoleLog = console.log;
|
|
91
172
|
const originalConsoleDebug = console.debug;
|
|
@@ -116,13 +197,145 @@ export default class Serve extends Command {
|
|
|
116
197
|
console.debug = (...args) => { if (!bootQuiet)
|
|
117
198
|
originalConsoleDebug(...args); };
|
|
118
199
|
// Load configuration
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
200
|
+
// --prebuilt: load as native ESM (no esbuild, no bundle-require) —
|
|
201
|
+
// intended for production where the config has been compiled to dist/.
|
|
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 });
|
|
209
|
+
let config = mod.default || mod;
|
|
210
|
+
if (!useArtifactFallback && !config) {
|
|
124
211
|
throw new Error(`No default export found in ${args.config}`);
|
|
125
212
|
}
|
|
213
|
+
// Preserve module-level named exports (e.g. `onEnable`, `onDisable`
|
|
214
|
+
// lifecycle hooks) that would otherwise be dropped when we unwrap
|
|
215
|
+
// `mod.default`. Without this AppPlugin can never invoke runtime hooks
|
|
216
|
+
// declared as `export const onEnable = ...` alongside the default
|
|
217
|
+
// `defineStack(...)` export.
|
|
218
|
+
if (mod.default != null && config !== mod) {
|
|
219
|
+
const merged = { ...config };
|
|
220
|
+
for (const key of Object.keys(mod)) {
|
|
221
|
+
if (key === 'default' || key in merged)
|
|
222
|
+
continue;
|
|
223
|
+
merged[key] = mod[key];
|
|
224
|
+
}
|
|
225
|
+
config = merged;
|
|
226
|
+
}
|
|
227
|
+
// Boot-mode dispatch: standalone goes directly through
|
|
228
|
+
// `@objectstack/runtime` (no cloud dependencies). runtime/cloud
|
|
229
|
+
// modes go through `@objectstack/service-cloud`.
|
|
230
|
+
if (useArtifactFallback || shouldBootWithLibrary(config)) {
|
|
231
|
+
// The boot stack returns only `{plugins, api}` — preserve the
|
|
232
|
+
// original stack metadata (notably `requires`, `analyticsCubes`,
|
|
233
|
+
// `tiers`) so the capability resolver further down can read it.
|
|
234
|
+
const originalConfig = config;
|
|
235
|
+
const resolvedMode = config.bootMode ?? process.env.OS_MODE ?? 'standalone';
|
|
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') {
|
|
245
|
+
const { createStandaloneStack } = await import('@objectstack/runtime');
|
|
246
|
+
const bootResult = await createStandaloneStack(config.standalone);
|
|
247
|
+
config = { ...originalConfig, ...bootResult };
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
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
|
+
}
|
|
263
|
+
const bootResult = await createBootStack({
|
|
264
|
+
mode: config.bootMode,
|
|
265
|
+
runtime: config.runtime ?? config.project,
|
|
266
|
+
cloud: config.cloud,
|
|
267
|
+
});
|
|
268
|
+
config = { ...originalConfig, ...bootResult };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// ── Resolve plugin tiers ──────────────────────────────────────
|
|
272
|
+
// Precedence: config.requires (capability declarations) >
|
|
273
|
+
// config.tiers > --preset > built-in default.
|
|
274
|
+
//
|
|
275
|
+
// `requires: ['ai', 'automation', ...]` is the recommended
|
|
276
|
+
// app-level way to declare platform dependencies. The CLI
|
|
277
|
+
// expands each capability name into the matching tier so the
|
|
278
|
+
// optional auto-registration blocks below light up without
|
|
279
|
+
// extra flags. Explicitly-listed `config.plugins` always load
|
|
280
|
+
// and shadow any capability resolution (i.e. an explicit
|
|
281
|
+
// instance wins over the auto-loader).
|
|
282
|
+
const presetName = flags.preset ?? (isDev ? 'default' : 'default');
|
|
283
|
+
const presetTiers = Serve.TIER_PRESETS[presetName] ?? Serve.TIER_PRESETS.default;
|
|
284
|
+
const requires = Array.isArray(config.requires)
|
|
285
|
+
? config.requires.filter((c) => typeof c === 'string')
|
|
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
|
+
}
|
|
319
|
+
// Capability → tier: any capability that is gated by a tier
|
|
320
|
+
// here automatically opens that tier when listed in `requires`.
|
|
321
|
+
// Capabilities NOT in this map (e.g. `automation`, `analytics`,
|
|
322
|
+
// `audit`) bypass tier gating and are loaded directly by the
|
|
323
|
+
// capability-resolver block further down.
|
|
324
|
+
const CAPABILITY_TO_TIER = {
|
|
325
|
+
ai: 'ai',
|
|
326
|
+
i18n: 'i18n',
|
|
327
|
+
ui: 'ui',
|
|
328
|
+
auth: 'auth',
|
|
329
|
+
};
|
|
330
|
+
const requiredTiers = requires
|
|
331
|
+
.map((c) => CAPABILITY_TO_TIER[c])
|
|
332
|
+
.filter((t) => typeof t === 'string');
|
|
333
|
+
const baseTiers = Array.isArray(config.tiers) && config.tiers.length > 0
|
|
334
|
+
? config.tiers
|
|
335
|
+
: presetTiers;
|
|
336
|
+
const tiers = new Set([...baseTiers, ...requiredTiers]);
|
|
337
|
+
const tierEnabled = (t) => tiers.has(t);
|
|
338
|
+
const requiresCapability = (c) => requires.includes(c);
|
|
126
339
|
// Import ObjectStack runtime
|
|
127
340
|
const { Runtime } = await import('@objectstack/runtime');
|
|
128
341
|
// Set kernel logger to 'silent' — the CLI manages its own output
|
|
@@ -151,24 +364,120 @@ export default class Serve extends Command {
|
|
|
151
364
|
// silent
|
|
152
365
|
}
|
|
153
366
|
}
|
|
154
|
-
// 2. Auto-register
|
|
367
|
+
// 2. Auto-register storage driver
|
|
368
|
+
// Priority:
|
|
369
|
+
// 1. OS_DATABASE_DRIVER env var (explicit override)
|
|
370
|
+
// 2. URL scheme inferred from OS_DATABASE_URL
|
|
371
|
+
// mongodb://, mongodb+srv:// → mongodb
|
|
372
|
+
// postgres://, postgresql:// → postgres
|
|
373
|
+
// mysql://, mysql2:// → mysql
|
|
374
|
+
// libsql://, http(s):// + .turso. → turso
|
|
375
|
+
// file:, sqlite:, *.db, :memory: → sqlite
|
|
376
|
+
// 3. Default: InMemoryDriver in dev mode
|
|
155
377
|
const hasDriver = plugins.some((p) => p.name?.includes('driver') || p.constructor?.name?.includes('Driver'));
|
|
156
|
-
if (
|
|
378
|
+
if (!hasDriver && config.objects) {
|
|
379
|
+
const explicitDriver = (process.env.OS_DATABASE_DRIVER ?? '').toLowerCase().trim();
|
|
380
|
+
const databaseUrl = process.env.OS_DATABASE_URL;
|
|
381
|
+
const inferDriverFromUrl = (url) => {
|
|
382
|
+
if (!url)
|
|
383
|
+
return '';
|
|
384
|
+
const u = url.trim();
|
|
385
|
+
if (/^mongodb(\+srv)?:\/\//i.test(u))
|
|
386
|
+
return 'mongodb';
|
|
387
|
+
if (/^postgres(ql)?:\/\//i.test(u))
|
|
388
|
+
return 'postgres';
|
|
389
|
+
if (/^mysql2?:\/\//i.test(u))
|
|
390
|
+
return 'mysql';
|
|
391
|
+
if (/^libsql:\/\//i.test(u))
|
|
392
|
+
return 'turso';
|
|
393
|
+
if (/^https?:\/\//i.test(u) && /\.turso\./i.test(u))
|
|
394
|
+
return 'turso';
|
|
395
|
+
if (/^file:/i.test(u) || /^sqlite:/i.test(u) || u === ':memory:' || /\.(db|sqlite|sqlite3)$/i.test(u))
|
|
396
|
+
return 'sqlite';
|
|
397
|
+
return '';
|
|
398
|
+
};
|
|
399
|
+
const driverType = explicitDriver || inferDriverFromUrl(databaseUrl);
|
|
157
400
|
try {
|
|
158
401
|
const { DriverPlugin } = await import('@objectstack/runtime');
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
402
|
+
if (driverType === 'mongodb' || driverType === 'mongo') {
|
|
403
|
+
const { MongoDBDriver } = await import('@objectstack/driver-mongodb');
|
|
404
|
+
await kernel.use(new DriverPlugin(new MongoDBDriver({
|
|
405
|
+
url: databaseUrl ?? 'mongodb://localhost:27017/objectstack',
|
|
406
|
+
})));
|
|
407
|
+
trackPlugin('MongoDBDriver');
|
|
408
|
+
resolvedDriverLabel = 'MongoDBDriver';
|
|
409
|
+
resolvedDatabaseUrl = databaseUrl ?? 'mongodb://localhost:27017/objectstack';
|
|
410
|
+
}
|
|
411
|
+
else if (driverType === 'sqlite' || driverType === 'sql') {
|
|
412
|
+
const { SqlDriver } = await import('@objectstack/driver-sql');
|
|
413
|
+
const filePath = (databaseUrl ?? ':memory:').replace(/^file:/, '').replace(/^sqlite:/, '').replace(/^sql:\/\//, '');
|
|
414
|
+
await kernel.use(new DriverPlugin(new SqlDriver({
|
|
415
|
+
client: 'better-sqlite3',
|
|
416
|
+
connection: { filename: filePath },
|
|
417
|
+
useNullAsDefault: true,
|
|
418
|
+
})));
|
|
419
|
+
trackPlugin('SqlDriver');
|
|
420
|
+
resolvedDriverLabel = 'SqlDriver(sqlite)';
|
|
421
|
+
resolvedDatabaseUrl = databaseUrl ?? ':memory:';
|
|
422
|
+
}
|
|
423
|
+
else if (driverType === 'postgres' || driverType === 'postgresql' || driverType === 'pg') {
|
|
424
|
+
const { SqlDriver } = await import('@objectstack/driver-sql');
|
|
425
|
+
await kernel.use(new DriverPlugin(new SqlDriver({
|
|
426
|
+
client: 'pg',
|
|
427
|
+
connection: databaseUrl,
|
|
428
|
+
pool: { min: 0, max: 5 },
|
|
429
|
+
})));
|
|
430
|
+
trackPlugin('PostgresDriver');
|
|
431
|
+
resolvedDriverLabel = 'SqlDriver(pg)';
|
|
432
|
+
resolvedDatabaseUrl = databaseUrl;
|
|
433
|
+
}
|
|
434
|
+
else if (driverType === 'mysql' || driverType === 'mysql2') {
|
|
435
|
+
const { SqlDriver } = await import('@objectstack/driver-sql');
|
|
436
|
+
await kernel.use(new DriverPlugin(new SqlDriver({
|
|
437
|
+
client: 'mysql2',
|
|
438
|
+
connection: databaseUrl,
|
|
439
|
+
pool: { min: 0, max: 5 },
|
|
440
|
+
})));
|
|
441
|
+
trackPlugin('MySQLDriver');
|
|
442
|
+
resolvedDriverLabel = 'SqlDriver(mysql2)';
|
|
443
|
+
resolvedDatabaseUrl = databaseUrl;
|
|
444
|
+
}
|
|
445
|
+
else if (driverType === 'turso' || driverType === 'libsql') {
|
|
446
|
+
const { TursoDriver } = await import('@objectstack/driver-turso');
|
|
447
|
+
await kernel.use(new DriverPlugin(new TursoDriver({
|
|
448
|
+
url: databaseUrl ?? 'file:./local.db',
|
|
449
|
+
authToken: process.env.OS_DATABASE_AUTH_TOKEN,
|
|
450
|
+
})));
|
|
451
|
+
trackPlugin('TursoDriver');
|
|
452
|
+
resolvedDriverLabel = 'TursoDriver';
|
|
453
|
+
resolvedDatabaseUrl = databaseUrl ?? 'file:./local.db';
|
|
454
|
+
}
|
|
455
|
+
else if (isDev) {
|
|
456
|
+
// Default in dev: in-memory driver
|
|
457
|
+
const { InMemoryDriver } = await import('@objectstack/driver-memory');
|
|
458
|
+
await kernel.use(new DriverPlugin(new InMemoryDriver()));
|
|
459
|
+
trackPlugin('MemoryDriver');
|
|
460
|
+
resolvedDriverLabel = 'InMemoryDriver';
|
|
461
|
+
resolvedDatabaseUrl = '(in-memory)';
|
|
462
|
+
}
|
|
162
463
|
}
|
|
163
464
|
catch (e) {
|
|
164
465
|
// silent
|
|
165
466
|
}
|
|
166
467
|
}
|
|
167
468
|
// 3. Auto-register AppPlugin if config contains app definitions
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
469
|
+
// (objects / manifest / apps / flows / apis). Even host/aggregator
|
|
470
|
+
// configs (those whose `plugins` array contains instantiated plugins)
|
|
471
|
+
// need this wrap when they ALSO carry top-level metadata — otherwise
|
|
472
|
+
// top-level `flows`, `objects`, etc. never reach the ObjectQL registry
|
|
473
|
+
// and downstream services like AutomationServicePlugin start with 0 flows.
|
|
474
|
+
//
|
|
475
|
+
// To avoid double-registration when the host already wraps itself with
|
|
476
|
+
// an AppPlugin (e.g. apps/objectos's dev-workspace stack), we skip if
|
|
477
|
+
// any plugin in `plugins[]` is already an AppPlugin instance.
|
|
478
|
+
const hasAppPluginAlready = plugins.some((p) => p && (p.type === 'app' || p.constructor?.name === 'AppPlugin' || (p.name && typeof p.name === 'string' && p.name.startsWith('plugin.app.'))));
|
|
479
|
+
const configHasMetadata = !!(config.objects || config.manifest || config.apps || config.flows || config.apis);
|
|
480
|
+
if (!hasAppPluginAlready && configHasMetadata) {
|
|
172
481
|
try {
|
|
173
482
|
const { AppPlugin } = await import('@objectstack/runtime');
|
|
174
483
|
await kernel.use(new AppPlugin(config));
|
|
@@ -182,11 +491,33 @@ export default class Serve extends Command {
|
|
|
182
491
|
// This ensures i18n REST routes work out of the box without manual plugin registration.
|
|
183
492
|
const hasI18nPlugin = plugins.some((p) => p.name === 'com.objectstack.service.i18n'
|
|
184
493
|
|| p.constructor?.name === 'I18nServicePlugin');
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
494
|
+
// Check the top-level config AND any nested AppPlugin bundles in the
|
|
495
|
+
// `plugins` array — host/aggregator configs (e.g. apps/objectos) don't
|
|
496
|
+
// define translations themselves but compose multiple `new AppPlugin(...)`
|
|
497
|
+
// entries, each carrying its own translations.
|
|
498
|
+
const pluginBundleHasTranslations = (bundle) => {
|
|
499
|
+
if (!bundle || typeof bundle !== 'object')
|
|
500
|
+
return false;
|
|
501
|
+
if (Array.isArray(bundle.translations) && bundle.translations.length > 0)
|
|
502
|
+
return true;
|
|
503
|
+
if (bundle.i18n)
|
|
504
|
+
return true;
|
|
505
|
+
if (bundle.manifest && ((Array.isArray(bundle.manifest.translations) && bundle.manifest.translations.length > 0)
|
|
506
|
+
|| bundle.manifest.i18n))
|
|
507
|
+
return true;
|
|
508
|
+
return false;
|
|
509
|
+
};
|
|
510
|
+
const anyAppPluginHasTranslations = plugins.some((p) => {
|
|
511
|
+
if (!p)
|
|
512
|
+
return false;
|
|
513
|
+
// AppPlugin instances expose their bundle on `.bundle`
|
|
514
|
+
if (p.bundle && pluginBundleHasTranslations(p.bundle))
|
|
515
|
+
return true;
|
|
516
|
+
return false;
|
|
517
|
+
});
|
|
518
|
+
const configHasTranslations = (pluginBundleHasTranslations(config)
|
|
519
|
+
|| anyAppPluginHasTranslations);
|
|
520
|
+
if (!hasI18nPlugin && configHasTranslations && tierEnabled('i18n')) {
|
|
190
521
|
try {
|
|
191
522
|
// Dynamic import with variable to prevent tsc from resolving the optional package
|
|
192
523
|
const i18nPkg = '@objectstack/service-i18n';
|
|
@@ -222,18 +553,426 @@ export default class Serve extends Command {
|
|
|
222
553
|
console.warn(chalk.yellow(` ⚠ HTTP server plugin not available: ${e.message}`));
|
|
223
554
|
}
|
|
224
555
|
}
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
};
|
|
229
735
|
try {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
await kernel.use(new SetupPlugin());
|
|
233
|
-
trackPlugin('Setup');
|
|
736
|
+
await kernel.use(guardPlugin);
|
|
737
|
+
trackPlugin('UnknownHostnameGuard');
|
|
234
738
|
}
|
|
235
739
|
catch {
|
|
236
|
-
//
|
|
740
|
+
// Best-effort.
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
// 5. Auto-register Studio single-project signal in dev mode.
|
|
744
|
+
//
|
|
745
|
+
// `objectstack dev` runs a vanilla user stack (e.g. examples/app-crm)
|
|
746
|
+
// as a single project — there is no apps/cloud control plane and no
|
|
747
|
+
// org/project picker is meaningful. Without this plugin Studio would
|
|
748
|
+
// fall back to its multi-project default and ask the user to "Create
|
|
749
|
+
// organization" before showing any platform metadata.
|
|
750
|
+
//
|
|
751
|
+
// The plugin only registers `GET /api/v1/studio/runtime-config`
|
|
752
|
+
// (returning `{ singleProject: true, defaultOrgId, defaultProjectId }`)
|
|
753
|
+
// — no identity seed, since CLI dev mode has no sys_organization /
|
|
754
|
+
// sys_project tables to write into. Skipped when the user config
|
|
755
|
+
// already carries a single-project / multi-project plugin.
|
|
756
|
+
const hasProjectModePlugin = plugins.some((p) => {
|
|
757
|
+
const n = p?.name ?? p?.constructor?.name ?? '';
|
|
758
|
+
return n === 'com.objectstack.studio.single-project'
|
|
759
|
+
|| n === 'com.objectstack.multi-project'
|
|
760
|
+
|| n === 'com.objectstack.studio.runtime-config';
|
|
761
|
+
});
|
|
762
|
+
if (isDev && !hasProjectModePlugin) {
|
|
763
|
+
try {
|
|
764
|
+
const cloudPkg = '@objectstack/service-cloud';
|
|
765
|
+
const { createSingleProjectPlugin } = await import(/* webpackIgnore: true */ cloudPkg);
|
|
766
|
+
await kernel.use(createSingleProjectPlugin({
|
|
767
|
+
projectId: process.env.OS_PROJECT_ID ?? 'proj_local',
|
|
768
|
+
orgId: process.env.OS_ORG_ID ?? 'org_local',
|
|
769
|
+
orgName: 'Local',
|
|
770
|
+
}));
|
|
771
|
+
trackPlugin('SingleProject');
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
// @objectstack/service-cloud not installed — Studio falls back
|
|
775
|
+
// to multi-project mode (org/project picker visible).
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
// 5b. Auto-register AuthPlugin (and paired Security/Audit) when the
|
|
779
|
+
// 'auth' tier is enabled and no auth plugin is already configured.
|
|
780
|
+
// The Studio + Account portals expect /api/v1/auth/* to be served by
|
|
781
|
+
// better-auth via @objectstack/plugin-auth. Without this block,
|
|
782
|
+
// running `objectstack dev` on a vanilla user stack would 404 on
|
|
783
|
+
// login/register flows.
|
|
784
|
+
const hasAuthPlugin = plugins.some((p) => p?.name === 'com.objectstack.auth' || p?.constructor?.name === 'AuthPlugin');
|
|
785
|
+
if (!hasAuthPlugin && tierEnabled('auth')) {
|
|
786
|
+
try {
|
|
787
|
+
const authPkg = '@objectstack/plugin-auth';
|
|
788
|
+
const { AuthPlugin } = await import(/* webpackIgnore: true */ authPkg);
|
|
789
|
+
// In dev, fall back to a stable local secret so users don't have
|
|
790
|
+
// to set AUTH_SECRET just to try the login/register flow.
|
|
791
|
+
const secret = process.env.AUTH_SECRET
|
|
792
|
+
?? process.env.OS_AUTH_SECRET
|
|
793
|
+
?? (isDev ? 'dev-only-insecure-secret-change-me-in-production' : undefined);
|
|
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) {
|
|
809
|
+
console.warn(chalk.yellow(' ⚠ AuthPlugin skipped — set AUTH_SECRET to enable authentication in production'));
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
const baseUrl = process.env.AUTH_BASE_URL
|
|
813
|
+
?? process.env.OS_BASE_URL
|
|
814
|
+
?? `http://localhost:${port}`;
|
|
815
|
+
const socialProviders = {};
|
|
816
|
+
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET)
|
|
817
|
+
socialProviders.google = { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET };
|
|
818
|
+
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET)
|
|
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
|
+
}
|
|
910
|
+
await kernel.use(new AuthPlugin({
|
|
911
|
+
secret,
|
|
912
|
+
baseUrl,
|
|
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,
|
|
941
|
+
}));
|
|
942
|
+
trackPlugin('Auth');
|
|
943
|
+
// Pair: SecurityPlugin (RBAC) — optional
|
|
944
|
+
try {
|
|
945
|
+
const securityPkg = '@objectstack/plugin-security';
|
|
946
|
+
const { SecurityPlugin } = await import(/* webpackIgnore: true */ securityPkg);
|
|
947
|
+
// `OS_MULTI_TENANT=false` disables wildcard tenant_isolation
|
|
948
|
+
// RLS policies and the `organization_id` auto-injection on
|
|
949
|
+
// insert. Keep multi-tenant on by default — most ObjectStack
|
|
950
|
+
// deployments are multi-org.
|
|
951
|
+
const multiTenant = String(process.env.OS_MULTI_TENANT ?? 'true').toLowerCase() !== 'false';
|
|
952
|
+
await kernel.use(new SecurityPlugin({ multiTenant }));
|
|
953
|
+
trackPlugin('Security');
|
|
954
|
+
}
|
|
955
|
+
catch {
|
|
956
|
+
// optional
|
|
957
|
+
}
|
|
958
|
+
// Pair: AuditPlugin — optional
|
|
959
|
+
try {
|
|
960
|
+
const auditPkg = '@objectstack/plugin-audit';
|
|
961
|
+
const { AuditPlugin } = await import(/* webpackIgnore: true */ auditPkg);
|
|
962
|
+
await kernel.use(new AuditPlugin());
|
|
963
|
+
trackPlugin('Audit');
|
|
964
|
+
}
|
|
965
|
+
catch {
|
|
966
|
+
// optional
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
catch (err) {
|
|
971
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
972
|
+
if (!msg.includes('Cannot find module') && !msg.includes('ERR_MODULE_NOT_FOUND')) {
|
|
973
|
+
console.warn(chalk.yellow(` ⚠ AuthPlugin failed to load: ${msg}`));
|
|
974
|
+
}
|
|
975
|
+
// @objectstack/plugin-auth not installed — login/register endpoints unavailable
|
|
237
976
|
}
|
|
238
977
|
}
|
|
239
978
|
if (plugins.length > 0) {
|
|
@@ -272,9 +1011,22 @@ export default class Serve extends Command {
|
|
|
272
1011
|
}
|
|
273
1012
|
// Register REST API and Dispatcher plugins (consume http.server + protocol services)
|
|
274
1013
|
if (flags.server) {
|
|
1014
|
+
// Read project-scoping config from the stack's top-level `api` field
|
|
1015
|
+
// (e.g. { api: { enableProjectScoping: true, projectResolution: 'auto' } }).
|
|
1016
|
+
// Forwarded to both REST and Dispatcher plugins so they mount scoped
|
|
1017
|
+
// routes consistently.
|
|
1018
|
+
const apiConfig = config.api ?? {};
|
|
1019
|
+
const enableProjectScoping = apiConfig.enableProjectScoping ?? false;
|
|
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);
|
|
275
1027
|
try {
|
|
276
1028
|
const { createRestApiPlugin } = await import('@objectstack/rest');
|
|
277
|
-
await kernel.use(createRestApiPlugin());
|
|
1029
|
+
await kernel.use(createRestApiPlugin({ api: { api: { enableProjectScoping, projectResolution, requireAuth } } }));
|
|
278
1030
|
trackPlugin('RestAPI');
|
|
279
1031
|
}
|
|
280
1032
|
catch (e) {
|
|
@@ -283,7 +1035,7 @@ export default class Serve extends Command {
|
|
|
283
1035
|
// Register Dispatcher plugin (auth, graphql, analytics, packages, hub, storage, automation)
|
|
284
1036
|
try {
|
|
285
1037
|
const { createDispatcherPlugin } = await import('@objectstack/runtime');
|
|
286
|
-
await kernel.use(createDispatcherPlugin());
|
|
1038
|
+
await kernel.use(createDispatcherPlugin({ scoping: { enableProjectScoping, projectResolution } }));
|
|
287
1039
|
trackPlugin('Dispatcher');
|
|
288
1040
|
}
|
|
289
1041
|
catch (e) {
|
|
@@ -295,7 +1047,7 @@ export default class Serve extends Command {
|
|
|
295
1047
|
// already in place when AIServicePlugin.start() fires the hook.
|
|
296
1048
|
const hasAIPlugin = plugins.some((p) => p.name === 'com.objectstack.service-ai'
|
|
297
1049
|
|| p.constructor?.name === 'AIServicePlugin');
|
|
298
|
-
if (!hasAIPlugin) {
|
|
1050
|
+
if (!hasAIPlugin && tierEnabled('ai')) {
|
|
299
1051
|
try {
|
|
300
1052
|
const aiPkg = '@objectstack/service-ai';
|
|
301
1053
|
const { AIServicePlugin } = await import(/* webpackIgnore: true */ aiPkg);
|
|
@@ -305,33 +1057,339 @@ export default class Serve extends Command {
|
|
|
305
1057
|
await kernel.use(new AIServicePlugin());
|
|
306
1058
|
trackPlugin('AIService');
|
|
307
1059
|
}
|
|
308
|
-
catch {
|
|
1060
|
+
catch (err) {
|
|
1061
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1062
|
+
if (!msg.includes('Cannot find module') && !msg.includes('ERR_MODULE_NOT_FOUND')) {
|
|
1063
|
+
console.error('[AI] AIServicePlugin failed to start:', msg);
|
|
1064
|
+
}
|
|
309
1065
|
// @objectstack/service-ai not installed — AI features unavailable
|
|
310
1066
|
}
|
|
311
1067
|
}
|
|
1068
|
+
const CAPABILITY_PROVIDERS = {
|
|
1069
|
+
automation: {
|
|
1070
|
+
pkg: '@objectstack/service-automation',
|
|
1071
|
+
export: 'AutomationServicePlugin',
|
|
1072
|
+
nameMatch: ['service-automation', 'AutomationServicePlugin'],
|
|
1073
|
+
// The default node packs ship from the same package; auto-register them
|
|
1074
|
+
// so flows actually have executors. Users can opt out by listing
|
|
1075
|
+
// their own subset explicitly in `plugins: []` (which sets
|
|
1076
|
+
// `nameMatch` to skip these auto-loads).
|
|
1077
|
+
extras: [
|
|
1078
|
+
{ pkg: '@objectstack/service-automation', export: 'CrudNodesPlugin', nameMatch: ['crud-nodes', 'CrudNodesPlugin'] },
|
|
1079
|
+
{ pkg: '@objectstack/service-automation', export: 'LogicNodesPlugin', nameMatch: ['logic-nodes', 'LogicNodesPlugin'] },
|
|
1080
|
+
{ pkg: '@objectstack/service-automation', export: 'HttpConnectorPlugin', nameMatch: ['http-connector', 'HttpConnectorPlugin'] },
|
|
1081
|
+
{ pkg: '@objectstack/service-automation', export: 'ScreenNodesPlugin', nameMatch: ['screen-nodes', 'ScreenNodesPlugin'] },
|
|
1082
|
+
],
|
|
1083
|
+
},
|
|
1084
|
+
analytics: {
|
|
1085
|
+
pkg: '@objectstack/service-analytics',
|
|
1086
|
+
export: 'AnalyticsServicePlugin',
|
|
1087
|
+
nameMatch: ['service-analytics', 'AnalyticsServicePlugin'],
|
|
1088
|
+
configKey: 'analyticsCubes',
|
|
1089
|
+
},
|
|
1090
|
+
audit: {
|
|
1091
|
+
pkg: '@objectstack/plugin-audit',
|
|
1092
|
+
export: 'AuditPlugin',
|
|
1093
|
+
nameMatch: ['audit', 'AuditPlugin'],
|
|
1094
|
+
},
|
|
1095
|
+
cache: {
|
|
1096
|
+
pkg: '@objectstack/service-cache',
|
|
1097
|
+
export: 'CacheServicePlugin',
|
|
1098
|
+
nameMatch: ['service-cache', 'CacheServicePlugin'],
|
|
1099
|
+
},
|
|
1100
|
+
storage: {
|
|
1101
|
+
pkg: '@objectstack/service-storage',
|
|
1102
|
+
export: 'StorageServicePlugin',
|
|
1103
|
+
nameMatch: ['service-storage', 'StorageServicePlugin'],
|
|
1104
|
+
},
|
|
1105
|
+
queue: {
|
|
1106
|
+
pkg: '@objectstack/service-queue',
|
|
1107
|
+
export: 'QueueServicePlugin',
|
|
1108
|
+
nameMatch: ['service-queue', 'QueueServicePlugin'],
|
|
1109
|
+
},
|
|
1110
|
+
job: {
|
|
1111
|
+
pkg: '@objectstack/service-job',
|
|
1112
|
+
export: 'JobServicePlugin',
|
|
1113
|
+
nameMatch: ['service-job', 'JobServicePlugin'],
|
|
1114
|
+
},
|
|
1115
|
+
realtime: {
|
|
1116
|
+
pkg: '@objectstack/service-realtime',
|
|
1117
|
+
export: 'RealtimeServicePlugin',
|
|
1118
|
+
nameMatch: ['service-realtime', 'RealtimeServicePlugin'],
|
|
1119
|
+
},
|
|
1120
|
+
feed: {
|
|
1121
|
+
pkg: '@objectstack/service-feed',
|
|
1122
|
+
export: 'FeedServicePlugin',
|
|
1123
|
+
nameMatch: ['service-feed', 'FeedServicePlugin'],
|
|
1124
|
+
},
|
|
1125
|
+
mcp: {
|
|
1126
|
+
pkg: '@objectstack/plugin-mcp-server',
|
|
1127
|
+
export: 'MCPServerPlugin',
|
|
1128
|
+
nameMatch: ['mcp-server', 'MCPServerPlugin'],
|
|
1129
|
+
},
|
|
1130
|
+
marketplace: {
|
|
1131
|
+
pkg: '@objectstack/service-package',
|
|
1132
|
+
export: 'PackageServicePlugin',
|
|
1133
|
+
nameMatch: ['service-package', 'PackageServicePlugin'],
|
|
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
|
+
},
|
|
1160
|
+
};
|
|
1161
|
+
const hasPluginMatching = (fragments) => plugins.some((p) => {
|
|
1162
|
+
const n = String(p?.name ?? '');
|
|
1163
|
+
const c = String(p?.constructor?.name ?? '');
|
|
1164
|
+
return fragments.some((f) => n.includes(f) || c.includes(f));
|
|
1165
|
+
});
|
|
1166
|
+
for (const cap of requires) {
|
|
1167
|
+
const spec = CAPABILITY_PROVIDERS[cap];
|
|
1168
|
+
if (!spec)
|
|
1169
|
+
continue; // tier-gated capabilities (ai/i18n/ui/auth) handled above
|
|
1170
|
+
if (hasPluginMatching(spec.nameMatch))
|
|
1171
|
+
continue;
|
|
1172
|
+
try {
|
|
1173
|
+
const mod = await import(/* webpackIgnore: true */ spec.pkg);
|
|
1174
|
+
const Ctor = mod[spec.export];
|
|
1175
|
+
if (!Ctor) {
|
|
1176
|
+
console.warn(chalk.yellow(` ⚠ Capability "${cap}": ${spec.pkg} did not export ${spec.export}`));
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1179
|
+
// analytics needs cubes from config, others take no args
|
|
1180
|
+
let arg;
|
|
1181
|
+
if (spec.configKey === 'analyticsCubes') {
|
|
1182
|
+
const cubes = config.analyticsCubes ?? config.cubes ?? [];
|
|
1183
|
+
arg = { cubes };
|
|
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
|
+
}
|
|
1253
|
+
await kernel.use(arg !== undefined ? new Ctor(arg) : new Ctor());
|
|
1254
|
+
trackPlugin(spec.export);
|
|
1255
|
+
if (spec.extras) {
|
|
1256
|
+
for (const ex of spec.extras) {
|
|
1257
|
+
if (hasPluginMatching(ex.nameMatch))
|
|
1258
|
+
continue;
|
|
1259
|
+
try {
|
|
1260
|
+
const exMod = await import(/* webpackIgnore: true */ ex.pkg);
|
|
1261
|
+
const ExCtor = exMod[ex.export];
|
|
1262
|
+
if (ExCtor) {
|
|
1263
|
+
await kernel.use(new ExCtor());
|
|
1264
|
+
trackPlugin(ex.export);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
catch {
|
|
1268
|
+
// optional extra — silently skip
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
catch (err) {
|
|
1274
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1275
|
+
if (!msg.includes('Cannot find module') && !msg.includes('ERR_MODULE_NOT_FOUND')) {
|
|
1276
|
+
console.error(`[Capability:${cap}] failed to load ${spec.pkg}: ${msg}`);
|
|
1277
|
+
}
|
|
1278
|
+
else {
|
|
1279
|
+
console.warn(chalk.yellow(` ⚠ Capability "${cap}" required but ${spec.pkg} is not installed`));
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
312
1283
|
// ── Studio UI ─────────────────────────────────────────────────
|
|
313
1284
|
// In dev mode, Studio UI is enabled by default (use --no-ui to disable).
|
|
314
1285
|
// Always serves the pre-built dist/ — no Vite dev server, no extra port.
|
|
315
|
-
const enableUI = flags.ui
|
|
1286
|
+
const enableUI = flags.ui && tierEnabled('ui');
|
|
316
1287
|
if (enableUI) {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
1288
|
+
// Pre-detect Console availability so we can demote Studio's root
|
|
1289
|
+
// redirect when the Console is going to claim `/`.
|
|
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;
|
|
1296
|
+
const consoleWillMount = !!(consolePath && hasConsoleDist(consolePath));
|
|
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
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
// ── Account portal ─────────────────────────────────────────
|
|
1320
|
+
// The account portal sits next to Studio under `/_account/` and
|
|
1321
|
+
// follows the same enable rules — it's a self-service surface
|
|
1322
|
+
// for end-users (login, organizations, profile, sessions).
|
|
1323
|
+
const accountPath = resolveAccountPath();
|
|
1324
|
+
if (!accountPath) {
|
|
1325
|
+
console.warn(chalk.yellow(` ⚠ @objectstack/account not found — skipping Account UI`));
|
|
320
1326
|
}
|
|
321
|
-
else if (
|
|
322
|
-
const
|
|
323
|
-
await kernel.use(
|
|
324
|
-
trackPlugin('
|
|
1327
|
+
else if (hasAccountDist(accountPath)) {
|
|
1328
|
+
const accountDistPath = path.join(accountPath, 'dist');
|
|
1329
|
+
await kernel.use(createAccountStaticPlugin(accountDistPath, { isDev }));
|
|
1330
|
+
trackPlugin('AccountUI');
|
|
325
1331
|
}
|
|
326
1332
|
else {
|
|
327
|
-
console.warn(chalk.yellow(` ⚠
|
|
1333
|
+
console.warn(chalk.yellow(` ⚠ Account dist not found — run "pnpm --filter @objectstack/account build" first`));
|
|
1334
|
+
}
|
|
1335
|
+
// ── Console portal ──────────────────────────────────────────
|
|
1336
|
+
// The opinionated, fork-ready runtime console (`@objectstack/console`)
|
|
1337
|
+
// mounts under `/_console/` exactly like Studio/Account. When
|
|
1338
|
+
// present, it owns root `/` redirect (preferred default UI). It
|
|
1339
|
+
// is optional — we only mount it when the package resolves and
|
|
1340
|
+
// a pre-built `dist/` is present.
|
|
1341
|
+
if (consolePath) {
|
|
1342
|
+
if (consoleWillMount) {
|
|
1343
|
+
const consoleDistPath = path.join(consolePath, 'dist');
|
|
1344
|
+
await kernel.use(createConsoleStaticPlugin(consoleDistPath, { isDev }));
|
|
1345
|
+
trackPlugin('ConsoleUI');
|
|
1346
|
+
}
|
|
1347
|
+
else {
|
|
1348
|
+
console.warn(chalk.yellow(` ⚠ Console dist not found — run "pnpm --filter @objectstack/console build" first`));
|
|
1349
|
+
}
|
|
328
1350
|
}
|
|
329
1351
|
}
|
|
330
1352
|
// Boot the runtime
|
|
331
1353
|
await runtime.start();
|
|
332
|
-
//
|
|
1354
|
+
// Brief delay to allow logger writes to flush before restoring stdout
|
|
333
1355
|
await new Promise(r => setTimeout(r, 100));
|
|
334
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
|
+
}
|
|
1375
|
+
// ── Driver introspection ──────────────────────────────────────
|
|
1376
|
+
// When the driver was registered by an app preset / per-project
|
|
1377
|
+
// factory (ProjectKernelFactory) instead of serve.ts's own
|
|
1378
|
+
// OS_DATABASE_URL fallback, `resolvedDriverLabel` is still
|
|
1379
|
+
// unset. Probe well-known service names so the banner can show
|
|
1380
|
+
// *something* useful regardless of who wired the driver.
|
|
1381
|
+
if (!resolvedDriverLabel) {
|
|
1382
|
+
try {
|
|
1383
|
+
const probe = describeRegisteredDriver(kernel);
|
|
1384
|
+
if (probe) {
|
|
1385
|
+
resolvedDriverLabel = probe.label;
|
|
1386
|
+
resolvedDatabaseUrl = probe.url;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
catch {
|
|
1390
|
+
// best-effort only
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
335
1393
|
// ── Clean startup summary ──────────────────────────────────────
|
|
336
1394
|
printServerReady({
|
|
337
1395
|
port,
|
|
@@ -341,14 +1399,14 @@ export default class Serve extends Command {
|
|
|
341
1399
|
pluginNames: loadedPlugins,
|
|
342
1400
|
uiEnabled: enableUI,
|
|
343
1401
|
studioPath: STUDIO_PATH,
|
|
1402
|
+
accountPath: ACCOUNT_PATH,
|
|
1403
|
+
consolePath: loadedPlugins.includes('ConsoleUI') ? CONSOLE_PATH : undefined,
|
|
1404
|
+
driverLabel: resolvedDriverLabel,
|
|
1405
|
+
databaseUrl: redactDbUrl(resolvedDatabaseUrl),
|
|
1406
|
+
multiTenant: String(process.env.OS_MULTI_TENANT ?? 'true').toLowerCase() !== 'false',
|
|
344
1407
|
});
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
console.warn(chalk.yellow(`\n\n⏹ Stopping server...`));
|
|
348
|
-
await runtime.getKernel().shutdown();
|
|
349
|
-
console.log(chalk.green(`✅ Server stopped`));
|
|
350
|
-
process.exit(0);
|
|
351
|
-
});
|
|
1408
|
+
// Kernel already registers SIGINT/SIGTERM handlers during bootstrap.
|
|
1409
|
+
// No duplicate handler needed here — just keep the process alive.
|
|
352
1410
|
}
|
|
353
1411
|
catch (error) {
|
|
354
1412
|
restoreOutput();
|
|
@@ -360,4 +1418,61 @@ export default class Serve extends Command {
|
|
|
360
1418
|
}
|
|
361
1419
|
}
|
|
362
1420
|
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Best-effort driver introspection.
|
|
1423
|
+
*
|
|
1424
|
+
* Drivers register themselves under the kernel service name
|
|
1425
|
+
* `driver.{driver.name}` (see `DriverPlugin.init`). We probe a list of
|
|
1426
|
+
* well-known names and return a single-line label + redacted URL so the
|
|
1427
|
+
* startup banner can show *something* even when the driver wasn't
|
|
1428
|
+
* registered through this command's own `OS_DATABASE_URL` fallback
|
|
1429
|
+
* (e.g. when the example app's preset or `ProjectKernelFactory` wired
|
|
1430
|
+
* it). Returns `null` when nothing matches; the caller treats that as
|
|
1431
|
+
* "no driver info available" and skips the line.
|
|
1432
|
+
*/
|
|
1433
|
+
function describeRegisteredDriver(kernel) {
|
|
1434
|
+
const candidates = [
|
|
1435
|
+
'driver.com.objectstack.driver.sql',
|
|
1436
|
+
'driver.com.objectstack.driver.mongodb',
|
|
1437
|
+
'driver.com.objectstack.driver.turso',
|
|
1438
|
+
'driver.com.objectstack.driver.memory',
|
|
1439
|
+
'driver.sql', 'driver.mongodb', 'driver.turso', 'driver.memory',
|
|
1440
|
+
];
|
|
1441
|
+
for (const name of candidates) {
|
|
1442
|
+
let driver;
|
|
1443
|
+
try {
|
|
1444
|
+
driver = kernel?.getService?.(name);
|
|
1445
|
+
}
|
|
1446
|
+
catch { /* not registered */ }
|
|
1447
|
+
if (!driver)
|
|
1448
|
+
continue;
|
|
1449
|
+
// SqlDriver: `{ client, connection: string | { filename, host, ... } }`
|
|
1450
|
+
const cfg = driver.config;
|
|
1451
|
+
if (cfg) {
|
|
1452
|
+
const client = cfg.client;
|
|
1453
|
+
const conn = cfg.connection;
|
|
1454
|
+
let url = '';
|
|
1455
|
+
if (typeof conn === 'string') {
|
|
1456
|
+
url = conn;
|
|
1457
|
+
}
|
|
1458
|
+
else if (conn && typeof conn === 'object') {
|
|
1459
|
+
url = conn.filename
|
|
1460
|
+
?? (conn.host ? `${conn.host}${conn.port ? `:${conn.port}` : ''}${conn.database ? `/${conn.database}` : ''}` : '');
|
|
1461
|
+
}
|
|
1462
|
+
const label = client ? `SqlDriver(${client})` : (driver.name ?? 'SqlDriver');
|
|
1463
|
+
return { label, url: url || '(unknown)' };
|
|
1464
|
+
}
|
|
1465
|
+
// MongoDB / Turso drivers expose the URL on the instance itself.
|
|
1466
|
+
if (driver.url) {
|
|
1467
|
+
const label = driver.constructor?.name ?? driver.name ?? 'Driver';
|
|
1468
|
+
return { label, url: String(driver.url) };
|
|
1469
|
+
}
|
|
1470
|
+
// InMemoryDriver — no URL.
|
|
1471
|
+
return {
|
|
1472
|
+
label: driver.constructor?.name ?? driver.name ?? 'Driver',
|
|
1473
|
+
url: '(in-memory)',
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
return null;
|
|
1477
|
+
}
|
|
363
1478
|
//# sourceMappingURL=serve.js.map
|