@notis_ai/cli 0.2.0 → 0.2.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.
- package/README.md +39 -10
- package/package.json +7 -1
- package/src/command-specs/apps.js +852 -47
- package/src/command-specs/helpers.js +28 -3
- package/src/command-specs/index.js +1 -1
- package/src/command-specs/tools.js +33 -6
- package/src/runtime/agent-browser.js +192 -0
- package/src/runtime/app-boundary-validator.js +132 -0
- package/src/runtime/app-dev-server.js +577 -0
- package/src/runtime/app-dev-sessions.js +87 -0
- package/src/runtime/app-platform.js +646 -71
- package/src/runtime/cli-mode.generated.js +4 -0
- package/src/runtime/cli-mode.js +29 -0
- package/src/runtime/output.js +2 -2
- package/src/runtime/ports.js +15 -0
- package/src/runtime/profiles.js +34 -3
- package/src/runtime/transport.js +129 -4
- package/template/.harness/index.html.tmpl +260 -0
- package/template/app/globals.css +3 -0
- package/template/app/layout.tsx +6 -0
- package/template/app/page.tsx +55 -0
- package/template/components/ui/badge.tsx +28 -0
- package/template/components/ui/button.tsx +53 -0
- package/template/components/ui/card.tsx +56 -0
- package/template/components.json +20 -0
- package/template/lib/utils.ts +6 -0
- package/template/notis.config.ts +33 -0
- package/template/package.json +31 -0
- package/template/packages/notis-sdk/package.json +26 -0
- package/template/packages/notis-sdk/src/components/MultiSelectActionBar.tsx +272 -0
- package/template/packages/notis-sdk/src/components/MultiSelectCheckbox.tsx +91 -0
- package/template/packages/notis-sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/template/packages/notis-sdk/src/config.ts +71 -0
- package/template/packages/notis-sdk/src/helpers.ts +131 -0
- package/template/packages/notis-sdk/src/hooks/useAppState.ts +50 -0
- package/template/packages/notis-sdk/src/hooks/useBackend.ts +41 -0
- package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +58 -0
- package/template/packages/notis-sdk/src/hooks/useDatabase.ts +88 -0
- package/template/packages/notis-sdk/src/hooks/useDocument.ts +61 -0
- package/template/packages/notis-sdk/src/hooks/useMultiSelect.ts +502 -0
- package/template/packages/notis-sdk/src/hooks/useNotis.ts +33 -0
- package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +49 -0
- package/template/packages/notis-sdk/src/hooks/useTool.ts +49 -0
- package/template/packages/notis-sdk/src/hooks/useTools.ts +56 -0
- package/template/packages/notis-sdk/src/hooks/useTopBarSearch.ts +73 -0
- package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +58 -0
- package/template/packages/notis-sdk/src/index.ts +67 -0
- package/template/packages/notis-sdk/src/provider.tsx +43 -0
- package/template/packages/notis-sdk/src/runtime.ts +182 -0
- package/template/packages/notis-sdk/src/styles.css +38 -0
- package/template/packages/notis-sdk/src/ui.ts +15 -0
- package/template/packages/notis-sdk/src/vite.ts +56 -0
- package/template/packages/notis-sdk/tsconfig.json +15 -0
- package/template/postcss.config.mjs +8 -0
- package/template/tailwind.config.ts +58 -0
- package/template/tsconfig.json +22 -0
- package/template/vite.config.ts +10 -0
- package/src/runtime/app-preview-server.js +0 -272
|
@@ -3,22 +3,34 @@
|
|
|
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
|
+
* Vite build, and packages the bundle for deployment.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync,
|
|
10
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
11
12
|
import { dirname, join, resolve } from 'node:path';
|
|
12
13
|
import { fileURLToPath } from 'node:url';
|
|
13
|
-
import {
|
|
14
|
+
import { gunzipSync } from 'node:zlib';
|
|
14
15
|
|
|
15
16
|
import { usageError } from './errors.js';
|
|
17
|
+
import { validateArtifactBoundary, validateProjectBoundary } from './app-boundary-validator.js';
|
|
16
18
|
|
|
17
19
|
const NOTIS_DIR = '.notis';
|
|
18
20
|
const STATE_FILE = join(NOTIS_DIR, 'state.json');
|
|
19
21
|
const OUTPUT_DIR = join(NOTIS_DIR, 'output');
|
|
20
|
-
const
|
|
22
|
+
const BUNDLE_DIR = join(OUTPUT_DIR, 'bundle');
|
|
21
23
|
const MANIFEST_FILE = join(OUTPUT_DIR, 'manifest.json');
|
|
24
|
+
const SOURCE_COPY_EXCLUDES = new Set([
|
|
25
|
+
'node_modules',
|
|
26
|
+
'.notis',
|
|
27
|
+
'.git',
|
|
28
|
+
'dist',
|
|
29
|
+
'package-lock.json',
|
|
30
|
+
'tsconfig.tsbuildinfo',
|
|
31
|
+
'.DS_Store',
|
|
32
|
+
]);
|
|
33
|
+
let appConfigImportNonce = 0;
|
|
22
34
|
|
|
23
35
|
// ---------------------------------------------------------------------------
|
|
24
36
|
// Project directory resolution
|
|
@@ -28,15 +40,86 @@ export function resolveProjectDir(inputDir = '.') {
|
|
|
28
40
|
return resolve(process.cwd(), inputDir);
|
|
29
41
|
}
|
|
30
42
|
|
|
43
|
+
export function getBundleDir(projectDir) {
|
|
44
|
+
return join(projectDir, BUNDLE_DIR);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolveBuiltBundleDir(projectDir) {
|
|
48
|
+
const bundleDir = getBundleDir(projectDir);
|
|
49
|
+
if (existsSync(join(bundleDir, 'app.js'))) {
|
|
50
|
+
return bundleDir;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeShadowScopedCss(css) {
|
|
56
|
+
return css
|
|
57
|
+
// Tailwind preflight emits `html,:host` in v3. Inside a shadow tree we want
|
|
58
|
+
// the shadow host itself to carry those defaults.
|
|
59
|
+
.replace(/html\s*,\s*:host\s*\{/g, ':host{')
|
|
60
|
+
.replace(/:root\s*,\s*:host\s*\{/g, ':host{')
|
|
61
|
+
.replace(/:root\s*\{/g, ':host{')
|
|
62
|
+
.replace(/html\s*\{/g, ':host{')
|
|
63
|
+
// Shadow trees do not contain a body element. Route those defaults to the
|
|
64
|
+
// app root contract instead so authors still get the expected reset.
|
|
65
|
+
.replace(/body\s*\{/g, '[data-notis-app-root]{');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeBundleStylesheets(projectDir) {
|
|
69
|
+
const bundleDir = join(projectDir, BUNDLE_DIR);
|
|
70
|
+
if (!existsSync(bundleDir)) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const entry of readdirSync(bundleDir)) {
|
|
75
|
+
if (!entry.endsWith('.css')) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const cssPath = join(bundleDir, entry);
|
|
79
|
+
const normalizedCss = normalizeShadowScopedCss(readFileSync(cssPath, 'utf-8'));
|
|
80
|
+
writeFileSync(cssPath, normalizedCss);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
31
84
|
// ---------------------------------------------------------------------------
|
|
32
85
|
// Config loading
|
|
33
86
|
// ---------------------------------------------------------------------------
|
|
34
87
|
|
|
35
88
|
/**
|
|
36
89
|
* Load and parse notis.config.ts from a project directory. Uses a simple
|
|
37
|
-
* approach: we read the file, transpile with
|
|
38
|
-
*
|
|
90
|
+
* approach: we read the file, transpile with the project's TypeScript compiler
|
|
91
|
+
* when available, then evaluate as ESM.
|
|
39
92
|
*/
|
|
93
|
+
function stripNotisSdkImports(source) {
|
|
94
|
+
return source
|
|
95
|
+
.replace(/import\s+type\s+[\s\S]*?from\s+['"][^'"]*['"]\s*;?/g, '')
|
|
96
|
+
.replace(/import\s*{[\s\S]*?\bdefineNotisApp\b[\s\S]*?}\s+from\s+['"]@notis\/sdk\/config['"]\s*;?/g, '')
|
|
97
|
+
.replace(/defineNotisApp\s*\(/g, '(');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function transpileTsConfigSource(source, configPath) {
|
|
101
|
+
// Strip the @notis/sdk/config import unconditionally. The SDK package often
|
|
102
|
+
// points its `exports` at raw .ts source, which Node cannot import from the
|
|
103
|
+
// temp .mjs file we emit below. `defineNotisApp` is just an identity helper,
|
|
104
|
+
// so removing the import and replacing the call with a parens-wrapped
|
|
105
|
+
// expression preserves the config value without ever resolving the SDK.
|
|
106
|
+
const stripped = stripNotisSdkImports(source);
|
|
107
|
+
const requireFromConfig = createRequire(`file://${configPath}`);
|
|
108
|
+
try {
|
|
109
|
+
const ts = requireFromConfig('typescript');
|
|
110
|
+
const transpiled = ts.transpileModule(stripped, {
|
|
111
|
+
compilerOptions: {
|
|
112
|
+
module: ts.ModuleKind.ESNext,
|
|
113
|
+
target: ts.ScriptTarget.ES2020,
|
|
114
|
+
},
|
|
115
|
+
fileName: configPath,
|
|
116
|
+
});
|
|
117
|
+
return transpiled.outputText;
|
|
118
|
+
} catch {
|
|
119
|
+
return stripped;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
40
123
|
export async function loadAppConfig(projectDir) {
|
|
41
124
|
const configPaths = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'];
|
|
42
125
|
let configPath = null;
|
|
@@ -54,24 +137,24 @@ export async function loadAppConfig(projectDir) {
|
|
|
54
137
|
}
|
|
55
138
|
|
|
56
139
|
// 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.
|
|
59
140
|
if (configPath.endsWith('.ts')) {
|
|
60
141
|
const source = readFileSync(configPath, 'utf-8');
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
.replace(/import\s+{[^}]*}\s+from\s+['"][^'"]*['"]\s*;?/g, '')
|
|
65
|
-
.replace(/export\s+default\s+/, 'export default ')
|
|
66
|
-
.replace(/defineNotisApp\s*\(/, '(');
|
|
67
|
-
|
|
68
|
-
// Write a temporary .mjs file and import it
|
|
69
|
-
const tmpPath = join(projectDir, '.notis', '_config_tmp.mjs');
|
|
142
|
+
const jsSource = transpileTsConfigSource(source, configPath);
|
|
143
|
+
|
|
144
|
+
const tmpPath = join(dirname(configPath), '._notis_config_tmp.mjs');
|
|
70
145
|
mkdirSync(dirname(tmpPath), { recursive: true });
|
|
71
146
|
writeFileSync(tmpPath, jsSource);
|
|
72
147
|
try {
|
|
73
|
-
const mod = await import(`file://${tmpPath}`);
|
|
148
|
+
const mod = await import(`file://${tmpPath}?v=${appConfigImportNonce++}`);
|
|
74
149
|
return mod.default || mod;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error instanceof SyntaxError) {
|
|
152
|
+
throw usageError(
|
|
153
|
+
`Failed to parse ${configPath}. Install project dependencies (including typescript) ` +
|
|
154
|
+
'or convert the config to plain JavaScript.',
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
throw error;
|
|
75
158
|
} finally {
|
|
76
159
|
try {
|
|
77
160
|
const { unlinkSync } = await import('node:fs');
|
|
@@ -95,10 +178,10 @@ export function detectProjectProblems(projectDir) {
|
|
|
95
178
|
problems.push('Missing package.json');
|
|
96
179
|
}
|
|
97
180
|
|
|
98
|
-
const
|
|
181
|
+
const hasViteConfig = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']
|
|
99
182
|
.some((name) => existsSync(join(projectDir, name)));
|
|
100
|
-
if (!
|
|
101
|
-
problems.push('Missing
|
|
183
|
+
if (!hasViteConfig) {
|
|
184
|
+
problems.push('Missing vite.config file');
|
|
102
185
|
}
|
|
103
186
|
|
|
104
187
|
if (!existsSync(join(projectDir, 'app'))) {
|
|
@@ -128,7 +211,7 @@ export function detectProjectWarnings(projectDir, appConfig = null) {
|
|
|
128
211
|
}
|
|
129
212
|
|
|
130
213
|
if (appConfig && (!Array.isArray(appConfig.databases) || appConfig.databases.length === 0)) {
|
|
131
|
-
warnings.push('No
|
|
214
|
+
warnings.push('No database references declared in notis.config.ts.');
|
|
132
215
|
}
|
|
133
216
|
|
|
134
217
|
return warnings;
|
|
@@ -156,6 +239,31 @@ export async function runProjectScript({ projectDir, scriptName, env = {}, stdio
|
|
|
156
239
|
});
|
|
157
240
|
}
|
|
158
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Derive an export name from a route path.
|
|
244
|
+
* '/' -> 'index', '/inbox' -> 'inbox', '/my-tasks' -> 'myTasks'
|
|
245
|
+
*/
|
|
246
|
+
export function exportNameFromPath(routePath) {
|
|
247
|
+
if (routePath === '/') return 'index';
|
|
248
|
+
const slug = routePath.replace(/^\//, '').replace(/\//g, '-');
|
|
249
|
+
const identifier = slug
|
|
250
|
+
.split(/[^A-Za-z0-9_$]+/)
|
|
251
|
+
.filter(Boolean)
|
|
252
|
+
.map((part, index) => {
|
|
253
|
+
const lower = part.toLowerCase();
|
|
254
|
+
if (index === 0) return lower;
|
|
255
|
+
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
|
256
|
+
})
|
|
257
|
+
.join('');
|
|
258
|
+
if (!identifier) return 'route';
|
|
259
|
+
return /^[A-Za-z_$]/.test(identifier) ? identifier : `r${identifier}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function slugFromPath(routePath) {
|
|
263
|
+
if (routePath === '/') return 'index';
|
|
264
|
+
return routePath.replace(/^\//, '').replace(/\//g, '-');
|
|
265
|
+
}
|
|
266
|
+
|
|
159
267
|
/**
|
|
160
268
|
* Auto-detect routes from the app/ directory by scanning for page.tsx files.
|
|
161
269
|
*/
|
|
@@ -174,13 +282,13 @@ function autoDetectRoutes(projectDir) {
|
|
|
174
282
|
scan(join(dir, entry.name), `${pathPrefix}/${entry.name}`);
|
|
175
283
|
} else if (entry.name === 'page.tsx' || entry.name === 'page.jsx' || entry.name === 'page.js') {
|
|
176
284
|
const routePath = pathPrefix || '/';
|
|
177
|
-
const slug = routePath
|
|
285
|
+
const slug = slugFromPath(routePath);
|
|
178
286
|
routes.push({
|
|
179
287
|
path: routePath,
|
|
180
288
|
slug,
|
|
181
289
|
name: slug === 'index' ? 'Home' : slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
182
290
|
default: routePath === '/',
|
|
183
|
-
|
|
291
|
+
export_name: exportNameFromPath(routePath),
|
|
184
292
|
});
|
|
185
293
|
}
|
|
186
294
|
}
|
|
@@ -190,89 +298,192 @@ function autoDetectRoutes(projectDir) {
|
|
|
190
298
|
return routes;
|
|
191
299
|
}
|
|
192
300
|
|
|
193
|
-
function
|
|
194
|
-
if (
|
|
195
|
-
|
|
301
|
+
function validateConfiguredRoutes(routes) {
|
|
302
|
+
if (routes.length === 0) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const slugSet = new Set();
|
|
307
|
+
let defaultCount = 0;
|
|
308
|
+
|
|
309
|
+
for (const route of routes) {
|
|
310
|
+
if (!route.slug || typeof route.slug !== 'string') {
|
|
311
|
+
throw usageError(`Route "${route.path}" must define a slug.`);
|
|
312
|
+
}
|
|
313
|
+
if (slugSet.has(route.slug)) {
|
|
314
|
+
throw usageError(`Duplicate route slug "${route.slug}".`);
|
|
315
|
+
}
|
|
316
|
+
slugSet.add(route.slug);
|
|
317
|
+
if (route.default) {
|
|
318
|
+
defaultCount += 1;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (defaultCount !== 1) {
|
|
323
|
+
throw usageError(`Expected exactly one default route, received ${defaultCount}.`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
for (const route of routes) {
|
|
327
|
+
if (route.parentSlug && !slugSet.has(route.parentSlug)) {
|
|
328
|
+
throw usageError(
|
|
329
|
+
`Route "${route.slug}" references unknown parentSlug "${route.parentSlug}".`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
const collection = route.collection || null;
|
|
333
|
+
if (
|
|
334
|
+
collection?.sidebar?.mode === 'tree' &&
|
|
335
|
+
(!collection.parentProperty || typeof collection.parentProperty !== 'string')
|
|
336
|
+
) {
|
|
337
|
+
throw usageError(
|
|
338
|
+
`Tree collection route "${route.slug}" must define collection.parentProperty.`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const childrenByParent = new Map();
|
|
344
|
+
for (const route of routes) {
|
|
345
|
+
if (!route.parentSlug) {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const children = childrenByParent.get(route.parentSlug) || [];
|
|
349
|
+
children.push(route.slug);
|
|
350
|
+
childrenByParent.set(route.parentSlug, children);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
for (const route of routes) {
|
|
354
|
+
const seen = new Set([route.slug]);
|
|
355
|
+
let current = route.parentSlug || null;
|
|
356
|
+
while (current) {
|
|
357
|
+
if (seen.has(current)) {
|
|
358
|
+
throw usageError(`Route parent cycle detected at "${route.slug}".`);
|
|
359
|
+
}
|
|
360
|
+
seen.add(current);
|
|
361
|
+
current = routes.find((candidate) => candidate.slug === current)?.parentSlug || null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
for (const route of routes) {
|
|
366
|
+
if (
|
|
367
|
+
route.collection?.sidebar?.mode === 'tree' &&
|
|
368
|
+
(childrenByParent.get(route.slug) || []).length > 0
|
|
369
|
+
) {
|
|
370
|
+
throw usageError(
|
|
371
|
+
`Tree collection route "${route.slug}" cannot also define static child routes.`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function resolveConfiguredRoutes(appConfig, projectDir) {
|
|
378
|
+
const configuredRoutes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
|
|
379
|
+
validateConfiguredRoutes(configuredRoutes);
|
|
380
|
+
const routes = configuredRoutes.map((route) => ({
|
|
381
|
+
...route,
|
|
382
|
+
slug: route.slug,
|
|
383
|
+
export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
|
|
384
|
+
}));
|
|
385
|
+
return routes.length > 0 ? routes : autoDetectRoutes(projectDir);
|
|
196
386
|
}
|
|
197
387
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
388
|
+
/**
|
|
389
|
+
* Generate the _entry.tsx file that re-exports each route's page component.
|
|
390
|
+
*/
|
|
391
|
+
function generateEntryFile(projectDir, routes) {
|
|
392
|
+
const entryDir = join(projectDir, NOTIS_DIR);
|
|
393
|
+
mkdirSync(entryDir, { recursive: true });
|
|
394
|
+
|
|
395
|
+
// Also re-export the layout if it exists
|
|
396
|
+
const lines = [];
|
|
397
|
+
const layoutPath = join(projectDir, 'app', 'layout.tsx');
|
|
398
|
+
if (existsSync(layoutPath)) {
|
|
399
|
+
lines.push(`export { default as __AppShell } from '../app/layout';`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
for (const route of routes) {
|
|
403
|
+
const pagePath = route.path === '/' ? '../app/page' : `../app${route.path}/page`;
|
|
404
|
+
lines.push(`export { default as ${route.export_name} } from '${pagePath}';`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const entryPath = join(entryDir, '_entry.tsx');
|
|
408
|
+
writeFileSync(entryPath, lines.join('\n') + '\n');
|
|
409
|
+
return entryPath;
|
|
201
410
|
}
|
|
202
411
|
|
|
203
412
|
/**
|
|
204
413
|
* Generate the manifest from app config and build output.
|
|
205
414
|
*/
|
|
206
|
-
function generateManifest(appConfig, projectDir) {
|
|
207
|
-
const routes = (appConfig
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
415
|
+
export function generateManifest(appConfig, projectDir) {
|
|
416
|
+
const routes = resolveConfiguredRoutes(appConfig, projectDir).map((route) => {
|
|
417
|
+
const entry = {
|
|
418
|
+
path: route.path,
|
|
419
|
+
slug: route.slug,
|
|
420
|
+
name: route.name,
|
|
421
|
+
icon: route.icon || null,
|
|
422
|
+
parentSlug: route.parentSlug || null,
|
|
423
|
+
default: route.default || false,
|
|
424
|
+
export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
|
|
425
|
+
collection: route.collection || null,
|
|
426
|
+
};
|
|
427
|
+
if (route.tool_access) {
|
|
428
|
+
entry.tool_access = route.tool_access;
|
|
429
|
+
}
|
|
430
|
+
return entry;
|
|
431
|
+
});
|
|
216
432
|
|
|
217
|
-
const databases =
|
|
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
|
-
}));
|
|
433
|
+
const databases = appConfig.databases || [];
|
|
231
434
|
|
|
232
435
|
return {
|
|
233
436
|
version: 1,
|
|
234
|
-
spec_version:
|
|
437
|
+
spec_version: 3,
|
|
235
438
|
app: {
|
|
236
439
|
name: appConfig.name,
|
|
237
440
|
description: appConfig.description || null,
|
|
238
441
|
icon: appConfig.icon || null,
|
|
239
442
|
},
|
|
240
443
|
routes,
|
|
444
|
+
bundle: {
|
|
445
|
+
js: 'bundle/app.js',
|
|
446
|
+
css: 'bundle/app.css',
|
|
447
|
+
},
|
|
241
448
|
databases,
|
|
242
449
|
tools: appConfig.tools || [],
|
|
243
450
|
};
|
|
244
451
|
}
|
|
245
452
|
|
|
246
453
|
/**
|
|
247
|
-
* Build the app
|
|
454
|
+
* Build the app bundle: generate entry file, run `vite build`, package into .notis/output/.
|
|
248
455
|
*/
|
|
249
456
|
export async function buildArtifact(projectDir) {
|
|
457
|
+
validateProjectBoundary(projectDir);
|
|
250
458
|
const appConfig = await loadAppConfig(projectDir);
|
|
251
459
|
|
|
252
|
-
//
|
|
460
|
+
// Auto-detect or use configured routes
|
|
461
|
+
const detectedRoutes = resolveConfiguredRoutes(appConfig, projectDir);
|
|
462
|
+
|
|
463
|
+
// Generate the entry file that re-exports all route page components
|
|
464
|
+
generateEntryFile(projectDir, detectedRoutes);
|
|
465
|
+
|
|
466
|
+
// Run Vite build
|
|
253
467
|
await runProjectScript({
|
|
254
468
|
projectDir,
|
|
255
469
|
scriptName: 'build',
|
|
256
|
-
env: {
|
|
257
|
-
NOTIS_BUILD: '1',
|
|
258
|
-
NOTIS_APP_BASE_PATH: '/__NOTIS_APP_BASE__',
|
|
259
|
-
NOTIS_ASSET_PREFIX: '/__NOTIS_ASSET_BASE__',
|
|
260
|
-
},
|
|
261
470
|
});
|
|
262
471
|
|
|
263
|
-
//
|
|
264
|
-
const
|
|
265
|
-
if (!
|
|
266
|
-
throw usageError(
|
|
472
|
+
// Verify the canonical `.notis/output/bundle` packaging contract.
|
|
473
|
+
const builtBundleDir = resolveBuiltBundleDir(projectDir);
|
|
474
|
+
if (!builtBundleDir) {
|
|
475
|
+
throw usageError(
|
|
476
|
+
'Vite build did not produce app.js in .notis/output/bundle. Check your vite.config.ts.',
|
|
477
|
+
);
|
|
267
478
|
}
|
|
479
|
+
normalizeBundleStylesheets(projectDir);
|
|
268
480
|
|
|
269
|
-
|
|
270
|
-
mkdirSync(outputSiteDir, { recursive: true });
|
|
271
|
-
cpSync(nextOutDir, outputSiteDir, { recursive: true });
|
|
481
|
+
validateArtifactBoundary(readArtifactFiles(projectDir));
|
|
272
482
|
|
|
273
483
|
// Generate manifest
|
|
274
484
|
const manifest = generateManifest(appConfig, projectDir);
|
|
275
485
|
const manifestPath = join(projectDir, MANIFEST_FILE);
|
|
486
|
+
mkdirSync(dirname(manifestPath), { recursive: true });
|
|
276
487
|
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
277
488
|
|
|
278
489
|
return { manifest, outputDir: join(projectDir, OUTPUT_DIR) };
|
|
@@ -322,11 +533,11 @@ export function requireLinkedAppId(projectDir, explicitAppId) {
|
|
|
322
533
|
export function scaffoldProject({ projectDir, appName }) {
|
|
323
534
|
const templateDir = resolve(
|
|
324
535
|
dirname(fileURLToPath(import.meta.url)),
|
|
325
|
-
'
|
|
536
|
+
'../../template',
|
|
326
537
|
);
|
|
327
538
|
|
|
328
539
|
if (!existsSync(templateDir)) {
|
|
329
|
-
throw usageError(`SDK template not found at ${templateDir}. Ensure @
|
|
540
|
+
throw usageError(`SDK template not found at ${templateDir}. Ensure @notis_ai/cli is installed correctly.`);
|
|
330
541
|
}
|
|
331
542
|
|
|
332
543
|
mkdirSync(projectDir, { recursive: true });
|
|
@@ -364,6 +575,8 @@ export function collectArtifactFiles(projectDir) {
|
|
|
364
575
|
throw usageError('No build output found. Run "notis apps build" first.');
|
|
365
576
|
}
|
|
366
577
|
|
|
578
|
+
validateArtifactBoundary(readArtifactFiles(projectDir));
|
|
579
|
+
|
|
367
580
|
const files = {};
|
|
368
581
|
|
|
369
582
|
function walk(dir, prefix) {
|
|
@@ -382,3 +595,365 @@ export function collectArtifactFiles(projectDir) {
|
|
|
382
595
|
walk(outputDir, '');
|
|
383
596
|
return files;
|
|
384
597
|
}
|
|
598
|
+
|
|
599
|
+
function shouldExcludeSourceEntry(name) {
|
|
600
|
+
return SOURCE_COPY_EXCLUDES.has(name) || name.startsWith('.env');
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function readSourceFiles(projectDir) {
|
|
604
|
+
const files = {};
|
|
605
|
+
|
|
606
|
+
function walk(dir, prefix) {
|
|
607
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
608
|
+
for (const entry of entries) {
|
|
609
|
+
if (shouldExcludeSourceEntry(entry.name) || entry.isSymbolicLink()) {
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
const fullPath = join(dir, entry.name);
|
|
613
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
614
|
+
if (entry.isDirectory()) {
|
|
615
|
+
walk(fullPath, relPath);
|
|
616
|
+
} else if (entry.isFile()) {
|
|
617
|
+
files[relPath] = readFileSync(fullPath);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
walk(projectDir, '');
|
|
623
|
+
return files;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function collectSourceFiles(projectDir) {
|
|
627
|
+
const files = readSourceFiles(projectDir);
|
|
628
|
+
const encoded = {};
|
|
629
|
+
for (const [relPath, content] of Object.entries(files)) {
|
|
630
|
+
encoded[relPath] = content.toString('base64');
|
|
631
|
+
}
|
|
632
|
+
return encoded;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function cleanTarPath(name) {
|
|
636
|
+
const cleaned = String(name || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
|
637
|
+
const parts = cleaned.split('/').filter(Boolean);
|
|
638
|
+
if (!parts.length || parts.includes('..')) {
|
|
639
|
+
throw usageError(`Refusing to extract unsafe path from source archive: ${name}`);
|
|
640
|
+
}
|
|
641
|
+
return parts.join('/');
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function extractTarGz(buffer, targetDir) {
|
|
645
|
+
const tar = gunzipSync(buffer);
|
|
646
|
+
let offset = 0;
|
|
647
|
+
while (offset + 512 <= tar.length) {
|
|
648
|
+
const header = tar.subarray(offset, offset + 512);
|
|
649
|
+
offset += 512;
|
|
650
|
+
if (header.every((byte) => byte === 0)) {
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const name = header.subarray(0, 100).toString('utf-8').replace(/\0.*$/, '');
|
|
655
|
+
const prefix = header.subarray(345, 500).toString('utf-8').replace(/\0.*$/, '');
|
|
656
|
+
const fullName = prefix ? `${prefix}/${name}` : name;
|
|
657
|
+
const sizeRaw = header.subarray(124, 136).toString('utf-8').replace(/\0.*$/, '').trim();
|
|
658
|
+
const size = Number.parseInt(sizeRaw || '0', 8);
|
|
659
|
+
const typeFlag = header[156];
|
|
660
|
+
const relPath = cleanTarPath(fullName);
|
|
661
|
+
const outputPath = join(targetDir, relPath);
|
|
662
|
+
|
|
663
|
+
if (typeFlag === 53) {
|
|
664
|
+
mkdirSync(outputPath, { recursive: true });
|
|
665
|
+
} else {
|
|
666
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
667
|
+
writeFileSync(outputPath, tar.subarray(offset, offset + size));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
offset += Math.ceil(size / 512) * 512;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export async function pullAppSource({
|
|
675
|
+
apiBase,
|
|
676
|
+
jwt,
|
|
677
|
+
appId,
|
|
678
|
+
targetDir,
|
|
679
|
+
version = 'latest',
|
|
680
|
+
force = false,
|
|
681
|
+
}) {
|
|
682
|
+
if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
|
|
683
|
+
if (!force) {
|
|
684
|
+
throw usageError(`Target directory is not empty: ${targetDir}. Pass --force to overwrite it.`);
|
|
685
|
+
}
|
|
686
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
687
|
+
}
|
|
688
|
+
mkdirSync(targetDir, { recursive: true });
|
|
689
|
+
|
|
690
|
+
const params = new URLSearchParams({ app_id: appId, version: String(version || 'latest') });
|
|
691
|
+
const response = await fetch(`${apiBase.replace(/\/$/, '')}/portal_apps/source?${params.toString()}`, {
|
|
692
|
+
headers: { Authorization: `Bearer ${jwt}` },
|
|
693
|
+
});
|
|
694
|
+
if (!response.ok) {
|
|
695
|
+
const data = await response.json().catch(() => ({}));
|
|
696
|
+
throw usageError(typeof data?.error === 'string' ? data.error : `Failed to pull app source (${response.status}).`);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const contentDisposition = response.headers.get('content-disposition') || '';
|
|
700
|
+
const versionMatch = /-v(\d+)\.tar\.gz/i.exec(contentDisposition);
|
|
701
|
+
const pulledVersion = versionMatch ? Number.parseInt(versionMatch[1], 10) : null;
|
|
702
|
+
extractTarGz(Buffer.from(await response.arrayBuffer()), targetDir);
|
|
703
|
+
writeLinkedState(targetDir, {
|
|
704
|
+
app_id: appId,
|
|
705
|
+
linked_at: new Date().toISOString(),
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
return { projectDir: targetDir, version: pulledVersion || version };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function readArtifactFiles(projectDir) {
|
|
712
|
+
const outputDir = join(projectDir, OUTPUT_DIR);
|
|
713
|
+
if (!existsSync(outputDir)) {
|
|
714
|
+
throw usageError('No build output found. Run "notis apps build" first.');
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const files = {};
|
|
718
|
+
|
|
719
|
+
function walk(dir, prefix) {
|
|
720
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
721
|
+
for (const entry of entries) {
|
|
722
|
+
const fullPath = join(dir, entry.name);
|
|
723
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
724
|
+
if (entry.isDirectory()) {
|
|
725
|
+
walk(fullPath, relPath);
|
|
726
|
+
} else {
|
|
727
|
+
files[relPath] = readFileSync(fullPath);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
walk(outputDir, '');
|
|
733
|
+
return files;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ---------------------------------------------------------------------------
|
|
737
|
+
// Direct deploy (bypasses backend server)
|
|
738
|
+
// ---------------------------------------------------------------------------
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Resolve Supabase credentials from the server/.env file in the repo workspace.
|
|
742
|
+
* Falls back to environment variables.
|
|
743
|
+
*/
|
|
744
|
+
function resolveSupabaseCredentials() {
|
|
745
|
+
const envPaths = [
|
|
746
|
+
resolve(process.cwd(), 'server/.env'),
|
|
747
|
+
resolve(process.cwd(), '../server/.env'),
|
|
748
|
+
resolve(process.cwd(), '../../server/.env'),
|
|
749
|
+
];
|
|
750
|
+
|
|
751
|
+
let supabaseUrl = process.env.SUPABASE_URL;
|
|
752
|
+
let supabaseSubdomain = process.env.SUPABASE_SUBDOMAIN;
|
|
753
|
+
let supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
|
|
754
|
+
|
|
755
|
+
for (const envPath of envPaths) {
|
|
756
|
+
if (existsSync(envPath)) {
|
|
757
|
+
const content = readFileSync(envPath, 'utf-8');
|
|
758
|
+
for (const line of content.split('\n')) {
|
|
759
|
+
const trimmed = line.trim();
|
|
760
|
+
if (trimmed.startsWith('#') || !trimmed.includes('=')) continue;
|
|
761
|
+
const eqIdx = trimmed.indexOf('=');
|
|
762
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
763
|
+
let value = trimmed.slice(eqIdx + 1).trim();
|
|
764
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
765
|
+
value = value.slice(1, -1);
|
|
766
|
+
}
|
|
767
|
+
if (key === 'SUPABASE_URL' && !supabaseUrl) supabaseUrl = value;
|
|
768
|
+
if (key === 'SUPABASE_SUBDOMAIN' && !supabaseSubdomain) supabaseSubdomain = value;
|
|
769
|
+
if ((key === 'SUPABASE_SERVICE_ROLE_KEY' || key === 'SUPABASE_SERVICE_KEY') && !supabaseKey) supabaseKey = value;
|
|
770
|
+
}
|
|
771
|
+
break;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Derive URL from subdomain if needed
|
|
776
|
+
if (!supabaseUrl && supabaseSubdomain) {
|
|
777
|
+
supabaseUrl = `https://${supabaseSubdomain}.supabase.co`;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (!supabaseUrl || !supabaseKey) {
|
|
781
|
+
throw usageError(
|
|
782
|
+
'Cannot resolve Supabase credentials for direct deploy. ' +
|
|
783
|
+
'Ensure server/.env exists with SUPABASE_SUBDOMAIN (or SUPABASE_URL) and SUPABASE_SERVICE_KEY, ' +
|
|
784
|
+
'or set them as environment variables.',
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
return { supabaseUrl, supabaseKey };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Upload a file to Supabase Storage with upsert.
|
|
793
|
+
*/
|
|
794
|
+
async function uploadToStorage(supabaseUrl, supabaseKey, bucket, storagePath, content, contentType) {
|
|
795
|
+
const url = `${supabaseUrl}/storage/v1/object/${bucket}/${storagePath}`;
|
|
796
|
+
const response = await fetch(url, {
|
|
797
|
+
method: 'POST',
|
|
798
|
+
headers: {
|
|
799
|
+
'Authorization': `Bearer ${supabaseKey}`,
|
|
800
|
+
'Content-Type': contentType,
|
|
801
|
+
'x-upsert': 'true',
|
|
802
|
+
},
|
|
803
|
+
body: content,
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
if (!response.ok) {
|
|
807
|
+
const text = await response.text().catch(() => '');
|
|
808
|
+
throw new Error(`Storage upload failed (${response.status}): ${text}`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async function ensureStorageBucket(supabaseUrl, supabaseKey, bucket, options = {}) {
|
|
813
|
+
const encodedBucket = encodeURIComponent(bucket);
|
|
814
|
+
const headers = {
|
|
815
|
+
'Authorization': `Bearer ${supabaseKey}`,
|
|
816
|
+
'apikey': supabaseKey,
|
|
817
|
+
};
|
|
818
|
+
const inspectResponse = await fetch(`${supabaseUrl}/storage/v1/bucket/${encodedBucket}`, {
|
|
819
|
+
headers,
|
|
820
|
+
});
|
|
821
|
+
if (inspectResponse.ok) {
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
if (inspectResponse.status !== 404) {
|
|
825
|
+
const text = await inspectResponse.text().catch(() => '');
|
|
826
|
+
throw new Error(`Failed to inspect storage bucket ${bucket} (${inspectResponse.status}): ${text}`);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const createResponse = await fetch(`${supabaseUrl}/storage/v1/bucket`, {
|
|
830
|
+
method: 'POST',
|
|
831
|
+
headers: {
|
|
832
|
+
...headers,
|
|
833
|
+
'Content-Type': 'application/json',
|
|
834
|
+
},
|
|
835
|
+
body: JSON.stringify({
|
|
836
|
+
id: bucket,
|
|
837
|
+
name: bucket,
|
|
838
|
+
public: Boolean(options.public),
|
|
839
|
+
}),
|
|
840
|
+
});
|
|
841
|
+
if (createResponse.ok) {
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const text = await createResponse.text().catch(() => '');
|
|
846
|
+
if (createResponse.status === 409 || /already exists|duplicate/i.test(text)) {
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
throw new Error(`Failed to create storage bucket ${bucket} (${createResponse.status}): ${text}`);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Get the current app manifest version from the apps table.
|
|
854
|
+
*/
|
|
855
|
+
async function getAppCurrentVersion(supabaseUrl, supabaseKey, appId) {
|
|
856
|
+
const encodedAppId = encodeURIComponent(appId);
|
|
857
|
+
const url = `${supabaseUrl}/rest/v1/apps?id=eq.${encodedAppId}&select=manifest`;
|
|
858
|
+
const response = await fetch(url, {
|
|
859
|
+
headers: {
|
|
860
|
+
'Authorization': `Bearer ${supabaseKey}`,
|
|
861
|
+
'apikey': supabaseKey,
|
|
862
|
+
},
|
|
863
|
+
});
|
|
864
|
+
if (!response.ok) throw new Error(`Failed to get app version: ${response.status}`);
|
|
865
|
+
const rows = await response.json();
|
|
866
|
+
if (!rows.length) throw usageError(`App ${appId} not found.`);
|
|
867
|
+
const manifest = rows[0].manifest || {};
|
|
868
|
+
return { currentVersion: manifest.version || 0, manifest };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Update the app manifest in the apps table.
|
|
873
|
+
*/
|
|
874
|
+
async function updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, manifest) {
|
|
875
|
+
const encodedAppId = encodeURIComponent(appId);
|
|
876
|
+
const url = `${supabaseUrl}/rest/v1/apps?id=eq.${encodedAppId}`;
|
|
877
|
+
const response = await fetch(url, {
|
|
878
|
+
method: 'PATCH',
|
|
879
|
+
headers: {
|
|
880
|
+
'Authorization': `Bearer ${supabaseKey}`,
|
|
881
|
+
'apikey': supabaseKey,
|
|
882
|
+
'Content-Type': 'application/json',
|
|
883
|
+
'Prefer': 'return=minimal',
|
|
884
|
+
},
|
|
885
|
+
body: JSON.stringify({
|
|
886
|
+
manifest: { ...manifest, version: newVersion },
|
|
887
|
+
updated_at: new Date().toISOString(),
|
|
888
|
+
}),
|
|
889
|
+
});
|
|
890
|
+
if (!response.ok) {
|
|
891
|
+
const text = await response.text().catch(() => '');
|
|
892
|
+
throw new Error(`Failed to update app version: ${response.status} ${text}`);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const CONTENT_TYPE_MAP = {
|
|
897
|
+
'.js': 'application/javascript',
|
|
898
|
+
'.css': 'text/css',
|
|
899
|
+
'.json': 'application/json',
|
|
900
|
+
'.html': 'text/html',
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Deploy app bundle directly to Supabase storage, bypassing the backend server.
|
|
905
|
+
*/
|
|
906
|
+
export async function directDeploy(projectDir, appId) {
|
|
907
|
+
const { supabaseUrl, supabaseKey } = resolveSupabaseCredentials();
|
|
908
|
+
const manifest = readManifest(projectDir);
|
|
909
|
+
const artifactFiles = readArtifactFiles(projectDir);
|
|
910
|
+
const sourceFiles = readSourceFiles(projectDir);
|
|
911
|
+
validateArtifactBoundary(artifactFiles);
|
|
912
|
+
|
|
913
|
+
// Get current version and increment
|
|
914
|
+
const { currentVersion } = await getAppCurrentVersion(supabaseUrl, supabaseKey, appId);
|
|
915
|
+
const newVersion = currentVersion + 1;
|
|
916
|
+
|
|
917
|
+
// Upload all files from the output directory
|
|
918
|
+
const bucket = 'app-code';
|
|
919
|
+
await ensureStorageBucket(supabaseUrl, supabaseKey, bucket, { public: false });
|
|
920
|
+
await ensureStorageBucket(supabaseUrl, supabaseKey, 'app-source', { public: false });
|
|
921
|
+
|
|
922
|
+
async function uploadDir(dir, prefix) {
|
|
923
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
924
|
+
for (const entry of entries) {
|
|
925
|
+
const fullPath = join(dir, entry.name);
|
|
926
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
927
|
+
if (entry.isDirectory()) {
|
|
928
|
+
await uploadDir(fullPath, relPath);
|
|
929
|
+
} else {
|
|
930
|
+
const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
|
|
931
|
+
const contentType = CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
|
|
932
|
+
const content = readFileSync(fullPath);
|
|
933
|
+
const storagePath = `${appId}/v${newVersion}/${relPath}`;
|
|
934
|
+
await uploadToStorage(supabaseUrl, supabaseKey, bucket, storagePath, content, contentType);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
await uploadDir(join(projectDir, OUTPUT_DIR), '');
|
|
940
|
+
|
|
941
|
+
for (const [relPath, content] of Object.entries(sourceFiles)) {
|
|
942
|
+
const ext = relPath.includes('.') ? `.${relPath.split('.').pop()}` : '';
|
|
943
|
+
const contentType = CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
|
|
944
|
+
const storagePath = `${appId}/v${newVersion}/${relPath}`;
|
|
945
|
+
await uploadToStorage(supabaseUrl, supabaseKey, 'app-source', storagePath, content, contentType);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Update the app record -- include storage_prefix so the portal can resolve bundle URLs
|
|
949
|
+
const storagePrefix = `${appId}/v${newVersion}/`;
|
|
950
|
+
const deployManifest = {
|
|
951
|
+
...manifest,
|
|
952
|
+
version: newVersion,
|
|
953
|
+
storage_bucket: bucket,
|
|
954
|
+
storage_prefix: storagePrefix,
|
|
955
|
+
};
|
|
956
|
+
await updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, deployManifest);
|
|
957
|
+
|
|
958
|
+
return { version: newVersion };
|
|
959
|
+
}
|