@notis_ai/cli 0.2.0-beta.32.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 (68) hide show
  1. package/README.md +41 -106
  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 +7 -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 +211 -46
  24. package/src/command-specs/helpers.js +0 -60
  25. package/src/command-specs/index.js +0 -6
  26. package/src/command-specs/meta.js +7 -6
  27. package/src/command-specs/tools.js +1 -33
  28. package/src/runtime/app-dev-server.js +30 -2
  29. package/src/runtime/app-platform.js +376 -24
  30. package/src/runtime/profiles.js +2 -2
  31. package/src/runtime/transport.js +2 -2
  32. package/template/.harness/index.html.tmpl +1 -50
  33. package/template/app/page.tsx +41 -6
  34. package/template/metadata/cover.png +0 -0
  35. package/template/metadata/screenshot-1.png +0 -0
  36. package/template/metadata/screenshot-2.png +0 -0
  37. package/template/metadata/screenshot-3.png +0 -0
  38. package/template/notis.config.ts +10 -6
  39. package/template/package.json +2 -2
  40. package/template/packages/{notis-sdk → sdk}/src/components/MultiSelectActionBar.tsx +2 -1
  41. package/template/packages/{notis-sdk → sdk}/src/components/MultiSelectCheckbox.tsx +2 -2
  42. package/template/packages/{notis-sdk → sdk}/src/components/MultiSelectDragOverlay.tsx +2 -2
  43. package/template/packages/{notis-sdk → sdk}/src/config.ts +1 -1
  44. package/template/packages/{notis-sdk → sdk}/src/hooks/useMultiSelect.ts +4 -3
  45. package/template/packages/sdk/src/hooks/useNotis.ts +30 -0
  46. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +3 -3
  47. package/template/packages/{notis-sdk → sdk}/src/hooks/useTool.ts +22 -7
  48. package/template/packages/{notis-sdk → sdk}/src/index.ts +3 -24
  49. package/template/packages/{notis-sdk → sdk}/src/provider.tsx +1 -1
  50. package/template/packages/sdk/src/runtime.ts +80 -0
  51. package/src/command-specs/auth.js +0 -178
  52. package/src/command-specs/db.js +0 -163
  53. package/template/packages/notis-sdk/src/helpers.ts +0 -131
  54. package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
  55. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
  56. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -88
  57. package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
  58. package/template/packages/notis-sdk/src/hooks/useNotis.ts +0 -33
  59. package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -58
  60. package/template/packages/notis-sdk/src/runtime.ts +0 -182
  61. /package/template/packages/{notis-sdk → sdk}/package.json +0 -0
  62. /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
  63. /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
  64. /package/template/packages/{notis-sdk → sdk}/src/hooks/useTopBarSearch.ts +0 -0
  65. /package/template/packages/{notis-sdk → sdk}/src/styles.css +0 -0
  66. /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
  67. /package/template/packages/{notis-sdk → sdk}/src/vite.ts +0 -0
  68. /package/template/packages/{notis-sdk → sdk}/tsconfig.json +0 -0
