@notis_ai/cli 0.2.5 → 0.2.7

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 (157) hide show
  1. package/README.md +33 -9
  2. package/dist/scaffolds/notis-affiliate-prospects/CHANGELOG.md +7 -0
  3. package/dist/scaffolds/notis-affiliate-prospects/README.md +20 -0
  4. package/dist/scaffolds/notis-affiliate-prospects/app/globals.css +42 -0
  5. package/dist/scaffolds/notis-affiliate-prospects/app/layout.tsx +7 -0
  6. package/dist/scaffolds/notis-affiliate-prospects/app/page.tsx +248 -0
  7. package/dist/scaffolds/notis-affiliate-prospects/app/prospects/page.tsx +134 -0
  8. package/dist/scaffolds/notis-affiliate-prospects/app/segments/page.tsx +108 -0
  9. package/dist/scaffolds/notis-affiliate-prospects/components/ui/card.tsx +35 -0
  10. package/dist/scaffolds/notis-affiliate-prospects/components.json +20 -0
  11. package/dist/scaffolds/notis-affiliate-prospects/index.html +12 -0
  12. package/dist/scaffolds/notis-affiliate-prospects/lib/affiliate-data.ts +218 -0
  13. package/dist/scaffolds/notis-affiliate-prospects/lib/demo-prospects.ts +52 -0
  14. package/dist/scaffolds/notis-affiliate-prospects/lib/notis-tools.ts +100 -0
  15. package/dist/scaffolds/notis-affiliate-prospects/lib/utils.ts +25 -0
  16. package/dist/scaffolds/notis-affiliate-prospects/metadata/screenshot-1.png +0 -0
  17. package/dist/scaffolds/notis-affiliate-prospects/metadata/screenshot-2.png +0 -0
  18. package/dist/scaffolds/notis-affiliate-prospects/metadata/screenshot-3.png +0 -0
  19. package/dist/scaffolds/notis-affiliate-prospects/metadata/screenshot-fixtures.json +187 -0
  20. package/dist/scaffolds/notis-affiliate-prospects/notis.config.ts +65 -0
  21. package/dist/scaffolds/notis-affiliate-prospects/package.json +32 -0
  22. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/package.json +32 -0
  23. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/components/MultiSelectActionBar.tsx +273 -0
  24. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  25. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  26. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/config.ts +90 -0
  27. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useBackend.ts +41 -0
  28. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useMultiSelect.ts +503 -0
  29. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useNotis.ts +34 -0
  30. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  31. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useTool.ts +64 -0
  32. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useTools.ts +56 -0
  33. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  34. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useUpsertDocument.ts +50 -0
  35. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/index.ts +54 -0
  36. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/provider.tsx +43 -0
  37. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/runtime.ts +161 -0
  38. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/styles.css +38 -0
  39. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/ui.ts +15 -0
  40. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/vite.ts +56 -0
  41. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/tsconfig.json +15 -0
  42. package/dist/scaffolds/notis-affiliate-prospects/postcss.config.mjs +6 -0
  43. package/dist/scaffolds/notis-affiliate-prospects/src/dev-main.tsx +72 -0
  44. package/dist/scaffolds/notis-affiliate-prospects/src/mock-runtime.ts +242 -0
  45. package/dist/scaffolds/notis-affiliate-prospects/tailwind.config.ts +51 -0
  46. package/dist/scaffolds/notis-affiliate-prospects/tsconfig.json +23 -0
  47. package/dist/scaffolds/notis-affiliate-prospects/vite.config.ts +11 -0
  48. package/dist/scaffolds/notis-database/CHANGELOG.md +5 -0
  49. package/dist/scaffolds/notis-database/notis.config.ts +0 -1
  50. package/dist/scaffolds/notis-database/src/mock-runtime.ts +1 -0
  51. package/dist/scaffolds/notis-journal/CHANGELOG.md +25 -0
  52. package/dist/scaffolds/notis-journal/app/globals.css +37 -0
  53. package/dist/scaffolds/notis-journal/app/insights/page.tsx +513 -0
  54. package/dist/scaffolds/notis-journal/app/journal-core.tsx +362 -0
  55. package/dist/scaffolds/notis-journal/app/journal-ui.tsx +337 -0
  56. package/dist/scaffolds/notis-journal/app/layout.tsx +6 -0
  57. package/dist/scaffolds/notis-journal/app/page.tsx +485 -0
  58. package/dist/scaffolds/notis-journal/components/ui/badge.tsx +28 -0
  59. package/dist/scaffolds/notis-journal/components/ui/button.tsx +53 -0
  60. package/dist/scaffolds/notis-journal/components/ui/card.tsx +56 -0
  61. package/dist/scaffolds/notis-journal/components.json +20 -0
  62. package/dist/scaffolds/notis-journal/index.html +12 -0
  63. package/dist/scaffolds/notis-journal/lib/utils.ts +6 -0
  64. package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
  65. package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
  66. package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
  67. package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
  68. package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
  69. package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
  70. package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +132 -0
  71. package/dist/scaffolds/notis-journal/notis.config.ts +93 -0
  72. package/dist/scaffolds/notis-journal/package.json +34 -0
  73. package/dist/scaffolds/notis-journal/packages/sdk/package.json +36 -0
  74. package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  75. package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +60 -0
  76. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
  77. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  78. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  79. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +145 -0
  80. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +229 -0
  81. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +41 -0
  82. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  83. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +78 -0
  84. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +121 -0
  85. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
  86. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +34 -0
  87. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  88. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +64 -0
  89. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +56 -0
  90. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  91. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  92. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +83 -0
  93. package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +43 -0
  94. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +220 -0
  95. package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +186 -0
  96. package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +15 -0
  97. package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +56 -0
  98. package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +15 -0
  99. package/dist/scaffolds/notis-journal/postcss.config.mjs +8 -0
  100. package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +120 -0
  101. package/dist/scaffolds/notis-journal/src/dev-main.tsx +58 -0
  102. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +197 -0
  103. package/dist/scaffolds/notis-journal/tailwind.config.ts +58 -0
  104. package/dist/scaffolds/notis-journal/tsconfig.json +23 -0
  105. package/dist/scaffolds/notis-journal/vite.config.ts +10 -0
  106. package/dist/scaffolds/notis-notes/CHANGELOG.md +5 -0
  107. package/dist/scaffolds/notis-notes/app/page.tsx +17 -373
  108. package/dist/scaffolds/notis-notes/notis.config.ts +0 -1
  109. package/dist/scaffolds/notis-random/CHANGELOG.md +5 -0
  110. package/dist/scaffolds/notis-random/notis.config.ts +0 -1
  111. package/dist/scaffolds/notis-random/src/mock-runtime.ts +1 -0
  112. package/dist/scaffolds.json +22 -0
  113. package/package.json +3 -3
  114. package/skills/notis-apps/SKILL.md +43 -5
  115. package/skills/notis-apps/cli.md +22 -6
  116. package/skills/notis-cli/SKILL.md +20 -2
  117. package/skills/notis-query/cli.md +1 -1
  118. package/src/cli.js +8 -1
  119. package/src/command-specs/apps.js +307 -36
  120. package/src/command-specs/diagnostics.js +674 -0
  121. package/src/command-specs/helpers.js +4 -1
  122. package/src/command-specs/index.js +7 -1
  123. package/src/command-specs/meta.js +8 -2
  124. package/src/command-specs/smoke.js +386 -0
  125. package/src/command-specs/tools.js +237 -44
  126. package/src/runtime/agent-browser.js +204 -21
  127. package/src/runtime/app-changelog.js +79 -0
  128. package/src/runtime/app-dev-server.js +21 -4
  129. package/src/runtime/app-dev-sessions.js +99 -1
  130. package/src/runtime/app-platform.js +197 -18
  131. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  132. package/src/runtime/desktop-auth.js +93 -0
  133. package/src/runtime/output.js +12 -1
  134. package/src/runtime/profiles.js +72 -16
  135. package/src/runtime/store-screenshot.js +138 -0
  136. package/src/runtime/transport.js +24 -82
  137. package/template/.harness/index.html.tmpl +128 -6
  138. package/template/CHANGELOG.md +5 -0
  139. package/template/app/page.tsx +11 -41
  140. package/template/notis.config.ts +0 -1
  141. package/template/package-lock.json +4137 -0
  142. package/template/package.json +1 -0
  143. package/template/packages/sdk/package.json +4 -0
  144. package/template/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  145. package/template/packages/sdk/src/components/Markdown.tsx +60 -0
  146. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +7 -2
  147. package/template/packages/sdk/src/config.ts +74 -0
  148. package/template/packages/sdk/src/documents.ts +229 -0
  149. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  150. package/template/packages/sdk/src/hooks/useDocument.ts +78 -0
  151. package/template/packages/sdk/src/hooks/useDocuments.ts +121 -0
  152. package/template/packages/sdk/src/hooks/useMultiSelect.ts +38 -2
  153. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +54 -9
  154. package/template/packages/sdk/src/index.ts +30 -1
  155. package/template/packages/sdk/src/runtime.ts +76 -17
  156. package/template/packages/sdk/src/styles.css +148 -0
  157. /package/{template → dist/scaffolds/notis-affiliate-prospects}/packages/sdk/src/hooks/useDatabase.ts +0 -0
