@notis_ai/cli 0.2.0-beta.21.1 → 0.2.0-beta.35.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 +74 -112
- 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 +8 -3
- package/skills/notis-apps/SKILL.md +168 -0
- package/skills/notis-apps/cli.md +208 -0
- package/skills/notis-cli/SKILL.md +263 -0
- package/skills/notis-query/cli.md +40 -0
- package/src/cli.js +1 -1
- package/src/command-specs/apps.js +962 -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 +725 -38
- 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/layout.tsx +1 -2
- package/template/app/page.tsx +41 -6
- 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 +25 -6
- package/template/package.json +3 -4
- 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/{notis-sdk → sdk}/src/config.ts +25 -2
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +503 -0
- package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +6 -7
- package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +4 -4
- package/template/packages/{notis-sdk → sdk}/src/hooks/useTool.ts +22 -7
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
- package/template/packages/sdk/src/index.ts +46 -0
- package/template/packages/sdk/src/provider.tsx +43 -0
- package/template/packages/sdk/src/runtime.ts +80 -0
- package/template/packages/sdk/src/styles.css +38 -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 -336
- package/template/packages/notis-sdk/src/helpers.ts +0 -131
- package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
- package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
- package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
- package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
- package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -58
- package/template/packages/notis-sdk/src/index.ts +0 -47
- package/template/packages/notis-sdk/src/provider.tsx +0 -44
- package/template/packages/notis-sdk/src/runtime.ts +0 -165
- package/template/packages/notis-sdk/src/styles.css +0 -123
- /package/template/packages/{notis-sdk → sdk}/package.json +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/vite.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/tsconfig.json +0 -0
|
@@ -7,18 +7,47 @@
|
|
|
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
11
|
import { createRequire } from 'node:module';
|
|
12
12
|
import { dirname, join, resolve } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
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
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
|
+
export const NOTIS_APP_CATEGORIES = [
|
|
31
|
+
'Productivity',
|
|
32
|
+
'Sales & Marketing',
|
|
33
|
+
'Operations',
|
|
34
|
+
'Product & Engineering',
|
|
35
|
+
'Personal',
|
|
36
|
+
];
|
|
37
|
+
const SOURCE_COPY_EXCLUDES = new Set([
|
|
38
|
+
'node_modules',
|
|
39
|
+
'.notis',
|
|
40
|
+
'.git',
|
|
41
|
+
'dist',
|
|
42
|
+
'tsconfig.tsbuildinfo',
|
|
43
|
+
'.DS_Store',
|
|
44
|
+
]);
|
|
45
|
+
const SCAFFOLD_COPY_EXCLUDES = new Set([
|
|
46
|
+
...SOURCE_COPY_EXCLUDES,
|
|
47
|
+
'coverage',
|
|
48
|
+
'.next',
|
|
49
|
+
'.turbo',
|
|
50
|
+
]);
|
|
22
51
|
let appConfigImportNonce = 0;
|
|
23
52
|
|
|
24
53
|
// ---------------------------------------------------------------------------
|
|
@@ -29,6 +58,47 @@ export function resolveProjectDir(inputDir = '.') {
|
|
|
29
58
|
return resolve(process.cwd(), inputDir);
|
|
30
59
|
}
|
|
31
60
|
|
|
61
|
+
export function getBundleDir(projectDir) {
|
|
62
|
+
return join(projectDir, BUNDLE_DIR);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function resolveBuiltBundleDir(projectDir) {
|
|
66
|
+
const bundleDir = getBundleDir(projectDir);
|
|
67
|
+
if (existsSync(join(bundleDir, 'app.js'))) {
|
|
68
|
+
return bundleDir;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeShadowScopedCss(css) {
|
|
74
|
+
return css
|
|
75
|
+
// Tailwind preflight emits `html,:host` in v3. Inside a shadow tree we want
|
|
76
|
+
// the shadow host itself to carry those defaults.
|
|
77
|
+
.replace(/html\s*,\s*:host\s*\{/g, ':host{')
|
|
78
|
+
.replace(/:root\s*,\s*:host\s*\{/g, ':host{')
|
|
79
|
+
.replace(/:root\s*\{/g, ':host{')
|
|
80
|
+
.replace(/html\s*\{/g, ':host{')
|
|
81
|
+
// Shadow trees do not contain a body element. Route those defaults to the
|
|
82
|
+
// app root contract instead so authors still get the expected reset.
|
|
83
|
+
.replace(/body\s*\{/g, '[data-notis-app-root]{');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeBundleStylesheets(projectDir) {
|
|
87
|
+
const bundleDir = join(projectDir, BUNDLE_DIR);
|
|
88
|
+
if (!existsSync(bundleDir)) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const entry of readdirSync(bundleDir)) {
|
|
93
|
+
if (!entry.endsWith('.css')) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const cssPath = join(bundleDir, entry);
|
|
97
|
+
const normalizedCss = normalizeShadowScopedCss(readFileSync(cssPath, 'utf-8'));
|
|
98
|
+
writeFileSync(cssPath, normalizedCss);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
32
102
|
// ---------------------------------------------------------------------------
|
|
33
103
|
// Config loading
|
|
34
104
|
// ---------------------------------------------------------------------------
|
|
@@ -38,11 +108,24 @@ export function resolveProjectDir(inputDir = '.') {
|
|
|
38
108
|
* approach: we read the file, transpile with the project's TypeScript compiler
|
|
39
109
|
* when available, then evaluate as ESM.
|
|
40
110
|
*/
|
|
111
|
+
function stripNotisSdkImports(source) {
|
|
112
|
+
return source
|
|
113
|
+
.replace(/import\s+type\s+[\s\S]*?from\s+['"][^'"]*['"]\s*;?/g, '')
|
|
114
|
+
.replace(/import\s*{[\s\S]*?\bdefineNotisApp\b[\s\S]*?}\s+from\s+['"]@notis\/sdk\/config['"]\s*;?/g, '')
|
|
115
|
+
.replace(/defineNotisApp\s*\(/g, '(');
|
|
116
|
+
}
|
|
117
|
+
|
|
41
118
|
function transpileTsConfigSource(source, configPath) {
|
|
119
|
+
// Strip the @notis/sdk/config import unconditionally. The SDK package often
|
|
120
|
+
// points its `exports` at raw .ts source, which Node cannot import from the
|
|
121
|
+
// temp .mjs file we emit below. `defineNotisApp` is just an identity helper,
|
|
122
|
+
// so removing the import and replacing the call with a parens-wrapped
|
|
123
|
+
// expression preserves the config value without ever resolving the SDK.
|
|
124
|
+
const stripped = stripNotisSdkImports(source);
|
|
42
125
|
const requireFromConfig = createRequire(`file://${configPath}`);
|
|
43
126
|
try {
|
|
44
127
|
const ts = requireFromConfig('typescript');
|
|
45
|
-
const transpiled = ts.transpileModule(
|
|
128
|
+
const transpiled = ts.transpileModule(stripped, {
|
|
46
129
|
compilerOptions: {
|
|
47
130
|
module: ts.ModuleKind.ESNext,
|
|
48
131
|
target: ts.ScriptTarget.ES2020,
|
|
@@ -51,10 +134,7 @@ function transpileTsConfigSource(source, configPath) {
|
|
|
51
134
|
});
|
|
52
135
|
return transpiled.outputText;
|
|
53
136
|
} catch {
|
|
54
|
-
return
|
|
55
|
-
.replace(/import\s+type\s+[\s\S]*?from\s+['"][^'"]*['"]\s*;?/g, '')
|
|
56
|
-
.replace(/import\s*{[\s\S]*?\bdefineNotisApp\b[\s\S]*?}\s+from\s+['"]@notis\/sdk\/config['"]\s*;?/g, '')
|
|
57
|
-
.replace(/defineNotisApp\s*\(/, '(');
|
|
137
|
+
return stripped;
|
|
58
138
|
}
|
|
59
139
|
}
|
|
60
140
|
|
|
@@ -152,9 +232,160 @@ export function detectProjectWarnings(projectDir, appConfig = null) {
|
|
|
152
232
|
warnings.push('No database references declared in notis.config.ts.');
|
|
153
233
|
}
|
|
154
234
|
|
|
235
|
+
if (appConfig) {
|
|
236
|
+
try {
|
|
237
|
+
const listing = inspectListingReadiness(projectDir, appConfig);
|
|
238
|
+
warnings.push(...listing.warnings);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
warnings.push(error instanceof Error ? error.message : String(error));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
155
244
|
return warnings;
|
|
156
245
|
}
|
|
157
246
|
|
|
247
|
+
function safeKebab(value) {
|
|
248
|
+
return String(value || '')
|
|
249
|
+
.trim()
|
|
250
|
+
.toLowerCase()
|
|
251
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
252
|
+
.replace(/(^-|-$)+/g, '');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function jsStringLiteral(value) {
|
|
256
|
+
return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function normalizeCategories(categories = []) {
|
|
260
|
+
if (!Array.isArray(categories)) {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
const allowed = new Set(NOTIS_APP_CATEGORIES);
|
|
264
|
+
const normalized = [];
|
|
265
|
+
for (const category of categories) {
|
|
266
|
+
if (typeof category !== 'string' || !category.trim()) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const trimmed = category.trim();
|
|
270
|
+
if (!allowed.has(trimmed)) {
|
|
271
|
+
throw usageError(
|
|
272
|
+
`Invalid Notis app category "${trimmed}". Use one of: ${NOTIS_APP_CATEGORIES.join(', ')}.`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (!normalized.includes(trimmed)) {
|
|
276
|
+
normalized.push(trimmed);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return normalized;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function pngDimensions(buffer) {
|
|
283
|
+
const signature = '89504e470d0a1a0a';
|
|
284
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 24 || buffer.subarray(0, 8).toString('hex') !== signature) {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
width: buffer.readUInt32BE(16),
|
|
289
|
+
height: buffer.readUInt32BE(20),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function imageAssetMeta(projectDir, relPath, { maxBytes, recommendedRatio = 1.6 }) {
|
|
294
|
+
const fullPath = join(projectDir, relPath);
|
|
295
|
+
const content = readFileSync(fullPath);
|
|
296
|
+
const dimensions = pngDimensions(content);
|
|
297
|
+
const ratio = dimensions?.height ? dimensions.width / dimensions.height : null;
|
|
298
|
+
const errors = [];
|
|
299
|
+
const warnings = [];
|
|
300
|
+
|
|
301
|
+
if (!relPath.toLowerCase().endsWith('.png')) {
|
|
302
|
+
errors.push(`${relPath} must be a PNG file.`);
|
|
303
|
+
}
|
|
304
|
+
if (content.length > maxBytes) {
|
|
305
|
+
errors.push(`${relPath} must be ${Math.round(maxBytes / 1024 / 1024)} MB or smaller.`);
|
|
306
|
+
}
|
|
307
|
+
if (ratio !== null && (ratio < 1.3 || ratio > 1.9)) {
|
|
308
|
+
errors.push(`${relPath} should be close to a 16:10 aspect ratio.`);
|
|
309
|
+
} else if (ratio !== null && Math.abs(ratio - recommendedRatio) > 0.08) {
|
|
310
|
+
warnings.push(`${relPath} is not 2000x1250; 16:10 PNG is recommended.`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
path: relPath,
|
|
315
|
+
content_type: 'image/png',
|
|
316
|
+
bytes: content.length,
|
|
317
|
+
width: dimensions?.width ?? null,
|
|
318
|
+
height: dimensions?.height ?? null,
|
|
319
|
+
errors,
|
|
320
|
+
warnings,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function discoverMetadataAssets(projectDir) {
|
|
325
|
+
const metadataDir = join(projectDir, METADATA_DIR);
|
|
326
|
+
const coverPath = join(METADATA_DIR, 'cover.png');
|
|
327
|
+
const cover = existsSync(join(projectDir, coverPath))
|
|
328
|
+
? imageAssetMeta(projectDir, coverPath, { maxBytes: 5 * 1024 * 1024 })
|
|
329
|
+
: null;
|
|
330
|
+
const screenshots = [];
|
|
331
|
+
|
|
332
|
+
if (existsSync(metadataDir)) {
|
|
333
|
+
for (const entry of readdirSync(metadataDir, { withFileTypes: true })) {
|
|
334
|
+
if (!entry.isFile()) continue;
|
|
335
|
+
const match = /^screenshot-(\d+)\.png$/i.exec(entry.name);
|
|
336
|
+
if (!match) continue;
|
|
337
|
+
screenshots.push({
|
|
338
|
+
index: Number.parseInt(match[1], 10),
|
|
339
|
+
...imageAssetMeta(projectDir, join(METADATA_DIR, entry.name), {
|
|
340
|
+
maxBytes: 2 * 1024 * 1024,
|
|
341
|
+
}),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
screenshots.sort((a, b) => a.index - b.index);
|
|
347
|
+
return {
|
|
348
|
+
cover,
|
|
349
|
+
screenshots: screenshots.slice(0, 6),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function inspectListingReadiness(projectDir, appConfig = null) {
|
|
354
|
+
const config = appConfig || {};
|
|
355
|
+
const warnings = [];
|
|
356
|
+
const errors = [];
|
|
357
|
+
const metadata = discoverMetadataAssets(projectDir);
|
|
358
|
+
const categories = normalizeCategories(config.categories || []);
|
|
359
|
+
|
|
360
|
+
if (!String(config.tagline || '').trim()) {
|
|
361
|
+
warnings.push('Listing tagline missing in notis.config.ts.');
|
|
362
|
+
}
|
|
363
|
+
if (categories.length === 0) {
|
|
364
|
+
warnings.push(`Listing category missing in notis.config.ts. Use one of: ${NOTIS_APP_CATEGORIES.join(', ')}.`);
|
|
365
|
+
}
|
|
366
|
+
if (!metadata.cover) {
|
|
367
|
+
warnings.push('Listing cover missing at metadata/cover.png.');
|
|
368
|
+
} else {
|
|
369
|
+
errors.push(...metadata.cover.errors);
|
|
370
|
+
warnings.push(...metadata.cover.warnings);
|
|
371
|
+
}
|
|
372
|
+
if (metadata.screenshots.length < 3) {
|
|
373
|
+
warnings.push(`Listing screenshots: ${metadata.screenshots.length}/3 minimum in metadata/screenshot-N.png.`);
|
|
374
|
+
}
|
|
375
|
+
for (const screenshot of metadata.screenshots) {
|
|
376
|
+
errors.push(...screenshot.errors);
|
|
377
|
+
warnings.push(...screenshot.warnings);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
ready: errors.length === 0 && Boolean(config.tagline) && categories.length > 0 && Boolean(metadata.cover) && metadata.screenshots.length >= 1,
|
|
382
|
+
warnings,
|
|
383
|
+
errors,
|
|
384
|
+
metadata,
|
|
385
|
+
categories,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
158
389
|
// ---------------------------------------------------------------------------
|
|
159
390
|
// Build
|
|
160
391
|
// ---------------------------------------------------------------------------
|
|
@@ -181,7 +412,7 @@ export async function runProjectScript({ projectDir, scriptName, env = {}, stdio
|
|
|
181
412
|
* Derive an export name from a route path.
|
|
182
413
|
* '/' -> 'index', '/inbox' -> 'inbox', '/my-tasks' -> 'myTasks'
|
|
183
414
|
*/
|
|
184
|
-
function exportNameFromPath(routePath) {
|
|
415
|
+
export function exportNameFromPath(routePath) {
|
|
185
416
|
if (routePath === '/') return 'index';
|
|
186
417
|
const slug = routePath.replace(/^\//, '').replace(/\//g, '-');
|
|
187
418
|
const identifier = slug
|
|
@@ -236,11 +467,88 @@ function autoDetectRoutes(projectDir) {
|
|
|
236
467
|
return routes;
|
|
237
468
|
}
|
|
238
469
|
|
|
470
|
+
function validateConfiguredRoutes(routes) {
|
|
471
|
+
if (routes.length === 0) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const slugSet = new Set();
|
|
476
|
+
let defaultCount = 0;
|
|
477
|
+
|
|
478
|
+
for (const route of routes) {
|
|
479
|
+
if (!route.slug || typeof route.slug !== 'string') {
|
|
480
|
+
throw usageError(`Route "${route.path}" must define a slug.`);
|
|
481
|
+
}
|
|
482
|
+
if (slugSet.has(route.slug)) {
|
|
483
|
+
throw usageError(`Duplicate route slug "${route.slug}".`);
|
|
484
|
+
}
|
|
485
|
+
slugSet.add(route.slug);
|
|
486
|
+
if (route.default) {
|
|
487
|
+
defaultCount += 1;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (defaultCount !== 1) {
|
|
492
|
+
throw usageError(`Expected exactly one default route, received ${defaultCount}.`);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
for (const route of routes) {
|
|
496
|
+
if (route.parentSlug && !slugSet.has(route.parentSlug)) {
|
|
497
|
+
throw usageError(
|
|
498
|
+
`Route "${route.slug}" references unknown parentSlug "${route.parentSlug}".`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
const collection = route.collection || null;
|
|
502
|
+
if (
|
|
503
|
+
collection?.sidebar?.mode === 'tree' &&
|
|
504
|
+
(!collection.parentProperty || typeof collection.parentProperty !== 'string')
|
|
505
|
+
) {
|
|
506
|
+
throw usageError(
|
|
507
|
+
`Tree collection route "${route.slug}" must define collection.parentProperty.`,
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const childrenByParent = new Map();
|
|
513
|
+
for (const route of routes) {
|
|
514
|
+
if (!route.parentSlug) {
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
const children = childrenByParent.get(route.parentSlug) || [];
|
|
518
|
+
children.push(route.slug);
|
|
519
|
+
childrenByParent.set(route.parentSlug, children);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
for (const route of routes) {
|
|
523
|
+
const seen = new Set([route.slug]);
|
|
524
|
+
let current = route.parentSlug || null;
|
|
525
|
+
while (current) {
|
|
526
|
+
if (seen.has(current)) {
|
|
527
|
+
throw usageError(`Route parent cycle detected at "${route.slug}".`);
|
|
528
|
+
}
|
|
529
|
+
seen.add(current);
|
|
530
|
+
current = routes.find((candidate) => candidate.slug === current)?.parentSlug || null;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
for (const route of routes) {
|
|
535
|
+
if (
|
|
536
|
+
route.collection?.sidebar?.mode === 'tree' &&
|
|
537
|
+
(childrenByParent.get(route.slug) || []).length > 0
|
|
538
|
+
) {
|
|
539
|
+
throw usageError(
|
|
540
|
+
`Tree collection route "${route.slug}" cannot also define static child routes.`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
239
546
|
function resolveConfiguredRoutes(appConfig, projectDir) {
|
|
240
547
|
const configuredRoutes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
|
|
548
|
+
validateConfiguredRoutes(configuredRoutes);
|
|
241
549
|
const routes = configuredRoutes.map((route) => ({
|
|
242
550
|
...route,
|
|
243
|
-
slug: route.slug
|
|
551
|
+
slug: route.slug,
|
|
244
552
|
export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
|
|
245
553
|
}));
|
|
246
554
|
return routes.length > 0 ? routes : autoDetectRoutes(projectDir);
|
|
@@ -270,16 +578,32 @@ function generateEntryFile(projectDir, routes) {
|
|
|
270
578
|
return entryPath;
|
|
271
579
|
}
|
|
272
580
|
|
|
581
|
+
export async function prepareArtifactBuild(projectDir) {
|
|
582
|
+
validateProjectBoundary(projectDir);
|
|
583
|
+
const appConfig = await loadAppConfig(projectDir);
|
|
584
|
+
const detectedRoutes = resolveConfiguredRoutes(appConfig, projectDir);
|
|
585
|
+
|
|
586
|
+
generateEntryFile(projectDir, detectedRoutes);
|
|
587
|
+
|
|
588
|
+
const manifest = generateManifest(appConfig, projectDir);
|
|
589
|
+
const manifestPath = join(projectDir, MANIFEST_FILE);
|
|
590
|
+
mkdirSync(dirname(manifestPath), { recursive: true });
|
|
591
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
592
|
+
|
|
593
|
+
return { appConfig, manifest, routes: detectedRoutes };
|
|
594
|
+
}
|
|
595
|
+
|
|
273
596
|
/**
|
|
274
597
|
* Generate the manifest from app config and build output.
|
|
275
598
|
*/
|
|
276
|
-
function generateManifest(appConfig, projectDir) {
|
|
599
|
+
export function generateManifest(appConfig, projectDir) {
|
|
277
600
|
const routes = resolveConfiguredRoutes(appConfig, projectDir).map((route) => {
|
|
278
601
|
const entry = {
|
|
279
602
|
path: route.path,
|
|
280
|
-
slug: route.slug
|
|
603
|
+
slug: route.slug,
|
|
281
604
|
name: route.name,
|
|
282
605
|
icon: route.icon || null,
|
|
606
|
+
parentSlug: route.parentSlug || null,
|
|
283
607
|
default: route.default || false,
|
|
284
608
|
export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
|
|
285
609
|
collection: route.collection || null,
|
|
@@ -291,15 +615,52 @@ function generateManifest(appConfig, projectDir) {
|
|
|
291
615
|
});
|
|
292
616
|
|
|
293
617
|
const databases = appConfig.databases || [];
|
|
618
|
+
const categories = normalizeCategories(appConfig.categories || []);
|
|
619
|
+
const metadata = discoverMetadataAssets(projectDir);
|
|
620
|
+
const appSlug = safeKebab(appConfig.name);
|
|
621
|
+
const displayTitle = appConfig.title || appConfig.displayName || appConfig.name;
|
|
622
|
+
const listingMedia = {
|
|
623
|
+
cover: metadata.cover
|
|
624
|
+
? {
|
|
625
|
+
path: metadata.cover.path,
|
|
626
|
+
content_type: metadata.cover.content_type,
|
|
627
|
+
width: metadata.cover.width,
|
|
628
|
+
height: metadata.cover.height,
|
|
629
|
+
bytes: metadata.cover.bytes,
|
|
630
|
+
}
|
|
631
|
+
: null,
|
|
632
|
+
screenshots: metadata.screenshots.map((screenshot) => ({
|
|
633
|
+
path: screenshot.path,
|
|
634
|
+
content_type: screenshot.content_type,
|
|
635
|
+
width: screenshot.width,
|
|
636
|
+
height: screenshot.height,
|
|
637
|
+
bytes: screenshot.bytes,
|
|
638
|
+
})),
|
|
639
|
+
};
|
|
294
640
|
|
|
295
641
|
return {
|
|
296
642
|
version: 1,
|
|
297
|
-
spec_version:
|
|
643
|
+
spec_version: 4,
|
|
298
644
|
app: {
|
|
299
|
-
name:
|
|
645
|
+
name: displayTitle,
|
|
646
|
+
slug: appSlug || null,
|
|
300
647
|
description: appConfig.description || null,
|
|
301
648
|
icon: appConfig.icon || null,
|
|
649
|
+
title: displayTitle,
|
|
650
|
+
tagline: appConfig.tagline || null,
|
|
651
|
+
categories,
|
|
652
|
+
author: appConfig.author || null,
|
|
653
|
+
version_notes: appConfig.versionNotes || appConfig.version_notes || null,
|
|
654
|
+
},
|
|
655
|
+
listing: {
|
|
656
|
+
title: displayTitle,
|
|
657
|
+
tagline: appConfig.tagline || null,
|
|
658
|
+
categories,
|
|
659
|
+
author: appConfig.author || null,
|
|
660
|
+
version_notes: appConfig.versionNotes || appConfig.version_notes || null,
|
|
661
|
+
media: listingMedia,
|
|
302
662
|
},
|
|
663
|
+
metadata: listingMedia,
|
|
303
664
|
routes,
|
|
304
665
|
bundle: {
|
|
305
666
|
js: 'bundle/app.js',
|
|
@@ -314,13 +675,7 @@ function generateManifest(appConfig, projectDir) {
|
|
|
314
675
|
* Build the app bundle: generate entry file, run `vite build`, package into .notis/output/.
|
|
315
676
|
*/
|
|
316
677
|
export async function buildArtifact(projectDir) {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
// Auto-detect or use configured routes
|
|
320
|
-
const detectedRoutes = resolveConfiguredRoutes(appConfig, projectDir);
|
|
321
|
-
|
|
322
|
-
// Generate the entry file that re-exports all route page components
|
|
323
|
-
generateEntryFile(projectDir, detectedRoutes);
|
|
678
|
+
await prepareArtifactBuild(projectDir);
|
|
324
679
|
|
|
325
680
|
// Run Vite build
|
|
326
681
|
await runProjectScript({
|
|
@@ -328,18 +683,19 @@ export async function buildArtifact(projectDir) {
|
|
|
328
683
|
scriptName: 'build',
|
|
329
684
|
});
|
|
330
685
|
|
|
331
|
-
// Verify output
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
686
|
+
// Verify the canonical `.notis/output/bundle` packaging contract.
|
|
687
|
+
const builtBundleDir = resolveBuiltBundleDir(projectDir);
|
|
688
|
+
if (!builtBundleDir) {
|
|
689
|
+
throw usageError(
|
|
690
|
+
'Vite build did not produce app.js in .notis/output/bundle. Check your vite.config.ts.',
|
|
691
|
+
);
|
|
336
692
|
}
|
|
693
|
+
normalizeBundleStylesheets(projectDir);
|
|
694
|
+
copyMetadataAssets(projectDir);
|
|
337
695
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
const
|
|
341
|
-
mkdirSync(dirname(manifestPath), { recursive: true });
|
|
342
|
-
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
696
|
+
validateArtifactBoundary(readArtifactFiles(projectDir));
|
|
697
|
+
|
|
698
|
+
const manifest = readManifest(projectDir);
|
|
343
699
|
|
|
344
700
|
return { manifest, outputDir: join(projectDir, OUTPUT_DIR) };
|
|
345
701
|
}
|
|
@@ -355,6 +711,22 @@ export function readManifest(projectDir) {
|
|
|
355
711
|
return JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
356
712
|
}
|
|
357
713
|
|
|
714
|
+
function copyMetadataAssets(projectDir) {
|
|
715
|
+
const metadataDir = join(projectDir, METADATA_DIR);
|
|
716
|
+
if (!existsSync(metadataDir)) {
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const outputMetadataDir = join(projectDir, OUTPUT_DIR, METADATA_DIR);
|
|
720
|
+
mkdirSync(outputMetadataDir, { recursive: true });
|
|
721
|
+
for (const entry of readdirSync(metadataDir, { withFileTypes: true })) {
|
|
722
|
+
if (!entry.isFile()) continue;
|
|
723
|
+
if (entry.name !== 'cover.png' && !/^screenshot-\d+\.png$/i.test(entry.name)) {
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
cpSync(join(metadataDir, entry.name), join(outputMetadataDir, entry.name));
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
358
730
|
// ---------------------------------------------------------------------------
|
|
359
731
|
// Linking
|
|
360
732
|
// ---------------------------------------------------------------------------
|
|
@@ -385,18 +757,30 @@ export function requireLinkedAppId(projectDir, explicitAppId) {
|
|
|
385
757
|
/**
|
|
386
758
|
* Scaffold a new Notis app project from the SDK template.
|
|
387
759
|
*/
|
|
388
|
-
export function
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
760
|
+
export function loadScaffoldCatalog() {
|
|
761
|
+
if (existsSync(SCAFFOLD_CATALOG_FILE)) {
|
|
762
|
+
const parsed = JSON.parse(readFileSync(SCAFFOLD_CATALOG_FILE, 'utf-8'));
|
|
763
|
+
const scaffolds = Array.isArray(parsed?.scaffolds) ? parsed.scaffolds : parsed;
|
|
764
|
+
return Array.isArray(scaffolds)
|
|
765
|
+
? scaffolds.filter((entry) => entry && typeof entry.slug === 'string')
|
|
766
|
+
: [];
|
|
767
|
+
}
|
|
768
|
+
return loadMonorepoScaffoldCatalog();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export function scaffoldProject({ projectDir, appName, fromSlug = null }) {
|
|
772
|
+
const templateDir = fromSlug ? resolveScaffoldSourceDir(fromSlug) : join(CLI_ROOT, 'template');
|
|
393
773
|
|
|
394
774
|
if (!existsSync(templateDir)) {
|
|
775
|
+
if (fromSlug) {
|
|
776
|
+
const known = loadScaffoldCatalog().map((entry) => entry.slug).join(', ') || 'none';
|
|
777
|
+
throw usageError(`Unknown scaffold "${fromSlug}". Available scaffolds: ${known}.`);
|
|
778
|
+
}
|
|
395
779
|
throw usageError(`SDK template not found at ${templateDir}. Ensure @notis_ai/cli is installed correctly.`);
|
|
396
780
|
}
|
|
397
781
|
|
|
398
782
|
mkdirSync(projectDir, { recursive: true });
|
|
399
|
-
|
|
783
|
+
copyScaffoldSource(templateDir, projectDir);
|
|
400
784
|
|
|
401
785
|
// Update package.json with the app name
|
|
402
786
|
const pkgPath = join(projectDir, 'package.json');
|
|
@@ -410,13 +794,115 @@ export function scaffoldProject({ projectDir, appName }) {
|
|
|
410
794
|
const configPath = join(projectDir, 'notis.config.ts');
|
|
411
795
|
if (existsSync(configPath)) {
|
|
412
796
|
let config = readFileSync(configPath, 'utf-8');
|
|
413
|
-
|
|
797
|
+
const displayName = jsStringLiteral(appName);
|
|
798
|
+
const slugName = jsStringLiteral(safeKebab(appName) || 'my-notis-app');
|
|
799
|
+
if (fromSlug) {
|
|
800
|
+
if (/name\s*:/.test(config)) {
|
|
801
|
+
config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
|
|
802
|
+
}
|
|
803
|
+
if (/title\s*:/.test(config)) {
|
|
804
|
+
config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
|
|
805
|
+
} else {
|
|
806
|
+
config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName},\n title: ${displayName}`);
|
|
807
|
+
}
|
|
808
|
+
} else {
|
|
809
|
+
if (/name\s*:/.test(config)) {
|
|
810
|
+
config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
|
|
811
|
+
}
|
|
812
|
+
if (/title\s*:/.test(config)) {
|
|
813
|
+
config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
|
|
814
|
+
} else {
|
|
815
|
+
config = config.replace(/'My Notis App'/, displayName);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
414
818
|
writeFileSync(configPath, config);
|
|
415
819
|
}
|
|
416
820
|
|
|
417
821
|
return { projectDir };
|
|
418
822
|
}
|
|
419
823
|
|
|
824
|
+
function resolveScaffoldSourceDir(fromSlug) {
|
|
825
|
+
const bundledDir = join(SCAFFOLD_SOURCE_DIR, fromSlug);
|
|
826
|
+
if (existsSync(bundledDir)) {
|
|
827
|
+
return bundledDir;
|
|
828
|
+
}
|
|
829
|
+
const monorepoDir = join(MONOREPO_APPS_DIR, fromSlug);
|
|
830
|
+
if (existsSync(join(monorepoDir, 'notis.config.ts'))) {
|
|
831
|
+
return monorepoDir;
|
|
832
|
+
}
|
|
833
|
+
return bundledDir;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function loadMonorepoScaffoldCatalog() {
|
|
837
|
+
if (!existsSync(MONOREPO_APPS_DIR)) {
|
|
838
|
+
return [];
|
|
839
|
+
}
|
|
840
|
+
const scaffolds = [];
|
|
841
|
+
for (const entry of readdirSync(MONOREPO_APPS_DIR, { withFileTypes: true })) {
|
|
842
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.startsWith('_')) {
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
const configPath = join(MONOREPO_APPS_DIR, entry.name, 'notis.config.ts');
|
|
846
|
+
if (!existsSync(configPath)) {
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
const configSource = readFileSync(configPath, 'utf-8');
|
|
850
|
+
const description = readTsStringProperty(configSource, 'description') || '';
|
|
851
|
+
scaffolds.push({
|
|
852
|
+
slug: entry.name,
|
|
853
|
+
name: readTsStringProperty(configSource, 'title') || readTsStringProperty(configSource, 'name') || entry.name,
|
|
854
|
+
description,
|
|
855
|
+
icon: readTsStringProperty(configSource, 'icon') || 'phosphor:squares-four',
|
|
856
|
+
categories: readTsStringArrayProperty(configSource, 'categories'),
|
|
857
|
+
tagline: readTsStringProperty(configSource, 'tagline') || description,
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
return scaffolds.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function readTsStringProperty(source, propertyName) {
|
|
864
|
+
const match = source.match(new RegExp(`\\b${propertyName}\\s*:\\s*(['"\`])([\\s\\S]*?)\\1`));
|
|
865
|
+
return match ? match[2] : null;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function readTsStringArrayProperty(source, propertyName) {
|
|
869
|
+
const match = source.match(new RegExp(`\\b${propertyName}\\s*:\\s*\\[([\\s\\S]*?)\\]`));
|
|
870
|
+
if (!match) {
|
|
871
|
+
return [];
|
|
872
|
+
}
|
|
873
|
+
return Array.from(match[1].matchAll(/(['"`])([\s\S]*?)\1/g), (entry) => entry[2].trim()).filter(Boolean);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function copyScaffoldSource(sourceDir, targetDir) {
|
|
877
|
+
function shouldCopy(path) {
|
|
878
|
+
const name = path.split(/[\\/]/).pop();
|
|
879
|
+
if (!name) return true;
|
|
880
|
+
return !SCAFFOLD_COPY_EXCLUDES.has(name)
|
|
881
|
+
&& !name.startsWith('.env')
|
|
882
|
+
&& !/\.(test|spec)\.[cm]?[jt]sx?$/i.test(name);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function walk(src, dest) {
|
|
886
|
+
if (!shouldCopy(src)) {
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
const stat = statSync(src);
|
|
890
|
+
if (stat.isDirectory()) {
|
|
891
|
+
mkdirSync(dest, { recursive: true });
|
|
892
|
+
for (const entry of readdirSync(src)) {
|
|
893
|
+
walk(join(src, entry), join(dest, entry));
|
|
894
|
+
}
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (stat.isFile()) {
|
|
898
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
899
|
+
cpSync(src, dest);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
walk(sourceDir, targetDir);
|
|
904
|
+
}
|
|
905
|
+
|
|
420
906
|
// ---------------------------------------------------------------------------
|
|
421
907
|
// Deploy helpers
|
|
422
908
|
// ---------------------------------------------------------------------------
|
|
@@ -430,6 +916,8 @@ export function collectArtifactFiles(projectDir) {
|
|
|
430
916
|
throw usageError('No build output found. Run "notis apps build" first.');
|
|
431
917
|
}
|
|
432
918
|
|
|
919
|
+
validateArtifactBoundary(readArtifactFiles(projectDir));
|
|
920
|
+
|
|
433
921
|
const files = {};
|
|
434
922
|
|
|
435
923
|
function walk(dir, prefix) {
|
|
@@ -449,6 +937,149 @@ export function collectArtifactFiles(projectDir) {
|
|
|
449
937
|
return files;
|
|
450
938
|
}
|
|
451
939
|
|
|
940
|
+
function shouldExcludeSourceEntry(name) {
|
|
941
|
+
return SOURCE_COPY_EXCLUDES.has(name) || name.startsWith('.env');
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function readSourceFiles(projectDir) {
|
|
945
|
+
const files = {};
|
|
946
|
+
|
|
947
|
+
function walk(dir, prefix) {
|
|
948
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
949
|
+
for (const entry of entries) {
|
|
950
|
+
if (shouldExcludeSourceEntry(entry.name) || entry.isSymbolicLink()) {
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
const fullPath = join(dir, entry.name);
|
|
954
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
955
|
+
if (entry.isDirectory()) {
|
|
956
|
+
walk(fullPath, relPath);
|
|
957
|
+
} else if (entry.isFile()) {
|
|
958
|
+
files[relPath] = readFileSync(fullPath);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
walk(projectDir, '');
|
|
964
|
+
return files;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
export function collectSourceFiles(projectDir) {
|
|
968
|
+
const files = readSourceFiles(projectDir);
|
|
969
|
+
const encoded = {};
|
|
970
|
+
for (const [relPath, content] of Object.entries(files)) {
|
|
971
|
+
encoded[relPath] = content.toString('base64');
|
|
972
|
+
}
|
|
973
|
+
return encoded;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function cleanTarPath(name) {
|
|
977
|
+
const cleaned = String(name || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
|
978
|
+
const parts = cleaned.split('/').filter(Boolean);
|
|
979
|
+
if (!parts.length || parts.includes('..')) {
|
|
980
|
+
throw usageError(`Refusing to extract unsafe path from source archive: ${name}`);
|
|
981
|
+
}
|
|
982
|
+
return parts.join('/');
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function extractTarGz(buffer, targetDir) {
|
|
986
|
+
const tar = gunzipSync(buffer);
|
|
987
|
+
let offset = 0;
|
|
988
|
+
while (offset + 512 <= tar.length) {
|
|
989
|
+
const header = tar.subarray(offset, offset + 512);
|
|
990
|
+
offset += 512;
|
|
991
|
+
if (header.every((byte) => byte === 0)) {
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const name = header.subarray(0, 100).toString('utf-8').replace(/\0.*$/, '');
|
|
996
|
+
const prefix = header.subarray(345, 500).toString('utf-8').replace(/\0.*$/, '');
|
|
997
|
+
const fullName = prefix ? `${prefix}/${name}` : name;
|
|
998
|
+
const sizeRaw = header.subarray(124, 136).toString('utf-8').replace(/\0.*$/, '').trim();
|
|
999
|
+
const size = Number.parseInt(sizeRaw || '0', 8);
|
|
1000
|
+
const typeFlag = header[156];
|
|
1001
|
+
const relPath = cleanTarPath(fullName);
|
|
1002
|
+
const outputPath = join(targetDir, relPath);
|
|
1003
|
+
|
|
1004
|
+
if (typeFlag === 53) {
|
|
1005
|
+
mkdirSync(outputPath, { recursive: true });
|
|
1006
|
+
} else {
|
|
1007
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
1008
|
+
writeFileSync(outputPath, tar.subarray(offset, offset + size));
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
offset += Math.ceil(size / 512) * 512;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
export async function pullAppSource({
|
|
1016
|
+
apiBase,
|
|
1017
|
+
jwt,
|
|
1018
|
+
appId,
|
|
1019
|
+
targetDir,
|
|
1020
|
+
version = 'latest',
|
|
1021
|
+
force = false,
|
|
1022
|
+
}) {
|
|
1023
|
+
if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
|
|
1024
|
+
if (!force) {
|
|
1025
|
+
throw usageError(`Target directory is not empty: ${targetDir}. Pass --force to overwrite it.`);
|
|
1026
|
+
}
|
|
1027
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
1028
|
+
}
|
|
1029
|
+
mkdirSync(targetDir, { recursive: true });
|
|
1030
|
+
|
|
1031
|
+
const params = new URLSearchParams({ app_id: appId, version: String(version || 'latest') });
|
|
1032
|
+
const response = await fetch(`${apiBase.replace(/\/$/, '')}/portal_apps/source?${params.toString()}`, {
|
|
1033
|
+
headers: { Authorization: `Bearer ${jwt}` },
|
|
1034
|
+
});
|
|
1035
|
+
if (!response.ok) {
|
|
1036
|
+
const data = await response.json().catch(() => ({}));
|
|
1037
|
+
throw usageError(typeof data?.error === 'string' ? data.error : `Failed to pull app source (${response.status}).`);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const contentDisposition = response.headers.get('content-disposition') || '';
|
|
1041
|
+
const versionMatch = /-v(\d+)\.tar\.gz/i.exec(contentDisposition);
|
|
1042
|
+
const pulledVersion = versionMatch ? Number.parseInt(versionMatch[1], 10) : null;
|
|
1043
|
+
extractTarGz(Buffer.from(await response.arrayBuffer()), targetDir);
|
|
1044
|
+
const linkedVersion = pulledVersion || (
|
|
1045
|
+
String(version || 'latest') === 'latest'
|
|
1046
|
+
? undefined
|
|
1047
|
+
: Number.parseInt(String(version), 10)
|
|
1048
|
+
);
|
|
1049
|
+
writeLinkedState(targetDir, {
|
|
1050
|
+
app_id: appId,
|
|
1051
|
+
...(Number.isFinite(linkedVersion) ? { version: linkedVersion } : {}),
|
|
1052
|
+
linked_at: new Date().toISOString(),
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
return { projectDir: targetDir, version: pulledVersion || version };
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function readArtifactFiles(projectDir) {
|
|
1059
|
+
const outputDir = join(projectDir, OUTPUT_DIR);
|
|
1060
|
+
if (!existsSync(outputDir)) {
|
|
1061
|
+
throw usageError('No build output found. Run "notis apps build" first.');
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const files = {};
|
|
1065
|
+
|
|
1066
|
+
function walk(dir, prefix) {
|
|
1067
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
1068
|
+
for (const entry of entries) {
|
|
1069
|
+
const fullPath = join(dir, entry.name);
|
|
1070
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
1071
|
+
if (entry.isDirectory()) {
|
|
1072
|
+
walk(fullPath, relPath);
|
|
1073
|
+
} else {
|
|
1074
|
+
files[relPath] = readFileSync(fullPath);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
walk(outputDir, '');
|
|
1080
|
+
return files;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
452
1083
|
// ---------------------------------------------------------------------------
|
|
453
1084
|
// Direct deploy (bypasses backend server)
|
|
454
1085
|
// ---------------------------------------------------------------------------
|
|
@@ -525,6 +1156,46 @@ async function uploadToStorage(supabaseUrl, supabaseKey, bucket, storagePath, co
|
|
|
525
1156
|
}
|
|
526
1157
|
}
|
|
527
1158
|
|
|
1159
|
+
async function ensureStorageBucket(supabaseUrl, supabaseKey, bucket, options = {}) {
|
|
1160
|
+
const encodedBucket = encodeURIComponent(bucket);
|
|
1161
|
+
const headers = {
|
|
1162
|
+
'Authorization': `Bearer ${supabaseKey}`,
|
|
1163
|
+
'apikey': supabaseKey,
|
|
1164
|
+
};
|
|
1165
|
+
const inspectResponse = await fetch(`${supabaseUrl}/storage/v1/bucket/${encodedBucket}`, {
|
|
1166
|
+
headers,
|
|
1167
|
+
});
|
|
1168
|
+
if (inspectResponse.ok) {
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
if (inspectResponse.status !== 404) {
|
|
1172
|
+
const text = await inspectResponse.text().catch(() => '');
|
|
1173
|
+
throw new Error(`Failed to inspect storage bucket ${bucket} (${inspectResponse.status}): ${text}`);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const createResponse = await fetch(`${supabaseUrl}/storage/v1/bucket`, {
|
|
1177
|
+
method: 'POST',
|
|
1178
|
+
headers: {
|
|
1179
|
+
...headers,
|
|
1180
|
+
'Content-Type': 'application/json',
|
|
1181
|
+
},
|
|
1182
|
+
body: JSON.stringify({
|
|
1183
|
+
id: bucket,
|
|
1184
|
+
name: bucket,
|
|
1185
|
+
public: Boolean(options.public),
|
|
1186
|
+
}),
|
|
1187
|
+
});
|
|
1188
|
+
if (createResponse.ok) {
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const text = await createResponse.text().catch(() => '');
|
|
1193
|
+
if (createResponse.status === 409 || /already exists|duplicate/i.test(text)) {
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
throw new Error(`Failed to create storage bucket ${bucket} (${createResponse.status}): ${text}`);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
528
1199
|
/**
|
|
529
1200
|
* Get the current app manifest version from the apps table.
|
|
530
1201
|
*/
|
|
@@ -574,6 +1245,9 @@ const CONTENT_TYPE_MAP = {
|
|
|
574
1245
|
'.css': 'text/css',
|
|
575
1246
|
'.json': 'application/json',
|
|
576
1247
|
'.html': 'text/html',
|
|
1248
|
+
'.png': 'image/png',
|
|
1249
|
+
'.jpg': 'image/jpeg',
|
|
1250
|
+
'.jpeg': 'image/jpeg',
|
|
577
1251
|
};
|
|
578
1252
|
|
|
579
1253
|
/**
|
|
@@ -582,14 +1256,18 @@ const CONTENT_TYPE_MAP = {
|
|
|
582
1256
|
export async function directDeploy(projectDir, appId) {
|
|
583
1257
|
const { supabaseUrl, supabaseKey } = resolveSupabaseCredentials();
|
|
584
1258
|
const manifest = readManifest(projectDir);
|
|
1259
|
+
const artifactFiles = readArtifactFiles(projectDir);
|
|
1260
|
+
const sourceFiles = readSourceFiles(projectDir);
|
|
1261
|
+
validateArtifactBoundary(artifactFiles);
|
|
585
1262
|
|
|
586
1263
|
// Get current version and increment
|
|
587
1264
|
const { currentVersion } = await getAppCurrentVersion(supabaseUrl, supabaseKey, appId);
|
|
588
1265
|
const newVersion = currentVersion + 1;
|
|
589
1266
|
|
|
590
1267
|
// Upload all files from the output directory
|
|
591
|
-
const outputDir = join(projectDir, OUTPUT_DIR);
|
|
592
1268
|
const bucket = 'app-code';
|
|
1269
|
+
await ensureStorageBucket(supabaseUrl, supabaseKey, bucket, { public: false });
|
|
1270
|
+
await ensureStorageBucket(supabaseUrl, supabaseKey, 'app-source', { public: false });
|
|
593
1271
|
|
|
594
1272
|
async function uploadDir(dir, prefix) {
|
|
595
1273
|
const entries = readdirSync(dir, { withFileTypes: true });
|
|
@@ -608,7 +1286,14 @@ export async function directDeploy(projectDir, appId) {
|
|
|
608
1286
|
}
|
|
609
1287
|
}
|
|
610
1288
|
|
|
611
|
-
await uploadDir(
|
|
1289
|
+
await uploadDir(join(projectDir, OUTPUT_DIR), '');
|
|
1290
|
+
|
|
1291
|
+
for (const [relPath, content] of Object.entries(sourceFiles)) {
|
|
1292
|
+
const ext = relPath.includes('.') ? `.${relPath.split('.').pop()}` : '';
|
|
1293
|
+
const contentType = CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
|
|
1294
|
+
const storagePath = `${appId}/v${newVersion}/${relPath}`;
|
|
1295
|
+
await uploadToStorage(supabaseUrl, supabaseKey, 'app-source', storagePath, content, contentType);
|
|
1296
|
+
}
|
|
612
1297
|
|
|
613
1298
|
// Update the app record -- include storage_prefix so the portal can resolve bundle URLs
|
|
614
1299
|
const storagePrefix = `${appId}/v${newVersion}/`;
|
|
@@ -617,6 +1302,8 @@ export async function directDeploy(projectDir, appId) {
|
|
|
617
1302
|
version: newVersion,
|
|
618
1303
|
storage_bucket: bucket,
|
|
619
1304
|
storage_prefix: storagePrefix,
|
|
1305
|
+
source_storage_bucket: 'app-source',
|
|
1306
|
+
source_storage_prefix: storagePrefix,
|
|
620
1307
|
};
|
|
621
1308
|
await updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, deployManifest);
|
|
622
1309
|
|