@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.
Files changed (79) hide show
  1. package/README.md +74 -112
  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 +8 -3
  18. package/skills/notis-apps/SKILL.md +168 -0
  19. package/skills/notis-apps/cli.md +208 -0
  20. package/skills/notis-cli/SKILL.md +263 -0
  21. package/skills/notis-query/cli.md +40 -0
  22. package/src/cli.js +1 -1
  23. package/src/command-specs/apps.js +962 -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 +725 -38
  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/layout.tsx +1 -2
  41. package/template/app/page.tsx +41 -6
  42. package/template/metadata/cover.png +0 -0
  43. package/template/metadata/screenshot-1.png +0 -0
  44. package/template/metadata/screenshot-2.png +0 -0
  45. package/template/metadata/screenshot-3.png +0 -0
  46. package/template/notis.config.ts +25 -6
  47. package/template/package.json +3 -4
  48. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +273 -0
  49. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  50. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  51. package/template/packages/{notis-sdk → sdk}/src/config.ts +25 -2
  52. package/template/packages/sdk/src/hooks/useMultiSelect.ts +503 -0
  53. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +6 -7
  54. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +4 -4
  55. package/template/packages/{notis-sdk → sdk}/src/hooks/useTool.ts +22 -7
  56. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  57. package/template/packages/sdk/src/index.ts +46 -0
  58. package/template/packages/sdk/src/provider.tsx +43 -0
  59. package/template/packages/sdk/src/runtime.ts +80 -0
  60. package/template/packages/sdk/src/styles.css +38 -0
  61. package/src/command-specs/auth.js +0 -178
  62. package/src/command-specs/db.js +0 -163
  63. package/src/runtime/app-preview-server.js +0 -336
  64. package/template/packages/notis-sdk/src/helpers.ts +0 -131
  65. package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
  66. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
  67. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
  68. package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
  69. package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -58
  70. package/template/packages/notis-sdk/src/index.ts +0 -47
  71. package/template/packages/notis-sdk/src/provider.tsx +0 -44
  72. package/template/packages/notis-sdk/src/runtime.ts +0 -165
  73. package/template/packages/notis-sdk/src/styles.css +0 -123
  74. /package/template/packages/{notis-sdk → sdk}/package.json +0 -0
  75. /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
  76. /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
  77. /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
  78. /package/template/packages/{notis-sdk → sdk}/src/vite.ts +0 -0
  79. /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, cpSync, readdirSync } from 'node:fs';
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(source, {
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 source
55
- .replace(/import\s+type\s+[\s\S]*?from\s+['"][^'"]*['"]\s*;?/g, '')
56
- .replace(/import\s*{[\s\S]*?\bdefineNotisApp\b[\s\S]*?}\s+from\s+['"]@notis\/sdk\/config['"]\s*;?/g, '')
57
- .replace(/defineNotisApp\s*\(/, '(');
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 || slugFromPath(route.path),
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 || slugFromPath(route.path),
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: 3,
643
+ spec_version: 4,
298
644
  app: {
299
- name: appConfig.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
- const appConfig = await loadAppConfig(projectDir);
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 bundleDir = join(projectDir, BUNDLE_DIR);
333
- const jsPath = join(bundleDir, 'app.js');
334
- if (!existsSync(jsPath)) {
335
- throw usageError('Vite build did not produce .notis/output/bundle/app.js. Check your vite.config.ts.');
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
- // Generate manifest
339
- const manifest = generateManifest(appConfig, projectDir);
340
- const manifestPath = join(projectDir, MANIFEST_FILE);
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 scaffoldProject({ projectDir, appName }) {
389
- const templateDir = resolve(
390
- dirname(fileURLToPath(import.meta.url)),
391
- '../../template',
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
- cpSync(templateDir, projectDir, { recursive: true });
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
- config = config.replace(/'My Notis App'/, `'${appName.replace(/'/g, "\\'")}'`);
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(outputDir, '');
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