@objectstack/cli 1.0.11 → 1.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.
@@ -0,0 +1,130 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { ZodError } from 'zod';
4
+ import { ObjectStackDefinitionSchema } from '@objectstack/spec';
5
+ import { loadConfig } from '../utils/config.js';
6
+ import {
7
+ printHeader,
8
+ printKV,
9
+ printSuccess,
10
+ printError,
11
+ printStep,
12
+ createTimer,
13
+ formatZodErrors,
14
+ collectMetadataStats,
15
+ printMetadataStats,
16
+ } from '../utils/format.js';
17
+
18
+ export const validateCommand = new Command('validate')
19
+ .description('Validate ObjectStack configuration against the protocol schema')
20
+ .argument('[config]', 'Configuration file path')
21
+ .option('--strict', 'Treat warnings as errors')
22
+ .option('--json', 'Output results as JSON')
23
+ .action(async (configPath, options) => {
24
+ const timer = createTimer();
25
+
26
+ if (!options.json) {
27
+ printHeader('Validate');
28
+ }
29
+
30
+ try {
31
+ // 1. Load configuration
32
+ if (!options.json) printStep('Loading configuration...');
33
+ const { config, absolutePath, duration } = await loadConfig(configPath);
34
+
35
+ if (!options.json) {
36
+ printKV('Config', absolutePath);
37
+ printKV('Load time', `${duration}ms`);
38
+ }
39
+
40
+ // 2. Validate against schema
41
+ if (!options.json) printStep('Validating against ObjectStack Protocol...');
42
+ const result = ObjectStackDefinitionSchema.safeParse(config);
43
+
44
+ if (!result.success) {
45
+ if (options.json) {
46
+ console.log(JSON.stringify({
47
+ valid: false,
48
+ errors: (result.error as unknown as ZodError).issues,
49
+ duration: timer.elapsed(),
50
+ }, null, 2));
51
+ process.exit(1);
52
+ }
53
+
54
+ console.log('');
55
+ printError('Validation failed');
56
+ formatZodErrors(result.error as unknown as ZodError);
57
+ process.exit(1);
58
+ }
59
+
60
+ // 3. Collect and display stats
61
+ const stats = collectMetadataStats(config);
62
+
63
+ if (options.json) {
64
+ console.log(JSON.stringify({
65
+ valid: true,
66
+ manifest: config.manifest,
67
+ stats,
68
+ duration: timer.elapsed(),
69
+ }, null, 2));
70
+ return;
71
+ }
72
+
73
+ // 4. Warnings (non-blocking)
74
+ const warnings: string[] = [];
75
+
76
+ if (stats.objects === 0) {
77
+ warnings.push('No objects defined — this stack has no data model');
78
+ }
79
+ if (stats.apps === 0 && stats.plugins === 0) {
80
+ warnings.push('No apps or plugins defined — this stack may not do much');
81
+ }
82
+ if (!config.manifest?.id) {
83
+ warnings.push('Missing manifest.id — required for deployment');
84
+ }
85
+ if (!config.manifest?.namespace) {
86
+ warnings.push('Missing manifest.namespace — required for multi-app hosting');
87
+ }
88
+
89
+ // 5. Display results
90
+ console.log('');
91
+ printSuccess(`Validation passed ${chalk.dim(`(${timer.display()})`)}`);
92
+ console.log('');
93
+
94
+ if (config.manifest) {
95
+ console.log(` ${chalk.bold(config.manifest.name || config.manifest.id || 'Unnamed')} ${chalk.dim(`v${config.manifest.version || '0.0.0'}`)}`);
96
+ if (config.manifest.description) {
97
+ console.log(chalk.dim(` ${config.manifest.description}`));
98
+ }
99
+ console.log('');
100
+ }
101
+
102
+ printMetadataStats(stats);
103
+
104
+ if (warnings.length > 0) {
105
+ console.log('');
106
+ for (const w of warnings) {
107
+ console.log(chalk.yellow(` ⚠ ${w}`));
108
+ }
109
+ if (options.strict) {
110
+ console.log('');
111
+ printError('Strict mode: warnings treated as errors');
112
+ process.exit(1);
113
+ }
114
+ }
115
+
116
+ console.log('');
117
+ } catch (error: any) {
118
+ if (options.json) {
119
+ console.log(JSON.stringify({
120
+ valid: false,
121
+ error: error.message,
122
+ duration: timer.elapsed(),
123
+ }, null, 2));
124
+ process.exit(1);
125
+ }
126
+ console.log('');
127
+ printError(error.message || String(error));
128
+ process.exit(1);
129
+ }
130
+ });
package/src/index.ts CHANGED
@@ -1 +1,10 @@
1
1
  export * from './commands/compile.js';
