@notis_ai/cli 0.2.1 → 0.2.3

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 (117) hide show
  1. package/README.md +106 -170
  2. package/config/notis_app_boundary_rules.json +50 -0
  3. package/dist/scaffolds/notis-database/app/globals.css +44 -0
  4. package/dist/scaffolds/notis-database/app/layout.tsx +6 -0
  5. package/dist/scaffolds/notis-database/app/page.tsx +1091 -0
  6. package/dist/scaffolds/notis-database/components/ui/badge.tsx +28 -0
  7. package/dist/scaffolds/notis-database/components/ui/button.tsx +53 -0
  8. package/dist/scaffolds/notis-database/components/ui/card.tsx +56 -0
  9. package/dist/scaffolds/notis-database/components/ui/table.tsx +120 -0
  10. package/dist/scaffolds/notis-database/components.json +20 -0
  11. package/dist/scaffolds/notis-database/index.html +12 -0
  12. package/dist/scaffolds/notis-database/lib/types.ts +134 -0
  13. package/dist/scaffolds/notis-database/lib/utils.ts +6 -0
  14. package/dist/scaffolds/notis-database/metadata/cover.png +0 -0
  15. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  16. package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
  17. package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
  18. package/dist/scaffolds/notis-database/notis.config.ts +27 -0
  19. package/dist/scaffolds/notis-database/package.json +31 -0
  20. package/dist/scaffolds/notis-database/postcss.config.mjs +8 -0
  21. package/dist/scaffolds/notis-database/src/dev-main.tsx +23 -0
  22. package/dist/scaffolds/notis-database/src/mock-runtime.ts +557 -0
  23. package/dist/scaffolds/notis-database/tailwind.config.ts +59 -0
  24. package/dist/scaffolds/notis-database/tsconfig.json +23 -0
  25. package/dist/scaffolds/notis-database/vite.config.ts +22 -0
  26. package/dist/scaffolds/notis-notes/app/globals.css +3 -0
  27. package/dist/scaffolds/notis-notes/app/layout.tsx +6 -0
  28. package/dist/scaffolds/notis-notes/app/page.tsx +2168 -0
  29. package/dist/scaffolds/notis-notes/components/ui/badge.tsx +28 -0
  30. package/dist/scaffolds/notis-notes/components/ui/button.tsx +53 -0
  31. package/dist/scaffolds/notis-notes/components/ui/card.tsx +56 -0
  32. package/dist/scaffolds/notis-notes/components.json +20 -0
  33. package/dist/scaffolds/notis-notes/lib/utils.ts +6 -0
  34. package/dist/scaffolds/notis-notes/metadata/cover.png +0 -0
  35. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  36. package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
  37. package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
  38. package/dist/scaffolds/notis-notes/notis.config.ts +36 -0
  39. package/dist/scaffolds/notis-notes/package.json +31 -0
  40. package/dist/scaffolds/notis-notes/postcss.config.mjs +8 -0
  41. package/dist/scaffolds/notis-notes/tailwind.config.ts +58 -0
  42. package/dist/scaffolds/notis-notes/tsconfig.json +23 -0
  43. package/dist/scaffolds/notis-notes/vite.config.ts +10 -0
  44. package/dist/scaffolds/notis-random/README.md +33 -0
  45. package/dist/scaffolds/notis-random/app/globals.css +11 -0
  46. package/dist/scaffolds/notis-random/app/history/page.tsx +66 -0
  47. package/dist/scaffolds/notis-random/app/layout.tsx +7 -0
  48. package/dist/scaffolds/notis-random/app/page.tsx +222 -0
  49. package/dist/scaffolds/notis-random/index.html +12 -0
  50. package/dist/scaffolds/notis-random/lib/notis-tools.ts +109 -0
  51. package/dist/scaffolds/notis-random/lib/rng.ts +42 -0
  52. package/dist/scaffolds/notis-random/lib/roll-record.ts +102 -0
  53. package/dist/scaffolds/notis-random/lib/utils.ts +25 -0
  54. package/dist/scaffolds/notis-random/metadata/cover.png +0 -0
  55. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  56. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  57. package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
  58. package/dist/scaffolds/notis-random/notis.config.ts +43 -0
  59. package/dist/scaffolds/notis-random/package.json +32 -0
  60. package/dist/scaffolds/notis-random/postcss.config.mjs +6 -0
  61. package/dist/scaffolds/notis-random/src/dev-main.tsx +70 -0
  62. package/dist/scaffolds/notis-random/src/mock-runtime.ts +128 -0
  63. package/dist/scaffolds/notis-random/tailwind.config.ts +43 -0
  64. package/dist/scaffolds/notis-random/tsconfig.json +23 -0
  65. package/dist/scaffolds/notis-random/vite.config.ts +11 -0
  66. package/dist/scaffolds.json +36 -0
  67. package/package.json +8 -3
  68. package/skills/notis-apps/SKILL.md +162 -0
  69. package/skills/notis-apps/cli.md +208 -0
  70. package/skills/notis-cli/SKILL.md +260 -0
  71. package/skills/notis-query/cli.md +39 -0
  72. package/src/cli.js +31 -3
  73. package/src/command-specs/apps.js +248 -56
  74. package/src/command-specs/helpers.js +72 -104
  75. package/src/command-specs/index.js +0 -6
  76. package/src/command-specs/meta.js +14 -13
  77. package/src/command-specs/tools.js +196 -115
  78. package/src/runtime/app-boundary-validator.js +65 -14
  79. package/src/runtime/app-dev-server.js +32 -4
  80. package/src/runtime/app-platform.js +404 -24
  81. package/src/runtime/profiles.js +12 -6
  82. package/src/runtime/transport.js +124 -39
  83. package/template/.harness/index.html.tmpl +1 -50
  84. package/template/app/page.tsx +41 -6
  85. package/template/metadata/cover.png +0 -0
  86. package/template/metadata/screenshot-1.png +0 -0
  87. package/template/metadata/screenshot-2.png +0 -0
  88. package/template/metadata/screenshot-3.png +0 -0
  89. package/template/notis.config.ts +10 -6
  90. package/template/package.json +2 -2
  91. package/template/packages/{notis-sdk → sdk}/package.json +6 -0
  92. package/template/packages/{notis-sdk → sdk}/src/components/MultiSelectActionBar.tsx +2 -1
  93. package/template/packages/{notis-sdk → sdk}/src/components/MultiSelectCheckbox.tsx +2 -2
  94. package/template/packages/{notis-sdk → sdk}/src/components/MultiSelectDragOverlay.tsx +2 -2
  95. package/template/packages/{notis-sdk → sdk}/src/config.ts +1 -1
  96. package/template/packages/{notis-sdk → sdk}/src/hooks/useDatabase.ts +1 -13
  97. package/template/packages/{notis-sdk → sdk}/src/hooks/useMultiSelect.ts +4 -3
  98. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +4 -3
  99. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +3 -3
  100. package/template/packages/{notis-sdk → sdk}/src/hooks/useTool.ts +22 -7
  101. package/template/packages/{notis-sdk → sdk}/src/hooks/useUpsertDocument.ts +0 -8
  102. package/template/packages/{notis-sdk → sdk}/src/index.ts +2 -15
  103. package/template/packages/{notis-sdk → sdk}/src/provider.tsx +1 -1
  104. package/template/packages/{notis-sdk → sdk}/src/runtime.ts +5 -26
  105. package/src/command-specs/auth.js +0 -178
  106. package/src/command-specs/db.js +0 -163
  107. package/template/packages/notis-sdk/src/helpers.ts +0 -131
  108. package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
  109. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
  110. package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
  111. /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
  112. /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
  113. /package/template/packages/{notis-sdk → sdk}/src/hooks/useTopBarSearch.ts +0 -0
  114. /package/template/packages/{notis-sdk → sdk}/src/styles.css +0 -0
  115. /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
  116. /package/template/packages/{notis-sdk → sdk}/src/vite.ts +0 -0
  117. /package/template/packages/{notis-sdk → sdk}/tsconfig.json +0 -0