@@ -0,0 +1,79 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export const APP_CHANGELOG_FILE = 'CHANGELOG.md';
5
+ export const CHANGELOG_MERGE_DATE = '{PR_MERGE_DATE}';
6
+
7
+ const ENTRY_HEADING = /^##\s+\[([^\]]+)]\s+-\s+(\{PR_MERGE_DATE\}|\d{4}-\d{2}-\d{2})\s*$/;
8
+
9
+ function validCalendarDate(value) {
10
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
11
+ const date = new Date(`${value}T00:00:00Z`);
12
+ return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
13
+ }
14
+
15
+ export function parseAppChangelog(markdown, { sourcePath = APP_CHANGELOG_FILE } = {}) {
16
+ const lines = String(markdown || '').replace(/\r\n?/g, '\n').split('\n');
17
+ const entries = [];
18
+ const errors = [];
19
+ let current = null;
20
+
21
+ function finishCurrent() {
22
+ if (!current) return;
23
+ entries.push({
24
+ title: current.title,
25
+ date: current.date,
26
+ body: current.body.join('\n').trim(),
27
+ });
28
+ }
29
+
30
+ for (const [index, line] of lines.entries()) {
31
+ // Only a level-2 heading (`## ...`, not `###`/`####`) can start a release
32
+ // entry; deeper sub-section headings are legitimate release-body content.
33
+ if (!/^##(?!#)/.test(line)) {
34
+ if (current) current.body.push(line);
35
+ continue;
36
+ }
37
+
38
+ const match = line.match(ENTRY_HEADING);
39
+ if (!match) {
40
+ errors.push(
41
+ `${sourcePath}:${index + 1} must use \`## [Release title] - YYYY-MM-DD\` or \`## [Release title] - {PR_MERGE_DATE}\`.`,
42
+ );
43
+ continue;
44
+ }
45
+
46
+ finishCurrent();
47
+ const title = match[1].trim();
48
+ const date = match[2];
49
+ if (!title) {
50
+ errors.push(`${sourcePath}:${index + 1} must include a release title inside square brackets.`);
51
+ }
52
+ if (date !== CHANGELOG_MERGE_DATE && !validCalendarDate(date)) {
53
+ errors.push(`${sourcePath}:${index + 1} contains an invalid calendar date.`);
54
+ }
55
+ current = { title, date, body: [] };
56
+ }
57
+ finishCurrent();
58
+
59
+ if (entries.length === 0 && errors.length === 0) {
60
+ errors.push(
61
+ `${sourcePath} must include at least one \`## [Release title] - YYYY-MM-DD\` entry.`,
62
+ );
63
+ }
64
+
65
+ return { source_path: sourcePath, entries, errors, exists: true };
66
+ }
67
+
68
+ export function readAppChangelog(projectDir) {
69
+ const path = join(projectDir, APP_CHANGELOG_FILE);
70
+ if (!existsSync(path)) {
71
+ return {
72
+ source_path: APP_CHANGELOG_FILE,
73
+ entries: [],
74
+ errors: [`${APP_CHANGELOG_FILE} is required for Store publication.`],
75
+ exists: false,
76
+ };
77
+ }
78
+ return parseAppChangelog(readFileSync(path, 'utf-8'));
79
+ }
@@ -216,7 +216,7 @@ function findHarnessRoute(manifest, routeSlug) {
216
216
  return routes.find((route) => route?.slug === routeSlug) || null;
217
217
  }
