@notis_ai/cli 0.2.0-beta.16.1

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 (52) hide show
  1. package/README.md +335 -0
  2. package/bin/notis.js +2 -0
  3. package/package.json +38 -0
  4. package/src/cli.js +147 -0
  5. package/src/command-specs/apps.js +496 -0
  6. package/src/command-specs/auth.js +178 -0
  7. package/src/command-specs/db.js +163 -0
  8. package/src/command-specs/helpers.js +193 -0
  9. package/src/command-specs/index.js +20 -0
  10. package/src/command-specs/meta.js +154 -0
  11. package/src/command-specs/tools.js +391 -0
  12. package/src/runtime/app-platform.js +624 -0
  13. package/src/runtime/app-preview-server.js +312 -0
  14. package/src/runtime/errors.js +55 -0
  15. package/src/runtime/help.js +60 -0
  16. package/src/runtime/output.js +180 -0
  17. package/src/runtime/profiles.js +202 -0
  18. package/src/runtime/transport.js +198 -0
  19. package/template/app/globals.css +3 -0
  20. package/template/app/layout.tsx +7 -0
  21. package/template/app/page.tsx +55 -0
  22. package/template/components/ui/badge.tsx +28 -0
  23. package/template/components/ui/button.tsx +53 -0
  24. package/template/components/ui/card.tsx +56 -0
  25. package/template/components.json +20 -0
  26. package/template/lib/utils.ts +6 -0
  27. package/template/notis.config.ts +18 -0
  28. package/template/package.json +32 -0
  29. package/template/packages/notis-sdk/package.json +26 -0
  30. package/template/packages/notis-sdk/src/config.ts +48 -0
  31. package/template/packages/notis-sdk/src/helpers.ts +131 -0
  32. package/template/packages/notis-sdk/src/hooks/useAppState.ts +50 -0
  33. package/template/packages/notis-sdk/src/hooks/useBackend.ts +41 -0
  34. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +58 -0
  35. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +87 -0
  36. package/template/packages/notis-sdk/src/hooks/useDocument.ts +61 -0
  37. package/template/packages/notis-sdk/src/hooks/useNotis.ts +31 -0
  38. package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +49 -0
  39. package/template/packages/notis-sdk/src/hooks/useTool.ts +49 -0
  40. package/template/packages/notis-sdk/src/hooks/useTools.ts +56 -0
  41. package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +57 -0
  42. package/template/packages/notis-sdk/src/index.ts +47 -0
  43. package/template/packages/notis-sdk/src/provider.tsx +44 -0
  44. package/template/packages/notis-sdk/src/runtime.ts +159 -0
  45. package/template/packages/notis-sdk/src/styles.css +123 -0
  46. package/template/packages/notis-sdk/src/ui.ts +15 -0
  47. package/template/packages/notis-sdk/src/vite.ts +54 -0
  48. package/template/packages/notis-sdk/tsconfig.json +15 -0
  49. package/template/postcss.config.mjs +8 -0
  50. package/template/tailwind.config.ts +58 -0
  51. package/template/tsconfig.json +22 -0
  52. package/template/vite.config.ts +10 -0
