@notis_ai/cli 0.2.0 → 0.2.2
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 +122 -158
- package/dist/scaffolds/notes/app/globals.css +3 -0
- package/dist/scaffolds/notes/app/layout.tsx +6 -0
- package/dist/scaffolds/notes/app/page.tsx +1465 -0
- package/dist/scaffolds/notes/components/ui/badge.tsx +28 -0
- package/dist/scaffolds/notes/components/ui/button.tsx +53 -0
- package/dist/scaffolds/notes/components/ui/card.tsx +56 -0
- package/dist/scaffolds/notes/components.json +20 -0
- package/dist/scaffolds/notes/lib/utils.ts +6 -0
- package/dist/scaffolds/notes/notis.config.ts +31 -0
- package/dist/scaffolds/notes/package.json +31 -0
- package/dist/scaffolds/notes/postcss.config.mjs +8 -0
- package/dist/scaffolds/notes/tailwind.config.ts +58 -0
- package/dist/scaffolds/notes/tsconfig.json +22 -0
- package/dist/scaffolds/notes/vite.config.ts +10 -0
- package/dist/scaffolds.json +13 -0
- package/package.json +12 -2
- package/skills/notis-apps/SKILL.md +162 -0
- package/skills/notis-apps/cli.md +208 -0
- package/skills/notis-cli/SKILL.md +258 -0
- package/skills/notis-query/cli.md +40 -0
- package/src/cli.js +1 -1
- package/src/command-specs/apps.js +1029 -59
- package/src/command-specs/helpers.js +6 -41
- package/src/command-specs/index.js +1 -7
- package/src/command-specs/meta.js +7 -6
- package/src/command-specs/tools.js +29 -34
- package/src/runtime/agent-browser.js +192 -0
- package/src/runtime/app-boundary-validator.js +132 -0
- package/src/runtime/app-dev-server.js +605 -0
- package/src/runtime/app-dev-sessions.js +87 -0
- package/src/runtime/app-platform.js +1037 -82
- 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 +36 -5
- package/src/runtime/transport.js +131 -6
- package/template/.harness/index.html.tmpl +211 -0
- package/template/app/globals.css +3 -0
- package/template/app/layout.tsx +6 -0
- package/template/app/page.tsx +90 -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/metadata/cover.png +0 -0
- package/template/metadata/screenshot-1.png +0 -0
- package/template/metadata/screenshot-2.png +0 -0
- package/template/metadata/screenshot-3.png +0 -0
- package/template/notis.config.ts +37 -0
- package/template/package.json +31 -0
- package/template/packages/sdk/package.json +32 -0
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +273 -0
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
- package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/template/packages/sdk/src/config.ts +71 -0
- package/template/packages/sdk/src/hooks/useBackend.ts +41 -0
- package/template/packages/sdk/src/hooks/useDatabase.ts +76 -0
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +503 -0
- package/template/packages/sdk/src/hooks/useNotis.ts +34 -0
- package/template/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
- package/template/packages/sdk/src/hooks/useTool.ts +64 -0
- package/template/packages/sdk/src/hooks/useTools.ts +56 -0
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
- package/template/packages/sdk/src/hooks/useUpsertDocument.ts +50 -0
- package/template/packages/sdk/src/index.ts +54 -0
- package/template/packages/sdk/src/provider.tsx +43 -0
- package/template/packages/sdk/src/runtime.ts +161 -0
- package/template/packages/sdk/src/styles.css +38 -0
- package/template/packages/sdk/src/ui.ts +15 -0
- package/template/packages/sdk/src/vite.ts +56 -0
- package/template/packages/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/command-specs/auth.js +0 -178
- package/src/command-specs/db.js +0 -163
- package/src/runtime/app-preview-server.js +0 -272
|
@@ -3,22 +3,53 @@
|
|
|
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, statSync } 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 METADATA_DIR = 'metadata';
|
|
25
|
+
const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
26
|
+
const MONOREPO_APPS_DIR = resolve(CLI_ROOT, '../..', 'apps');
|
|
27
|
+
const DIST_DIR = join(CLI_ROOT, 'dist');
|
|
28
|
+
const SCAFFOLD_CATALOG_FILE = join(DIST_DIR, 'scaffolds.json');
|
|
29
|
+
const SCAFFOLD_SOURCE_DIR = join(DIST_DIR, 'scaffolds');
|
|
30
|
+
const TEMPLATE_SDK_DIR = join(CLI_ROOT, 'template', 'packages', 'sdk');
|
|
31
|
+
export const NOTIS_APP_CATEGORIES = [
|
|
32
|
+
'Productivity',
|
|
33
|
+
'Sales & Marketing',
|
|
34
|
+
'Operations',
|
|
35
|
+
'Product & Engineering',
|
|
36
|
+
'Personal',
|
|
37
|
+
];
|
|
38
|
+
const SOURCE_COPY_EXCLUDES = new Set([
|
|
39
|
+
'node_modules',
|
|
40
|
+
'.notis',
|
|
41
|
+
'.git',
|
|
42
|
+
'dist',
|
|
43
|
+
'tsconfig.tsbuildinfo',
|
|
44
|
+
'.DS_Store',
|
|
45
|
+
]);
|
|
46
|
+
const SCAFFOLD_COPY_EXCLUDES = new Set([
|
|
47
|
+
...SOURCE_COPY_EXCLUDES,
|
|
48
|
+
'coverage',
|
|
49
|
+
'.next',
|
|
50
|
+
'.turbo',
|
|
51
|
+
]);
|
|
52
|
+
let appConfigImportNonce = 0;
|
|
22
53
|
|
|
23
54
|
// ---------------------------------------------------------------------------
|
|
24
55
|
// Project directory resolution
|
|
@@ -28,15 +59,86 @@ export function resolveProjectDir(inputDir = '.') {
|
|
|
28
59
|
return resolve(process.cwd(), inputDir);
|
|
29
60
|
}
|
|
30
61
|
|
|
62
|
+
export function getBundleDir(projectDir) {
|
|
63
|
+
return join(projectDir, BUNDLE_DIR);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function resolveBuiltBundleDir(projectDir) {
|
|
67
|
+
const bundleDir = getBundleDir(projectDir);
|
|
68
|
+
if (existsSync(join(bundleDir, 'app.js'))) {
|
|
69
|
+
return bundleDir;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeShadowScopedCss(css) {
|
|
75
|
+
return css
|
|
76
|
+
// Tailwind preflight emits `html,:host` in v3. Inside a shadow tree we want
|
|
77
|
+
// the shadow host itself to carry those defaults.
|
|
78
|
+
.replace(/html\s*,\s*:host\s*\{/g, ':host{')
|
|
79
|
+
.replace(/:root\s*,\s*:host\s*\{/g, ':host{')
|
|
80
|
+
.replace(/:root\s*\{/g, ':host{')
|
|
81
|
+
.replace(/html\s*\{/g, ':host{')
|
|
82
|
+
// Shadow trees do not contain a body element. Route those defaults to the
|
|
83
|
+
// app root contract instead so authors still get the expected reset.
|
|
84
|
+
.replace(/body\s*\{/g, '[data-notis-app-root]{');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizeBundleStylesheets(projectDir) {
|
|
88
|
+
const bundleDir = join(projectDir, BUNDLE_DIR);
|
|
89
|
+
if (!existsSync(bundleDir)) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const entry of readdirSync(bundleDir)) {
|
|
94
|
+
if (!entry.endsWith('.css')) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const cssPath = join(bundleDir, entry);
|
|
98
|
+
const normalizedCss = normalizeShadowScopedCss(readFileSync(cssPath, 'utf-8'));
|
|
99
|
+
writeFileSync(cssPath, normalizedCss);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
31
103
|
// ---------------------------------------------------------------------------
|
|
32
104
|
// Config loading
|
|
33
105
|
// ---------------------------------------------------------------------------
|
|
34
106
|
|
|
35
107
|
/**
|
|
36
108
|
* Load and parse notis.config.ts from a project directory. Uses a simple
|
|
37
|
-
* approach: we read the file, transpile with
|
|
38
|
-
*
|
|
109
|
+
* approach: we read the file, transpile with the project's TypeScript compiler
|
|
110
|
+
* when available, then evaluate as ESM.
|
|
39
111
|
*/
|
|
112
|
+
function stripNotisSdkImports(source) {
|
|
113
|
+
return source
|
|
114
|
+
.replace(/import\s+type\s+[\s\S]*?from\s+['"][^'"]*['"]\s*;?/g, '')
|
|
115
|
+
.replace(/import\s*{[\s\S]*?\bdefineNotisApp\b[\s\S]*?}\s+from\s+['"]@notis\/sdk\/config['"]\s*;?/g, '')
|
|
116
|
+
.replace(/defineNotisApp\s*\(/g, '(');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function transpileTsConfigSource(source, configPath) {
|
|
120
|
+
// Strip the @notis/sdk/config import unconditionally. The SDK package often
|
|
121
|
+
// points its `exports` at raw .ts source, which Node cannot import from the
|
|
122
|
+
// temp .mjs file we emit below. `defineNotisApp` is just an identity helper,
|
|
123
|
+
// so removing the import and replacing the call with a parens-wrapped
|
|
124
|
+
// expression preserves the config value without ever resolving the SDK.
|
|
125
|
+
const stripped = stripNotisSdkImports(source);
|
|
126
|
+
const requireFromConfig = createRequire(`file://${configPath}`);
|
|
127
|
+
try {
|
|
128
|
+
const ts = requireFromConfig('typescript');
|
|
129
|
+
const transpiled = ts.transpileModule(stripped, {
|
|
130
|
+
compilerOptions: {
|
|
131
|
+
module: ts.ModuleKind.ESNext,
|
|
132
|
+
target: ts.ScriptTarget.ES2020,
|
|
133
|
+
},
|
|
134
|
+
fileName: configPath,
|
|
135
|
+
});
|
|
136
|
+
return transpiled.outputText;
|
|
137
|
+
} catch {
|
|
138
|
+
return stripped;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
40
142
|
export async function loadAppConfig(projectDir) {
|
|
41
143
|
const configPaths = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'];
|
|
42
144
|
let configPath = null;
|
|
@@ -54,24 +156,24 @@ export async function loadAppConfig(projectDir) {
|
|
|
54
156
|
}
|
|
55
157
|
|
|
56
158
|
// 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
159
|
if (configPath.endsWith('.ts')) {
|
|
60
160
|
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');
|
|
161
|
+
const jsSource = transpileTsConfigSource(source, configPath);
|
|
162
|
+
|
|
163
|
+
const tmpPath = join(dirname(configPath), '._notis_config_tmp.mjs');
|
|
70
164
|
mkdirSync(dirname(tmpPath), { recursive: true });
|
|
71
165
|
writeFileSync(tmpPath, jsSource);
|
|
72
166
|
try {
|
|
73
|
-
const mod = await import(`file://${tmpPath}`);
|
|
167
|
+
const mod = await import(`file://${tmpPath}?v=${appConfigImportNonce++}`);
|
|
74
168
|
return mod.default || mod;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (error instanceof SyntaxError) {
|
|
171
|
+
throw usageError(
|
|
172
|
+
`Failed to parse ${configPath}. Install project dependencies (including typescript) ` +
|
|
173
|
+
'or convert the config to plain JavaScript.',
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
throw error;
|
|
75
177
|
} finally {
|
|
76
178
|
try {
|
|
77
179
|
const { unlinkSync } = await import('node:fs');
|
|
@@ -95,10 +197,10 @@ export function detectProjectProblems(projectDir) {
|
|
|
95
197
|
problems.push('Missing package.json');
|
|
96
198
|
}
|
|
97
199
|
|
|
98
|
-
const
|
|
200
|
+
const hasViteConfig = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']
|
|
99
201
|
.some((name) => existsSync(join(projectDir, name)));
|
|
100
|
-
if (!
|
|
101
|
-
problems.push('Missing
|
|
202
|
+
if (!hasViteConfig) {
|
|
203
|
+
problems.push('Missing vite.config file');
|
|
102
204
|
}
|
|
103
205
|
|
|
104
206
|
if (!existsSync(join(projectDir, 'app'))) {
|
|
@@ -128,12 +230,163 @@ export function detectProjectWarnings(projectDir, appConfig = null) {
|
|
|
128
230
|
}
|
|
129
231
|
|
|
130
232
|
if (appConfig && (!Array.isArray(appConfig.databases) || appConfig.databases.length === 0)) {
|
|
131
|
-
warnings.push('No
|
|
233
|
+
warnings.push('No database references declared in notis.config.ts.');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (appConfig) {
|
|
237
|
+
try {
|
|
238
|
+
const listing = inspectListingReadiness(projectDir, appConfig);
|
|
239
|
+
warnings.push(...listing.warnings);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
warnings.push(error instanceof Error ? error.message : String(error));
|
|
242
|
+
}
|
|
132
243
|
}
|
|
133
244
|
|
|
134
245
|
return warnings;
|
|
135
246
|
}
|
|
136
247
|
|
|
248
|
+
function safeKebab(value) {
|
|
249
|
+
return String(value || '')
|
|
250
|
+
.trim()
|
|
251
|
+
.toLowerCase()
|
|
252
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
253
|
+
.replace(/(^-|-$)+/g, '');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function jsStringLiteral(value) {
|
|
257
|
+
return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function normalizeCategories(categories = []) {
|
|
261
|
+
if (!Array.isArray(categories)) {
|
|
262
|
+
return [];
|
|
263
|
+
}
|
|
264
|
+
const allowed = new Set(NOTIS_APP_CATEGORIES);
|
|
265
|
+
const normalized = [];
|
|
266
|
+
for (const category of categories) {
|
|
267
|
+
if (typeof category !== 'string' || !category.trim()) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const trimmed = category.trim();
|
|
271
|
+
if (!allowed.has(trimmed)) {
|
|
272
|
+
throw usageError(
|
|
273
|
+
`Invalid Notis app category "${trimmed}". Use one of: ${NOTIS_APP_CATEGORIES.join(', ')}.`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
if (!normalized.includes(trimmed)) {
|
|
277
|
+
normalized.push(trimmed);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return normalized;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function pngDimensions(buffer) {
|
|
284
|
+
const signature = '89504e470d0a1a0a';
|
|
285
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 24 || buffer.subarray(0, 8).toString('hex') !== signature) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
width: buffer.readUInt32BE(16),
|
|
290
|
+
height: buffer.readUInt32BE(20),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function imageAssetMeta(projectDir, relPath, { maxBytes, recommendedRatio = 1.6 }) {
|
|
295
|
+
const fullPath = join(projectDir, relPath);
|
|
296
|
+
const content = readFileSync(fullPath);
|
|
297
|
+
const dimensions = pngDimensions(content);
|
|
298
|
+
const ratio = dimensions?.height ? dimensions.width / dimensions.height : null;
|
|
299
|
+
const errors = [];
|
|
300
|
+
const warnings = [];
|
|
301
|
+
|
|
302
|
+
if (!relPath.toLowerCase().endsWith('.png')) {
|
|
303
|
+
errors.push(`${relPath} must be a PNG file.`);
|
|
304
|
+
}
|
|
305
|
+
if (content.length > maxBytes) {
|
|
306
|
+
errors.push(`${relPath} must be ${Math.round(maxBytes / 1024 / 1024)} MB or smaller.`);
|
|
307
|
+
}
|
|
308
|
+
if (ratio !== null && (ratio < 1.3 || ratio > 1.9)) {
|
|
309
|
+
errors.push(`${relPath} should be close to a 16:10 aspect ratio.`);
|
|
310
|
+
} else if (ratio !== null && Math.abs(ratio - recommendedRatio) > 0.08) {
|
|
311
|
+
warnings.push(`${relPath} is not 2000x1250; 16:10 PNG is recommended.`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
path: relPath,
|
|
316
|
+
content_type: 'image/png',
|
|
317
|
+
bytes: content.length,
|
|
318
|
+
width: dimensions?.width ?? null,
|
|
319
|
+
height: dimensions?.height ?? null,
|
|
320
|
+
errors,
|
|
321
|
+
warnings,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function discoverMetadataAssets(projectDir) {
|
|
326
|
+
const metadataDir = join(projectDir, METADATA_DIR);
|
|
327
|
+
const coverPath = join(METADATA_DIR, 'cover.png');
|
|
328
|
+
const cover = existsSync(join(projectDir, coverPath))
|
|
329
|
+
? imageAssetMeta(projectDir, coverPath, { maxBytes: 5 * 1024 * 1024 })
|
|
330
|
+
: null;
|
|
331
|
+
const screenshots = [];
|
|
332
|
+
|
|
333
|
+
if (existsSync(metadataDir)) {
|
|
334
|
+
for (const entry of readdirSync(metadataDir, { withFileTypes: true })) {
|
|
335
|
+
if (!entry.isFile()) continue;
|
|
336
|
+
const match = /^screenshot-(\d+)\.png$/i.exec(entry.name);
|
|
337
|
+
if (!match) continue;
|
|
338
|
+
screenshots.push({
|
|
339
|
+
index: Number.parseInt(match[1], 10),
|
|
340
|
+
...imageAssetMeta(projectDir, join(METADATA_DIR, entry.name), {
|
|
341
|
+
maxBytes: 2 * 1024 * 1024,
|
|
342
|
+
}),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
screenshots.sort((a, b) => a.index - b.index);
|
|
348
|
+
return {
|
|
349
|
+
cover,
|
|
350
|
+
screenshots: screenshots.slice(0, 6),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function inspectListingReadiness(projectDir, appConfig = null) {
|
|
355
|
+
const config = appConfig || {};
|
|
356
|
+
const warnings = [];
|
|
357
|
+
const errors = [];
|
|
358
|
+
const metadata = discoverMetadataAssets(projectDir);
|
|
359
|
+
const categories = normalizeCategories(config.categories || []);
|
|
360
|
+
|
|
361
|
+
if (!String(config.tagline || '').trim()) {
|
|
362
|
+
warnings.push('Listing tagline missing in notis.config.ts.');
|
|
363
|
+
}
|
|
364
|
+
if (categories.length === 0) {
|
|
365
|
+
warnings.push(`Listing category missing in notis.config.ts. Use one of: ${NOTIS_APP_CATEGORIES.join(', ')}.`);
|
|
366
|
+
}
|
|
367
|
+
if (!metadata.cover) {
|
|
368
|
+
warnings.push('Listing cover missing at metadata/cover.png.');
|
|
369
|
+
} else {
|
|
370
|
+
errors.push(...metadata.cover.errors);
|
|
371
|
+
warnings.push(...metadata.cover.warnings);
|
|
372
|
+
}
|
|
373
|
+
if (metadata.screenshots.length < 3) {
|
|
374
|
+
warnings.push(`Listing screenshots: ${metadata.screenshots.length}/3 minimum in metadata/screenshot-N.png.`);
|
|
375
|
+
}
|
|
376
|
+
for (const screenshot of metadata.screenshots) {
|
|
377
|
+
errors.push(...screenshot.errors);
|
|
378
|
+
warnings.push(...screenshot.warnings);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
ready: errors.length === 0 && Boolean(config.tagline) && categories.length > 0 && Boolean(metadata.cover) && metadata.screenshots.length >= 1,
|
|
383
|
+
warnings,
|
|
384
|
+
errors,
|
|
385
|
+
metadata,
|
|
386
|
+
categories,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
137
390
|
// ---------------------------------------------------------------------------
|
|
138
391
|
// Build
|
|
139
392
|
// ---------------------------------------------------------------------------
|
|
@@ -156,6 +409,31 @@ export async function runProjectScript({ projectDir, scriptName, env = {}, stdio
|
|
|
156
409
|
});
|
|
157
410
|
}
|
|
158
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Derive an export name from a route path.
|
|
414
|
+
* '/' -> 'index', '/inbox' -> 'inbox', '/my-tasks' -> 'myTasks'
|
|
415
|
+
*/
|
|
416
|
+
export function exportNameFromPath(routePath) {
|
|
417
|
+
if (routePath === '/') return 'index';
|
|
418
|
+
const slug = routePath.replace(/^\//, '').replace(/\//g, '-');
|
|
419
|
+
const identifier = slug
|
|
420
|
+
.split(/[^A-Za-z0-9_$]+/)
|
|
421
|
+
.filter(Boolean)
|
|
422
|
+
.map((part, index) => {
|
|
423
|
+
const lower = part.toLowerCase();
|
|
424
|
+
if (index === 0) return lower;
|
|
425
|
+
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
|
426
|
+
})
|
|
427
|
+
.join('');
|
|
428
|
+
if (!identifier) return 'route';
|
|
429
|
+
return /^[A-Za-z_$]/.test(identifier) ? identifier : `r${identifier}`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function slugFromPath(routePath) {
|
|
433
|
+
if (routePath === '/') return 'index';
|
|
434
|
+
return routePath.replace(/^\//, '').replace(/\//g, '-');
|
|
435
|
+
}
|
|
436
|
+
|
|
159
437
|
/**
|
|
160
438
|
* Auto-detect routes from the app/ directory by scanning for page.tsx files.
|
|
161
439
|
*/
|
|
@@ -174,13 +452,13 @@ function autoDetectRoutes(projectDir) {
|
|
|
174
452
|
scan(join(dir, entry.name), `${pathPrefix}/${entry.name}`);
|
|
175
453
|
} else if (entry.name === 'page.tsx' || entry.name === 'page.jsx' || entry.name === 'page.js') {
|
|
176
454
|
const routePath = pathPrefix || '/';
|
|
177
|
-
const slug = routePath
|
|
455
|
+
const slug = slugFromPath(routePath);
|
|
178
456
|
routes.push({
|
|
179
457
|
path: routePath,
|
|
180
458
|
slug,
|
|
181
459
|
name: slug === 'index' ? 'Home' : slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
182
460
|
default: routePath === '/',
|
|
183
|
-
|
|
461
|
+
export_name: exportNameFromPath(routePath),
|
|
184
462
|
});
|
|
185
463
|
}
|
|
186
464
|
}
|
|
@@ -190,90 +468,235 @@ function autoDetectRoutes(projectDir) {
|
|
|
190
468
|
return routes;
|
|
191
469
|
}
|
|
192
470
|
|
|
193
|
-
function
|
|
194
|
-
if (
|
|
195
|
-
|
|
471
|
+
function validateConfiguredRoutes(routes) {
|
|
472
|
+
if (routes.length === 0) {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const slugSet = new Set();
|
|
477
|
+
let defaultCount = 0;
|
|
478
|
+
|
|
479
|
+
for (const route of routes) {
|
|
480
|
+
if (!route.slug || typeof route.slug !== 'string') {
|
|
481
|
+
throw usageError(`Route "${route.path}" must define a slug.`);
|
|
482
|
+
}
|
|
483
|
+
if (slugSet.has(route.slug)) {
|
|
484
|
+
throw usageError(`Duplicate route slug "${route.slug}".`);
|
|
485
|
+
}
|
|
486
|
+
slugSet.add(route.slug);
|
|
487
|
+
if (route.default) {
|
|
488
|
+
defaultCount += 1;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (defaultCount !== 1) {
|
|
493
|
+
throw usageError(`Expected exactly one default route, received ${defaultCount}.`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
for (const route of routes) {
|
|
497
|
+
if (route.parentSlug && !slugSet.has(route.parentSlug)) {
|
|
498
|
+
throw usageError(
|
|
499
|
+
`Route "${route.slug}" references unknown parentSlug "${route.parentSlug}".`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
const collection = route.collection || null;
|
|
503
|
+
if (
|
|
504
|
+
collection?.sidebar?.mode === 'tree' &&
|
|
505
|
+
(!collection.parentProperty || typeof collection.parentProperty !== 'string')
|
|
506
|
+
) {
|
|
507
|
+
throw usageError(
|
|
508
|
+
`Tree collection route "${route.slug}" must define collection.parentProperty.`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const childrenByParent = new Map();
|
|
514
|
+
for (const route of routes) {
|
|
515
|
+
if (!route.parentSlug) {
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
const children = childrenByParent.get(route.parentSlug) || [];
|
|
519
|
+
children.push(route.slug);
|
|
520
|
+
childrenByParent.set(route.parentSlug, children);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
for (const route of routes) {
|
|
524
|
+
const seen = new Set([route.slug]);
|
|
525
|
+
let current = route.parentSlug || null;
|
|
526
|
+
while (current) {
|
|
527
|
+
if (seen.has(current)) {
|
|
528
|
+
throw usageError(`Route parent cycle detected at "${route.slug}".`);
|
|
529
|
+
}
|
|
530
|
+
seen.add(current);
|
|
531
|
+
current = routes.find((candidate) => candidate.slug === current)?.parentSlug || null;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
for (const route of routes) {
|
|
536
|
+
if (
|
|
537
|
+
route.collection?.sidebar?.mode === 'tree' &&
|
|
538
|
+
(childrenByParent.get(route.slug) || []).length > 0
|
|
539
|
+
) {
|
|
540
|
+
throw usageError(
|
|
541
|
+
`Tree collection route "${route.slug}" cannot also define static child routes.`,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function resolveConfiguredRoutes(appConfig, projectDir) {
|
|
548
|
+
const configuredRoutes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
|
|
549
|
+
validateConfiguredRoutes(configuredRoutes);
|
|
550
|
+
const routes = configuredRoutes.map((route) => ({
|
|
551
|
+
...route,
|
|
552
|
+
slug: route.slug,
|
|
553
|
+
export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
|
|
554
|
+
}));
|
|
555
|
+
return routes.length > 0 ? routes : autoDetectRoutes(projectDir);
|
|
196
556
|
}
|
|
197
557
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
558
|
+
/**
|
|
559
|
+
* Generate the _entry.tsx file that re-exports each route's page component.
|
|
560
|
+
*/
|
|
561
|
+
function generateEntryFile(projectDir, routes) {
|
|
562
|
+
const entryDir = join(projectDir, NOTIS_DIR);
|
|
563
|
+
mkdirSync(entryDir, { recursive: true });
|
|
564
|
+
|
|
565
|
+
// Also re-export the layout if it exists
|
|
566
|
+
const lines = [];
|
|
567
|
+
const layoutPath = join(projectDir, 'app', 'layout.tsx');
|
|
568
|
+
if (existsSync(layoutPath)) {
|
|
569
|
+
lines.push(`export { default as __AppShell } from '../app/layout';`);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
for (const route of routes) {
|
|
573
|
+
const pagePath = route.path === '/' ? '../app/page' : `../app${route.path}/page`;
|
|
574
|
+
lines.push(`export { default as ${route.export_name} } from '${pagePath}';`);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const entryPath = join(entryDir, '_entry.tsx');
|
|
578
|
+
writeFileSync(entryPath, lines.join('\n') + '\n');
|
|
579
|
+
return entryPath;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export async function prepareArtifactBuild(projectDir) {
|
|
583
|
+
validateProjectBoundary(projectDir);
|
|
584
|
+
const appConfig = await loadAppConfig(projectDir);
|
|
585
|
+
const detectedRoutes = resolveConfiguredRoutes(appConfig, projectDir);
|
|
586
|
+
|
|
587
|
+
generateEntryFile(projectDir, detectedRoutes);
|
|
588
|
+
|
|
589
|
+
const manifest = generateManifest(appConfig, projectDir);
|
|
590
|
+
const manifestPath = join(projectDir, MANIFEST_FILE);
|
|
591
|
+
mkdirSync(dirname(manifestPath), { recursive: true });
|
|
592
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
593
|
+
|
|
594
|
+
return { appConfig, manifest, routes: detectedRoutes };
|
|
201
595
|
}
|
|
202
596
|
|
|
203
597
|
/**
|
|
204
598
|
* Generate the manifest from app config and build output.
|
|
205
599
|
*/
|
|
206
|
-
function generateManifest(appConfig, projectDir) {
|
|
207
|
-
const routes = (appConfig
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
600
|
+
export function generateManifest(appConfig, projectDir) {
|
|
601
|
+
const routes = resolveConfiguredRoutes(appConfig, projectDir).map((route) => {
|
|
602
|
+
const entry = {
|
|
603
|
+
path: route.path,
|
|
604
|
+
slug: route.slug,
|
|
605
|
+
name: route.name,
|
|
606
|
+
icon: route.icon || null,
|
|
607
|
+
parentSlug: route.parentSlug || null,
|
|
608
|
+
default: route.default || false,
|
|
609
|
+
export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
|
|
610
|
+
collection: route.collection || null,
|
|
611
|
+
};
|
|
612
|
+
if (route.tool_access) {
|
|
613
|
+
entry.tool_access = route.tool_access;
|
|
614
|
+
}
|
|
615
|
+
return entry;
|
|
616
|
+
});
|
|
216
617
|
|
|
217
|
-
const databases =
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
618
|
+
const databases = appConfig.databases || [];
|
|
619
|
+
const categories = normalizeCategories(appConfig.categories || []);
|
|
620
|
+
const metadata = discoverMetadataAssets(projectDir);
|
|
621
|
+
const appSlug = safeKebab(appConfig.name);
|
|
622
|
+
const displayTitle = appConfig.title || appConfig.displayName || appConfig.name;
|
|
623
|
+
const listingMedia = {
|
|
624
|
+
cover: metadata.cover
|
|
625
|
+
? {
|
|
626
|
+
path: metadata.cover.path,
|
|
627
|
+
content_type: metadata.cover.content_type,
|
|
628
|
+
width: metadata.cover.width,
|
|
629
|
+
height: metadata.cover.height,
|
|
630
|
+
bytes: metadata.cover.bytes,
|
|
631
|
+
}
|
|
632
|
+
: null,
|
|
633
|
+
screenshots: metadata.screenshots.map((screenshot) => ({
|
|
634
|
+
path: screenshot.path,
|
|
635
|
+
content_type: screenshot.content_type,
|
|
636
|
+
width: screenshot.width,
|
|
637
|
+
height: screenshot.height,
|
|
638
|
+
bytes: screenshot.bytes,
|
|
229
639
|
})),
|
|
230
|
-
}
|
|
640
|
+
};
|
|
231
641
|
|
|
232
642
|
return {
|
|
233
643
|
version: 1,
|
|
234
|
-
spec_version:
|
|
644
|
+
spec_version: 4,
|
|
235
645
|
app: {
|
|
236
|
-
name:
|
|
646
|
+
name: displayTitle,
|
|
647
|
+
slug: appSlug || null,
|
|
237
648
|
description: appConfig.description || null,
|
|
238
649
|
icon: appConfig.icon || null,
|
|
650
|
+
title: displayTitle,
|
|
651
|
+
tagline: appConfig.tagline || null,
|
|
652
|
+
categories,
|
|
653
|
+
author: appConfig.author || null,
|
|
654
|
+
version_notes: appConfig.versionNotes || appConfig.version_notes || null,
|
|
655
|
+
},
|
|
656
|
+
listing: {
|
|
657
|
+
title: displayTitle,
|
|
658
|
+
tagline: appConfig.tagline || null,
|
|
659
|
+
categories,
|
|
660
|
+
author: appConfig.author || null,
|
|
661
|
+
version_notes: appConfig.versionNotes || appConfig.version_notes || null,
|
|
662
|
+
media: listingMedia,
|
|
239
663
|
},
|
|
664
|
+
metadata: listingMedia,
|
|
240
665
|
routes,
|
|
666
|
+
bundle: {
|
|
667
|
+
js: 'bundle/app.js',
|
|
668
|
+
css: 'bundle/app.css',
|
|
669
|
+
},
|
|
241
670
|
databases,
|
|
242
671
|
tools: appConfig.tools || [],
|
|
243
672
|
};
|
|
244
673
|
}
|
|
245
674
|
|
|
246
675
|
/**
|
|
247
|
-
* Build the app
|
|
676
|
+
* Build the app bundle: generate entry file, run `vite build`, package into .notis/output/.
|
|
248
677
|
*/
|
|
249
678
|
export async function buildArtifact(projectDir) {
|
|
250
|
-
|
|
679
|
+
await prepareArtifactBuild(projectDir);
|
|
251
680
|
|
|
252
|
-
// Run
|
|
681
|
+
// Run Vite build
|
|
253
682
|
await runProjectScript({
|
|
254
683
|
projectDir,
|
|
255
684
|
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
685
|
});
|
|
262
686
|
|
|
263
|
-
//
|
|
264
|
-
const
|
|
265
|
-
if (!
|
|
266
|
-
throw usageError(
|
|
687
|
+
// Verify the canonical `.notis/output/bundle` packaging contract.
|
|
688
|
+
const builtBundleDir = resolveBuiltBundleDir(projectDir);
|
|
689
|
+
if (!builtBundleDir) {
|
|
690
|
+
throw usageError(
|
|
691
|
+
'Vite build did not produce app.js in .notis/output/bundle. Check your vite.config.ts.',
|
|
692
|
+
);
|
|
267
693
|
}
|
|
694
|
+
normalizeBundleStylesheets(projectDir);
|
|
695
|
+
copyMetadataAssets(projectDir);
|
|
268
696
|
|
|
269
|
-
|
|
270
|
-
mkdirSync(outputSiteDir, { recursive: true });
|
|
271
|
-
cpSync(nextOutDir, outputSiteDir, { recursive: true });
|
|
697
|
+
validateArtifactBoundary(readArtifactFiles(projectDir));
|
|
272
698
|
|
|
273
|
-
|
|
274
|
-
const manifest = generateManifest(appConfig, projectDir);
|
|
275
|
-
const manifestPath = join(projectDir, MANIFEST_FILE);
|
|
276
|
-
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
699
|
+
const manifest = readManifest(projectDir);
|
|
277
700
|
|
|
278
701
|
return { manifest, outputDir: join(projectDir, OUTPUT_DIR) };
|
|
279
702
|
}
|
|
@@ -289,6 +712,22 @@ export function readManifest(projectDir) {
|
|
|
289
712
|
return JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
290
713
|
}
|
|
291
714
|
|
|
715
|
+
function copyMetadataAssets(projectDir) {
|
|
716
|
+
const metadataDir = join(projectDir, METADATA_DIR);
|
|
717
|
+
if (!existsSync(metadataDir)) {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
const outputMetadataDir = join(projectDir, OUTPUT_DIR, METADATA_DIR);
|
|
721
|
+
mkdirSync(outputMetadataDir, { recursive: true });
|
|
722
|
+
for (const entry of readdirSync(metadataDir, { withFileTypes: true })) {
|
|
723
|
+
if (!entry.isFile()) continue;
|
|
724
|
+
if (entry.name !== 'cover.png' && !/^screenshot-\d+\.png$/i.test(entry.name)) {
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
cpSync(join(metadataDir, entry.name), join(outputMetadataDir, entry.name));
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
292
731
|
// ---------------------------------------------------------------------------
|
|
293
732
|
// Linking
|
|
294
733
|
// ---------------------------------------------------------------------------
|
|
@@ -319,24 +758,37 @@ export function requireLinkedAppId(projectDir, explicitAppId) {
|
|
|
319
758
|
/**
|
|
320
759
|
* Scaffold a new Notis app project from the SDK template.
|
|
321
760
|
*/
|
|
322
|
-
export function
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
761
|
+
export function loadScaffoldCatalog() {
|
|
762
|
+
if (existsSync(SCAFFOLD_CATALOG_FILE)) {
|
|
763
|
+
const parsed = JSON.parse(readFileSync(SCAFFOLD_CATALOG_FILE, 'utf-8'));
|
|
764
|
+
const scaffolds = Array.isArray(parsed?.scaffolds) ? parsed.scaffolds : parsed;
|
|
765
|
+
return Array.isArray(scaffolds)
|
|
766
|
+
? scaffolds.filter((entry) => entry && typeof entry.slug === 'string')
|
|
767
|
+
: [];
|
|
768
|
+
}
|
|
769
|
+
return loadMonorepoScaffoldCatalog();
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
export function scaffoldProject({ projectDir, appName, fromSlug = null }) {
|
|
773
|
+
const templateDir = fromSlug ? resolveScaffoldSourceDir(fromSlug) : join(CLI_ROOT, 'template');
|
|
327
774
|
|
|
328
775
|
if (!existsSync(templateDir)) {
|
|
329
|
-
|
|
776
|
+
if (fromSlug) {
|
|
777
|
+
const known = loadScaffoldCatalog().map((entry) => entry.slug).join(', ') || 'none';
|
|
778
|
+
throw usageError(`Unknown scaffold "${fromSlug}". Available scaffolds: ${known}.`);
|
|
779
|
+
}
|
|
780
|
+
throw usageError(`SDK template not found at ${templateDir}. Ensure @notis_ai/cli is installed correctly.`);
|
|
330
781
|
}
|
|
331
782
|
|
|
332
783
|
mkdirSync(projectDir, { recursive: true });
|
|
333
|
-
|
|
784
|
+
copyScaffoldSource(templateDir, projectDir);
|
|
334
785
|
|
|
335
786
|
// Update package.json with the app name
|
|
336
787
|
const pkgPath = join(projectDir, 'package.json');
|
|
337
788
|
if (existsSync(pkgPath)) {
|
|
338
789
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
339
790
|
pkg.name = appName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
|
|
791
|
+
ensureScaffoldLocalSdk(projectDir, pkg);
|
|
340
792
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
341
793
|
}
|
|
342
794
|
|
|
@@ -344,13 +796,141 @@ export function scaffoldProject({ projectDir, appName }) {
|
|
|
344
796
|
const configPath = join(projectDir, 'notis.config.ts');
|
|
345
797
|
if (existsSync(configPath)) {
|
|
346
798
|
let config = readFileSync(configPath, 'utf-8');
|
|
347
|
-
|
|
799
|
+
const displayName = jsStringLiteral(appName);
|
|
800
|
+
const slugName = jsStringLiteral(safeKebab(appName) || 'my-notis-app');
|
|
801
|
+
if (fromSlug) {
|
|
802
|
+
if (/name\s*:/.test(config)) {
|
|
803
|
+
config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
|
|
804
|
+
}
|
|
805
|
+
if (/title\s*:/.test(config)) {
|
|
806
|
+
config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
|
|
807
|
+
} else {
|
|
808
|
+
config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName},\n title: ${displayName}`);
|
|
809
|
+
}
|
|
810
|
+
} else {
|
|
811
|
+
if (/name\s*:/.test(config)) {
|
|
812
|
+
config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
|
|
813
|
+
}
|
|
814
|
+
if (/title\s*:/.test(config)) {
|
|
815
|
+
config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
|
|
816
|
+
} else {
|
|
817
|
+
config = config.replace(/'My Notis App'/, displayName);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
348
820
|
writeFileSync(configPath, config);
|
|
349
821
|
}
|
|
350
822
|
|
|
351
823
|
return { projectDir };
|
|
352
824
|
}
|
|
353
825
|
|
|
826
|
+
function ensureScaffoldLocalSdk(projectDir, pkg) {
|
|
827
|
+
let shouldInstallLocalSdk = false;
|
|
828
|
+
for (const dependencyGroup of ['dependencies', 'devDependencies']) {
|
|
829
|
+
const dependencyValue = pkg[dependencyGroup]?.['@notis/sdk'];
|
|
830
|
+
if (typeof dependencyValue === 'string' && dependencyValue.startsWith('file:')) {
|
|
831
|
+
pkg[dependencyGroup]['@notis/sdk'] = 'file:./packages/sdk';
|
|
832
|
+
shouldInstallLocalSdk = true;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (!shouldInstallLocalSdk) {
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const localSdkDir = join(projectDir, 'packages', 'sdk');
|
|
841
|
+
if (existsSync(join(localSdkDir, 'package.json'))) {
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (!existsSync(join(TEMPLATE_SDK_DIR, 'package.json'))) {
|
|
845
|
+
throw usageError(`SDK template not found at ${TEMPLATE_SDK_DIR}. Ensure @notis_ai/cli is installed correctly.`);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
mkdirSync(dirname(localSdkDir), { recursive: true });
|
|
849
|
+
cpSync(TEMPLATE_SDK_DIR, localSdkDir, { recursive: true, dereference: true });
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function resolveScaffoldSourceDir(fromSlug) {
|
|
853
|
+
const bundledDir = join(SCAFFOLD_SOURCE_DIR, fromSlug);
|
|
854
|
+
if (existsSync(bundledDir)) {
|
|
855
|
+
return bundledDir;
|
|
856
|
+
}
|
|
857
|
+
const monorepoDir = join(MONOREPO_APPS_DIR, fromSlug);
|
|
858
|
+
if (existsSync(join(monorepoDir, 'notis.config.ts'))) {
|
|
859
|
+
return monorepoDir;
|
|
860
|
+
}
|
|
861
|
+
return bundledDir;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function loadMonorepoScaffoldCatalog() {
|
|
865
|
+
if (!existsSync(MONOREPO_APPS_DIR)) {
|
|
866
|
+
return [];
|
|
867
|
+
}
|
|
868
|
+
const scaffolds = [];
|
|
869
|
+
for (const entry of readdirSync(MONOREPO_APPS_DIR, { withFileTypes: true })) {
|
|
870
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.startsWith('_')) {
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
const configPath = join(MONOREPO_APPS_DIR, entry.name, 'notis.config.ts');
|
|
874
|
+
if (!existsSync(configPath)) {
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
const configSource = readFileSync(configPath, 'utf-8');
|
|
878
|
+
const description = readTsStringProperty(configSource, 'description') || '';
|
|
879
|
+
scaffolds.push({
|
|
880
|
+
slug: entry.name,
|
|
881
|
+
name: readTsStringProperty(configSource, 'title') || readTsStringProperty(configSource, 'name') || entry.name,
|
|
882
|
+
description,
|
|
883
|
+
icon: readTsStringProperty(configSource, 'icon') || 'phosphor:squares-four',
|
|
884
|
+
categories: readTsStringArrayProperty(configSource, 'categories'),
|
|
885
|
+
tagline: readTsStringProperty(configSource, 'tagline') || description,
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
return scaffolds.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function readTsStringProperty(source, propertyName) {
|
|
892
|
+
const match = source.match(new RegExp(`\\b${propertyName}\\s*:\\s*(['"\`])([\\s\\S]*?)\\1`));
|
|
893
|
+
return match ? match[2] : null;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function readTsStringArrayProperty(source, propertyName) {
|
|
897
|
+
const match = source.match(new RegExp(`\\b${propertyName}\\s*:\\s*\\[([\\s\\S]*?)\\]`));
|
|
898
|
+
if (!match) {
|
|
899
|
+
return [];
|
|
900
|
+
}
|
|
901
|
+
return Array.from(match[1].matchAll(/(['"`])([\s\S]*?)\1/g), (entry) => entry[2].trim()).filter(Boolean);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function copyScaffoldSource(sourceDir, targetDir) {
|
|
905
|
+
function shouldCopy(path) {
|
|
906
|
+
const name = path.split(/[\\/]/).pop();
|
|
907
|
+
if (!name) return true;
|
|
908
|
+
return !SCAFFOLD_COPY_EXCLUDES.has(name)
|
|
909
|
+
&& !name.startsWith('.env')
|
|
910
|
+
&& !/\.(test|spec)\.[cm]?[jt]sx?$/i.test(name);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function walk(src, dest) {
|
|
914
|
+
if (!shouldCopy(src)) {
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
const stat = statSync(src);
|
|
918
|
+
if (stat.isDirectory()) {
|
|
919
|
+
mkdirSync(dest, { recursive: true });
|
|
920
|
+
for (const entry of readdirSync(src)) {
|
|
921
|
+
walk(join(src, entry), join(dest, entry));
|
|
922
|
+
}
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
if (stat.isFile()) {
|
|
926
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
927
|
+
cpSync(src, dest);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
walk(sourceDir, targetDir);
|
|
932
|
+
}
|
|
933
|
+
|
|
354
934
|
// ---------------------------------------------------------------------------
|
|
355
935
|
// Deploy helpers
|
|
356
936
|
// ---------------------------------------------------------------------------
|
|
@@ -364,6 +944,8 @@ export function collectArtifactFiles(projectDir) {
|
|
|
364
944
|
throw usageError('No build output found. Run "notis apps build" first.');
|
|
365
945
|
}
|
|
366
946
|
|
|
947
|
+
validateArtifactBoundary(readArtifactFiles(projectDir));
|
|
948
|
+
|
|
367
949
|
const files = {};
|
|
368
950
|
|
|
369
951
|
function walk(dir, prefix) {
|
|
@@ -382,3 +964,376 @@ export function collectArtifactFiles(projectDir) {
|
|
|
382
964
|
walk(outputDir, '');
|
|
383
965
|
return files;
|
|
384
966
|
}
|
|
967
|
+
|
|
968
|
+
function shouldExcludeSourceEntry(name) {
|
|
969
|
+
return SOURCE_COPY_EXCLUDES.has(name) || name.startsWith('.env');
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function readSourceFiles(projectDir) {
|
|
973
|
+
const files = {};
|
|
974
|
+
|
|
975
|
+
function walk(dir, prefix) {
|
|
976
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
977
|
+
for (const entry of entries) {
|
|
978
|
+
if (shouldExcludeSourceEntry(entry.name) || entry.isSymbolicLink()) {
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
const fullPath = join(dir, entry.name);
|
|
982
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
983
|
+
if (entry.isDirectory()) {
|
|
984
|
+
walk(fullPath, relPath);
|
|
985
|
+
} else if (entry.isFile()) {
|
|
986
|
+
files[relPath] = readFileSync(fullPath);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
walk(projectDir, '');
|
|
992
|
+
return files;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
export function collectSourceFiles(projectDir) {
|
|
996
|
+
const files = readSourceFiles(projectDir);
|
|
997
|
+
const encoded = {};
|
|
998
|
+
for (const [relPath, content] of Object.entries(files)) {
|
|
999
|
+
encoded[relPath] = content.toString('base64');
|
|
1000
|
+
}
|
|
1001
|
+
return encoded;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function cleanTarPath(name) {
|
|
1005
|
+
const cleaned = String(name || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
|
1006
|
+
const parts = cleaned.split('/').filter(Boolean);
|
|
1007
|
+
if (!parts.length || parts.includes('..')) {
|
|
1008
|
+
throw usageError(`Refusing to extract unsafe path from source archive: ${name}`);
|
|
1009
|
+
}
|
|
1010
|
+
return parts.join('/');
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function extractTarGz(buffer, targetDir) {
|
|
1014
|
+
const tar = gunzipSync(buffer);
|
|
1015
|
+
let offset = 0;
|
|
1016
|
+
while (offset + 512 <= tar.length) {
|
|
1017
|
+
const header = tar.subarray(offset, offset + 512);
|
|
1018
|
+
offset += 512;
|
|
1019
|
+
if (header.every((byte) => byte === 0)) {
|
|
1020
|
+
break;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const name = header.subarray(0, 100).toString('utf-8').replace(/\0.*$/, '');
|
|
1024
|
+
const prefix = header.subarray(345, 500).toString('utf-8').replace(/\0.*$/, '');
|
|
1025
|
+
const fullName = prefix ? `${prefix}/${name}` : name;
|
|
1026
|
+
const sizeRaw = header.subarray(124, 136).toString('utf-8').replace(/\0.*$/, '').trim();
|
|
1027
|
+
const size = Number.parseInt(sizeRaw || '0', 8);
|
|
1028
|
+
const typeFlag = header[156];
|
|
1029
|
+
const relPath = cleanTarPath(fullName);
|
|
1030
|
+
const outputPath = join(targetDir, relPath);
|
|
1031
|
+
|
|
1032
|
+
if (typeFlag === 53) {
|
|
1033
|
+
mkdirSync(outputPath, { recursive: true });
|
|
1034
|
+
} else {
|
|
1035
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
1036
|
+
writeFileSync(outputPath, tar.subarray(offset, offset + size));
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
offset += Math.ceil(size / 512) * 512;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
export async function pullAppSource({
|
|
1044
|
+
apiBase,
|
|
1045
|
+
jwt,
|
|
1046
|
+
appId,
|
|
1047
|
+
targetDir,
|
|
1048
|
+
version = 'latest',
|
|
1049
|
+
force = false,
|
|
1050
|
+
}) {
|
|
1051
|
+
if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
|
|
1052
|
+
if (!force) {
|
|
1053
|
+
throw usageError(`Target directory is not empty: ${targetDir}. Pass --force to overwrite it.`);
|
|
1054
|
+
}
|
|
1055
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
1056
|
+
}
|
|
1057
|
+
mkdirSync(targetDir, { recursive: true });
|
|
1058
|
+
|
|
1059
|
+
const params = new URLSearchParams({ app_id: appId, version: String(version || 'latest') });
|
|
1060
|
+
const response = await fetch(`${apiBase.replace(/\/$/, '')}/portal_apps/source?${params.toString()}`, {
|
|
1061
|
+
headers: { Authorization: `Bearer ${jwt}` },
|
|
1062
|
+
});
|
|
1063
|
+
if (!response.ok) {
|
|
1064
|
+
const data = await response.json().catch(() => ({}));
|
|
1065
|
+
throw usageError(typeof data?.error === 'string' ? data.error : `Failed to pull app source (${response.status}).`);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const contentDisposition = response.headers.get('content-disposition') || '';
|
|
1069
|
+
const versionMatch = /-v(\d+)\.tar\.gz/i.exec(contentDisposition);
|
|
1070
|
+
const pulledVersion = versionMatch ? Number.parseInt(versionMatch[1], 10) : null;
|
|
1071
|
+
extractTarGz(Buffer.from(await response.arrayBuffer()), targetDir);
|
|
1072
|
+
const linkedVersion = pulledVersion || (
|
|
1073
|
+
String(version || 'latest') === 'latest'
|
|
1074
|
+
? undefined
|
|
1075
|
+
: Number.parseInt(String(version), 10)
|
|
1076
|
+
);
|
|
1077
|
+
writeLinkedState(targetDir, {
|
|
1078
|
+
app_id: appId,
|
|
1079
|
+
...(Number.isFinite(linkedVersion) ? { version: linkedVersion } : {}),
|
|
1080
|
+
linked_at: new Date().toISOString(),
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
return { projectDir: targetDir, version: pulledVersion || version };
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function readArtifactFiles(projectDir) {
|
|
1087
|
+
const outputDir = join(projectDir, OUTPUT_DIR);
|
|
1088
|
+
if (!existsSync(outputDir)) {
|
|
1089
|
+
throw usageError('No build output found. Run "notis apps build" first.');
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const files = {};
|
|
1093
|
+
|
|
1094
|
+
function walk(dir, prefix) {
|
|
1095
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
1096
|
+
for (const entry of entries) {
|
|
1097
|
+
const fullPath = join(dir, entry.name);
|
|
1098
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
1099
|
+
if (entry.isDirectory()) {
|
|
1100
|
+
walk(fullPath, relPath);
|
|
1101
|
+
} else {
|
|
1102
|
+
files[relPath] = readFileSync(fullPath);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
walk(outputDir, '');
|
|
1108
|
+
return files;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// ---------------------------------------------------------------------------
|
|
1112
|
+
// Direct deploy (bypasses backend server)
|
|
1113
|
+
// ---------------------------------------------------------------------------
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Resolve Supabase credentials from the server/.env file in the repo workspace.
|
|
1117
|
+
* Falls back to environment variables.
|
|
1118
|
+
*/
|
|
1119
|
+
function resolveSupabaseCredentials() {
|
|
1120
|
+
const envPaths = [
|
|
1121
|
+
resolve(process.cwd(), 'server/.env'),
|
|
1122
|
+
resolve(process.cwd(), '../server/.env'),
|
|
1123
|
+
resolve(process.cwd(), '../../server/.env'),
|
|
1124
|
+
];
|
|
1125
|
+
|
|
1126
|
+
let supabaseUrl = process.env.SUPABASE_URL;
|
|
1127
|
+
let supabaseSubdomain = process.env.SUPABASE_SUBDOMAIN;
|
|
1128
|
+
let supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
|
|
1129
|
+
|
|
1130
|
+
for (const envPath of envPaths) {
|
|
1131
|
+
if (existsSync(envPath)) {
|
|
1132
|
+
const content = readFileSync(envPath, 'utf-8');
|
|
1133
|
+
for (const line of content.split('\n')) {
|
|
1134
|
+
const trimmed = line.trim();
|
|
1135
|
+
if (trimmed.startsWith('#') || !trimmed.includes('=')) continue;
|
|
1136
|
+
const eqIdx = trimmed.indexOf('=');
|
|
1137
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
1138
|
+
let value = trimmed.slice(eqIdx + 1).trim();
|
|
1139
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
1140
|
+
value = value.slice(1, -1);
|
|
1141
|
+
}
|
|
1142
|
+
if (key === 'SUPABASE_URL' && !supabaseUrl) supabaseUrl = value;
|
|
1143
|
+
if (key === 'SUPABASE_SUBDOMAIN' && !supabaseSubdomain) supabaseSubdomain = value;
|
|
1144
|
+
if ((key === 'SUPABASE_SERVICE_ROLE_KEY' || key === 'SUPABASE_SERVICE_KEY') && !supabaseKey) supabaseKey = value;
|
|
1145
|
+
}
|
|
1146
|
+
break;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Derive URL from subdomain if needed
|
|
1151
|
+
if (!supabaseUrl && supabaseSubdomain) {
|
|
1152
|
+
supabaseUrl = `https://${supabaseSubdomain}.supabase.co`;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
if (!supabaseUrl || !supabaseKey) {
|
|
1156
|
+
throw usageError(
|
|
1157
|
+
'Cannot resolve Supabase credentials for direct deploy. ' +
|
|
1158
|
+
'Ensure server/.env exists with SUPABASE_SUBDOMAIN (or SUPABASE_URL) and SUPABASE_SERVICE_KEY, ' +
|
|
1159
|
+
'or set them as environment variables.',
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
return { supabaseUrl, supabaseKey };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Upload a file to Supabase Storage with upsert.
|
|
1168
|
+
*/
|
|
1169
|
+
async function uploadToStorage(supabaseUrl, supabaseKey, bucket, storagePath, content, contentType) {
|
|
1170
|
+
const url = `${supabaseUrl}/storage/v1/object/${bucket}/${storagePath}`;
|
|
1171
|
+
const response = await fetch(url, {
|
|
1172
|
+
method: 'POST',
|
|
1173
|
+
headers: {
|
|
1174
|
+
'Authorization': `Bearer ${supabaseKey}`,
|
|
1175
|
+
'Content-Type': contentType,
|
|
1176
|
+
'x-upsert': 'true',
|
|
1177
|
+
},
|
|
1178
|
+
body: content,
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
if (!response.ok) {
|
|
1182
|
+
const text = await response.text().catch(() => '');
|
|
1183
|
+
throw new Error(`Storage upload failed (${response.status}): ${text}`);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
async function ensureStorageBucket(supabaseUrl, supabaseKey, bucket, options = {}) {
|
|
1188
|
+
const encodedBucket = encodeURIComponent(bucket);
|
|
1189
|
+
const headers = {
|
|
1190
|
+
'Authorization': `Bearer ${supabaseKey}`,
|
|
1191
|
+
'apikey': supabaseKey,
|
|
1192
|
+
};
|
|
1193
|
+
const inspectResponse = await fetch(`${supabaseUrl}/storage/v1/bucket/${encodedBucket}`, {
|
|
1194
|
+
headers,
|
|
1195
|
+
});
|
|
1196
|
+
if (inspectResponse.ok) {
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
if (inspectResponse.status !== 404) {
|
|
1200
|
+
const text = await inspectResponse.text().catch(() => '');
|
|
1201
|
+
throw new Error(`Failed to inspect storage bucket ${bucket} (${inspectResponse.status}): ${text}`);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const createResponse = await fetch(`${supabaseUrl}/storage/v1/bucket`, {
|
|
1205
|
+
method: 'POST',
|
|
1206
|
+
headers: {
|
|
1207
|
+
...headers,
|
|
1208
|
+
'Content-Type': 'application/json',
|
|
1209
|
+
},
|
|
1210
|
+
body: JSON.stringify({
|
|
1211
|
+
id: bucket,
|
|
1212
|
+
name: bucket,
|
|
1213
|
+
public: Boolean(options.public),
|
|
1214
|
+
}),
|
|
1215
|
+
});
|
|
1216
|
+
if (createResponse.ok) {
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
const text = await createResponse.text().catch(() => '');
|
|
1221
|
+
if (createResponse.status === 409 || /already exists|duplicate/i.test(text)) {
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
throw new Error(`Failed to create storage bucket ${bucket} (${createResponse.status}): ${text}`);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Get the current app manifest version from the apps table.
|
|
1229
|
+
*/
|
|
1230
|
+
async function getAppCurrentVersion(supabaseUrl, supabaseKey, appId) {
|
|
1231
|
+
const encodedAppId = encodeURIComponent(appId);
|
|
1232
|
+
const url = `${supabaseUrl}/rest/v1/apps?id=eq.${encodedAppId}&select=manifest`;
|
|
1233
|
+
const response = await fetch(url, {
|
|
1234
|
+
headers: {
|
|
1235
|
+
'Authorization': `Bearer ${supabaseKey}`,
|
|
1236
|
+
'apikey': supabaseKey,
|
|
1237
|
+
},
|
|
1238
|
+
});
|
|
1239
|
+
if (!response.ok) throw new Error(`Failed to get app version: ${response.status}`);
|
|
1240
|
+
const rows = await response.json();
|
|
1241
|
+
if (!rows.length) throw usageError(`App ${appId} not found.`);
|
|
1242
|
+
const manifest = rows[0].manifest || {};
|
|
1243
|
+
return { currentVersion: manifest.version || 0, manifest };
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* Update the app manifest in the apps table.
|
|
1248
|
+
*/
|
|
1249
|
+
async function updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, manifest) {
|
|
1250
|
+
const encodedAppId = encodeURIComponent(appId);
|
|
1251
|
+
const url = `${supabaseUrl}/rest/v1/apps?id=eq.${encodedAppId}`;
|
|
1252
|
+
const response = await fetch(url, {
|
|
1253
|
+
method: 'PATCH',
|
|
1254
|
+
headers: {
|
|
1255
|
+
'Authorization': `Bearer ${supabaseKey}`,
|
|
1256
|
+
'apikey': supabaseKey,
|
|
1257
|
+
'Content-Type': 'application/json',
|
|
1258
|
+
'Prefer': 'return=minimal',
|
|
1259
|
+
},
|
|
1260
|
+
body: JSON.stringify({
|
|
1261
|
+
manifest: { ...manifest, version: newVersion },
|
|
1262
|
+
updated_at: new Date().toISOString(),
|
|
1263
|
+
}),
|
|
1264
|
+
});
|
|
1265
|
+
if (!response.ok) {
|
|
1266
|
+
const text = await response.text().catch(() => '');
|
|
1267
|
+
throw new Error(`Failed to update app version: ${response.status} ${text}`);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const CONTENT_TYPE_MAP = {
|
|
1272
|
+
'.js': 'application/javascript',
|
|
1273
|
+
'.css': 'text/css',
|
|
1274
|
+
'.json': 'application/json',
|
|
1275
|
+
'.html': 'text/html',
|
|
1276
|
+
'.png': 'image/png',
|
|
1277
|
+
'.jpg': 'image/jpeg',
|
|
1278
|
+
'.jpeg': 'image/jpeg',
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* Deploy app bundle directly to Supabase storage, bypassing the backend server.
|
|
1283
|
+
*/
|
|
1284
|
+
export async function directDeploy(projectDir, appId) {
|
|
1285
|
+
const { supabaseUrl, supabaseKey } = resolveSupabaseCredentials();
|
|
1286
|
+
const manifest = readManifest(projectDir);
|
|
1287
|
+
const artifactFiles = readArtifactFiles(projectDir);
|
|
1288
|
+
const sourceFiles = readSourceFiles(projectDir);
|
|
1289
|
+
validateArtifactBoundary(artifactFiles);
|
|
1290
|
+
|
|
1291
|
+
// Get current version and increment
|
|
1292
|
+
const { currentVersion } = await getAppCurrentVersion(supabaseUrl, supabaseKey, appId);
|
|
1293
|
+
const newVersion = currentVersion + 1;
|
|
1294
|
+
|
|
1295
|
+
// Upload all files from the output directory
|
|
1296
|
+
const bucket = 'app-code';
|
|
1297
|
+
await ensureStorageBucket(supabaseUrl, supabaseKey, bucket, { public: false });
|
|
1298
|
+
await ensureStorageBucket(supabaseUrl, supabaseKey, 'app-source', { public: false });
|
|
1299
|
+
|
|
1300
|
+
async function uploadDir(dir, prefix) {
|
|
1301
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
1302
|
+
for (const entry of entries) {
|
|
1303
|
+
const fullPath = join(dir, entry.name);
|
|
1304
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
1305
|
+
if (entry.isDirectory()) {
|
|
1306
|
+
await uploadDir(fullPath, relPath);
|
|
1307
|
+
} else {
|
|
1308
|
+
const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
|
|
1309
|
+
const contentType = CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
|
|
1310
|
+
const content = readFileSync(fullPath);
|
|
1311
|
+
const storagePath = `${appId}/v${newVersion}/${relPath}`;
|
|
1312
|
+
await uploadToStorage(supabaseUrl, supabaseKey, bucket, storagePath, content, contentType);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
await uploadDir(join(projectDir, OUTPUT_DIR), '');
|
|
1318
|
+
|
|
1319
|
+
for (const [relPath, content] of Object.entries(sourceFiles)) {
|
|
1320
|
+
const ext = relPath.includes('.') ? `.${relPath.split('.').pop()}` : '';
|
|
1321
|
+
const contentType = CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
|
|
1322
|
+
const storagePath = `${appId}/v${newVersion}/${relPath}`;
|
|
1323
|
+
await uploadToStorage(supabaseUrl, supabaseKey, 'app-source', storagePath, content, contentType);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// Update the app record -- include storage_prefix so the portal can resolve bundle URLs
|
|
1327
|
+
const storagePrefix = `${appId}/v${newVersion}/`;
|
|
1328
|
+
const deployManifest = {
|
|
1329
|
+
...manifest,
|
|
1330
|
+
version: newVersion,
|
|
1331
|
+
storage_bucket: bucket,
|
|
1332
|
+
storage_prefix: storagePrefix,
|
|
1333
|
+
source_storage_bucket: 'app-source',
|
|
1334
|
+
source_storage_prefix: storagePrefix,
|
|
1335
|
+
};
|
|
1336
|
+
await updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, deployManifest);
|
|
1337
|
+
|
|
1338
|
+
return { version: newVersion };
|
|
1339
|
+
}
|