@@ -171,37 +171,6 @@ async function toolsExecHandler(ctx) {
171
171
 
172
172
  const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
173
173
 
174
- if (ctx.options.watch) {
175
- const intervalSec = Math.max(1, parseInt(ctx.options.watch, 10));
176
- const intervalMs = intervalSec * 1000;
177
- process.on('SIGINT', () => process.exit(0));
178
-
179
- while (true) {
180
- try {
181
- const result = await runToolCommand({
182
- runtime: ctx.runtime,
183
- toolName: 'notis_execute_tool',
184
- arguments_: {
185
- tool_name: ctx.args.toolName,
186
- arguments: args,
187
- ...(toolkits.length ? { toolkits } : {}),
188
- },
189
- mutating: true,
190
- idempotencyKey: nextIdempotencyKey(ctx.globalOptions),
191
- });
192
- ctx.output.emitSuccess({
193
- command: ctx.spec.command_path.join(' '),
194
- data: result.payload,
195
- humanSummary: `[${new Date().toISOString()}] ${ctx.args.toolName}`,
196
- renderHuman: () => JSON.stringify(result.payload, null, 2),
197
- });
198
- } catch (error) {
199
- process.stderr.write(`[${new Date().toISOString()}] Error: ${error.message}\n`);
200
- }
201
- await new Promise((r) => setTimeout(r, intervalMs));
202
- }
203
- }
204
-
205
174
  const result = await runToolCommand({
206
175
  runtime: ctx.runtime,
207
176
  toolName: 'notis_execute_tool',
@@ -359,17 +328,16 @@ export const toolsCommandSpecs = [
359
328
  { flags: '--arguments <json>', description: 'JSON object, @file path, or - for stdin.' },
360
329
  { flags: '--get-schema', description: 'Display the tool parameter schema without executing.' },
361
330
  { flags: '--dry-run', description: 'Validate arguments against the tool schema without executing.' },
362
- { flags: '--watch <seconds>', description: 'Re-execute on an interval and stream results.' },
363
331
  { flags: '--toolkits <csv-or-json>', description: 'Optional toolkit namespace(s) to use when resolving the tool.' },
364
332
  ],
365
333
  },
366
334
  examples: [
367
335
  'notis tools exec notis-default-query --arguments \'{"database_slug":"tasks","query":{}}\'',
336
+ 'notis tools exec notis-default-get_database --arguments \'{"database_slug":"tasks"}\'',
368
337
  'notis tools exec notis-default-query --get-schema',
369
338
  'notis tools exec notis-default-query --dry-run --arguments \'{"database_slug":"tasks","query":{}}\'',
370
339
  'notis tools exec notis-default-query --arguments @query.json',
371
340
  'notis tools exec notis-default-query --arguments - < query.json',
372
- 'notis tools exec notis-default-query --watch 10 --arguments \'{"database_slug":"tasks","query":{}}\'',
373
341
  ],
374
342
  output_schema: 'Returns the raw tool execution payload.',
375
343
  mutates: true,
@@ -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;
@@ -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,33 @@ 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
+ export const NOTIS_APP_CATEGORIES = [
31
+ 'Productivity',
32
+ 'Sales & Marketing',
33
+ 'Operations',
34
+ 'Product & Engineering',
35
+ 'Personal',
36
+ ];
24
37
  const SOURCE_COPY_EXCLUDES = new Set([
25
38
  'node_modules',
26
39
  '.notis',
27
40
  '.git',
28
41
  'dist',
29
- 'package-lock.json',
30
42
  'tsconfig.tsbuildinfo',
31
43
  '.DS_Store',
32
44
  ]);
45
+ const SCAFFOLD_COPY_EXCLUDES = new Set([
46
+ ...SOURCE_COPY_EXCLUDES,
47
+ 'coverage',
48
+ '.next',
49
+ '.turbo',
50
+ ]);
33
51
  let appConfigImportNonce = 0;
34
52
 
35
53
  // ---------------------------------------------------------------------------
@@ -214,9 +232,160 @@ export function detectProjectWarnings(projectDir, appConfig = null) {
214
232
  warnings.push('No database references declared in notis.config.ts.');
215
233
  }
216
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
+
217
244
  return warnings;
218
245
  }
219
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
+
220
389
  // ---------------------------------------------------------------------------
221
390
  // Build
222
391
  // ---------------------------------------------------------------------------
@@ -409,6 +578,21 @@ function generateEntryFile(projectDir, routes) {
409
578
  return entryPath;
410
579
  }
411
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
+
412
596
  /**
413
597
  * Generate the manifest from app config and build output.
414
598
  */
@@ -431,15 +615,52 @@ export function generateManifest(appConfig, projectDir) {
431
615
  });
432
616
 
433
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
+ };
434
640
 
