@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.
Files changed (81) hide show
  1. package/README.md +122 -158
  2. package/dist/scaffolds/notes/app/globals.css +3 -0
  3. package/dist/scaffolds/notes/app/layout.tsx +6 -0
  4. package/dist/scaffolds/notes/app/page.tsx +1465 -0
  5. package/dist/scaffolds/notes/components/ui/badge.tsx +28 -0
  6. package/dist/scaffolds/notes/components/ui/button.tsx +53 -0
  7. package/dist/scaffolds/notes/components/ui/card.tsx +56 -0
  8. package/dist/scaffolds/notes/components.json +20 -0
  9. package/dist/scaffolds/notes/lib/utils.ts +6 -0
  10. package/dist/scaffolds/notes/notis.config.ts +31 -0
  11. package/dist/scaffolds/notes/package.json +31 -0
  12. package/dist/scaffolds/notes/postcss.config.mjs +8 -0
  13. package/dist/scaffolds/notes/tailwind.config.ts +58 -0
  14. package/dist/scaffolds/notes/tsconfig.json +22 -0
  15. package/dist/scaffolds/notes/vite.config.ts +10 -0
  16. package/dist/scaffolds.json +13 -0
  17. package/package.json +12 -2
  18. package/skills/notis-apps/SKILL.md +162 -0
  19. package/skills/notis-apps/cli.md +208 -0
  20. package/skills/notis-cli/SKILL.md +258 -0
  21. package/skills/notis-query/cli.md +40 -0
  22. package/src/cli.js +1 -1
  23. package/src/command-specs/apps.js +1029 -59
  24. package/src/command-specs/helpers.js +6 -41
  25. package/src/command-specs/index.js +1 -7
  26. package/src/command-specs/meta.js +7 -6
  27. package/src/command-specs/tools.js +29 -34
  28. package/src/runtime/agent-browser.js +192 -0
  29. package/src/runtime/app-boundary-validator.js +132 -0
  30. package/src/runtime/app-dev-server.js +605 -0
  31. package/src/runtime/app-dev-sessions.js +87 -0
  32. package/src/runtime/app-platform.js +1037 -82
  33. package/src/runtime/cli-mode.generated.js +4 -0
  34. package/src/runtime/cli-mode.js +29 -0
  35. package/src/runtime/output.js +2 -2
  36. package/src/runtime/ports.js +15 -0
  37. package/src/runtime/profiles.js +36 -5
  38. package/src/runtime/transport.js +131 -6
  39. package/template/.harness/index.html.tmpl +211 -0
  40. package/template/app/globals.css +3 -0
  41. package/template/app/layout.tsx +6 -0
  42. package/template/app/page.tsx +90 -0
  43. package/template/components/ui/badge.tsx +28 -0
  44. package/template/components/ui/button.tsx +53 -0
  45. package/template/components/ui/card.tsx +56 -0
  46. package/template/components.json +20 -0
  47. package/template/lib/utils.ts +6 -0
  48. package/template/metadata/cover.png +0 -0
  49. package/template/metadata/screenshot-1.png +0 -0
  50. package/template/metadata/screenshot-2.png +0 -0
  51. package/template/metadata/screenshot-3.png +0 -0
  52. package/template/notis.config.ts +37 -0
  53. package/template/package.json +31 -0
  54. package/template/packages/sdk/package.json +32 -0
  55. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +273 -0
  56. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  57. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  58. package/template/packages/sdk/src/config.ts +71 -0
  59. package/template/packages/sdk/src/hooks/useBackend.ts +41 -0
  60. package/template/packages/sdk/src/hooks/useDatabase.ts +76 -0
  61. package/template/packages/sdk/src/hooks/useMultiSelect.ts +503 -0
  62. package/template/packages/sdk/src/hooks/useNotis.ts +34 -0
  63. package/template/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  64. package/template/packages/sdk/src/hooks/useTool.ts +64 -0
  65. package/template/packages/sdk/src/hooks/useTools.ts +56 -0
  66. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  67. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +50 -0
  68. package/template/packages/sdk/src/index.ts +54 -0
  69. package/template/packages/sdk/src/provider.tsx +43 -0
  70. package/template/packages/sdk/src/runtime.ts +161 -0
  71. package/template/packages/sdk/src/styles.css +38 -0
  72. package/template/packages/sdk/src/ui.ts +15 -0
  73. package/template/packages/sdk/src/vite.ts +56 -0
  74. package/template/packages/sdk/tsconfig.json +15 -0
  75. package/template/postcss.config.mjs +8 -0
  76. package/template/tailwind.config.ts +58 -0
  77. package/template/tsconfig.json +22 -0
  78. package/template/vite.config.ts +10 -0
  79. package/src/command-specs/auth.js +0 -178
  80. package/src/command-specs/db.js +0 -163
  81. 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
