@notis_ai/cli 0.2.0-beta.16.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -6
- package/package.json +1 -6
- package/src/command-specs/apps.js +10 -77
- package/src/runtime/app-platform.js +69 -309
- package/src/runtime/app-preview-server.js +96 -136
- package/template/app/globals.css +0 -3
- package/template/app/layout.tsx +0 -7
- package/template/app/page.tsx +0 -55
- package/template/components/ui/badge.tsx +0 -28
- package/template/components/ui/button.tsx +0 -53
- package/template/components/ui/card.tsx +0 -56
- package/template/components.json +0 -20
- package/template/lib/utils.ts +0 -6
- package/template/notis.config.ts +0 -18
- package/template/package.json +0 -32
- package/template/packages/notis-sdk/package.json +0 -26
- package/template/packages/notis-sdk/src/config.ts +0 -48
- package/template/packages/notis-sdk/src/helpers.ts +0 -131
- package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
- package/template/packages/notis-sdk/src/hooks/useBackend.ts +0 -41
- package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
- package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
- package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
- package/template/packages/notis-sdk/src/hooks/useNotis.ts +0 -31
- package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +0 -49
- package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
- package/template/packages/notis-sdk/src/hooks/useTools.ts +0 -56
- package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
- package/template/packages/notis-sdk/src/index.ts +0 -47
- package/template/packages/notis-sdk/src/provider.tsx +0 -44
- package/template/packages/notis-sdk/src/runtime.ts +0 -159
- package/template/packages/notis-sdk/src/styles.css +0 -123
- package/template/packages/notis-sdk/src/ui.ts +0 -15
- package/template/packages/notis-sdk/src/vite.ts +0 -54
- package/template/packages/notis-sdk/tsconfig.json +0 -15
- package/template/postcss.config.mjs +0 -8
- package/template/tailwind.config.ts +0 -58
- package/template/tsconfig.json +0 -22
- package/template/vite.config.ts +0 -10
|
@@ -3,23 +3,22 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Handles project scaffolding, validation, building, and linking. This is the
|
|
5
5
|
* CLI-side counterpart to @notis/sdk -- it reads notis.config.ts, runs the
|
|
6
|
-
*
|
|
6
|
+
* Next.js build, and packages the artifact for deployment.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
10
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync, readdirSync } from 'node:fs';
|
|
11
|
-
import { createRequire } from 'node:module';
|
|
12
11
|
import { dirname, join, resolve } from 'node:path';
|
|
13
12
|
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
14
|
|
|
15
15
|
import { usageError } from './errors.js';
|
|
16
16
|
|
|
17
17
|
const NOTIS_DIR = '.notis';
|
|
18
18
|
const STATE_FILE = join(NOTIS_DIR, 'state.json');
|
|
19
19
|
const OUTPUT_DIR = join(NOTIS_DIR, 'output');
|
|
20
|
-
const
|
|
20
|
+
const SITE_DIR = join(OUTPUT_DIR, 'site');
|
|
21
21
|
const MANIFEST_FILE = join(OUTPUT_DIR, 'manifest.json');
|
|
22
|
-
let appConfigImportNonce = 0;
|
|
23
22
|
|
|
24
23
|
// ---------------------------------------------------------------------------
|
|
25
24
|
// Project directory resolution
|
|
@@ -35,29 +34,9 @@ export function resolveProjectDir(inputDir = '.') {
|
|
|
35
34
|
|
|
36
35
|
/**
|
|
37
36
|
* Load and parse notis.config.ts from a project directory. Uses a simple
|
|
38
|
-
* approach: we read the file, transpile with
|
|
39
|
-
*
|
|
37
|
+
* approach: we read the file, transpile with a basic regex to strip types,
|
|
38
|
+
* and evaluate. For production use this should use tsx or similar.
|
|
40
39
|
*/
|
|
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
40
|
export async function loadAppConfig(projectDir) {
|
|
62
41
|
const configPaths = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'];
|
|
63
42
|
let configPath = null;
|
|
@@ -75,24 +54,24 @@ export async function loadAppConfig(projectDir) {
|
|
|
75
54
|
}
|
|
76
55
|
|
|
77
56
|
// For .js/.mjs files, import directly. For .ts, we need transpilation.
|
|
57
|
+
// In the CLI context, we use a simple approach: read the file, strip TS
|
|
58
|
+
// syntax, and eval. This avoids requiring tsx as a dependency.
|
|
78
59
|
if (configPath.endsWith('.ts')) {
|
|
79
60
|
const source = readFileSync(configPath, 'utf-8');
|
|
80
|
-
|
|
61
|
+
// Strip import type annotations and type declarations
|
|
62
|
+
const jsSource = source
|
|
63
|
+
.replace(/import\s+type\s+{[^}]*}\s+from\s+['"][^'"]*['"]\s*;?/g, '')
|
|
64
|
+
.replace(/import\s+{[^}]*}\s+from\s+['"][^'"]*['"]\s*;?/g, '')
|
|
65
|
+
.replace(/export\s+default\s+/, 'export default ')
|
|
66
|
+
.replace(/defineNotisApp\s*\(/, '(');
|
|
81
67
|
|
|
82
|
-
|
|
68
|
+
// Write a temporary .mjs file and import it
|
|
69
|
+
const tmpPath = join(projectDir, '.notis', '_config_tmp.mjs');
|
|
83
70
|
mkdirSync(dirname(tmpPath), { recursive: true });
|
|
84
71
|
writeFileSync(tmpPath, jsSource);
|
|
85
72
|
try {
|
|
86
|
-
const mod = await import(`file://${tmpPath}
|
|
73
|
+
const mod = await import(`file://${tmpPath}`);
|
|
87
74
|
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
75
|
} finally {
|
|
97
76
|
try {
|
|
98
77
|
const { unlinkSync } = await import('node:fs');
|
|
@@ -116,10 +95,10 @@ export function detectProjectProblems(projectDir) {
|
|
|
116
95
|
problems.push('Missing package.json');
|
|
117
96
|
}
|
|
118
97
|
|
|
119
|
-
const
|
|
98
|
+
const hasNextConfig = ['next.config.ts', 'next.config.mjs', 'next.config.js']
|
|
120
99
|
.some((name) => existsSync(join(projectDir, name)));
|
|
121
|
-
if (!
|
|
122
|
-
problems.push('Missing
|
|
100
|
+
if (!hasNextConfig) {
|
|
101
|
+
problems.push('Missing next.config file');
|
|
123
102
|
}
|
|
124
103
|
|
|
125
104
|
if (!existsSync(join(projectDir, 'app'))) {
|
|
@@ -149,7 +128,7 @@ export function detectProjectWarnings(projectDir, appConfig = null) {
|
|
|
149
128
|
}
|
|
150
129
|
|
|
151
130
|
if (appConfig && (!Array.isArray(appConfig.databases) || appConfig.databases.length === 0)) {
|
|
152
|
-
warnings.push('No
|
|
131
|
+
warnings.push('No databases declared in notis.config.ts.');
|
|
153
132
|
}
|
|
154
133
|
|
|
155
134
|
return warnings;
|
|
@@ -177,31 +156,6 @@ export async function runProjectScript({ projectDir, scriptName, env = {}, stdio
|
|
|
177
156
|
});
|
|
178
157
|
}
|
|
179
158
|
|
|
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
159
|
/**
|
|
206
160
|
* Auto-detect routes from the app/ directory by scanning for page.tsx files.
|
|
207
161
|
*/
|
|
@@ -220,13 +174,13 @@ function autoDetectRoutes(projectDir) {
|
|
|
220
174
|
scan(join(dir, entry.name), `${pathPrefix}/${entry.name}`);
|
|
221
175
|
} else if (entry.name === 'page.tsx' || entry.name === 'page.jsx' || entry.name === 'page.js') {
|
|
222
176
|
const routePath = pathPrefix || '/';
|
|
223
|
-
const slug =
|
|
177
|
+
const slug = routePath === '/' ? 'index' : routePath.replace(/^\//, '').replace(/\//g, '-');
|
|
224
178
|
routes.push({
|
|
225
179
|
path: routePath,
|
|
226
180
|
slug,
|
|
227
181
|
name: slug === 'index' ? 'Home' : slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
228
182
|
default: routePath === '/',
|
|
229
|
-
|
|
183
|
+
entry_html: routePath === '/' ? 'index.html' : `${routePath.replace(/^\//, '')}/index.html`,
|
|
230
184
|
});
|
|
231
185
|
}
|
|
232
186
|
}
|
|
@@ -236,109 +190,89 @@ function autoDetectRoutes(projectDir) {
|
|
|
236
190
|
return routes;
|
|
237
191
|
}
|
|
238
192
|
|
|
239
|
-
function
|
|
240
|
-
|
|
241
|
-
|
|
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);
|
|
193
|
+
function slugFromPath(routePath) {
|
|
194
|
+
if (routePath === '/') return 'index';
|
|
195
|
+
return routePath.replace(/^\//, '').replace(/\//g, '-');
|
|
247
196
|
}
|
|
248
197
|
|
|
249
|
-
|
|
250
|
-
|
|
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;
|
|
198
|
+
function entryHtmlFromPath(routePath) {
|
|
199
|
+
if (routePath === '/') return 'index.html';
|
|
200
|
+
return `${routePath.replace(/^\//, '')}/index.html`;
|
|
271
201
|
}
|
|
272
202
|
|
|
273
203
|
/**
|
|
274
204
|
* Generate the manifest from app config and build output.
|
|
275
205
|
*/
|
|
276
206
|
function generateManifest(appConfig, projectDir) {
|
|
277
|
-
const routes =
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
};
|
|
287
|
-
if (route.tool_access) {
|
|
288
|
-
entry.tool_access = route.tool_access;
|
|
289
|
-
}
|
|
290
|
-
return entry;
|
|
291
|
-
});
|
|
207
|
+
const routes = (appConfig.routes || autoDetectRoutes(projectDir)).map((route) => ({
|
|
208
|
+
path: route.path,
|
|
209
|
+
slug: route.slug || slugFromPath(route.path),
|
|
210
|
+
name: route.name,
|
|
211
|
+
icon: route.icon || null,
|
|
212
|
+
default: route.default || false,
|
|
213
|
+
entry_html: route.entry_html || entryHtmlFromPath(route.path),
|
|
214
|
+
collection: route.collection || null,
|
|
215
|
+
}));
|
|
292
216
|
|
|
293
|
-
const databases = appConfig.databases || []
|
|
217
|
+
const databases = (appConfig.databases || []).map((db) => ({
|
|
218
|
+
slug: db.slug,
|
|
219
|
+
title: db.title,
|
|
220
|
+
description: db.description || null,
|
|
221
|
+
icon: db.icon || null,
|
|
222
|
+
properties: (db.properties || []).map((prop) => ({
|
|
223
|
+
name: prop.name,
|
|
224
|
+
type: prop.type,
|
|
225
|
+
description: prop.description || null,
|
|
226
|
+
options: prop.options
|
|
227
|
+
? prop.options.map((opt) => typeof opt === 'string' ? { name: opt } : opt)
|
|
228
|
+
: null,
|
|
229
|
+
})),
|
|
230
|
+
}));
|
|
294
231
|
|
|
295
232
|
return {
|
|
296
233
|
version: 1,
|
|
297
|
-
spec_version:
|
|
234
|
+
spec_version: 2,
|
|
298
235
|
app: {
|
|
299
236
|
name: appConfig.name,
|
|
300
237
|
description: appConfig.description || null,
|
|
301
238
|
icon: appConfig.icon || null,
|
|
302
239
|
},
|
|
303
240
|
routes,
|
|
304
|
-
bundle: {
|
|
305
|
-
js: 'bundle/app.js',
|
|
306
|
-
css: 'bundle/app.css',
|
|
307
|
-
},
|
|
308
241
|
databases,
|
|
309
242
|
tools: appConfig.tools || [],
|
|
310
243
|
};
|
|
311
244
|
}
|
|
312
245
|
|
|
313
246
|
/**
|
|
314
|
-
* Build the app
|
|
247
|
+
* Build the app artifact: run `next build`, then package into .notis/output/.
|
|
315
248
|
*/
|
|
316
249
|
export async function buildArtifact(projectDir) {
|
|
317
250
|
const appConfig = await loadAppConfig(projectDir);
|
|
318
251
|
|
|
319
|
-
//
|
|
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
|
|
252
|
+
// Run the Next.js build
|
|
326
253
|
await runProjectScript({
|
|
327
254
|
projectDir,
|
|
328
255
|
scriptName: 'build',
|
|
256
|
+
env: {
|
|
257
|
+
NOTIS_BUILD: '1',
|
|
258
|
+
NOTIS_APP_BASE_PATH: '/__NOTIS_APP_BASE__',
|
|
259
|
+
NOTIS_ASSET_PREFIX: '/__NOTIS_ASSET_BASE__',
|
|
260
|
+
},
|
|
329
261
|
});
|
|
330
262
|
|
|
331
|
-
//
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
throw usageError('Vite build did not produce .notis/output/bundle/app.js. Check your vite.config.ts.');
|
|
263
|
+
// Copy the static export into .notis/output/site/
|
|
264
|
+
const nextOutDir = join(projectDir, 'out');
|
|
265
|
+
if (!existsSync(nextOutDir)) {
|
|
266
|
+
throw usageError('Next.js build did not produce an `out/` directory. Ensure `output: "export"` is set (withNotis does this automatically).');
|
|
336
267
|
}
|
|
337
268
|
|
|
269
|
+
const outputSiteDir = join(projectDir, SITE_DIR);
|
|
270
|
+
mkdirSync(outputSiteDir, { recursive: true });
|
|
271
|
+
cpSync(nextOutDir, outputSiteDir, { recursive: true });
|
|
272
|
+
|
|
338
273
|
// Generate manifest
|
|
339
274
|
const manifest = generateManifest(appConfig, projectDir);
|
|
340
275
|
const manifestPath = join(projectDir, MANIFEST_FILE);
|
|
341
|
-
mkdirSync(dirname(manifestPath), { recursive: true });
|
|
342
276
|
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
343
277
|
|
|
344
278
|
return { manifest, outputDir: join(projectDir, OUTPUT_DIR) };
|
|
@@ -388,11 +322,11 @@ export function requireLinkedAppId(projectDir, explicitAppId) {
|
|
|
388
322
|
export function scaffoldProject({ projectDir, appName }) {
|
|
389
323
|
const templateDir = resolve(
|
|
390
324
|
dirname(fileURLToPath(import.meta.url)),
|
|
391
|
-
'
|
|
325
|
+
'../../../../packages/notis-sdk/template',
|
|
392
326
|
);
|
|
393
327
|
|
|
394
328
|
if (!existsSync(templateDir)) {
|
|
395
|
-
throw usageError(`SDK template not found at ${templateDir}. Ensure @
|
|
329
|
+
throw usageError(`SDK template not found at ${templateDir}. Ensure @notis/sdk is installed.`);
|
|
396
330
|
}
|
|
397
331
|
|
|
398
332
|
mkdirSync(projectDir, { recursive: true });
|
|
@@ -448,177 +382,3 @@ export function collectArtifactFiles(projectDir) {
|
|
|
448
382
|
walk(outputDir, '');
|
|
449
383
|
return files;
|
|
450
384
|
}
|
|
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
|
-
}
|