@@ -23,6 +23,7 @@ import {
23
23
  exportNameFromPath,
24
24
  getBundleDir,
25
25
  loadAppConfig,
26
+ prepareArtifactBuild,
26
27
  readManifest,
27
28
  } from './app-platform.js';
28
29
 
@@ -77,6 +78,14 @@ function scriptJson(value) {
77
78
  .replace(/\u2029/g, '\\u2029');
78
79
  }
79
80
 
81
+ function timingMs(startedAt) {
82
+ return Number(process.hrtime.bigint() - startedAt) / 1_000_000;
83
+ }
84
+
85
+ function formatTimingMs(value) {
86
+ return value.toFixed(value < 10 ? 2 : 1);
87
+ }
88
+
80
89
  function readJsonFile(path) {
81
90
  if (!existsSync(path)) {
82
91
  return null;
@@ -105,8 +114,8 @@ function reactVersionFromPeer(peerRange) {
105
114
  function resolveHarnessReactVersion(projectDir) {
106
115
  const candidates = [
107
116
  join(projectDir, 'node_modules', '@notis', 'sdk', 'package.json'),
108
- join(REPO_ROOT, 'packages', 'notis-sdk', 'package.json'),
109
- join(CLI_ROOT, 'template', 'packages', 'notis-sdk', 'package.json'),
117
+ join(REPO_ROOT, 'packages', 'sdk', 'package.json'),
118
+ join(CLI_ROOT, 'template', 'packages', 'sdk', 'package.json'),
110
119
  ];
111
120
  for (const candidate of candidates) {
112
121
  const pkg = readJsonFile(candidate);
@@ -234,7 +243,7 @@ function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions }
234
243
  /**
235
244
  * Start the dev server for one or more apps.
236
245
  *
237
- * @param {{apps: Array<{slug: string, projectDir: string, appId?: string}>, port: number, watch?: boolean, harness?: { mode?: string, apiBase?: string, jwt?: string }, log?: (m: string) => void, logError?: (m: string) => void}} options
246
+ * @param {{apps: Array<{slug: string, projectDir: string, appId?: string, userId?: string}>, port: number, watch?: boolean, harness?: { mode?: string, apiBase?: string, jwt?: string }, log?: (m: string) => void, logError?: (m: string) => void}} options
238
247
  */
239
248
  export async function startAppDevServer({
240
249
  apps,
@@ -254,6 +263,7 @@ export async function startAppDevServer({
254
263
  slug: app.slug,
255
264
  projectDir: app.projectDir,
256
265
  appId: app.appId || null,
266
+ userId: app.userId || null,
257
267
  canonicalBundleDir: getBundleDir(app.projectDir),
258
268
  bundleDir: null,
259
269
  jsPath: null,
@@ -381,11 +391,21 @@ export async function startAppDevServer({
381
391
  }
382
392
 
383
393
  if (url.pathname === '/healthz') {
394
+ const now = new Date().toISOString();
395
+ const sessions = Array.from(appState.values()).map((state) => ({
396
+ appId: state.appId,
397
+ userId: state.userId,
398
+ devSlug: state.slug,
399
+ bundleBaseUrl: `http://127.0.0.1:${port}/a/${state.slug}`,
400
+ projectDir: state.projectDir,
401
+ lastHeartbeatAt: now,
402
+ status: 'connected',
403
+ }));
384
404
  res.writeHead(200, {
385
405
  ...headers,
386
406
  'Content-Type': 'application/json; charset=utf-8',
387
407
  });
388
- res.end(JSON.stringify({ ok: true, apps: Array.from(appState.keys()) }));
408
+ res.end(JSON.stringify({ ok: true, apps: Array.from(appState.keys()), sessions }));
389
409
  return;
390
410
  }
391
411
 
@@ -454,17 +474,20 @@ export async function startAppDevServer({
454
474
  }
455
475
 
456
476
  if (routed.rest.startsWith('bundle/')) {
477
+ const startedAt = process.hrtime.bigint();
457
478
  updateBundleDir(state, resolveBundleDir(state));
458
479
  const rel = routed.rest.slice('bundle/'.length);
459
480
  if (!state.bundleDir) {
460
481
  res.writeHead(404, headers);
461
482
  res.end('not found');
483
+ log(`[notis apps timing] ${state.slug}: GET bundle/${rel} -> 404 in ${formatTimingMs(timingMs(startedAt))}ms`);
462
484
  return;
463
485
  }
464
486
  const full = safeJoin(state.bundleDir, rel);
465
487
  if (!full || !existsSync(full)) {
466
488
  res.writeHead(404, headers);
467
489
  res.end('not found');
490
+ log(`[notis apps timing] ${state.slug}: GET bundle/${rel} -> 404 in ${formatTimingMs(timingMs(startedAt))}ms`);
468
491
  return;
469
492
  }
470
493
  const ext = extFor(rel);
@@ -480,6 +503,10 @@ export async function startAppDevServer({
480
503
  } else {
481
504
  res.end(content);
482
505
  }
506
+ log(
507
+ `[notis apps timing] ${state.slug}: GET bundle/${rel} -> 200 ` +
508
+ `${content.byteLength}B in ${formatTimingMs(timingMs(startedAt))}ms`,
509
+ );
483
510
  return;
484
511
  }
485
512
 
@@ -527,6 +554,7 @@ export async function startAppDevServer({
527
554
 
528
555
  for (const state of appState.values()) {
529
556
  if (watch) {
557
+ await prepareArtifactBuild(state.projectDir);
530
558
  pollForBundleAndWatch(state);
531
559
 
532
560
  state.buildProcess = spawn('npm', ['run', 'build', '--', '--watch'], {
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { spawn } from 'node:child_process';
10
- import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } 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';
@@ -21,15 +21,34 @@ const STATE_FILE = join(NOTIS_DIR, 'state.json');
21
21
  const OUTPUT_DIR = join(NOTIS_DIR, 'output');
22
22
  const BUNDLE_DIR = join(OUTPUT_DIR, 'bundle');
23
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
+ ];
24
38
  const SOURCE_COPY_EXCLUDES = new Set([
25
39
  'node_modules',
26
40
  '.notis',
27
41
  '.git',
28
42
  'dist',
29
- 'package-lock.json',
30
43
  'tsconfig.tsbuildinfo',
31
44
  '.DS_Store',
32
45
  ]);
46
+ const SCAFFOLD_COPY_EXCLUDES = new Set([
47
+ ...SOURCE_COPY_EXCLUDES,
48
+ 'coverage',
49
+ '.next',
50
+ '.turbo',
51
+ ]);
33
52
  let appConfigImportNonce = 0;
34
53
 
35
54
  // ---------------------------------------------------------------------------
@@ -214,9 +233,160 @@ export function detectProjectWarnings(projectDir, appConfig = null) {
214
233
  warnings.push('No database references declared in notis.config.ts.');
215
234
  }
216
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
+ }
243
+ }
244
+
217
245
  return warnings;
218
246
  }
219
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
+
220
390
  // ---------------------------------------------------------------------------
221
391
  // Build
222
392
  // ---------------------------------------------------------------------------
@@ -409,6 +579,21 @@ function generateEntryFile(projectDir, routes) {
409
579
  return entryPath;
410
580
  }
411
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 };
595
+ }
596
+
412
597
  /**
413
598
  * Generate the manifest from app config and build output.
414
599
  */
@@ -431,15 +616,52 @@ export function generateManifest(appConfig, projectDir) {
431
616
  });
432
617
 
433
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,
639
+ })),
640
+ };
434
641
 
435
642
  return {
436
643
  version: 1,
437
- spec_version: 3,
644
+ spec_version: 4,
438
645
  app: {
439
- name: appConfig.name,
646
+ name: displayTitle,
647
+ slug: appSlug || null,
440
648
  description: appConfig.description || null,
441
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,
442
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,
663
+ },
664
+ metadata: listingMedia,
443
665
  routes,
444
666
  bundle: {
445
667
  js: 'bundle/app.js',
@@ -454,14 +676,7 @@ export function generateManifest(appConfig, projectDir) {
454
676
  * Build the app bundle: generate entry file, run `vite build`, package into .notis/output/.
455
677
  */
456
678
  export async function buildArtifact(projectDir) {
457
- validateProjectBoundary(projectDir);
458
- const appConfig = await loadAppConfig(projectDir);
459
-
460
- // Auto-detect or use configured routes
461
- const detectedRoutes = resolveConfiguredRoutes(appConfig, projectDir);
462
-
463
- // Generate the entry file that re-exports all route page components
464
- generateEntryFile(projectDir, detectedRoutes);
679
+ await prepareArtifactBuild(projectDir);
465
680
 
466
681
  // Run Vite build
467
682
  await runProjectScript({
@@ -477,14 +692,11 @@ export async function buildArtifact(projectDir) {
477
692
  );
478
693
  }
479
694
  normalizeBundleStylesheets(projectDir);
695
+ copyMetadataAssets(projectDir);
480
696
 
481
697
  validateArtifactBoundary(readArtifactFiles(projectDir));
482
698
 
483
- // Generate manifest
484
- const manifest = generateManifest(appConfig, projectDir);
485
- const manifestPath = join(projectDir, MANIFEST_FILE);
486
- mkdirSync(dirname(manifestPath), { recursive: true });
487
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
699
+ const manifest = readManifest(projectDir);
488
700
 
489
701
  return { manifest, outputDir: join(projectDir, OUTPUT_DIR) };
490
702
  }
@@ -500,6 +712,22 @@ export function readManifest(projectDir) {
500
712
  return JSON.parse(readFileSync(manifestPath, 'utf-8'));
501
713
  }
502
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
+
503
731
  // ---------------------------------------------------------------------------
504
732
  // Linking
505
733
  // ---------------------------------------------------------------------------
@@ -530,24 +758,37 @@ export function requireLinkedAppId(projectDir, explicitAppId) {
530
758
  /**
531
759
  * Scaffold a new Notis app project from the SDK template.
532
760
  */
533
- export function scaffoldProject({ projectDir, appName }) {
534
- const templateDir = resolve(
535
- dirname(fileURLToPath(import.meta.url)),
536
- '../../template',
537
- );
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');
538
774
 
539
775
  if (!existsSync(templateDir)) {
776
+ if (fromSlug) {
777
+ const known = loadScaffoldCatalog().map((entry) => entry.slug).join(', ') || 'none';
778
+ throw usageError(`Unknown scaffold "${fromSlug}". Available scaffolds: ${known}.`);
779
+ }
540
780
  throw usageError(`SDK template not found at ${templateDir}. Ensure @notis_ai/cli is installed correctly.`);
541
781
  }
542
782
 
543
783
  mkdirSync(projectDir, { recursive: true });
544
- cpSync(templateDir, projectDir, { recursive: true });
784
+ copyScaffoldSource(templateDir, projectDir);
545
785
 
546
786
  // Update package.json with the app name
547
787
  const pkgPath = join(projectDir, 'package.json');
548
788
  if (existsSync(pkgPath)) {
549
789
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
550
790
  pkg.name = appName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
791
+ ensureScaffoldLocalSdk(projectDir, pkg);
551
792
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
552
793
  }
553
794
 
@@ -555,13 +796,141 @@ export function scaffoldProject({ projectDir, appName }) {
555
796
  const configPath = join(projectDir, 'notis.config.ts');
556
797
  if (existsSync(configPath)) {
557
798
  let config = readFileSync(configPath, 'utf-8');
558
- 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
+ }
559
820
  writeFileSync(configPath, config);
560
821
  }
561
822
 
562
823
  return { projectDir };
563
824
  }
564
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
+
565
934
  // ---------------------------------------------------------------------------
566
935
  // Deploy helpers
567
936
  // ---------------------------------------------------------------------------
@@ -700,8 +1069,14 @@ export async function pullAppSource({
700
1069
  const versionMatch = /-v(\d+)\.tar\.gz/i.exec(contentDisposition);
701
1070
  const pulledVersion = versionMatch ? Number.parseInt(versionMatch[1], 10) : null;
702
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
+ );
703
1077
  writeLinkedState(targetDir, {
704
1078
  app_id: appId,
1079
+ ...(Number.isFinite(linkedVersion) ? { version: linkedVersion } : {}),
705
1080
  linked_at: new Date().toISOString(),
706
1081
  });
707
1082
 
@@ -898,6 +1273,9 @@ const CONTENT_TYPE_MAP = {
898
1273
  '.css': 'text/css',
899
1274
  '.json': 'application/json',
900
1275
  '.html': 'text/html',
1276
+ '.png': 'image/png',
1277
+ '.jpg': 'image/jpeg',
1278
+ '.jpeg': 'image/jpeg',
901
1279
  };
902
1280
 
903
1281
  /**
@@ -952,6 +1330,8 @@ export async function directDeploy(projectDir, appId) {
952
1330
  version: newVersion,
953
1331
  storage_bucket: bucket,
954
1332
  storage_prefix: storagePrefix,
1333
+ source_storage_bucket: 'app-source',
1334
+ source_storage_prefix: storagePrefix,
955
1335
  };
956
1336
  await updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, deployManifest);
957
1337
 
@@ -205,22 +205,28 @@ export function resolveRuntimeProfile(globalOptions = {}, { requireAuth = true }
205
205
  exitCode: EXIT_CODES.auth,
206
206
  hints: [
207
207
  {
208
- command: 'notis auth login --jwt <token>',
209
- reason: 'Configure a token for non-interactive use',
208
+ command: 'Open the Notis desktop app and sign in',
209
+ reason: 'Set NOTIS_JWT or sign in through the Notis desktop app',
210
210
  },
211
211
  ],
212
212
  });
213
213
  }
214
214
 
215
+ // An explicit NOTIS_JWT is a complete credential override: use it verbatim and do
216
+ // NOT attempt a stored-profile dev_portal token refresh (whose refresh_token may be
217
+ // stale or belong to a different session). Without this, a long-lived shell with an
218
+ // expired profile refresh_token fails every command with invalid_grant even though
219
+ // the supplied NOTIS_JWT is valid.
220
+ const usingEnvJwt = Boolean(process.env.NOTIS_JWT);
215
221
  return {
216
222
  config,
217
223
  profileName,
218
224
  apiBase,
219
225
  jwt,
220
- authMode: profile.auth_mode,
221
- refreshToken: profile.refresh_token,
222
- accessExpiresAt: profile.access_expires_at,
223
- refreshExpiresAt: profile.refresh_expires_at,
226
+ authMode: usingEnvJwt ? 'jwt' : profile.auth_mode,
227
+ refreshToken: usingEnvJwt ? undefined : profile.refresh_token,
228
+ accessExpiresAt: usingEnvJwt ? undefined : profile.access_expires_at,
229
+ refreshExpiresAt: usingEnvJwt ? undefined : profile.refresh_expires_at,
224
230
  agentMode,
225
231
  nonInteractive,
226
232
  outputMode,