- * Next.js build, and packages the artifact for deployment.
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, cpSync, readdirSync } from 'node:fs';
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 { createHash } from 'node:crypto';
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 SITE_DIR = join(OUTPUT_DIR, 'site');
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 a basic regex to strip types,
38
- * and evaluate. For production use this should use tsx or similar.
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
- // Strip import type annotations and type declarations
62
- const jsSource = source
63
- .replace(/import\s+type\s+{[^}]*}\s+from\s+['"][^'"]*['"]\s*;?/g, '')
64
- .replace(/import\s+{[^}]*}\s+from\s+['"][^'"]*['"]\s*;?/g, '')
65
- .replace(/export\s+default\s+/, 'export default ')
66
- .replace(/defineNotisApp\s*\(/, '(');
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 hasNextConfig = ['next.config.ts', 'next.config.mjs', 'next.config.js']
200
+ const hasViteConfig = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']
99
201
  .some((name) => existsSync(join(projectDir, name)));
100
- if (!hasNextConfig) {
101
- problems.push('Missing next.config file');
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 databases declared in notis.config.ts.');
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 === '/' ? 'index' : routePath.replace(/^\//, '').replace(/\//g, '-');
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
- entry_html: routePath === '/' ? 'index.html' : `${routePath.replace(/^\//, '')}/index.html`,
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 slugFromPath(routePath) {
194
- if (routePath === '/') return 'index';
195
- return routePath.replace(/^\//, '').replace(/\//g, '-');
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
- function entryHtmlFromPath(routePath) {
199
- if (routePath === '/') return 'index.html';
200
- return `${routePath.replace(/^\//, '')}/index.html`;
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.routes || autoDetectRoutes(projectDir)).map((route) => ({
208
- path: route.path,
209
- slug: route.slug || slugFromPath(route.path),
210
- name: route.name,
211
- icon: route.icon || null,
212
- default: route.default || false,
213
- entry_html: route.entry_html || entryHtmlFromPath(route.path),
214
- collection: route.collection || null,
215
- }));
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 = (appConfig.databases || []).map((db) => ({
218
- slug: db.slug,
219
- title: db.title,
220
- description: db.description || null,
221
- icon: db.icon || null,
222
- properties: (db.properties || []).map((prop) => ({
223
- name: prop.name,
224
- type: prop.type,
225
- description: prop.description || null,
226
- options: prop.options
227
- ? prop.options.map((opt) => typeof opt === 'string' ? { name: opt } : opt)
228
- : null,
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: 2,
644
+ spec_version: 4,
235
645
  app: {
236
- name: appConfig.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 artifact: run `next build`, then package into .notis/output/.
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
- const appConfig = await loadAppConfig(projectDir);
679
+ await prepareArtifactBuild(projectDir);
251
680
 
252
- // Run the Next.js build
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
- // Copy the static export into .notis/output/site/
264
- const nextOutDir = join(projectDir, 'out');
265
- if (!existsSync(nextOutDir)) {
266
- throw usageError('Next.js build did not produce an `out/` directory. Ensure `output: "export"` is set (withNotis does this automatically).');
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
- const outputSiteDir = join(projectDir, SITE_DIR);
270
- mkdirSync(outputSiteDir, { recursive: true });
271
- cpSync(nextOutDir, outputSiteDir, { recursive: true });
697
+ validateArtifactBoundary(readArtifactFiles(projectDir));
272
698
 
273
- // Generate manifest
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 scaffoldProject({ projectDir, appName }) {
323
- const templateDir = resolve(
324
- dirname(fileURLToPath(import.meta.url)),
325
- '../../../../packages/notis-sdk/template',
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
- throw usageError(`SDK template not found at ${templateDir}. Ensure @notis/sdk is installed.`);
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
- cpSync(templateDir, projectDir, { recursive: true });
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
- config = config.replace(/'My Notis App'/, `'${appName.replace(/'/g, "\\'")}'`);
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
+ }