@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.
Files changed (269) hide show
  1. package/README.md +44 -25
  2. package/dist/commands/build.d.ts +5 -0
  3. package/dist/commands/build.d.ts.map +1 -0
  4. package/dist/commands/build.js +6 -0
  5. package/dist/commands/build.js.map +1 -0
  6. package/dist/commands/cloud/login.d.ts +16 -0
  7. package/dist/commands/cloud/login.d.ts.map +1 -0
  8. package/dist/commands/cloud/login.js +166 -0
  9. package/dist/commands/cloud/login.js.map +1 -0
  10. package/dist/commands/cloud/logout.d.ts +15 -0
  11. package/dist/commands/cloud/logout.d.ts.map +1 -0
  12. package/dist/commands/cloud/logout.js +51 -0
  13. package/dist/commands/cloud/logout.js.map +1 -0
  14. package/dist/commands/cloud/whoami.d.ts +15 -0
  15. package/dist/commands/cloud/whoami.d.ts.map +1 -0
  16. package/dist/commands/cloud/whoami.js +81 -0
  17. package/dist/commands/cloud/whoami.js.map +1 -0
  18. package/dist/commands/compile.d.ts +3 -0
  19. package/dist/commands/compile.d.ts.map +1 -1
  20. package/dist/commands/compile.js +128 -6
  21. package/dist/commands/compile.js.map +1 -1
  22. package/dist/commands/create.js +1 -1
  23. package/dist/commands/data/create.js +2 -2
  24. package/dist/commands/data/create.js.map +1 -1
  25. package/dist/commands/data/delete.js +2 -2
  26. package/dist/commands/data/delete.js.map +1 -1
  27. package/dist/commands/data/get.js +2 -2
  28. package/dist/commands/data/get.js.map +1 -1
  29. package/dist/commands/data/query.js +2 -2
  30. package/dist/commands/data/query.js.map +1 -1
  31. package/dist/commands/data/update.js +2 -2
  32. package/dist/commands/data/update.js.map +1 -1
  33. package/dist/commands/dev.d.ts +9 -0
  34. package/dist/commands/dev.d.ts.map +1 -1
  35. package/dist/commands/dev.js +116 -22
  36. package/dist/commands/dev.js.map +1 -1
  37. package/dist/commands/generate.js +9 -9
  38. package/dist/commands/generate.js.map +1 -1
  39. package/dist/commands/i18n/check.d.ts +18 -0
  40. package/dist/commands/i18n/check.d.ts.map +1 -0
  41. package/dist/commands/i18n/check.js +153 -0
  42. package/dist/commands/i18n/check.js.map +1 -0
  43. package/dist/commands/init.js +2 -2
  44. package/dist/commands/lint.d.ts +3 -0
  45. package/dist/commands/lint.d.ts.map +1 -1
  46. package/dist/commands/lint.js +24 -0
  47. package/dist/commands/lint.js.map +1 -1
  48. package/dist/commands/login.d.ts +17 -0
  49. package/dist/commands/login.d.ts.map +1 -0
  50. package/dist/commands/login.js +313 -0
  51. package/dist/commands/login.js.map +1 -0
  52. package/dist/commands/logout.d.ts.map +1 -0
  53. package/dist/commands/{auth/logout.js → logout.js} +14 -2
  54. package/dist/commands/logout.js.map +1 -0
  55. package/dist/commands/meta/delete.js +2 -2
  56. package/dist/commands/meta/delete.js.map +1 -1
  57. package/dist/commands/meta/get.js +2 -2
  58. package/dist/commands/meta/get.js.map +1 -1
  59. package/dist/commands/meta/list.js +2 -2
  60. package/dist/commands/meta/list.js.map +1 -1
  61. package/dist/commands/meta/register.js +2 -2
  62. package/dist/commands/meta/register.js.map +1 -1
  63. package/dist/commands/package/publish.d.ts +32 -0
  64. package/dist/commands/package/publish.d.ts.map +1 -0
  65. package/dist/commands/package/publish.js +324 -0
  66. package/dist/commands/package/publish.js.map +1 -0
  67. package/dist/commands/projects/bind.d.ts +30 -0
  68. package/dist/commands/projects/bind.d.ts.map +1 -0
  69. package/dist/commands/projects/bind.js +132 -0
  70. package/dist/commands/projects/bind.js.map +1 -0
  71. package/dist/commands/projects/create.d.ts +28 -0
  72. package/dist/commands/projects/create.d.ts.map +1 -0
  73. package/dist/commands/projects/create.js +120 -0
  74. package/dist/commands/projects/create.js.map +1 -0
  75. package/dist/commands/projects/list.d.ts +21 -0
  76. package/dist/commands/projects/list.d.ts.map +1 -0
  77. package/dist/commands/projects/list.js +79 -0
  78. package/dist/commands/projects/list.js.map +1 -0
  79. package/dist/commands/projects/projects.test.d.ts +2 -0
  80. package/dist/commands/projects/projects.test.d.ts.map +1 -0
  81. package/dist/commands/projects/projects.test.js +56 -0
  82. package/dist/commands/projects/projects.test.js.map +1 -0
  83. package/dist/commands/projects/show.d.ts +21 -0
  84. package/dist/commands/projects/show.d.ts.map +1 -0
  85. package/dist/commands/projects/show.js +72 -0
  86. package/dist/commands/projects/show.js.map +1 -0
  87. package/dist/commands/projects/switch.d.ts +24 -0
  88. package/dist/commands/projects/switch.d.ts.map +1 -0
  89. package/dist/commands/projects/switch.js +64 -0
  90. package/dist/commands/projects/switch.js.map +1 -0
  91. package/dist/commands/publish.d.ts +17 -0
  92. package/dist/commands/publish.d.ts.map +1 -0
  93. package/dist/commands/publish.js +135 -0
  94. package/dist/commands/publish.js.map +1 -0
  95. package/dist/commands/{auth/login.d.ts → register.d.ts} +3 -2
  96. package/dist/commands/register.d.ts.map +1 -0
  97. package/dist/commands/{auth/login.js → register.js} +44 -61
  98. package/dist/commands/register.js.map +1 -0
  99. package/dist/commands/rollback.d.ts +13 -0
  100. package/dist/commands/rollback.d.ts.map +1 -0
  101. package/dist/commands/rollback.js +77 -0
  102. package/dist/commands/rollback.js.map +1 -0
  103. package/dist/commands/serve.d.ts +22 -0
  104. package/dist/commands/serve.d.ts.map +1 -1
  105. package/dist/commands/serve.js +1173 -58
  106. package/dist/commands/serve.js.map +1 -1
  107. package/dist/commands/start.d.ts +18 -0
  108. package/dist/commands/start.d.ts.map +1 -0
  109. package/dist/commands/start.js +112 -0
  110. package/dist/commands/start.js.map +1 -0
  111. package/dist/commands/whoami.d.ts.map +1 -0
  112. package/dist/commands/{auth/whoami.js → whoami.js} +5 -5
  113. package/dist/commands/whoami.js.map +1 -0
  114. package/dist/index.d.ts +11 -4
  115. package/dist/index.d.ts.map +1 -1
  116. package/dist/index.js +14 -5
  117. package/dist/index.js.map +1 -1
  118. package/dist/utils/account.d.ts +31 -0
  119. package/dist/utils/account.d.ts.map +1 -0
  120. package/dist/utils/account.js +154 -0
  121. package/dist/utils/account.js.map +1 -0
  122. package/dist/utils/api-client.d.ts +10 -4
  123. package/dist/utils/api-client.d.ts.map +1 -1
  124. package/dist/utils/api-client.js +13 -7
  125. package/dist/utils/api-client.js.map +1 -1
  126. package/dist/utils/auth-config.d.ts +6 -0
  127. package/dist/utils/auth-config.d.ts.map +1 -1
  128. package/dist/utils/auth-config.js.map +1 -1
  129. package/dist/utils/auth-flows.d.ts +31 -0
  130. package/dist/utils/auth-flows.d.ts.map +1 -0
  131. package/dist/utils/auth-flows.js +151 -0
  132. package/dist/utils/auth-flows.js.map +1 -0
  133. package/dist/utils/build-runtime.d.ts +45 -0
  134. package/dist/utils/build-runtime.d.ts.map +1 -0
  135. package/dist/utils/build-runtime.js +154 -0
  136. package/dist/utils/build-runtime.js.map +1 -0
  137. package/dist/utils/cloud-config.d.ts +24 -0
  138. package/dist/utils/cloud-config.d.ts.map +1 -0
  139. package/dist/utils/cloud-config.js +75 -0
  140. package/dist/utils/cloud-config.js.map +1 -0
  141. package/dist/utils/config.d.ts.map +1 -1
  142. package/dist/utils/config.js +17 -2
  143. package/dist/utils/config.js.map +1 -1
  144. package/dist/utils/console.d.ts +33 -0
  145. package/dist/utils/console.d.ts.map +1 -0
  146. package/dist/utils/console.js +172 -0
  147. package/dist/utils/console.js.map +1 -0
  148. package/dist/utils/extract-hook-body.d.ts +13 -0
  149. package/dist/utils/extract-hook-body.d.ts.map +1 -0
  150. package/dist/utils/extract-hook-body.js +175 -0
  151. package/dist/utils/extract-hook-body.js.map +1 -0
  152. package/dist/utils/format.d.ts +8 -0
  153. package/dist/utils/format.d.ts.map +1 -1
  154. package/dist/utils/format.js +15 -2
  155. package/dist/utils/format.js.map +1 -1
  156. package/dist/utils/i18n-coverage.d.ts +61 -0
  157. package/dist/utils/i18n-coverage.d.ts.map +1 -0
  158. package/dist/utils/i18n-coverage.js +176 -0
  159. package/dist/utils/i18n-coverage.js.map +1 -0
  160. package/dist/utils/lower-callables.d.ts +17 -0
  161. package/dist/utils/lower-callables.d.ts.map +1 -0
  162. package/dist/utils/lower-callables.js +181 -0
  163. package/dist/utils/lower-callables.js.map +1 -0
  164. package/dist/utils/plugin-detection.d.ts +1 -0
  165. package/dist/utils/plugin-detection.d.ts.map +1 -1
  166. package/dist/utils/plugin-detection.js +41 -0
  167. package/dist/utils/plugin-detection.js.map +1 -1
  168. package/dist/utils/studio.d.ts +1 -0
  169. package/dist/utils/studio.d.ts.map +1 -1
  170. package/dist/utils/studio.js +24 -9
  171. package/dist/utils/studio.js.map +1 -1
  172. package/package.json +60 -22
  173. package/.turbo/turbo-build.log +0 -4
  174. package/CHANGELOG.md +0 -821
  175. package/bin/run-dev.js +0 -5
  176. package/dist/commands/auth/login.d.ts.map +0 -1
  177. package/dist/commands/auth/login.js.map +0 -1
  178. package/dist/commands/auth/logout.d.ts.map +0 -1
  179. package/dist/commands/auth/logout.js.map +0 -1
  180. package/dist/commands/auth/whoami.d.ts.map +0 -1
  181. package/dist/commands/auth/whoami.js.map +0 -1
  182. package/dist/commands/codemod/v2-to-v3.d.ts +0 -10
  183. package/dist/commands/codemod/v2-to-v3.d.ts.map +0 -1
  184. package/dist/commands/codemod/v2-to-v3.js +0 -145
  185. package/dist/commands/codemod/v2-to-v3.js.map +0 -1
  186. package/dist/commands/plugin/add.d.ts +0 -22
  187. package/dist/commands/plugin/add.d.ts.map +0 -1
  188. package/dist/commands/plugin/add.js +0 -93
  189. package/dist/commands/plugin/add.js.map +0 -1
  190. package/dist/commands/plugin/build.d.ts +0 -29
  191. package/dist/commands/plugin/build.d.ts.map +0 -1
  192. package/dist/commands/plugin/build.js +0 -170
  193. package/dist/commands/plugin/build.js.map +0 -1
  194. package/dist/commands/plugin/info.d.ts +0 -10
  195. package/dist/commands/plugin/info.d.ts.map +0 -1
  196. package/dist/commands/plugin/info.js +0 -65
  197. package/dist/commands/plugin/info.js.map +0 -1
  198. package/dist/commands/plugin/list.d.ts +0 -13
  199. package/dist/commands/plugin/list.d.ts.map +0 -1
  200. package/dist/commands/plugin/list.js +0 -78
  201. package/dist/commands/plugin/list.js.map +0 -1
  202. package/dist/commands/plugin/publish.d.ts +0 -27
  203. package/dist/commands/plugin/publish.d.ts.map +0 -1
  204. package/dist/commands/plugin/publish.js +0 -152
  205. package/dist/commands/plugin/publish.js.map +0 -1
  206. package/dist/commands/plugin/remove.d.ts +0 -20
  207. package/dist/commands/plugin/remove.d.ts.map +0 -1
  208. package/dist/commands/plugin/remove.js +0 -79
  209. package/dist/commands/plugin/remove.js.map +0 -1
  210. package/dist/commands/plugin/validate.d.ts +0 -23
  211. package/dist/commands/plugin/validate.d.ts.map +0 -1
  212. package/dist/commands/plugin/validate.js +0 -251
  213. package/dist/commands/plugin/validate.js.map +0 -1
  214. package/src/bin.ts +0 -13
  215. package/src/commands/auth/login.ts +0 -188
  216. package/src/commands/auth/logout.ts +0 -51
  217. package/src/commands/auth/whoami.ts +0 -85
  218. package/src/commands/codemod/v2-to-v3.ts +0 -171
  219. package/src/commands/compile.ts +0 -114
  220. package/src/commands/create.ts +0 -281
  221. package/src/commands/data/create.ts +0 -110
  222. package/src/commands/data/delete.ts +0 -84
  223. package/src/commands/data/get.ts +0 -84
  224. package/src/commands/data/query.ts +0 -127
  225. package/src/commands/data/update.ts +0 -114
  226. package/src/commands/dev.ts +0 -83
  227. package/src/commands/diff.ts +0 -294
  228. package/src/commands/doctor.ts +0 -572
  229. package/src/commands/explain.ts +0 -412
  230. package/src/commands/generate.ts +0 -924
  231. package/src/commands/info.ts +0 -124
  232. package/src/commands/init.ts +0 -327
  233. package/src/commands/lint.ts +0 -315
  234. package/src/commands/meta/delete.ts +0 -79
  235. package/src/commands/meta/get.ts +0 -73
  236. package/src/commands/meta/list.ts +0 -105
  237. package/src/commands/meta/register.ts +0 -97
  238. package/src/commands/plugin/add.ts +0 -112
  239. package/src/commands/plugin/build.ts +0 -193
  240. package/src/commands/plugin/info.ts +0 -79
  241. package/src/commands/plugin/list.ts +0 -93
  242. package/src/commands/plugin/publish.ts +0 -176
  243. package/src/commands/plugin/remove.ts +0 -97
  244. package/src/commands/plugin/validate.ts +0 -268
  245. package/src/commands/serve.ts +0 -411
  246. package/src/commands/studio.ts +0 -52
  247. package/src/commands/test.ts +0 -135
  248. package/src/commands/validate.ts +0 -143
  249. package/src/index.ts +0 -22
  250. package/src/utils/api-client.ts +0 -88
  251. package/src/utils/auth-config.ts +0 -107
  252. package/src/utils/config.ts +0 -80
  253. package/src/utils/format.ts +0 -267
  254. package/src/utils/output-formatter.ts +0 -91
  255. package/src/utils/plugin-detection.ts +0 -16
  256. package/src/utils/plugin-helpers.ts +0 -37
  257. package/src/utils/studio.ts +0 -350
  258. package/test/commands.test.ts +0 -128
  259. package/test/create.test.ts +0 -25
  260. package/test/plugin-commands.test.ts +0 -44
  261. package/test/plugin.test.ts +0 -169
  262. package/test/remote-api-commands.test.ts +0 -188
  263. package/test/remote-api-utils.test.ts +0 -196
  264. package/test/serve-host-config.test.ts +0 -77
  265. package/tsconfig.build.json +0 -20
  266. package/tsconfig.json +0 -25
  267. package/tsup.config.ts +0 -23
  268. /package/dist/commands/{auth/logout.d.ts → logout.d.ts} +0 -0
  269. /package/dist/commands/{auth/whoami.d.ts → whoami.d.ts} +0 -0