2
+ export * from './commands/validate.js';
3
+ export * from './commands/info.js';
4
+ export * from './commands/init.js';
5
+ export * from './commands/generate.js';
6
+ export * from './commands/create.js';
7
+ export * from './commands/dev.js';
8
+ export * from './commands/serve.js';
9
+ export * from './commands/test.js';
10
+ export * from './commands/doctor.js';
@@ -0,0 +1,78 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ import chalk from 'chalk';
4
+ import { bundleRequire } from 'bundle-require';
5
+ import { printError } from './format.js';
6
+
7
+ export interface LoadedConfig {
8
+ config: any;
9
+ absolutePath: string;
10
+ duration: number;
11
+ }
12
+
13
+ /**
14
+ * Resolve the config file path. Supports:
15
+ * - explicit path (objectstack.config.ts)
16
+ * - auto-detection (searches for objectstack.config.{ts,js,mjs})
17
+ */
18
+ export function resolveConfigPath(source?: string): string {
19
+ if (source) {
20
+ const abs = path.resolve(process.cwd(), source);
21
+ if (!fs.existsSync(abs)) {
22
+ printError(`Config file not found: ${chalk.white(abs)}`);
23
+ console.log('');
24
+ console.log(chalk.dim(' Hint: Run this command from a directory with objectstack.config.ts'));
25
+ console.log(chalk.dim(' Or specify the path: objectstack <command> path/to/config.ts'));
26
+ process.exit(1);
27
+ }
28
+ return abs;
29
+ }
30
+
31
+ // Auto-detect
32
+ const candidates = [
33
+ 'objectstack.config.ts',
34
+ 'objectstack.config.js',
35
+ 'objectstack.config.mjs',
36
+ ];
37
+
38
+ for (const candidate of candidates) {
39
+ const abs = path.resolve(process.cwd(), candidate);
40
+ if (fs.existsSync(abs)) return abs;
41
+ }
42
+
43
+ printError('No objectstack.config.{ts,js,mjs} found in current directory');
44
+ console.log('');
45
+ console.log(chalk.dim(' Hint: Run `objectstack init` to create a new project'));
46
+ process.exit(1);
47
+ }
48
+
49
+ /**
50
+ * Load and bundle a config file using bundle-require.
51
+ * Returns the resolved config object and load time.
52
+ */
53
+ export async function loadConfig(source?: string): Promise<LoadedConfig> {
54
+ const absolutePath = resolveConfigPath(source);
55
+ const start = Date.now();
56
+
57
+ const { mod } = await bundleRequire({
58
+ filepath: absolutePath,
59
+ });
60
+
61
+ const config = mod.default || mod;
62
+ if (!config) {
63
+ throw new Error(`No default export found in ${path.basename(absolutePath)}`);
64
+ }
65
+
66
+ return {
67
+ config,
68
+ absolutePath,
69
+ duration: Date.now() - start,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Check whether a file exists at the given path (relative to cwd).
75
+ */
76
+ export function configExists(name: string = 'objectstack.config.ts'): boolean {
77
+ return fs.existsSync(path.resolve(process.cwd(), name));
78
+ }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Console UI Integration Utilities
3
+ *
4
+ * Handles resolving, spawning, and proxying the @objectstack/console
5
+ * frontend when the CLI is started with --ui or via the `studio` command.
6
+ */
7
+ import path from 'path';
8
+ import fs from 'fs';
9
+ import net from 'net';
10
+ import { spawn, type ChildProcess } from 'child_process';
11
+ import chalk from 'chalk';
12
+
13
+ // ─── Constants ──────────────────────────────────────────────────────
14
+
15
+ /** URL mount path for the Console UI inside the ObjectStack server */
16
+ export const STUDIO_PATH = '/_studio';
17
+
18
+ /** Internal port range start for the Vite dev server */
19
+ const VITE_PORT_START = 24678;
20
+
21
+ // ─── Path Resolution ────────────────────────────────────────────────
22
+
23
+ /**
24
+ * Resolve the filesystem path to the @objectstack/console package.
25
+ * Searches workspace locations first, then falls back to node_modules.
26
+ */
27
+ export function resolveConsolePath(): string | null {
28
+ const cwd = process.cwd();
29
+
30
+ // Workspace candidates (monorepo layouts)
31
+ const candidates = [
32
+ path.resolve(cwd, 'apps/console'),
33
+ path.resolve(cwd, '../../apps/console'),
34
+ path.resolve(cwd, '../apps/console'),
35
+ ];
36
+
37
+ for (const candidate of candidates) {
38
+ const pkgPath = path.join(candidate, 'package.json');
39
+ if (fs.existsSync(pkgPath)) {
40
+ try {
41
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
42
+ if (pkg.name === '@objectstack/console') return candidate;
43
+ } catch {
44
+ // Skip invalid package.json
45
+ }
46
+ }
47
+ }
48
+
49
+ // Fallback: resolve from node_modules
50
+ try {
51
+ const { createRequire } = require('module');
52
+ const req = createRequire(import.meta.url);
53
+ const resolved = req.resolve('@objectstack/console/package.json');
54
+ return path.dirname(resolved);
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Check whether the Console has a pre-built `dist/` directory.
62
+ */
63
+ export function hasConsoleDist(consolePath: string): boolean {
64
+ return fs.existsSync(path.join(consolePath, 'dist', 'index.html'));
65
+ }
66
+
67
+ // ─── Port Utilities ─────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Find the next available TCP port starting from `start`.
71
+ */
72
+ export function findAvailablePort(start: number = VITE_PORT_START): Promise<number> {
73
+ return new Promise((resolve, reject) => {
74
+ const server = net.createServer();
75
+ server.once('error', () => {
76
+ // Port in use — try next
77
+ findAvailablePort(start + 1).then(resolve, reject);
78
+ });
79
+ server.once('listening', () => {
80
+ server.close(() => resolve(start));
81
+ });
82
+ server.listen(start);
83
+ });
84
+ }
85
+
86
+ // ─── Vite Dev Server ────────────────────────────────────────────────
87
+
88
+ export interface ViteDevResult {
89
+ /** Port the Vite dev server is listening on */
90
+ port: number;
91
+ /** Child process handle */
92
+ process: ChildProcess;
93
+ }
94
+
95
+ /**
96
+ * Spawn a Vite dev server for the Console application.
97
+ *
98
+ * Sets environment variables so the Console runs in server mode and
99
+ * connects to the ObjectStack API on the same origin.
100
+ *
101
+ * @param consolePath - Absolute path to the @objectstack/console package
102
+ * @param options.serverPort - The main ObjectStack server port (for display only)
103
+ */
104
+ export async function spawnViteDevServer(
105
+ consolePath: string,
106
+ options: { serverPort?: number } = {},
107
+ ): Promise<ViteDevResult> {
108
+ const vitePort = await findAvailablePort(VITE_PORT_START);
109
+
110
+ // Resolve the Vite binary from the Console's own dependencies
111
+ const viteBinCandidates = [
112
+ path.join(consolePath, 'node_modules', '.bin', 'vite'),
113
+ path.join(consolePath, '..', '..', 'node_modules', '.bin', 'vite'),
114
+ ];
115
+
116
+ let viteBin: string | null = null;
117
+ for (const candidate of viteBinCandidates) {
118
+ if (fs.existsSync(candidate)) {
119
+ viteBin = candidate;
120
+ break;
121
+ }
122
+ }
123
+
124
+ const command = viteBin || 'npx';
125
+ const args = viteBin
126
+ ? ['--port', String(vitePort), '--strictPort']
127
+ : ['vite', '--port', String(vitePort), '--strictPort'];
128
+
129
+ const child = spawn(command, args, {
130
+ cwd: consolePath,
131
+ env: {
132
+ ...process.env,
133
+ VITE_BASE: `${STUDIO_PATH}/`,
134
+ VITE_PORT: String(vitePort),
135
+ VITE_HMR_PORT: String(vitePort),
136
+ VITE_RUNTIME_MODE: 'server',
137
+ VITE_SERVER_URL: '', // Same-origin API
138
+ NODE_ENV: 'development',
139
+ },
140
+ stdio: ['ignore', 'pipe', 'pipe'],
141
+ });
142
+
143
+ // Accumulate stderr for error reporting
144
+ let stderr = '';
145
+ child.stderr?.on('data', (data: Buffer) => {
146
+ stderr += data.toString();
147
+ });
148
+
149
+ // Wait for Vite to signal readiness
150
+ await new Promise<void>((resolve, reject) => {
151
+ const timeout = setTimeout(() => {
152
+ child.kill();
153
+ reject(new Error(`Vite dev server timed out after 30 s.\n${stderr}`));
154
+ }, 30_000);
155
+
156
+ child.stdout?.on('data', (data: Buffer) => {
157
+ const output = data.toString();
158
+ // Vite prints "ready in Xms" or "Local: http://..." when ready
159
+ if (output.includes('Local:') || output.includes('ready in')) {
160
+ clearTimeout(timeout);
161
+ resolve();
162
+ }
163
+ });
164
+
165
+ child.on('error', (err) => {
166
+ clearTimeout(timeout);
167
+ reject(err);
168
+ });
169
+
170
+ child.on('exit', (code) => {
171
+ if (code !== 0 && code !== null) {
172
+ clearTimeout(timeout);
173
+ reject(new Error(`Vite exited with code ${code}.\n${stderr}`));
174
+ }
175
+ });
176
+ });
177
+
178
+ return { port: vitePort, process: child };
179
+ }
180
+
181
+ // ─── Console Plugin Factories ───────────────────────────────────────
182
+
183
+ /**
184
+ * Create a lightweight kernel plugin that proxies `/_studio/*` requests
185
+ * to the Vite dev server. Used in development mode.
186
+ */
187
+ export function createConsoleProxyPlugin(vitePort: number) {
188
+ return {
189
+ name: 'com.objectstack.console-proxy',
190
+
191
+ init: async () => {},
192
+
193
+ start: async (ctx: any) => {
194
+ const httpServer = ctx.getService?.('http.server');
195
+ if (!httpServer?.getRawApp) {
196
+ ctx.logger?.warn?.('Console proxy: http.server service not found — skipping');
197
+ return;
198
+ }
199
+
200
+ const app = httpServer.getRawApp();
201
+
202
+ // Redirect bare path to trailing-slash (SPA convention)
203
+ app.get(STUDIO_PATH, (c: any) => c.redirect(`${STUDIO_PATH}/`));
204
+
205
+ // Proxy all /_studio/* requests to the Vite dev server
206
+ app.all(`${STUDIO_PATH}/*`, async (c: any) => {
207
+ const targetUrl = `http://localhost:${vitePort}${c.req.path}`;
208
+
209
+ try {
210
+ const headers = new Headers(c.req.raw.headers);
211
+ headers.delete('host');
212
+
213
+ const isBodyAllowed = !['GET', 'HEAD'].includes(c.req.method);
214
+
215
+ const resp = await fetch(targetUrl, {
216
+ method: c.req.method,
217
+ headers,
218
+ body: isBodyAllowed ? c.req.raw.body : undefined,
219
+ // @ts-expect-error — duplex required for streaming request body
220
+ duplex: isBodyAllowed ? 'half' : undefined,
221
+ });
222
+
223
+ // Forward the full response (status, headers, body)
224
+ return new Response(resp.body, {
225
+ status: resp.status,
226
+ headers: resp.headers,
227
+ });
228
+ } catch {
229
+ return c.text('Console dev server is starting…', 502);
230
+ }
231
+ });
232
+ },
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Create a lightweight kernel plugin that serves the pre-built Console
238
+ * static files at `/_studio/*`. Used in production mode.
239
+ *
240
+ * Uses Node.js built-in fs for static file serving to avoid external
241
+ * bundling dependencies.
242
+ */
243
+ export function createConsoleStaticPlugin(distPath: string) {
244
+ return {
245
+ name: 'com.objectstack.console-static',
246
+
247
+ init: async () => {},
248
+
249
+ start: async (ctx: any) => {
250
+ const httpServer = ctx.getService?.('http.server');
251
+ if (!httpServer?.getRawApp) {
252
+ ctx.logger?.warn?.('Console static: http.server service not found — skipping');
253
+ return;
254
+ }
255
+
256
+ const app = httpServer.getRawApp();
257
+ const absoluteDist = path.resolve(distPath);
258
+
259
+ const indexPath = path.join(absoluteDist, 'index.html');
260
+ if (!fs.existsSync(indexPath)) {
261
+ ctx.logger?.warn?.(`Console static: dist not found at ${absoluteDist}`);
262
+ return;
263
+ }
264
+
265
+ // Redirect bare path
266
+ app.get(STUDIO_PATH, (c: any) => c.redirect(`${STUDIO_PATH}/`));
267
+
268
+ // Serve static files with SPA fallback
269
+ app.get(`${STUDIO_PATH}/*`, async (c: any) => {
270
+ const reqPath = c.req.path.substring(STUDIO_PATH.length) || '/';
271
+ const filePath = path.join(absoluteDist, reqPath);
272
+
273
+ // Security: prevent path traversal
274
+ if (!filePath.startsWith(absoluteDist)) {
275
+ return c.text('Forbidden', 403);
276
+ }
277
+
278
+ // Try serving the exact file
279
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
280
+ const content = fs.readFileSync(filePath);
281
+ return new Response(content, {
282
+ headers: { 'content-type': mimeType(filePath) },
283
+ });
284
+ }
285
+
286
+ // SPA fallback: serve index.html for non-file routes
287
+ const html = fs.readFileSync(indexPath);
288
+ return new Response(html, {
289
+ headers: { 'content-type': 'text/html; charset=utf-8' },
290
+ });
291
+ });
292
+ },
293
+ };
294
+ }
295
+
296
+ // ─── Helpers ────────────────────────────────────────────────────────
297
+
298
+ const MIME_TYPES: Record<string, string> = {
299
+ '.html': 'text/html; charset=utf-8',
300
+ '.js': 'application/javascript; charset=utf-8',
301
+ '.mjs': 'application/javascript; charset=utf-8',
302
+ '.css': 'text/css; charset=utf-8',
303
+ '.json': 'application/json; charset=utf-8',
304
+ '.svg': 'image/svg+xml',
305
+ '.png': 'image/png',
306
+ '.jpg': 'image/jpeg',
307
+ '.jpeg': 'image/jpeg',
308
+ '.gif': 'image/gif',
309
+ '.ico': 'image/x-icon',
310
+ '.woff': 'font/woff',
311
+ '.woff2': 'font/woff2',
312
+ '.ttf': 'font/ttf',
313
+ '.map': 'application/json',
314
+ };
315
+
316
+ function mimeType(filePath: string): string {
317
+ const ext = path.extname(filePath).toLowerCase();
318
+ return MIME_TYPES[ext] || 'application/octet-stream';
319
+ }