218
218
 
219
- function buildHarnessDescriptor({ state, manifest, appConfig, route }) {
219
+ function buildHarnessDescriptor({ state, manifest, appConfig, route, scenario = null }) {
220
220
  const databases = normalizeDatabaseDescriptors(
221
221
  Array.isArray(appConfig?.databases) && appConfig.databases.length
222
222
  ? appConfig.databases
@@ -246,14 +246,28 @@ function buildHarnessDescriptor({ state, manifest, appConfig, route }) {
246
246
  collection: route.collection || null,
247
247
  },
248
248
  databases,
249
- context: { collectionItem: null },
249
+ context: { collectionItem: null, screenshotScenario: scenario },
250
250
  tools,
251
251
  };
252
252
  }
253
253
 
254
- function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions }) {
254
+ function harnessFixtures(projectDir, scenario) {
255
+ const fixtureConfig = readJsonFile(join(projectDir, 'metadata', 'screenshot-fixtures.json')) || {};
256
+ const scenarios = fixtureConfig.scenarios && typeof fixtureConfig.scenarios === 'object'
257
+ ? fixtureConfig.scenarios
258
+ : {};
259
+ return {
260
+ tools: fixtureConfig.tools && typeof fixtureConfig.tools === 'object' ? fixtureConfig.tools : {},
261
+ requests: fixtureConfig.requests && typeof fixtureConfig.requests === 'object' ? fixtureConfig.requests : {},
262
+ scenario: scenario && scenarios[scenario] && typeof scenarios[scenario] === 'object'
263
+ ? scenarios[scenario]
264
+ : null,
265
+ };
266
+ }
267
+
268
+ function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions, scenario = null }) {
255
269
  const template = readFileSync(HARNESS_TEMPLATE_PATH, 'utf-8');
256
- const descriptor = buildHarnessDescriptor({ state, manifest, appConfig, route });
270
+ const descriptor = buildHarnessDescriptor({ state, manifest, appConfig, route, scenario });
257
271
  const routeExport = route.export_name || route.exportName || exportNameFromPath(route.path || '/');
258
272
  const replacements = {
259
273
  '{{REACT_VERSION}}': resolveHarnessReactVersion(state.projectDir),
@@ -262,6 +276,7 @@ function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions }
262
276
  '{{MODE}}': scriptJson(harnessOptions.mode || 'stub'),
263
277
  '{{API_BASE}}': scriptJson(harnessOptions.apiBase || null),
264
278
  '{{JWT}}': scriptJson(harnessOptions.jwt || null),
279
+ '{{FIXTURES}}': scriptJson(harnessFixtures(state.projectDir, scenario)),
265
280
  };
