@notis_ai/cli 0.2.4 → 0.2.6

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 (119) hide show
  1. package/README.md +47 -5
  2. package/dist/scaffolds/notis-database/CHANGELOG.md +5 -0
  3. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  4. package/dist/scaffolds/notis-database/notis.config.ts +0 -1
  5. package/dist/scaffolds/notis-database/src/mock-runtime.ts +1 -0
  6. package/dist/scaffolds/notis-journal/CHANGELOG.md +25 -0
  7. package/dist/scaffolds/notis-journal/app/globals.css +37 -0
  8. package/dist/scaffolds/notis-journal/app/insights/page.tsx +513 -0
  9. package/dist/scaffolds/notis-journal/app/journal-core.tsx +362 -0
  10. package/dist/scaffolds/notis-journal/app/journal-ui.tsx +337 -0
  11. package/dist/scaffolds/notis-journal/app/layout.tsx +6 -0
  12. package/dist/scaffolds/notis-journal/app/page.tsx +485 -0
  13. package/dist/scaffolds/notis-journal/components/ui/badge.tsx +28 -0
  14. package/dist/scaffolds/notis-journal/components/ui/button.tsx +53 -0
  15. package/dist/scaffolds/notis-journal/components/ui/card.tsx +56 -0
  16. package/dist/scaffolds/notis-journal/components.json +20 -0
  17. package/dist/scaffolds/notis-journal/index.html +12 -0
  18. package/dist/scaffolds/notis-journal/lib/utils.ts +6 -0
  19. package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
  20. package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
  21. package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
  22. package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
  23. package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
  24. package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
  25. package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +132 -0
  26. package/dist/scaffolds/notis-journal/notis.config.ts +93 -0
  27. package/dist/scaffolds/notis-journal/package.json +34 -0
  28. package/dist/scaffolds/notis-journal/packages/sdk/package.json +36 -0
  29. package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  30. package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +60 -0
  31. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
  32. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  33. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  34. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +145 -0
  35. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +229 -0
  36. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +41 -0
  37. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  38. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +78 -0
  39. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +121 -0
  40. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
  41. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +34 -0
  42. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  43. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +64 -0
  44. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +56 -0
  45. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  46. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  47. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +83 -0
  48. package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +43 -0
  49. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +220 -0
  50. package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +186 -0
  51. package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +15 -0
  52. package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +56 -0
  53. package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +15 -0
  54. package/dist/scaffolds/notis-journal/postcss.config.mjs +8 -0
  55. package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +120 -0
  56. package/dist/scaffolds/notis-journal/src/dev-main.tsx +58 -0
  57. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +197 -0
  58. package/dist/scaffolds/notis-journal/tailwind.config.ts +58 -0
  59. package/dist/scaffolds/notis-journal/tsconfig.json +23 -0
  60. package/dist/scaffolds/notis-journal/vite.config.ts +10 -0
  61. package/dist/scaffolds/notis-notes/CHANGELOG.md +5 -0
  62. package/dist/scaffolds/notis-notes/app/page.tsx +325 -543
  63. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  64. package/dist/scaffolds/notis-notes/notis.config.ts +0 -1
  65. package/dist/scaffolds/notis-random/CHANGELOG.md +5 -0
  66. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  67. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  68. package/dist/scaffolds/notis-random/notis.config.ts +0 -1
  69. package/dist/scaffolds/notis-random/src/mock-runtime.ts +1 -0
  70. package/dist/scaffolds.json +11 -0
  71. package/package.json +3 -3
  72. package/skills/notis-apps/SKILL.md +64 -6
  73. package/skills/notis-apps/cli.md +38 -2
  74. package/skills/notis-cli/SKILL.md +20 -2
  75. package/skills/notis-query/cli.md +1 -1
  76. package/src/command-specs/apps.js +529 -16
  77. package/src/command-specs/index.js +1 -1
  78. package/src/command-specs/meta.js +8 -2
  79. package/src/command-specs/tools.js +50 -37
  80. package/src/runtime/agent-browser.js +273 -1
  81. package/src/runtime/app-changelog.js +79 -0
  82. package/src/runtime/app-dev-server.js +113 -6
  83. package/src/runtime/app-dev-sessions.js +176 -12
  84. package/src/runtime/app-platform.js +200 -39
  85. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  86. package/src/runtime/desktop-auth.js +93 -0
  87. package/src/runtime/profiles.js +37 -15
  88. package/src/runtime/store-screenshot.js +138 -0
  89. package/src/runtime/transport.js +21 -82
  90. package/template/.harness/index.html.tmpl +128 -6
  91. package/template/CHANGELOG.md +5 -0
  92. package/template/app/page.tsx +11 -41
  93. package/template/notis.config.ts +0 -1
  94. package/template/package-lock.json +4137 -0
  95. package/template/package.json +1 -0
  96. package/template/packages/sdk/package.json +4 -0
  97. package/template/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  98. package/template/packages/sdk/src/components/Markdown.tsx +60 -0
  99. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +7 -2
  100. package/template/packages/sdk/src/config.ts +74 -0
  101. package/template/packages/sdk/src/documents.ts +229 -0
  102. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  103. package/template/packages/sdk/src/hooks/useDocument.ts +78 -0
  104. package/template/packages/sdk/src/hooks/useDocuments.ts +121 -0
  105. package/template/packages/sdk/src/hooks/useMultiSelect.ts +38 -2
  106. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +54 -9
  107. package/template/packages/sdk/src/index.ts +30 -1
  108. package/template/packages/sdk/src/runtime.ts +76 -17
  109. package/template/packages/sdk/src/styles.css +148 -0
  110. package/dist/scaffolds/notis-database/metadata/cover.png +0 -0
  111. package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
  112. package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
  113. package/dist/scaffolds/notis-notes/metadata/cover.png +0 -0
  114. package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
  115. package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
  116. package/dist/scaffolds/notis-random/metadata/cover.png +0 -0
  117. package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
  118. package/template/metadata/cover.png +0 -0
  119. package/template/packages/sdk/src/hooks/useDatabase.ts +0 -76