435
641
  return {
436
642
  version: 1,
437
- spec_version: 3,
643
+ spec_version: 4,
438
644
  app: {
439
- name: appConfig.name,
645
+ name: displayTitle,
646
+ slug: appSlug || null,
440
647
  description: appConfig.description || null,
441
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,
442
662
  },
663
+ metadata: listingMedia,
443
664
  routes,
444
665
  bundle: {
445
666
  js: 'bundle/app.js',
@@ -454,14 +675,7 @@ export function generateManifest(appConfig, projectDir) {
454
675
  * Build the app bundle: generate entry file, run `vite build`, package into .notis/output/.
455
676
  */
456
677
  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);
678
+ await prepareArtifactBuild(projectDir);
465
679
 
466
680
  // Run Vite build
467
681
  await runProjectScript({
@@ -477,14 +691,11 @@ export async function buildArtifact(projectDir) {
477
691
  );
478
692
  }
479
693
  normalizeBundleStylesheets(projectDir);
694
+ copyMetadataAssets(projectDir);
480
695
 
481
696
  validateArtifactBoundary(readArtifactFiles(projectDir));
482
697
 
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');
698
+ const manifest = readManifest(projectDir);
488
699
 
489
700
  return { manifest, outputDir: join(projectDir, OUTPUT_DIR) };
490
701
  }
@@ -500,6 +711,22 @@ export function readManifest(projectDir) {
500
711
  return JSON.parse(readFileSync(manifestPath, 'utf-8'));
501
712
  }
502
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
+
503
730
  // ---------------------------------------------------------------------------
504
731
  // Linking
505
732
  // ---------------------------------------------------------------------------
@@ -530,18 +757,30 @@ export function requireLinkedAppId(projectDir, explicitAppId) {
530
757
  /**
531
758
  * Scaffold a new Notis app project from the SDK template.
532
759
  */
533
- export function scaffoldProject({ projectDir, appName }) {
534
- const templateDir = resolve(
535
- dirname(fileURLToPath(import.meta.url)),
536
- '../../template',
537
- );
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');
538
773
 
539
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
+ }
540
779
  throw usageError(`SDK template not found at ${templateDir}. Ensure @notis_ai/cli is installed correctly.`);
541
780
  }
542
781
 
543
782
  mkdirSync(projectDir, { recursive: true });
544
- cpSync(templateDir, projectDir, { recursive: true });
783
+ copyScaffoldSource(templateDir, projectDir);
545
784
 
546
785
  // Update package.json with the app name
547
786
  const pkgPath = join(projectDir, 'package.json');
@@ -555,13 +794,115 @@ export function scaffoldProject({ projectDir, appName }) {
555
794
  const configPath = join(projectDir, 'notis.config.ts');
556
795
  if (existsSync(configPath)) {
557
796
  let config = readFileSync(configPath, 'utf-8');
558
- 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
+ }
559
818
  writeFileSync(configPath, config);
560
819
  }
561
820
 
562
821
  return { projectDir };
563
822
  }
564
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
+
565
906
  // ---------------------------------------------------------------------------
566
907
  // Deploy helpers
567
908
  // ---------------------------------------------------------------------------
@@ -700,8 +1041,14 @@ export async function pullAppSource({
700
1041
  const versionMatch = /-v(\d+)\.tar\.gz/i.exec(contentDisposition);
701
1042
  const pulledVersion = versionMatch ? Number.parseInt(versionMatch[1], 10) : null;
702
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
+ );
703
1049
  writeLinkedState(targetDir, {
704
1050
  app_id: appId,
1051
+ ...(Number.isFinite(linkedVersion) ? { version: linkedVersion } : {}),
705
1052
  linked_at: new Date().toISOString(),
706
1053
  });
707
1054
 
@@ -898,6 +1245,9 @@ const CONTENT_TYPE_MAP = {
898
1245
  '.css': 'text/css',
899
1246
  '.json': 'application/json',
900
1247
  '.html': 'text/html',
1248
+ '.png': 'image/png',
1249
+ '.jpg': 'image/jpeg',
1250
+ '.jpeg': 'image/jpeg',
901
1251
  };
902
1252
 
903
1253
  /**
@@ -952,6 +1302,8 @@ export async function directDeploy(projectDir, appId) {
952
1302
  version: newVersion,
953
1303
  storage_bucket: bucket,
954
1304
  storage_prefix: storagePrefix,
1305
+ source_storage_bucket: 'app-source',
1306
+ source_storage_prefix: storagePrefix,
955
1307
  };
956
1308
  await updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, deployManifest);
957
1309
 
@@ -205,8 +205,8 @@ 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
  });