266
281
  let html = template;
267
282
  for (const [token, value] of Object.entries(replacements)) {
@@ -370,6 +385,7 @@ export async function startAppDevServer({
370
385
  }
371
386
 
372
387
  const routeSlug = url.searchParams.get('route') || '';
388
+ const scenario = url.searchParams.get('scenario') || null;
373
389
  const route = findHarnessRoute(manifest, routeSlug);
374
390
  if (!route) {
375
391
  res.writeHead(404, {
@@ -386,6 +402,7 @@ export async function startAppDevServer({
386
402
  appConfig,
387
403
  route,
388
404
  harnessOptions: harness,
405
+ scenario,
389
406
  });
390
407
  res.writeHead(200, {
391
408
  ...headers,
@@ -7,6 +7,7 @@ import { CONFIG_DIR } from './profiles.js';
7
7
  export const DEFAULT_APP_DEV_SESSIONS_FILE = join(CONFIG_DIR, 'app-dev-sessions.json');
8
8
  export const APP_DEV_SESSIONS_FILE = DEFAULT_APP_DEV_SESSIONS_FILE;
9
9
  export const APP_DEV_SESSIONS_VERSION = 1;
10
+ export const APP_DEV_SESSION_MOUNT_ACKS_VERSION = 1;
10
11
 
11
12
  /**
12
13
  * Walk up from `startDir` looking for a workspace-scoped dev-sessions registry.
@@ -54,6 +55,12 @@ export function getAppDevSessionsFile(filePath) {
54
55
  return DEFAULT_APP_DEV_SESSIONS_FILE;
55
56
  }
56
57
 
58
+ export function getAppDevSessionMountAcksFile(sessionsFilePath) {
59
+ const resolvedSessionsFile = getAppDevSessionsFile(sessionsFilePath);
60
+ const parsed = parsePath(resolvedSessionsFile);
61
+ return join(parsed.dir, `${parsed.name}-mount-acks${parsed.ext || '.json'}`);
62
+ }
63
+
57
64
  function normalizeSessions(raw) {
58
65
  if (!raw || typeof raw !== 'object' || !Array.isArray(raw.sessions)) {
59
66
  return [];
@@ -149,5 +156,96 @@ export function linkAppDevSessionTarget({ appId, devSlug, targetAppId, lastHeart
149
156
  export function removeAppDevSession(sessionId, filePath) {
150
157
  const registry = readAppDevSessions(filePath);
151
158
  registry.sessions = registry.sessions.filter((session) => session.sessionId !== sessionId);
152
- return writeAppDevSessions(registry, filePath);
159
+ const result = writeAppDevSessions(registry, filePath);
160
+ removeAppDevSessionMountAcknowledgements(sessionId, filePath);
161
+ return result;
162
+ }
163
+
164
+ function normalizeMountAcknowledgements(raw) {
165
+ if (!raw || typeof raw !== 'object' || !Array.isArray(raw.acknowledgements)) {
166
+ return [];
167
+ }
168
+ return raw.acknowledgements.filter((acknowledgement) =>
169
+ acknowledgement &&
170
+ typeof acknowledgement === 'object' &&
171
+ typeof acknowledgement.sessionId === 'string' &&
172
+ typeof acknowledgement.appId === 'string' &&
173
+ typeof acknowledgement.devSlug === 'string' &&
174
+ typeof acknowledgement.mountNonce === 'string' &&
175
+ typeof acknowledgement.acknowledgedAt === 'string' &&
176
+ acknowledgement.stage === 'listed',
177
+ );
178
+ }
179
+
180
+ export function readAppDevSessionMountAcknowledgements(sessionsFilePath) {
181
+ const filePath = getAppDevSessionMountAcksFile(sessionsFilePath);
182
+ if (!existsSync(filePath)) {
183
+ return { version: APP_DEV_SESSION_MOUNT_ACKS_VERSION, acknowledgements: [] };
184
+ }
185
+ try {
186
+ const raw = JSON.parse(readFileSync(filePath, 'utf8'));
187
+ return {
188
+ version: APP_DEV_SESSION_MOUNT_ACKS_VERSION,
189
+ acknowledgements: normalizeMountAcknowledgements(raw),
190
+ };
191
+ } catch {
192
+ return { version: APP_DEV_SESSION_MOUNT_ACKS_VERSION, acknowledgements: [] };
193
+ }
194
+ }
195
+
196
+ function writeAppDevSessionMountAcknowledgements(registry, sessionsFilePath) {
197
+ const filePath = getAppDevSessionMountAcksFile(sessionsFilePath);
198
+ mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
199
+ const normalized = {
200
+ version: APP_DEV_SESSION_MOUNT_ACKS_VERSION,
201
+ acknowledgements: normalizeMountAcknowledgements(registry),
202
+ };
203
+ const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
204
+ writeFileSync(tempPath, JSON.stringify(normalized, null, 2), { mode: 0o600 });
205
+ renameSync(tempPath, filePath);
206
+ return normalized;
207
+ }
208
+
209
+ export function removeAppDevSessionMountAcknowledgements(sessionId, sessionsFilePath) {
210
+ const filePath = getAppDevSessionMountAcksFile(sessionsFilePath);
211
+ if (!existsSync(filePath)) {
212
+ return { version: APP_DEV_SESSION_MOUNT_ACKS_VERSION, acknowledgements: [] };
213
+ }
214
+ const registry = readAppDevSessionMountAcknowledgements(sessionsFilePath);
215
+ registry.acknowledgements = registry.acknowledgements.filter(
216
+ (acknowledgement) => acknowledgement.sessionId !== sessionId,
217
+ );
218
+ return writeAppDevSessionMountAcknowledgements(registry, sessionsFilePath);
219
+ }
220
+
221
+ function mountAcknowledgementKey(value) {
222
+ return `${value.sessionId}:${value.appId}:${value.devSlug}:${value.mountNonce}`;
223
+ }
224
+
225
+ export async function waitForAppDevSessionMountAcknowledgements(expectedSessions, options = {}) {
226
+ const expected = Array.isArray(expectedSessions) ? expectedSessions : [expectedSessions];
227
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(0, options.timeoutMs) : 15_000;
228
+ const pollIntervalMs = Number.isFinite(options.pollIntervalMs)
229
+ ? Math.max(1, options.pollIntervalMs)
230
+ : 100;
231
+ const now = typeof options.now === 'function' ? options.now : Date.now;
232
+ const sleep = typeof options.sleep === 'function'
233
+ ? options.sleep
234
+ : (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs));
235
+ const startedAt = now();
236
+
237
+ while (true) {
238
+ const registry = readAppDevSessionMountAcknowledgements(options.sessionsFilePath);
239
+ const acknowledgedKeys = new Set(registry.acknowledgements.map(mountAcknowledgementKey));
240
+ const missing = expected.filter((session) => !acknowledgedKeys.has(mountAcknowledgementKey(session)));
241
+ if (missing.length === 0) {
242
+ return { mounted: true, missing: [], acknowledgements: registry.acknowledgements };
243
+ }
244
+
245
+ const elapsedMs = now() - startedAt;
246
+ if (elapsedMs >= timeoutMs) {
247
+ return { mounted: false, missing, acknowledgements: registry.acknowledgements };
248
+ }
249
+ await sleep(Math.min(pollIntervalMs, timeoutMs - elapsedMs));
250
+ }
153
251
  }
@@ -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 {
@@ -346,33 +347,135 @@ export function discoverMetadataAssets(projectDir) {
346
347
  };
347
348
  }
348
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
+
349
424
  export function inspectListingReadiness(projectDir, appConfig = null) {
350
425
  const config = appConfig || {};
351
426
  const warnings = [];
352
427
  const errors = [];
353
- const metadata = discoverMetadataAssets(projectDir);
428
+ const screenshots = resolveListingScreenshots(projectDir, config);
429
+ const metadata = { screenshots };
354
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
+ }
355
448
 
356
449
  if (!String(config.tagline || '').trim()) {
357
- warnings.push('Listing tagline missing in notis.config.ts.');
450
+ errors.push('Listing tagline missing in notis.config.ts.');
358
451
  }
359
452
  if (categories.length === 0) {
360
- warnings.push(`Listing category missing in notis.config.ts. Use one of: ${NOTIS_APP_CATEGORIES.join(', ')}.`);
453
+ errors.push(`Listing category missing in notis.config.ts. Use one of: ${NOTIS_APP_CATEGORIES.join(', ')}.`);
361
454
  }
362
- if (metadata.screenshots.length < 3) {
363
- warnings.push(`Listing screenshots: ${metadata.screenshots.length}/3 minimum in metadata/screenshot-N.png. Run \`notis apps screenshot\` to generate them.`);
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.`);
364
457
  }
365
458
  for (const screenshot of metadata.screenshots) {
366
459
  errors.push(...screenshot.errors);
367
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
+ }
368
464
  }
369
465
 
370
466
  return {
371
- ready: errors.length === 0 && Boolean(config.tagline) && categories.length > 0 && 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,
372
474
  warnings,
373
475
  errors,
374
476
  metadata,
375
477
  categories,
478
+ changelog,
376
479
  };
377
480
  }
378
481
 
@@ -606,9 +709,15 @@ export function generateManifest(appConfig, projectDir) {
606
709
 
607
710
  const databases = appConfig.databases || [];
608
711
  const categories = normalizeCategories(appConfig.categories || []);
609
- const metadata = discoverMetadataAssets(projectDir);
712
+ const metadata = { screenshots: resolveListingScreenshots(projectDir, appConfig) };
610
713
  const appSlug = safeKebab(appConfig.name);
611
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
+ }));
612
721
  const listingMedia = {
613
722
  screenshots: metadata.screenshots.map((screenshot) => ({
614
723
  path: screenshot.path,
@@ -616,8 +725,27 @@ export function generateManifest(appConfig, projectDir) {
616
725
  width: screenshot.width,
617
726
  height: screenshot.height,
618
727
  bytes: screenshot.bytes,
728
+ alt: screenshot.alt || null,
729
+ theme: screenshot.theme || 'light',
619
730
  })),
620
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
+ }
621
749
 
622
750
  return {
623
751
  version: 1,
@@ -632,14 +760,23 @@ export function generateManifest(appConfig, projectDir) {
632
760
  tagline: appConfig.tagline || null,
633
761
  categories,
634
762
  author: appConfig.author || null,
635
- version_notes: appConfig.versionNotes || appConfig.version_notes || null,
763
+ release_version: releaseVersion,
764
+ version_notes: legacyVersionNotes,
636
765
  },
637
766
  listing: {
638
767
  title: displayTitle,
639
768
  tagline: appConfig.tagline || null,
640
769
  categories,
641
770
  author: appConfig.author || null,
642
- 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
+ : {}),
643
780
  media: listingMedia,
644
781
  },
645
782
  metadata: listingMedia,
@@ -650,9 +787,47 @@ export function generateManifest(appConfig, projectDir) {
650
787
  },
651
788
  databases,
652
789
  tools: appConfig.tools || [],
790
+ skills,
791
+ onboarding: appConfig.onboarding || null,
653
792
  };
654
793
  }
655
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
+
656
831
  /**
657
832
  * Build the app bundle: generate entry file, run `vite build`, package into .notis/output/.
658
833
  */
@@ -695,10 +870,14 @@ export function readManifest(projectDir) {
695
870
 
696
871
  function copyMetadataAssets(projectDir) {
697
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 });
698
878
  if (!existsSync(metadataDir)) {
699
879
  return;
700
880
  }
701
- const outputMetadataDir = join(projectDir, OUTPUT_DIR, METADATA_DIR);
702
881
  mkdirSync(outputMetadataDir, { recursive: true });
703
882
  for (const entry of readdirSync(metadataDir, { withFileTypes: true })) {
704
883
  if (!entry.isFile()) continue;
@@ -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
+ }