@@ -0,0 +1,624 @@
1
+ /**
2
+ * App platform utilities for the Notis CLI.
3
+ *
4
+ * Handles project scaffolding, validation, building, and linking. This is the
5
+ * CLI-side counterpart to @notis/sdk -- it reads notis.config.ts, runs the
6
+ * Vite build, and packages the bundle for deployment.
7
+ */
8
+
9
+ import { spawn } from 'node:child_process';
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync, readdirSync } from 'node:fs';
11
+ import { createRequire } from 'node:module';
12
+ import { dirname, join, resolve } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ import { usageError } from './errors.js';
16
+
17
+ const NOTIS_DIR = '.notis';
18
+ const STATE_FILE = join(NOTIS_DIR, 'state.json');
19
+ const OUTPUT_DIR = join(NOTIS_DIR, 'output');
20
+ const BUNDLE_DIR = join(OUTPUT_DIR, 'bundle');
21
+ const MANIFEST_FILE = join(OUTPUT_DIR, 'manifest.json');
22
+ let appConfigImportNonce = 0;
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Project directory resolution
26
+ // ---------------------------------------------------------------------------
27
+
28
+ export function resolveProjectDir(inputDir = '.') {
29
+ return resolve(process.cwd(), inputDir);
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Config loading
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Load and parse notis.config.ts from a project directory. Uses a simple
38
+ * approach: we read the file, transpile with the project's TypeScript compiler
39
+ * when available, then evaluate as ESM.
40
+ */
41
+ function transpileTsConfigSource(source, configPath) {
42
+ const requireFromConfig = createRequire(`file://${configPath}`);
43
+ try {
44
+ const ts = requireFromConfig('typescript');
45
+ const transpiled = ts.transpileModule(source, {
46
+ compilerOptions: {
47
+ module: ts.ModuleKind.ESNext,
48
+ target: ts.ScriptTarget.ES2020,
49
+ },
50
+ fileName: configPath,
51
+ });
52
+ return transpiled.outputText;
53
+ } catch {
54
+ return source
55
+ .replace(/import\s+type\s+[\s\S]*?from\s+['"][^'"]*['"]\s*;?/g, '')
56
+ .replace(/import\s*{[\s\S]*?\bdefineNotisApp\b[\s\S]*?}\s+from\s+['"]@notis\/sdk\/config['"]\s*;?/g, '')
57
+ .replace(/defineNotisApp\s*\(/, '(');
58
+ }
59
+ }
60
+
61
+ export async function loadAppConfig(projectDir) {
62
+ const configPaths = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'];
63
+ let configPath = null;
64
+
65
+ for (const name of configPaths) {
66
+ const candidate = join(projectDir, name);
67
+ if (existsSync(candidate)) {
68
+ configPath = candidate;
69
+ break;
70
+ }
71
+ }
72
+
73
+ if (!configPath) {
74
+ throw usageError('No notis.config.ts found in project directory.');
75
+ }
76
+
77
+ // For .js/.mjs files, import directly. For .ts, we need transpilation.
78
+ if (configPath.endsWith('.ts')) {
79
+ const source = readFileSync(configPath, 'utf-8');
80
+ const jsSource = transpileTsConfigSource(source, configPath);
81
+
82
+ const tmpPath = join(dirname(configPath), '._notis_config_tmp.mjs');
83
+ mkdirSync(dirname(tmpPath), { recursive: true });
84
+ writeFileSync(tmpPath, jsSource);
85
+ try {
86
+ const mod = await import(`file://${tmpPath}?v=${appConfigImportNonce++}`);
87
+ return mod.default || mod;
88
+ } catch (error) {
89
+ if (error instanceof SyntaxError) {
90
+ throw usageError(
91
+ `Failed to parse ${configPath}. Install project dependencies (including typescript) ` +
92
+ 'or convert the config to plain JavaScript.',
93
+ );
94
+ }
95
+ throw error;
96
+ } finally {
97
+ try {
98
+ const { unlinkSync } = await import('node:fs');
99
+ unlinkSync(tmpPath);
100
+ } catch {}
101
+ }
102
+ }
103
+
104
+ const mod = await import(`file://${configPath}`);
105
+ return mod.default || mod;
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Project validation
110
+ // ---------------------------------------------------------------------------
111
+
112
+ export function detectProjectProblems(projectDir) {
113
+ const problems = [];
114
+
115
+ if (!existsSync(join(projectDir, 'package.json'))) {
116
+ problems.push('Missing package.json');
117
+ }
118
+
119
+ const hasViteConfig = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']
120
+ .some((name) => existsSync(join(projectDir, name)));
121
+ if (!hasViteConfig) {
122
+ problems.push('Missing vite.config file');
123
+ }
124
+
125
+ if (!existsSync(join(projectDir, 'app'))) {
126
+ problems.push('Missing app/ directory');
127
+ }
128
+
129
+ const hasNotisConfig = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs']
130
+ .some((name) => existsSync(join(projectDir, name)));
131
+ if (!hasNotisConfig) {
132
+ problems.push('Missing notis.config.ts');
133
+ }
134
+
135
+ return problems;
136
+ }
137
+
138
+ export function detectProjectWarnings(projectDir, appConfig = null) {
139
+ const warnings = [];
140
+
141
+ if (!existsSync(join(projectDir, 'components.json'))) {
142
+ warnings.push('Missing components.json -- shadcn is not configured.');
143
+ }
144
+
145
+ const hasTailwind = ['tailwind.config.ts', 'tailwind.config.js', 'tailwind.config.cjs']
146
+ .some((name) => existsSync(join(projectDir, name)));
147
+ if (!hasTailwind) {
148
+ warnings.push('Missing Tailwind config.');
149
+ }
150
+
151
+ if (appConfig && (!Array.isArray(appConfig.databases) || appConfig.databases.length === 0)) {
152
+ warnings.push('No database references declared in notis.config.ts.');
153
+ }
154
+
155
+ return warnings;
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Build
160
+ // ---------------------------------------------------------------------------
161
+
162
+ export async function runProjectScript({ projectDir, scriptName, env = {}, stdio = 'inherit' }) {
163
+ await new Promise((resolvePromise, rejectPromise) => {
164
+ const child = spawn('npm', ['run', scriptName], {
165
+ cwd: projectDir,
166
+ stdio,
167
+ env: { ...process.env, ...env },
168
+ });
169
+ child.on('error', rejectPromise);
170
+ child.on('exit', (code) => {
171
+ if (code === 0) {
172
+ resolvePromise();
173
+ return;
174
+ }
175
+ rejectPromise(new Error(`npm run ${scriptName} failed with exit code ${code}`));
176
+ });
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Derive an export name from a route path.
182
+ * '/' -> 'index', '/inbox' -> 'inbox', '/my-tasks' -> 'myTasks'
183
+ */
184
+ function exportNameFromPath(routePath) {
185
+ if (routePath === '/') return 'index';
186
+ const slug = routePath.replace(/^\//, '').replace(/\//g, '-');
187
+ const identifier = slug
188
+ .split(/[^A-Za-z0-9_$]+/)
189
+ .filter(Boolean)
190
+ .map((part, index) => {
191
+ const lower = part.toLowerCase();
192
+ if (index === 0) return lower;
193
+ return lower.charAt(0).toUpperCase() + lower.slice(1);
194
+ })
195
+ .join('');
196
+ if (!identifier) return 'route';
197
+ return /^[A-Za-z_$]/.test(identifier) ? identifier : `r${identifier}`;
198
+ }
199
+
200
+ function slugFromPath(routePath) {
201
+ if (routePath === '/') return 'index';
202
+ return routePath.replace(/^\//, '').replace(/\//g, '-');
203
+ }
204
+
205
+ /**
206
+ * Auto-detect routes from the app/ directory by scanning for page.tsx files.
207
+ */
208
+ function autoDetectRoutes(projectDir) {
209
+ const appDir = join(projectDir, 'app');
210
+ const routes = [];
211
+
212
+ function scan(dir, pathPrefix) {
213
+ if (!existsSync(dir)) return;
214
+ const entries = readdirSync(dir, { withFileTypes: true });
215
+
216
+ for (const entry of entries) {
217
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
218
+
219
+ if (entry.isDirectory()) {
220
+ scan(join(dir, entry.name), `${pathPrefix}/${entry.name}`);
221
+ } else if (entry.name === 'page.tsx' || entry.name === 'page.jsx' || entry.name === 'page.js') {
222
+ const routePath = pathPrefix || '/';
223
+ const slug = slugFromPath(routePath);
224
+ routes.push({
225
+ path: routePath,
226
+ slug,
227
+ name: slug === 'index' ? 'Home' : slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
228
+ default: routePath === '/',
229
+ export_name: exportNameFromPath(routePath),
230
+ });
231
+ }
232
+ }
233
+ }
234
+
235
+ scan(appDir, '');
236
+ return routes;
237
+ }
238
+
239
+ function resolveConfiguredRoutes(appConfig, projectDir) {
240
+ const configuredRoutes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
241
+ const routes = configuredRoutes.map((route) => ({
242
+ ...route,
243
+ slug: route.slug || slugFromPath(route.path),
244
+ export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
245
+ }));
246
+ return routes.length > 0 ? routes : autoDetectRoutes(projectDir);
247
+ }
248
+
249
+ /**
250
+ * Generate the _entry.tsx file that re-exports each route's page component.
251
+ */
252
+ function generateEntryFile(projectDir, routes) {
253
+ const entryDir = join(projectDir, NOTIS_DIR);
254
+ mkdirSync(entryDir, { recursive: true });
255
+
256
+ // Also re-export the layout if it exists
257
+ const lines = [];
258
+ const layoutPath = join(projectDir, 'app', 'layout.tsx');
259
+ if (existsSync(layoutPath)) {
260
+ lines.push(`export { default as __AppShell } from '../app/layout';`);
261
+ }
262
+
263
+ for (const route of routes) {
264
+ const pagePath = route.path === '/' ? '../app/page' : `../app${route.path}/page`;
265
+ lines.push(`export { default as ${route.export_name} } from '${pagePath}';`);
266
+ }
267
+
268
+ const entryPath = join(entryDir, '_entry.tsx');
269
+ writeFileSync(entryPath, lines.join('\n') + '\n');
270
+ return entryPath;
271
+ }
272
+
273
+ /**
274
+ * Generate the manifest from app config and build output.
275
+ */
276
+ function generateManifest(appConfig, projectDir) {
277
+ const routes = resolveConfiguredRoutes(appConfig, projectDir).map((route) => {
278
+ const entry = {
279
+ path: route.path,
280
+ slug: route.slug || slugFromPath(route.path),
281
+ name: route.name,
282
+ icon: route.icon || null,
283
+ default: route.default || false,
284
+ export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
285
+ collection: route.collection || null,
286
+ };
287
+ if (route.tool_access) {
288
+ entry.tool_access = route.tool_access;
289
+ }
290
+ return entry;
291
+ });
292
+
293
+ const databases = appConfig.databases || [];
294
+
295
+ return {
296
+ version: 1,
297
+ spec_version: 3,
298
+ app: {
299
+ name: appConfig.name,
300
+ description: appConfig.description || null,
301
+ icon: appConfig.icon || null,
302
+ },
303
+ routes,
304
+ bundle: {
305
+ js: 'bundle/app.js',
306
+ css: 'bundle/app.css',
307
+ },
308
+ databases,
309
+ tools: appConfig.tools || [],
310
+ };
311
+ }
312
+
313
+ /**
314
+ * Build the app bundle: generate entry file, run `vite build`, package into .notis/output/.
315
+ */
316
+ export async function buildArtifact(projectDir) {
317
+ const appConfig = await loadAppConfig(projectDir);
318
+
319
+ // Auto-detect or use configured routes
320
+ const detectedRoutes = resolveConfiguredRoutes(appConfig, projectDir);
321
+
322
+ // Generate the entry file that re-exports all route page components
323
+ generateEntryFile(projectDir, detectedRoutes);
324
+
325
+ // Run Vite build
326
+ await runProjectScript({
327
+ projectDir,
328
+ scriptName: 'build',
329
+ });
330
+
331
+ // Verify output
332
+ const bundleDir = join(projectDir, BUNDLE_DIR);
333
+ const jsPath = join(bundleDir, 'app.js');
334
+ if (!existsSync(jsPath)) {
335
+ throw usageError('Vite build did not produce .notis/output/bundle/app.js. Check your vite.config.ts.');
336
+ }
337
+
338
+ // Generate manifest
339
+ const manifest = generateManifest(appConfig, projectDir);
340
+ const manifestPath = join(projectDir, MANIFEST_FILE);
341
+ mkdirSync(dirname(manifestPath), { recursive: true });
342
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
343
+
344
+ return { manifest, outputDir: join(projectDir, OUTPUT_DIR) };
345
+ }
346
+
347
+ /**
348
+ * Read the manifest from a built project.
349
+ */
350
+ export function readManifest(projectDir) {
351
+ const manifestPath = join(projectDir, MANIFEST_FILE);
352
+ if (!existsSync(manifestPath)) {
353
+ throw usageError('No manifest found. Run "notis apps build" first.');
354
+ }
355
+ return JSON.parse(readFileSync(manifestPath, 'utf-8'));
356
+ }
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // Linking
360
+ // ---------------------------------------------------------------------------
361
+
362
+ export function readLinkedState(projectDir) {
363
+ const statePath = join(projectDir, STATE_FILE);
364
+ if (!existsSync(statePath)) return null;
365
+ return JSON.parse(readFileSync(statePath, 'utf-8'));
366
+ }
367
+
368
+ export function writeLinkedState(projectDir, state) {
369
+ const statePath = join(projectDir, STATE_FILE);
370
+ mkdirSync(dirname(statePath), { recursive: true });
371
+ writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
372
+ }
373
+
374
+ export function requireLinkedAppId(projectDir, explicitAppId) {
375
+ if (explicitAppId) return explicitAppId;
376
+ const state = readLinkedState(projectDir);
377
+ if (state?.app_id) return state.app_id;
378
+ throw usageError('This project is not linked to a Notis app. Run "notis apps link <app-id> ." first.');
379
+ }
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // Scaffolding
383
+ // ---------------------------------------------------------------------------
384
+
385
+ /**
386
+ * Scaffold a new Notis app project from the SDK template.
387
+ */
388
+ export function scaffoldProject({ projectDir, appName }) {
389
+ const templateDir = resolve(
390
+ dirname(fileURLToPath(import.meta.url)),
391
+ '../../template',
392
+ );
393
+
394
+ if (!existsSync(templateDir)) {
395
+ throw usageError(`SDK template not found at ${templateDir}. Ensure @notis_ai/cli is installed correctly.`);
396
+ }
397
+
398
+ mkdirSync(projectDir, { recursive: true });
399
+ cpSync(templateDir, projectDir, { recursive: true });
400
+
401
+ // Update package.json with the app name
402
+ const pkgPath = join(projectDir, 'package.json');
403
+ if (existsSync(pkgPath)) {
404
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
405
+ pkg.name = appName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
406
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
407
+ }
408
+
409
+ // Update notis.config.ts with the app name
410
+ const configPath = join(projectDir, 'notis.config.ts');
411
+ if (existsSync(configPath)) {
412
+ let config = readFileSync(configPath, 'utf-8');
413
+ config = config.replace(/'My Notis App'/, `'${appName.replace(/'/g, "\\'")}'`);
414
+ writeFileSync(configPath, config);
415
+ }
416
+
417
+ return { projectDir };
418
+ }
419
+
420
+ // ---------------------------------------------------------------------------
421
+ // Deploy helpers
422
+ // ---------------------------------------------------------------------------
423
+
424
+ /**
425
+ * Collect all files from .notis/output/ as base64-encoded entries for upload.
426
+ */
427
+ export function collectArtifactFiles(projectDir) {
428
+ const outputDir = join(projectDir, OUTPUT_DIR);
429
+ if (!existsSync(outputDir)) {
430
+ throw usageError('No build output found. Run "notis apps build" first.');
431
+ }
432
+
433
+ const files = {};
434
+
435
+ function walk(dir, prefix) {
436
+ const entries = readdirSync(dir, { withFileTypes: true });
437
+ for (const entry of entries) {
438
+ const fullPath = join(dir, entry.name);
439
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
440
+ if (entry.isDirectory()) {
441
+ walk(fullPath, relPath);
442
+ } else {
443
+ files[relPath] = readFileSync(fullPath).toString('base64');
444
+ }
445
+ }
446
+ }
447
+
448
+ walk(outputDir, '');
449
+ return files;
450
+ }
451
+
452
+ // ---------------------------------------------------------------------------
453
+ // Direct deploy (bypasses backend server)
454
+ // ---------------------------------------------------------------------------
455
+
456
+ /**
457
+ * Resolve Supabase credentials from the server/.env file in the repo workspace.
458
+ * Falls back to environment variables.
459
+ */
460
+ function resolveSupabaseCredentials() {
461
+ const envPaths = [
462
+ resolve(process.cwd(), 'server/.env'),
463
+ resolve(process.cwd(), '../server/.env'),
464
+ resolve(process.cwd(), '../../server/.env'),
465
+ ];
466
+
467
+ let supabaseUrl = process.env.SUPABASE_URL;
468
+ let supabaseSubdomain = process.env.SUPABASE_SUBDOMAIN;
469
+ let supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
470
+
471
+ for (const envPath of envPaths) {
472
+ if (existsSync(envPath)) {
473
+ const content = readFileSync(envPath, 'utf-8');
474
+ for (const line of content.split('\n')) {
475
+ const trimmed = line.trim();
476
+ if (trimmed.startsWith('#') || !trimmed.includes('=')) continue;
477
+ const eqIdx = trimmed.indexOf('=');
478
+ const key = trimmed.slice(0, eqIdx).trim();
479
+ let value = trimmed.slice(eqIdx + 1).trim();
480
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
481
+ value = value.slice(1, -1);
482
+ }
483
+ if (key === 'SUPABASE_URL' && !supabaseUrl) supabaseUrl = value;
484
+ if (key === 'SUPABASE_SUBDOMAIN' && !supabaseSubdomain) supabaseSubdomain = value;
485
+ if ((key === 'SUPABASE_SERVICE_ROLE_KEY' || key === 'SUPABASE_SERVICE_KEY') && !supabaseKey) supabaseKey = value;
486
+ }
487
+ break;
488
+ }
489
+ }
490
+
491
+ // Derive URL from subdomain if needed
492
+ if (!supabaseUrl && supabaseSubdomain) {
493
+ supabaseUrl = `https://${supabaseSubdomain}.supabase.co`;
494
+ }
495
+
496
+ if (!supabaseUrl || !supabaseKey) {
497
+ throw usageError(
498
+ 'Cannot resolve Supabase credentials for direct deploy. ' +
499
+ 'Ensure server/.env exists with SUPABASE_SUBDOMAIN (or SUPABASE_URL) and SUPABASE_SERVICE_KEY, ' +
500
+ 'or set them as environment variables.',
501
+ );
502
+ }
503
+
504
+ return { supabaseUrl, supabaseKey };
505
+ }
506
+
507
+ /**
508
+ * Upload a file to Supabase Storage with upsert.
509
+ */
510
+ async function uploadToStorage(supabaseUrl, supabaseKey, bucket, storagePath, content, contentType) {
511
+ const url = `${supabaseUrl}/storage/v1/object/${bucket}/${storagePath}`;
512
+ const response = await fetch(url, {
513
+ method: 'POST',
514
+ headers: {
515
+ 'Authorization': `Bearer ${supabaseKey}`,
516
+ 'Content-Type': contentType,
517
+ 'x-upsert': 'true',
518
+ },
519
+ body: content,
520
+ });
521
+
522
+ if (!response.ok) {
523
+ const text = await response.text().catch(() => '');
524
+ throw new Error(`Storage upload failed (${response.status}): ${text}`);
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Get the current app manifest version from the apps table.
530
+ */
531
+ async function getAppCurrentVersion(supabaseUrl, supabaseKey, appId) {
532
+ const encodedAppId = encodeURIComponent(appId);
533
+ const url = `${supabaseUrl}/rest/v1/apps?id=eq.${encodedAppId}&select=manifest`;
534
+ const response = await fetch(url, {
535
+ headers: {
536
+ 'Authorization': `Bearer ${supabaseKey}`,
537
+ 'apikey': supabaseKey,
538
+ },
539
+ });
540
+ if (!response.ok) throw new Error(`Failed to get app version: ${response.status}`);
541
+ const rows = await response.json();
542
+ if (!rows.length) throw usageError(`App ${appId} not found.`);
543
+ const manifest = rows[0].manifest || {};
544
+ return { currentVersion: manifest.version || 0, manifest };
545
+ }
546
+
547
+ /**
548
+ * Update the app manifest in the apps table.
549
+ */
550
+ async function updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, manifest) {
551
+ const encodedAppId = encodeURIComponent(appId);
552
+ const url = `${supabaseUrl}/rest/v1/apps?id=eq.${encodedAppId}`;
553
+ const response = await fetch(url, {
554
+ method: 'PATCH',
555
+ headers: {
556
+ 'Authorization': `Bearer ${supabaseKey}`,
557
+ 'apikey': supabaseKey,
558
+ 'Content-Type': 'application/json',
559
+ 'Prefer': 'return=minimal',
560
+ },
561
+ body: JSON.stringify({
562
+ manifest: { ...manifest, version: newVersion },
563
+ updated_at: new Date().toISOString(),
564
+ }),
565
+ });
566
+ if (!response.ok) {
567
+ const text = await response.text().catch(() => '');
568
+ throw new Error(`Failed to update app version: ${response.status} ${text}`);
569
+ }
570
+ }
571
+
572
+ const CONTENT_TYPE_MAP = {
573
+ '.js': 'application/javascript',
574
+ '.css': 'text/css',
575
+ '.json': 'application/json',
576
+ '.html': 'text/html',
577
+ };
578
+
579
+ /**
580
+ * Deploy app bundle directly to Supabase storage, bypassing the backend server.
581
+ */
582
+ export async function directDeploy(projectDir, appId) {
583
+ const { supabaseUrl, supabaseKey } = resolveSupabaseCredentials();
584
+ const manifest = readManifest(projectDir);
585
+
586
+ // Get current version and increment
587
+ const { currentVersion } = await getAppCurrentVersion(supabaseUrl, supabaseKey, appId);
588
+ const newVersion = currentVersion + 1;
589
+
590
+ // Upload all files from the output directory
591
+ const outputDir = join(projectDir, OUTPUT_DIR);
592
+ const bucket = 'app-code';
593
+
594
+ async function uploadDir(dir, prefix) {
595
+ const entries = readdirSync(dir, { withFileTypes: true });
596
+ for (const entry of entries) {
597
+ const fullPath = join(dir, entry.name);
598
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
599
+ if (entry.isDirectory()) {
600
+ await uploadDir(fullPath, relPath);
601
+ } else {
602
+ const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
603
+ const contentType = CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
604
+ const content = readFileSync(fullPath);
605
+ const storagePath = `${appId}/v${newVersion}/${relPath}`;
606
+ await uploadToStorage(supabaseUrl, supabaseKey, bucket, storagePath, content, contentType);
607
+ }
608
+ }
609
+ }
610
+
611
+ await uploadDir(outputDir, '');
612
+
613
+ // Update the app record -- include storage_prefix so the portal can resolve bundle URLs
614
+ const storagePrefix = `${appId}/v${newVersion}/`;
615
+ const deployManifest = {
616
+ ...manifest,
617
+ version: newVersion,
618
+ storage_bucket: bucket,
619
+ storage_prefix: storagePrefix,
620
+ };
621
+ await updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, deployManifest);
622
+
623
+ return { version: newVersion };
624
+ }