@@ -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 { isHostConfig } from '../utils/plugin-detection.js';
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 with plugins from configuration';
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 in dev mode)' }),
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
- if (!fs.existsSync(absolutePath)) {
67
- printError(`Configuration file not found: ${absolutePath}`);
68
- console.log(chalk.dim(' Hint: Run `objectstack init` to create a new project'));
69
- this.exit(1);
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
- console.log(chalk.dim(` Loading ${relativeConfig}...`));
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
- const { mod } = await bundleRequire({
120
- filepath: absolutePath,
121
- });
122
- const config = mod.default || mod;
123
- if (!config) {
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 Memory Driver if in Dev and no driver configured
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 (isDev && !hasDriver && config.objects) {
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
- const { InMemoryDriver } = await import('@objectstack/driver-memory');
160
- await kernel.use(new DriverPlugin(new InMemoryDriver()));
161
- trackPlugin('MemoryDriver');
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
- // Skip if config is a host/aggregator config that already contains
169
- // instantiated plugins wrapping it would cause duplicate registration
170
- // and startup failures (e.g. plugin.app.dev-workspace).
171
- if (!isHostConfig(config) && (config.objects || config.manifest || config.apps)) {
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
- const configHasTranslations = ((Array.isArray(config.translations) && config.translations.length > 0)
186
- || config.i18n
187
- || (config.manifest && ((Array.isArray(config.manifest.translations) && config.manifest.translations.length > 0)
188
- || config.manifest.i18n)));
189
- if (!hasI18nPlugin && configHasTranslations) {
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
- // 5. Auto-register SetupPlugin BEFORE config plugins so that other
226
- // plugins (e.g. AuthPlugin) can call setupNav.contribute() during init.
227
- const hasSetupPlugin = plugins.some((p) => p.name === 'com.objectstack.setup' || p.constructor?.name === 'SetupPlugin');
228
- if (!hasSetupPlugin) {
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
+ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;',
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
- const setupPkg = '@objectstack/plugin-setup';
231
- const { SetupPlugin } = await import(/* webpackIgnore: true */ setupPkg);
232
- await kernel.use(new SetupPlugin());
233
- trackPlugin('Setup');
736
+ await kernel.use(guardPlugin);
737
+ trackPlugin('UnknownHostnameGuard');
234
738
  }
235
739
  catch {
236
- // @objectstack/plugin-setup not installed — setup app unavailable
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 || isDev;
1286
+ const enableUI = flags.ui && tierEnabled('ui');
316
1287
  if (enableUI) {
317
- const studioPath = resolveStudioPath();
318
- if (!studioPath) {
319
- console.warn(chalk.yellow(` @objectstack/studio not found skipping UI`));
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 (hasStudioDist(studioPath)) {
322
- const distPath = path.join(studioPath, 'dist');
323
- await kernel.use(createStudioStaticPlugin(distPath, { isDev }));
324
- trackPlugin('StudioUI');
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(` ⚠ Studio dist not found — run "pnpm --filter @objectstack/studio build" first`));
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
- // Wait briefly for pino worker thread buffers to flush, then restore
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
- // Keep process alive
346
- process.on('SIGINT', async () => {
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