@@ -9,12 +9,13 @@
9
9
  import { spawn } from 'node:child_process';
10
10
  import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync, statSync } from 'node:fs';
11
11
  import { createRequire } from 'node:module';
12
- import { dirname, join, resolve } from 'node:path';
12
+ import { dirname, join, relative, resolve } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { gunzipSync } from 'node:zlib';
15
15
 
16
16
  import { usageError } from './errors.js';
17
17
  import { validateArtifactBoundary, validateProjectBoundary } from './app-boundary-validator.js';
18
+ import { readAppChangelog } from './app-changelog.js';
18
19
 
19
20
  const NOTIS_DIR = '.notis';
20
21
  const STATE_FILE = join(NOTIS_DIR, 'state.json');
@@ -35,6 +36,7 @@ export const NOTIS_APP_CATEGORIES = [
35
36
  'Product & Engineering',
36
37
  'Personal',
37
38
  ];
39
+ export const MIN_LISTING_SCREENSHOTS = 3;
38
40
  const SOURCE_COPY_EXCLUDES = new Set([
39
41
  'node_modules',
40
42
  '.notis',
@@ -236,7 +238,7 @@ export function detectProjectWarnings(projectDir, appConfig = null) {
236
238
  if (appConfig) {
237
239
  try {
238
240
  const listing = inspectListingReadiness(projectDir, appConfig);
239
- warnings.push(...listing.warnings);
241
+ warnings.push(...listing.errors, ...listing.warnings);
240
242
  } catch (error) {
241
243
  warnings.push(error instanceof Error ? error.message : String(error));
242
244
  }
@@ -291,11 +293,10 @@ function pngDimensions(buffer) {
291
293
  };
292
294
  }
293
295
 
294
- function imageAssetMeta(projectDir, relPath, { maxBytes, recommendedRatio = 1.6 }) {
296
+ function imageAssetMeta(projectDir, relPath, { maxBytes }) {
295
297
  const fullPath = join(projectDir, relPath);
296
298
  const content = readFileSync(fullPath);
297
299
  const dimensions = pngDimensions(content);
298
- const ratio = dimensions?.height ? dimensions.width / dimensions.height : null;
299
300
  const errors = [];
300
301
  const warnings = [];
301
302
 
@@ -305,10 +306,10 @@ function imageAssetMeta(projectDir, relPath, { maxBytes, recommendedRatio = 1.6
305
306
  if (content.length > maxBytes) {
306
307
  errors.push(`${relPath} must be ${Math.round(maxBytes / 1024 / 1024)} MB or smaller.`);
307
308
  }
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.`);
309
+ if (!dimensions) {
310
+ errors.push(`${relPath} is not a valid PNG file.`);
311
+ } else if (dimensions.width !== 2000 || dimensions.height !== 1250) {
312
+ errors.push(`${relPath} must be exactly 2000x1250 pixels.`);
312
313
  }
313
314
 
314
315
  return {
@@ -324,10 +325,6 @@ function imageAssetMeta(projectDir, relPath, { maxBytes, recommendedRatio = 1.6
324
325
 
325
326
  export function discoverMetadataAssets(projectDir) {
326
327
  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
328
  const screenshots = [];
332
329
 
333
330
  if (existsSync(metadataDir)) {
@@ -346,44 +343,139 @@ export function discoverMetadataAssets(projectDir) {
346
343
 
347
344
  screenshots.sort((a, b) => a.index - b.index);
348
345
  return {
349
- cover,
350
346
  screenshots: screenshots.slice(0, 6),
351
347
  };
352
348
  }
353
349
 
350
+ function normalizeConfiguredScreenshots(appConfig = null) {
351
+ if (!Array.isArray(appConfig?.screenshots) || appConfig.screenshots.length === 0) {
352
+ return null;
353
+ }
354
+ if (appConfig.screenshots.length > 6) {
355
+ throw usageError('Configure at most six listing screenshots.');
356
+ }
357
+
358
+ const seenPaths = new Set();
359
+ return appConfig.screenshots.map((entry, index) => {
360
+ const path = typeof entry?.path === 'string' ? entry.path.trim().replace(/\\/g, '/') : '';
361
+ const alt = typeof entry?.alt === 'string' ? entry.alt.trim() : '';
362
+ const route = typeof entry?.route === 'string' ? entry.route.trim() : '';
363
+ const scenario = typeof entry?.scenario === 'string' ? entry.scenario.trim() : '';
364
+ const focus = typeof entry?.focus === 'string' ? entry.focus.trim() : '';
365
+ const theme = typeof entry?.theme === 'string' ? entry.theme.trim().toLowerCase() : '';
366
+ if (!/^metadata\/screenshot-\d+\.png$/i.test(path)) {
367
+ throw usageError(`screenshots[${index}].path must match metadata/screenshot-N.png.`);
368
+ }
369
+ const expectedPath = `metadata/screenshot-${index + 1}.png`;
370
+ if (path !== expectedPath) {
371
+ throw usageError(`screenshots[${index}].path must be ${expectedPath} so capture order stays stable.`);
372
+ }
373
+ if (seenPaths.has(path)) {
374
+ throw usageError(`Duplicate listing screenshot path: ${path}`);
375
+ }
376
+ if (theme && theme !== 'light' && theme !== 'dark') {
377
+ throw usageError(`screenshots[${index}].theme must be light or dark.`);
378
+ }
379
+ seenPaths.add(path);
380
+ return {
381
+ path,
382
+ alt,
383
+ route: route || null,
384
+ scenario: scenario || null,
385
+ focus: focus || null,
386
+ theme: theme || 'light',
387
+ };
388
+ });
389
+ }
390
+
391
+ /** Resolve listing screenshot files in their configured editorial order. */
392
+ export function resolveListingScreenshots(projectDir, appConfig = null) {
393
+ const discovered = discoverMetadataAssets(projectDir).screenshots;
394
+ const configured = normalizeConfiguredScreenshots(appConfig);
395
+ if (!configured) {
396
+ return discovered.map((asset) => ({
397
+ ...asset,
398
+ alt: null,
399
+ route: null,
400
+ scenario: null,
401
+ focus: null,
402
+ theme: 'light',
403
+ }));
404
+ }
405
+
406
+ const byPath = new Map(discovered.map((asset) => [asset.path.replace(/\\/g, '/'), asset]));
407
+ return configured.map((entry) => {
408
+ const asset = byPath.get(entry.path);
409
+ if (!asset) {
410
+ return {
411
+ ...entry,
412
+ content_type: 'image/png',
413
+ bytes: null,
414
+ width: null,
415
+ height: null,
416
+ errors: [`${entry.path} is configured but the file does not exist.`],
417
+ warnings: [],
418
+ };
419
+ }
420
+ return { ...asset, ...entry };
421
+ });
422
+ }
423
+
354
424
  export function inspectListingReadiness(projectDir, appConfig = null) {
355
425
  const config = appConfig || {};
356
426
  const warnings = [];
357
427
  const errors = [];
358
- const metadata = discoverMetadataAssets(projectDir);
428
+ const screenshots = resolveListingScreenshots(projectDir, config);
429
+ const metadata = { screenshots };
359
430
  const categories = normalizeCategories(config.categories || []);
431
+ const changelog = readAppChangelog(projectDir);
432
+ errors.push(...changelog.errors);
433
+
434
+ const packagePath = join(projectDir, 'package.json');
435
+ if (!existsSync(packagePath)) {
436
+ errors.push('package.json is required for Store publication.');
437
+ } else {
438
+ try {
439
+ const packageJson = JSON.parse(readFileSync(packagePath, 'utf-8'));
440
+ const notisAppVersion = String(packageJson.notisAppVersion || '').trim();
441
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(notisAppVersion)) {
442
+ errors.push('package.json must include a semver `notisAppVersion` (for example, "0.1.0").');
443
+ }
444
+ } catch {
445
+ errors.push('package.json must contain valid JSON for Store publication.');
446
+ }
447
+ }
360
448
 
361
449
  if (!String(config.tagline || '').trim()) {
362
- warnings.push('Listing tagline missing in notis.config.ts.');
450
+ errors.push('Listing tagline missing in notis.config.ts.');
363
451
  }
364
452
  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);
453
+ errors.push(`Listing category missing in notis.config.ts. Use one of: ${NOTIS_APP_CATEGORIES.join(', ')}.`);
372
454
  }
373
- if (metadata.screenshots.length < 3) {
374
- warnings.push(`Listing screenshots: ${metadata.screenshots.length}/3 minimum in metadata/screenshot-N.png.`);
455
+ if (metadata.screenshots.length < MIN_LISTING_SCREENSHOTS) {
456
+ errors.push(`Listing screenshots: ${metadata.screenshots.length}/${MIN_LISTING_SCREENSHOTS} minimum in metadata/screenshot-N.png. Run \`notis apps screenshot\` to generate them.`);
375
457
  }
376
458
  for (const screenshot of metadata.screenshots) {
377
459
  errors.push(...screenshot.errors);
378
460
  warnings.push(...screenshot.warnings);
461
+ if (!screenshot.alt) {
462
+ errors.push(`${screenshot.path} is missing descriptive alt text in notis.config.ts -> screenshots.`);
463
+ }
379
464
  }
380
465
 
381
466
  return {
382
- ready: errors.length === 0 && Boolean(config.tagline) && categories.length > 0 && Boolean(metadata.cover) && metadata.screenshots.length >= 1,
467
+ ready:
468
+ errors.length === 0 &&
469
+ Boolean(String(config.tagline || '').trim()) &&
470
+ categories.length > 0 &&
471
+ metadata.screenshots.length >= MIN_LISTING_SCREENSHOTS &&
472
+ metadata.screenshots.every((screenshot) => Boolean(screenshot.alt)) &&
473
+ changelog.entries.length > 0,
383
474
  warnings,
384
475
  errors,
385
476
  metadata,
386
477
  categories,
478
+ changelog,
387
479
  };
388
480
  }
389
481
 
@@ -617,27 +709,43 @@ export function generateManifest(appConfig, projectDir) {
617
709
 
618
710
  const databases = appConfig.databases || [];
619
711
  const categories = normalizeCategories(appConfig.categories || []);
620
- const metadata = discoverMetadataAssets(projectDir);
712
+ const metadata = { screenshots: resolveListingScreenshots(projectDir, appConfig) };
621
713
  const appSlug = safeKebab(appConfig.name);
622
714
  const displayTitle = appConfig.title || appConfig.displayName || appConfig.name;
715
+ const skills = (Array.isArray(appConfig.skills) ? appConfig.skills : []).map((skill) => ({
716
+ key: skill.key,
717
+ path: String(skill.path || '').replace(/^\.\/+/, ''),
718
+ name: skill.name,
719
+ description: skill.description || null,
720
+ }));
623
721
  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
722
  screenshots: metadata.screenshots.map((screenshot) => ({
634
723
  path: screenshot.path,
635
724
  content_type: screenshot.content_type,
636
725
  width: screenshot.width,
637
726
  height: screenshot.height,
638
727
  bytes: screenshot.bytes,
728
+ alt: screenshot.alt || null,
729
+ theme: screenshot.theme || 'light',
639
730
  })),
640
731
  };
732
+ const changelog = readAppChangelog(projectDir);
733
+ if (changelog.exists && changelog.errors.length > 0) {
734
+ throw usageError(changelog.errors.join('\n'));
735
+ }
736
+ const latestChangelogEntry = changelog.entries[0] || null;
737
+ const legacyVersionNotes = latestChangelogEntry?.body
738
+ || appConfig.versionNotes
739
+ || appConfig.version_notes
740
+ || null;
741
+ let releaseVersion = null;
742
+ try {
743
+ const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
744
+ releaseVersion = String(packageJson.notisAppVersion || '').trim() || null;
745
+ } catch {
746
+ // Store readiness reports missing or malformed package metadata. Ordinary
747
+ // development builds remain usable without a Store release version.
748
+ }
641
749
 
642
750
  return {
643
751
  version: 1,
@@ -647,18 +755,28 @@ export function generateManifest(appConfig, projectDir) {
647
755
  slug: appSlug || null,
648
756
  description: appConfig.description || null,
649
757
  icon: appConfig.icon || null,
758
+ accent: appConfig.accent || null,
650
759
  title: displayTitle,
651
760
  tagline: appConfig.tagline || null,
652
761
  categories,
653
762
  author: appConfig.author || null,
654
- version_notes: appConfig.versionNotes || appConfig.version_notes || null,
763
+ release_version: releaseVersion,
764
+ version_notes: legacyVersionNotes,
655
765
  },
656
766
  listing: {
657
767
  title: displayTitle,
658
768
  tagline: appConfig.tagline || null,
659
769
  categories,
660
770
  author: appConfig.author || null,
661
- version_notes: appConfig.versionNotes || appConfig.version_notes || null,
771
+ version_notes: legacyVersionNotes,
772
+ ...(changelog.exists
773
+ ? {
774
+ changelog: {
775
+ source_path: changelog.source_path,
776
+ entries: changelog.entries,
777
+ },
778
+ }
779
+ : {}),
662
780
  media: listingMedia,
663
781
  },
664
782
  metadata: listingMedia,
@@ -669,9 +787,47 @@ export function generateManifest(appConfig, projectDir) {
669
787
  },
670
788
  databases,
671
789
  tools: appConfig.tools || [],
790
+ skills,
791
+ onboarding: appConfig.onboarding || null,
672
792
  };
673
793
  }
674
794
 
795
+ export function resolveConfiguredAppSkills(appConfig, projectDir) {
796
+ const configured = Array.isArray(appConfig.skills) ? appConfig.skills : [];
797
+ const projectRoot = resolve(projectDir);
798
+ const seenKeys = new Set();
799
+
800
+ return configured.map((skill, index) => {
801
+ const key = typeof skill?.key === 'string' ? skill.key.trim() : '';
802
+ const sourcePath = typeof skill?.path === 'string' ? skill.path.trim() : '';
803
+ const name = typeof skill?.name === 'string' ? skill.name.trim() : '';
804
+ if (!key || !sourcePath || !name) {
805
+ throw usageError(`skills[${index}] must define non-empty key, path, and name values.`);
806
+ }
807
+ if (seenKeys.has(key)) {
808
+ throw usageError(`Duplicate app skill key: ${key}`);
809
+ }
810
+ seenKeys.add(key);
811
+
812
+ const absolutePath = resolve(projectRoot, sourcePath);
813
+ const relativePath = relative(projectRoot, absolutePath).replace(/\\/g, '/');
814
+ if (!relativePath || relativePath.startsWith('../') || relativePath === '..') {
815
+ throw usageError(`App skill path must stay inside the project: ${sourcePath}`);
816
+ }
817
+ if (!existsSync(absolutePath) || !statSync(absolutePath).isFile()) {
818
+ throw usageError(`App skill entrypoint not found: ${sourcePath}`);
819
+ }
820
+
821
+ return {
822
+ key,
823
+ path: relativePath,
824
+ name,
825
+ description: typeof skill.description === 'string' ? skill.description.trim() : null,
826
+ skill_md: readFileSync(absolutePath, 'utf8'),
827
+ };
828
+ });
829
+ }
830
+
675
831
  /**
676
832
  * Build the app bundle: generate entry file, run `vite build`, package into .notis/output/.
677
833
  */
@@ -714,14 +870,18 @@ export function readManifest(projectDir) {
714
870
 
715
871
  function copyMetadataAssets(projectDir) {
716
872
  const metadataDir = join(projectDir, METADATA_DIR);
873
+ const outputMetadataDir = join(projectDir, OUTPUT_DIR, METADATA_DIR);
874
+ // Vite clears the bundle directory, not sibling listing media. Always make
875
+ // the packaged metadata an exact reflection of the source tree so removed
876
+ // placeholders can never survive into a later deploy.
877
+ rmSync(outputMetadataDir, { recursive: true, force: true });
717
878
  if (!existsSync(metadataDir)) {
718
879
  return;
719
880
  }
720
- const outputMetadataDir = join(projectDir, OUTPUT_DIR, METADATA_DIR);
721
881
  mkdirSync(outputMetadataDir, { recursive: true });
722
882
  for (const entry of readdirSync(metadataDir, { withFileTypes: true })) {
723
883
  if (!entry.isFile()) continue;
724
- if (entry.name !== 'cover.png' && !/^screenshot-\d+\.png$/i.test(entry.name)) {
884
+ if (!/^screenshot-\d+\.png$/i.test(entry.name)) {
725
885
  continue;
726
886
  }
727
887
  cpSync(join(metadataDir, entry.name), join(outputMetadataDir, entry.name));
@@ -1259,6 +1419,7 @@ async function updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, man
1259
1419
  },
1260
1420
  body: JSON.stringify({
1261
1421
  manifest: { ...manifest, version: newVersion },
1422
+ accent: manifest?.app?.accent ?? null,
1262
1423
  updated_at: new Date().toISOString(),
1263
1424
  }),
1264
1425
  });
@@ -0,0 +1,93 @@
1
+ import { CliError, EXIT_CODES } from './errors.js';
2
+
3
+ function isPidRunning(pid) {
4
+ if (!Number.isInteger(pid) || pid <= 0) {
5
+ return false;
6
+ }
7
+ try {
8
+ process.kill(pid, 0);
9
+ return true;
10
+ } catch (error) {
11
+ return error?.code === 'EPERM';
12
+ }
13
+ }
14
+
15
+ function quoteShellArgument(value) {
16
+ return `'${String(value).replace(/'/g, `'"'"'`)}'`;
17
+ }
18
+
19
+ function defaultDesktopAppName(apiBase) {
20
+ try {
21
+ return new URL(apiBase).hostname === 'api-beta.notis.ai' ? 'Notis Beta' : 'Notis';
22
+ } catch {
23
+ return 'Notis';
24
+ }
25
+ }
26
+
27
+ export function getDesktopAuthRecovery(runtime) {
28
+ const desktopRunning = isPidRunning(runtime.desktopPid);
29
+ const appName = runtime.desktopAppName || defaultDesktopAppName(runtime.apiBase);
30
+ let command = 'Start the Notis desktop app';
31
+ if (process.platform === 'darwin') {
32
+ command = `open -a ${quoteShellArgument(appName)}`;
33
+ }
34
+
35
+ return {
36
+ desktopRunning,
37
+ appName,
38
+ hints: [
39
+ {
40
+ command,
41
+ reason: desktopRunning
42
+ ? `Bring ${appName} forward so it can renew CLI authentication, then retry`
43
+ : `Start ${appName} to renew expired CLI authentication, then retry`,
44
+ },
45
+ {
46
+ command: 'notis doctor',
47
+ reason: 'Retry the auth and API checks after the desktop app is ready',
48
+ },
49
+ ],
50
+ };
51
+ }
52
+
53
+ export function createExpiredAuthError(runtime) {
54
+ if (runtime.credentialSource === 'env') {
55
+ return new CliError({
56
+ code: 'auth_expired',
57
+ message: 'NOTIS_JWT is expired',
58
+ exitCode: EXIT_CODES.auth,
59
+ details: { credential_source: 'env' },
60
+ hints: [
61
+ {
62
+ command: 'Set NOTIS_JWT to a fresh token',
63
+ reason: 'The explicit environment credential overrides desktop-managed auth',
64
+ },
65
+ ],
66
+ });
67
+ }
68
+
69
+ const recovery = getDesktopAuthRecovery(runtime);
70
+ return new CliError({
71
+ code: 'auth_expired',
72
+ message: 'Notis CLI authentication has expired',
73
+ exitCode: EXIT_CODES.auth,
74
+ details: {
75
+ credential_source: 'desktop',
76
+ desktop_running: recovery.desktopRunning,
77
+ desktop_app_name: recovery.appName,
78
+ },
79
+ hints: recovery.hints,
80
+ });
81
+ }
82
+
83
+ export function createInvalidAuthHints(runtime) {
84
+ if (runtime?.credentialSource === 'env') {
85
+ return [
86
+ {
87
+ command: 'Set NOTIS_JWT to a fresh token',
88
+ reason: 'The explicit environment credential was rejected',
89
+ },
90
+ ];
91
+ }
92
+ return getDesktopAuthRecovery(runtime || {}).hints;
93
+ }
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { CliError, EXIT_CODES } from './errors.js';
5
+ import { getDesktopAuthRecovery } from './desktop-auth.js';
5
6
 
6
7
  export const CONFIG_DIR = join(homedir(), '.notis');
7
8
  export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
@@ -37,6 +38,9 @@ export function normalizeConfig(rawConfig = {}) {
37
38
  typeof profile.access_expires_at === 'number' ? profile.access_expires_at : undefined,
38
39
  refresh_expires_at:
39
40
  typeof profile.refresh_expires_at === 'number' ? profile.refresh_expires_at : undefined,
41
+ desktop_app_name:
42
+ typeof profile.desktop_app_name === 'string' ? profile.desktop_app_name : undefined,
43
+ desktop_pid: typeof profile.desktop_pid === 'number' ? profile.desktop_pid : undefined,
40
44
  };
41
45
  }
42
46
 
@@ -65,6 +69,9 @@ export function normalizeConfig(rawConfig = {}) {
65
69
  typeof raw.access_expires_at === 'number' ? raw.access_expires_at : undefined,
66
70
  refresh_expires_at:
67
71
  typeof raw.refresh_expires_at === 'number' ? raw.refresh_expires_at : undefined,
72
+ desktop_app_name:
73
+ typeof raw.desktop_app_name === 'string' ? raw.desktop_app_name : undefined,
74
+ desktop_pid: typeof raw.desktop_pid === 'number' ? raw.desktop_pid : undefined,
68
75
  },
69
76
  },
70
77
  };
@@ -199,34 +206,30 @@ export function resolveRuntimeProfile(globalOptions = {}, { requireAuth = true }
199
206
  const timeoutMs = resolveTimeoutMs(globalOptions);
200
207
 
201
208
  if (requireAuth && !jwt) {
209
+ const recovery = getDesktopAuthRecovery({
210
+ apiBase,
211
+ desktopAppName: profile.desktop_app_name,
212
+ desktopPid: profile.desktop_pid,
213
+ });
202
214
  throw new CliError({
203
215
  code: 'auth_missing',
204
216
  message: `No JWT configured for profile ${profileName}`,
205
217
  exitCode: EXIT_CODES.auth,
206
- hints: [
207
- {
208
- command: 'Open the Notis desktop app and sign in',
209
- reason: 'Set NOTIS_JWT or sign in through the Notis desktop app',
210
- },
211
- ],
218
+ hints: recovery.hints,
212
219
  });
213
220
  }
214
221
 
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.
222
+ // An explicit NOTIS_JWT is a complete credential override. Use it verbatim
223
+ // and never replace it with a token later synced by the desktop profile.
220
224
  const usingEnvJwt = Boolean(process.env.NOTIS_JWT);
221
225
  return {
222
226
  config,
223
227
  profileName,
224
228
  apiBase,
225
229
  jwt,
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,
230
+ credentialSource: usingEnvJwt ? 'env' : 'profile',
231
+ desktopAppName: usingEnvJwt ? undefined : profile.desktop_app_name,
232
+ desktopPid: usingEnvJwt ? undefined : profile.desktop_pid,
230
233
  agentMode,
231
234
  nonInteractive,
232
235
  outputMode,
@@ -234,6 +237,25 @@ export function resolveRuntimeProfile(globalOptions = {}, { requireAuth = true }
234
237
  };
235
238
  }
236
239
 
240
+ export function getJwtExpiration(jwt) {
241
+ if (typeof jwt !== 'string' || !jwt) {
242
+ return null;
243
+ }
244
+ try {
245
+ const parts = jwt.split('.');
246
+ if (parts.length !== 3) return null;
247
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
248
+ return typeof payload.exp === 'number' ? payload.exp : null;
249
+ } catch {
250
+ return null;
251
+ }
252
+ }
253
+
254
+ export function isJwtExpired(jwt, nowSeconds = Math.floor(Date.now() / 1000)) {
255
+ const expiration = getJwtExpiration(jwt);
256
+ return expiration !== null && expiration <= nowSeconds;
257
+ }
258
+
237
259
  export function workspacePath(appId) {
238
260
  return join(WORKSPACE_DIR, appId);
239
261
  }