@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
@@ -94,6 +94,14 @@ export class OutputManager {
94
94
  process.stderr.write(`${message}\n`);
95
95
  }
96
96
 
97
+ emitProgress({ phase, message, requestId = null }) {
98
+ if (this.runtime.quiet) {
99
+ return;
100
+ }
101
+ const suffix = requestId ? ` (${requestId})` : '';
102
+ process.stderr.write(`[${phase}] ${message}${suffix}\n`);
103
+ }
104
+
97
105
  emitSuccess({
98
106
  ok = true,
99
107
  command,
@@ -116,7 +124,10 @@ export class OutputManager {
116
124
  meta: {
117
125
  profile: this.runtime.profileName,
118
126
  api_base: this.runtime.apiBase,
119
- mutating: Boolean(meta.mutating),
127
+ mutating:
128
+ meta.mutating === true
129
+ ? true
130
+ : (meta.mutating === false ? false : null),
120
131
  ...meta,
121
132
  },
122
133
  };
@@ -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
  };
@@ -187,7 +194,37 @@ export function resolveTimeoutMs(globalOptions = {}) {
187
194
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 30000;
188
195
  }
189
196
 
190
- export function resolveRuntimeProfile(globalOptions = {}, { requireAuth = true } = {}) {
197
+ export function parseDebugEntitlementOverride(value = process.env.NOTIS_DEBUG_ENTITLEMENT_OVERRIDE) {
198
+ if (!value) {
199
+ return null;
200
+ }
201
+ const candidates = [value];
202
+ try {
203
+ candidates.push(Buffer.from(value, 'base64url').toString('utf-8'));
204
+ } catch {
205
+ // The plain JSON candidate below will provide the actionable error.
206
+ }
207
+ for (const candidate of candidates) {
208
+ try {
209
+ const parsed = JSON.parse(candidate);
210
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
211
+ return parsed;
212
+ }
213
+ } catch {
214
+ // Try the next supported encoding.
215
+ }
216
+ }
217
+ throw new CliError({
218
+ code: 'debug_entitlement_override_invalid',
219
+ message: 'NOTIS_DEBUG_ENTITLEMENT_OVERRIDE must be a JSON object or base64url-encoded JSON object.',
220
+ exitCode: EXIT_CODES.usage,
221
+ });
222
+ }
223
+
224
+ export function resolveRuntimeProfile(
225
+ globalOptions = {},
226
+ { requireAuth = true, includeDebugEntitlementOverride = true } = {},
227
+ ) {
191
228
  const config = loadConfig();
192
229
  const profileName = getCurrentProfileName(config, globalOptions.profile);
193
230
  const apiBase = getApiBase(config, profileName, globalOptions.apiBase);
@@ -197,43 +234,62 @@ export function resolveRuntimeProfile(globalOptions = {}, { requireAuth = true }
197
234
  const nonInteractive = isNonInteractive(globalOptions);
198
235
  const outputMode = resolveOutputMode(globalOptions);
199
236
  const timeoutMs = resolveTimeoutMs(globalOptions);
237
+ const debugEntitlementOverride = includeDebugEntitlementOverride
238
+ ? parseDebugEntitlementOverride()
239
+ : null;
200
240
 
201
241
  if (requireAuth && !jwt) {
242
+ const recovery = getDesktopAuthRecovery({
243
+ apiBase,
244
+ desktopAppName: profile.desktop_app_name,
245
+ desktopPid: profile.desktop_pid,
246
+ });
202
247
  throw new CliError({
203
248
  code: 'auth_missing',
204
249
  message: `No JWT configured for profile ${profileName}`,
205
250
  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
- ],
251
+ hints: recovery.hints,
212
252
  });
213
253
  }
214
254
 
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.
255
+ // An explicit NOTIS_JWT is a complete credential override. Use it verbatim
256
+ // and never replace it with a token later synced by the desktop profile.
220
257
  const usingEnvJwt = Boolean(process.env.NOTIS_JWT);
221
258
  return {
222
259
  config,
223
260
  profileName,
224
261
  apiBase,
225
262
  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,
263
+ credentialSource: usingEnvJwt ? 'env' : 'profile',
264
+ desktopAppName: usingEnvJwt ? undefined : profile.desktop_app_name,
265
+ desktopPid: usingEnvJwt ? undefined : profile.desktop_pid,
230
266
  agentMode,
231
267
  nonInteractive,
232
268
  outputMode,
233
269
  timeoutMs,
270
+ debugEntitlementOverride,
234
271
  };
235
272
  }
236
273
 
274
+ export function getJwtExpiration(jwt) {
275
+ if (typeof jwt !== 'string' || !jwt) {
276
+ return null;
277
+ }
278
+ try {
279
+ const parts = jwt.split('.');
280
+ if (parts.length !== 3) return null;
281
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
282
+ return typeof payload.exp === 'number' ? payload.exp : null;
283
+ } catch {
284
+ return null;
285
+ }
286
+ }
287
+
288
+ export function isJwtExpired(jwt, nowSeconds = Math.floor(Date.now() / 1000)) {
289
+ const expiration = getJwtExpiration(jwt);
290
+ return expiration !== null && expiration <= nowSeconds;
291
+ }
292
+
237
293
  export function workspacePath(appId) {
238
294
  return join(WORKSPACE_DIR, appId);
239
295
  }
@@ -0,0 +1,138 @@
1
+ import sharp from 'sharp';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ const STORE_SCREENSHOT_ASPECT = 16 / 10;
5
+ const SHARED_BACKDROP_PATH = fileURLToPath(
6
+ new URL('./assets/store-screenshot-dark.png', import.meta.url),
7
+ );
8
+
9
+ const ACCENT_PALETTES = {
10
+ blue: { glow: '#4f7cff', glow2: '#274690' },
11
+ violet: { glow: '#9b87f5', glow2: '#5038a1' },
12
+ emerald: { glow: '#34d399', glow2: '#176b55' },
13
+ amber: { glow: '#f2a94a', glow2: '#8b4f20' },
14
+ rose: { glow: '#fb7185', glow2: '#8f3048' },
15
+ sky: { glow: '#38bdf8', glow2: '#176887' },
16
+ fuchsia: { glow: '#d946ef', glow2: '#772786' },
17
+ teal: { glow: '#2dd4bf', glow2: '#176e68' },
18
+ };
19
+
20
+ const ACCENT_NAMES = Object.keys(ACCENT_PALETTES);
21
+
22
+ function stableHash(value) {
23
+ let hash = 2166136261;
24
+ for (const character of String(value || 'notis-app')) {
25
+ hash ^= character.codePointAt(0);
26
+ hash = Math.imul(hash, 16777619);
27
+ }
28
+ return hash >>> 0;
29
+ }
30
+
31
+ export function resolveStoreScreenshotAccent(accent, seed = 'notis-app') {
32
+ if (accent && ACCENT_PALETTES[accent]) {
33
+ return accent;
34
+ }
35
+ return ACCENT_NAMES[stableHash(seed) % ACCENT_NAMES.length];
36
+ }
37
+
38
+ export function storeScreenshotLayout(width = 2000, height = 1250) {
39
+ const horizontalInset = Math.max(48, Math.round(width * 0.06));
40
+ const verticalInset = Math.max(40, Math.round(height * 0.06));
41
+ let cardWidth = width - horizontalInset * 2;
42
+ let cardHeight = Math.round(cardWidth / STORE_SCREENSHOT_ASPECT);
43
+ const maximumCardHeight = height - verticalInset * 2;
44
+ if (cardHeight > maximumCardHeight) {
45
+ cardHeight = maximumCardHeight;
46
+ cardWidth = Math.round(cardHeight * STORE_SCREENSHOT_ASPECT);
47
+ }
48
+ const cardLeft = Math.round((width - cardWidth) / 2);
49
+ const cardTop = Math.round((height - cardHeight) / 2) - Math.round(height * 0.008);
50
+ return {
51
+ width,
52
+ height,
53
+ cardWidth,
54
+ cardHeight,
55
+ cardLeft,
56
+ cardTop,
57
+ radius: Math.max(18, Math.round(width * 0.016)),
58
+ };
59
+ }
60
+
61
+ async function backdropImage(layout) {
62
+ return sharp(SHARED_BACKDROP_PATH)
63
+ .resize(layout.width, layout.height, { fit: 'cover', position: 'centre' })
64
+ .png()
65
+ .toBuffer();
66
+ }
67
+
68
+ function roundedRectSvg(width, height, radius, { fill = '#ffffff', stroke = null } = {}) {
69
+ const strokeAttribute = stroke
70
+ ? ` fill="none" stroke="${stroke}" stroke-width="2"`
71
+ : ` fill="${fill}"`;
72
+ return Buffer.from(
73
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect x="1" y="1" width="${width - 2}" height="${height - 2}" rx="${radius}" ry="${radius}"${strokeAttribute}/></svg>`,
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Composite a truthful browser capture into the deterministic Store frame.
79
+ * The source pixels are only resized/cropped; the app UI is never redrawn.
80
+ */
81
+ export async function composeStoreScreenshot({
82
+ inputPath,
83
+ outputPath,
84
+ width = 2000,
85
+ height = 1250,
86
+ accent = null,
87
+ seed = 'notis-app',
88
+ focused = false,
89
+ theme = 'light',
90
+ }) {
91
+ const resolvedTheme = theme === 'dark' ? 'dark' : 'light';
92
+ const layout = storeScreenshotLayout(width, height);
93
+ const resolvedAccent = resolveStoreScreenshotAccent(accent, seed);
94
+
95
+ const roundedCapture = await sharp(inputPath)
96
+ .resize(layout.cardWidth, layout.cardHeight, {
97
+ fit: 'cover',
98
+ position: focused ? 'top' : 'centre',
99
+ })
100
+ .composite([{
101
+ input: roundedRectSvg(layout.cardWidth, layout.cardHeight, layout.radius),
102
+ blend: 'dest-in',
103
+ }])
104
+ .png()
105
+ .toBuffer();
106
+
107
+ const border = roundedRectSvg(
108
+ layout.cardWidth,
109
+ layout.cardHeight,
110
+ layout.radius,
111
+ { stroke: resolvedTheme === 'dark' ? '#FFFFFF2A' : '#0F172A22' },
112
+ );
113
+
114
+ await sharp(await backdropImage(layout))
115
+ .composite([
116
+ { input: roundedCapture, left: layout.cardLeft, top: layout.cardTop },
117
+ { input: border, left: layout.cardLeft, top: layout.cardTop },
118
+ ])
119
+ .png({
120
+ compressionLevel: 9,
121
+ adaptiveFiltering: true,
122
+ })
123
+ .toFile(outputPath);
124
+
125
+ return {
126
+ mode: 'framed',
127
+ accent: resolvedAccent,
128
+ focused: Boolean(focused),
129
+ theme: resolvedTheme,
130
+ card: {
131
+ left: layout.cardLeft,
132
+ top: layout.cardTop,
133
+ width: layout.cardWidth,
134
+ height: layout.cardHeight,
135
+ radius: layout.radius,
136
+ },
137
+ };
138
+ }
@@ -3,11 +3,11 @@ import { createReadStream } from 'node:fs';
3
3
  import { CliError, EXIT_CODES } from './errors.js';
4
4
  import {
5
5
  DEFAULT_PROFILE,
6
- ensureProfile,
7
6
  getProfile,
7
+ isJwtExpired,
8
8
  loadConfig,
9
- saveConfig,
10
9
  } from './profiles.js';
10
+ import { createExpiredAuthError, createInvalidAuthHints } from './desktop-auth.js';
11
11
 
12
12
  function escapeMultipartHeaderValue(value) {
13
13
  return String(value ?? '')
@@ -76,7 +76,7 @@ function createMultipartFileUpload(payload, bindingMetadata, fileBindings) {
76
76
  };
77
77
  }
78
78
 
79
- function normalizeBackendError(status, payload) {
79
+ function normalizeBackendError(status, payload, runtime) {
80
80
  const backendError = payload?.error;
81
81
  const message =
82
82
  backendError?.message ||
@@ -91,10 +91,7 @@ function normalizeBackendError(status, payload) {
91
91
  message,
92
92
  exitCode: EXIT_CODES.auth,
93
93
  hints: [
94
- {
95
- command: 'Open the Notis desktop app and sign in',
96
- reason: 'Open the Notis desktop app to refresh CLI auth, or set NOTIS_JWT',
97
- },
94
+ ...createInvalidAuthHints(runtime),
98
95
  ],
99
96
  details: payload || {},
100
97
  });
@@ -145,46 +142,10 @@ function normalizeBackendError(status, payload) {
145
142
  });
146
143
  }
147
144
 
148
- function shouldAttemptDevPortalRefresh(runtime, { force = false } = {}) {
149
- if (runtime.authMode !== 'dev_portal' || !runtime.refreshToken || !runtime.apiBase) {
150
- return false;
151
- }
152
-
153
- if (force) {
154
- return true;
155
- }
156
-
157
- if (typeof runtime.accessExpiresAt !== 'number' || runtime.accessExpiresAt <= 0) {
145
+ function reloadJwtFromConfig(runtime) {
146
+ if (runtime.credentialSource === 'env') {
158
147
  return false;
159
148
  }
160
-
161
- const thresholdSeconds = 60;
162
- return runtime.accessExpiresAt - Math.floor(Date.now() / 1000) <= thresholdSeconds;
163
- }
164
-
165
- function persistDevPortalRuntimeAuth(runtime, session) {
166
- const config = ensureProfile(loadConfig(), runtime.profileName || DEFAULT_PROFILE);
167
- const profileName = runtime.profileName || DEFAULT_PROFILE;
168
- config.current_profile = profileName;
169
- config.profiles[profileName] = {
170
- ...config.profiles[profileName],
171
- jwt: session.access_token,
172
- api_base: runtime.apiBase,
173
- auth_mode: 'dev_portal',
174
- refresh_token: session.refresh_token,
175
- access_expires_at: session.access_expires_at,
176
- refresh_expires_at: session.refresh_expires_at,
177
- };
178
- saveConfig(config);
179
-
180
- runtime.jwt = session.access_token;
181
- runtime.authMode = 'dev_portal';
182
- runtime.refreshToken = session.refresh_token;
183
- runtime.accessExpiresAt = session.access_expires_at;
184
- runtime.refreshExpiresAt = session.refresh_expires_at;
185
- }
186
-
187
- function reloadJwtFromConfig(runtime) {
188
149
  const profileName = runtime.profileName || DEFAULT_PROFILE;
189
150
  let profile;
190
151
  try {
@@ -197,39 +158,8 @@ function reloadJwtFromConfig(runtime) {
197
158
  return false;
198
159
  }
199
160
  runtime.jwt = nextJwt;
200
- runtime.authMode = profile.auth_mode;
201
- runtime.refreshToken = profile.refresh_token;
202
- runtime.accessExpiresAt = profile.access_expires_at;
203
- runtime.refreshExpiresAt = profile.refresh_expires_at;
204
- return true;
205
- }
206
-
207
- async function maybeRefreshDevPortalAuth(runtime, { force = false } = {}) {
208
- if (!shouldAttemptDevPortalRefresh(runtime, { force })) {
209
- return false;
210
- }
211
-
212
- const response = await fetch(`${runtime.apiBase}/portal_auth/dev-refresh`, {
213
- method: 'POST',
214
- headers: {
215
- 'Content-Type': 'application/json',
216
- 'X-Notis-CLI-Version': runtime.cliVersion,
217
- },
218
- body: JSON.stringify({ refresh_token: runtime.refreshToken }),
219
- });
220
-
221
- let payload = null;
222
- try {
223
- payload = await response.json();
224
- } catch {
225
- payload = null;
226
- }
227
-
228
- if (!response.ok || !payload?.session?.access_token || !payload?.session?.refresh_token) {
229
- throw normalizeBackendError(response.status, payload);
230
- }
231
-
232
- persistDevPortalRuntimeAuth(runtime, payload.session);
161
+ runtime.desktopAppName = profile.desktop_app_name;
162
+ runtime.desktopPid = profile.desktop_pid;
233
163
  return true;
234
164
  }
235
165
 
@@ -241,7 +171,14 @@ export async function httpRequest({
241
171
  multipart = false,
242
172
  requireAuth = true,
243
173
  }) {
244
- await maybeRefreshDevPortalAuth(runtime);
174
+ // The desktop renderer is the single owner of the rotating Supabase refresh
175
+ // token. Each CLI request only consumes the newest access token it synced to
176
+ // disk, avoiding refresh-token races between independent CLI processes and
177
+ // the running desktop session.
178
+ reloadJwtFromConfig(runtime);
179
+ if (requireAuth && isJwtExpired(runtime.jwt)) {
180
+ throw createExpiredAuthError(runtime);
181
+ }
245
182
 
246
183
  let controller = new AbortController();
247
184
  let timeout = setTimeout(() => controller.abort(), runtime.timeoutMs);
@@ -298,8 +235,7 @@ export async function httpRequest({
298
235
  }
299
236
 
300
237
  if (response.status === 401) {
301
- const refreshed = (await maybeRefreshDevPortalAuth(runtime, { force: true }))
302
- || reloadJwtFromConfig(runtime);
238
+ const refreshed = reloadJwtFromConfig(runtime) && !isJwtExpired(runtime.jwt);
303
239
  if (refreshed) {
304
240
  if (requireAuth && runtime.jwt) {
305
241
  headers.Authorization = `Bearer ${runtime.jwt}`;
@@ -324,7 +260,10 @@ export async function httpRequest({
324
260
  clearTimeout(timeout);
325
261
 
326
262
  if (!response.ok) {
327
- throw normalizeBackendError(response.status, payload);
263
+ if (response.status === 401 && isJwtExpired(runtime.jwt)) {
264
+ throw createExpiredAuthError(runtime);
265
+ }
266
+ throw normalizeBackendError(response.status, payload, runtime);
328
267
  }
329
268
 
330
269
  return {
@@ -379,6 +318,9 @@ export async function callTool({
379
318
  cwd: process.cwd(),
380
319
  agent_mode: runtime.agentMode,
381
320
  cli_version: runtime.cliVersion,
321
+ ...(runtime.debugEntitlementOverride
322
+ ? { debug_entitlement_override: runtime.debugEntitlementOverride }
323
+ : {}),
382
324
  },
383
325
  };
384
326
 
@@ -3,6 +3,11 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <title>Notis App Harness</title>
6
+ <script>
7
+ const harnessTheme = new URLSearchParams(window.location.search).get('theme') === 'dark' ? 'dark' : 'light';
8
+ document.documentElement.classList.toggle('dark', harnessTheme === 'dark');
9
+ document.documentElement.dataset.theme = harnessTheme;
10
+ </script>
6
11
  <link rel="stylesheet" href="./bundle/app.css">
7
12
  <script type="importmap">
8
13
  {
@@ -14,8 +19,80 @@
14
19
  }
15
20
  }
16
21
  </script>
22
+ <link rel="preconnect" href="https://fonts.googleapis.com">
23
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
24
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
17
25
  <style>
18
- body { margin: 0; font-family: system-ui, sans-serif; }
26
+ /*
27
+ * Portal-parity theme tokens. In production the portal copies its computed
28
+ * :root custom properties onto the app root, so app CSS written against
29
+ * hsl(var(--token)) resolves to the real portal palette. The harness mirrors
30
+ * the portal's light theme here so dev renders and listing screenshots look
31
+ * exactly like the app inside the portal. Keep in sync with
32
+ * portal/src/app/globals.css (:root).
33
+ */
34
+ :root {
35
+ color-scheme: light;
36
+ --background: 0 0% 100%;
37
+ --foreground: 222.2 84% 4.9%;
38
+ --card: 0 0% 100%;
39
+ --card-foreground: 222.2 84% 4.9%;
40
+ --popover: 0 0% 100%;
41
+ --popover-foreground: 222.2 84% 4.9%;
42
+ --primary: 222.2 47.4% 11.2%;
43
+ --primary-foreground: 210 40% 98%;
44
+ --secondary: 210 40% 96.1%;
45
+ --secondary-foreground: 222.2 47.4% 11.2%;
46
+ --muted: 210 40% 96.1%;
47
+ --muted-foreground: 0 0% 46.9%;
48
+ --accent: 210 40% 96.1%;
49
+ --accent-foreground: 222.2 47.4% 11.2%;
50
+ --destructive: 0 84.2% 60.2%;
51
+ --destructive-foreground: 210 40% 98%;
52
+ --border: 214.3 31.8% 91.4%;
53
+ --input: 214.3 31.8% 91.4%;
54
+ --ring: 222.2 84% 4.9%;
55
+ --radius: 0.5rem;
56
+ --chart-1: 210 100% 49%;
57
+ --chart-2: 173 58% 39%;
58
+ --chart-3: 197 37% 24%;
59
+ --chart-4: 43 74% 66%;
60
+ --chart-5: 27 87% 67%;
61
+ }
62
+ :root.dark {
63
+ color-scheme: dark;
64
+ --background: 0 0% 13%;
65
+ --foreground: 210 40% 98%;
66
+ --card: 0 0% 13%;
67
+ --card-foreground: 210 40% 98%;
68
+ --popover: 0 0% 13%;
69
+ --popover-foreground: 210 40% 98%;
70
+ --primary: 210 40% 98%;
71
+ --primary-foreground: 222.2 47.4% 11.2%;
72
+ --secondary: 0 0% 18%;
73
+ --secondary-foreground: 210 40% 98%;
74
+ --muted: 0 0% 18%;
75
+ --muted-foreground: 0 0% 65.1%;
76
+ --accent: 0 0% 18%;
77
+ --accent-foreground: 210 40% 98%;
78
+ --destructive: 0 84.2% 60.2%;
79
+ --destructive-foreground: 210 40% 98%;
80
+ --border: 0 0% 23.5%;
81
+ --input: 0 0% 23.5%;
82
+ --ring: 212.7 26.8% 83.9%;
83
+ --chart-1: 220 70% 50%;
84
+ --chart-2: 160 60% 45%;
85
+ --chart-3: 30 80% 55%;
86
+ --chart-4: 280 65% 60%;
87
+ --chart-5: 340 75% 55%;
88
+ }
89
+ body {
90
+ margin: 0;
91
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
92
+ background: hsl(var(--background));
93
+ color: hsl(var(--foreground));
94
+ -webkit-font-smoothing: antialiased;
95
+ }
19
96
  #harness-status { position: fixed; top: 0; left: 0; right: 0; padding: 4px 8px; font-size: 11px; background: #111; color: #0f0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; z-index: 9999; }
20
97
  #root { padding-top: 24px; min-height: 100vh; }
21
98
  </style>
@@ -29,6 +106,7 @@
29
106
  const mode = {{MODE}};
30
107
  const apiBase = {{API_BASE}};
31
108
  const jwt = {{JWT}};
109
+ const fixtures = {{FIXTURES}};
32
110
  const status = document.getElementById('harness-status');
33
111
  const setStatus = (msg, color = '#0f0') => {
34
112
  status.textContent = `harness: ${msg}`;
@@ -42,6 +120,7 @@
42
120
  mounted: false,
43
121
  mode,
44
122
  route: descriptor.route && descriptor.route.slug,
123
+ scenario: descriptor.context && descriptor.context.screenshotScenario,
45
124
  };
46
125
 
47
126
  window.addEventListener('error', (event) => {
@@ -118,15 +197,48 @@
118
197
  },
119
198
  callTool: async (name, args) => {
120
199
  record('callTool', { name, arguments: args || {} });
200
+ if (fixtures && fixtures.tools && Object.prototype.hasOwnProperty.call(fixtures.tools, name)) {
201
+ return structuredClone(fixtures.tools[name]);
202
+ }
121
203
  return { ok: true, result: null };
122
204
  },
123
205
  request: async (path, options) => {
124
206
  record('request', { path, options });
207
+ if (fixtures && fixtures.requests && Object.prototype.hasOwnProperty.call(fixtures.requests, path)) {
208
+ return structuredClone(fixtures.requests[path]);
209
+ }
125
210
  return null;
126
211
  },
127
212
  };
128
213
  }
129
214
 
215
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
216
+
217
+ async function applyScenarioActions() {
218
+ const actions = fixtures && fixtures.scenario && Array.isArray(fixtures.scenario.actions)
219
+ ? fixtures.scenario.actions
220
+ : [];
221
+ for (const action of actions) {
222
+ if (action && action.type === 'wait') {
223
+ await delay(Math.max(0, Number(action.ms) || 0));
224
+ continue;
225
+ }
226
+ if (action && action.type === 'click' && typeof action.selector === 'string') {
227
+ const deadline = Date.now() + 5000;
228
+ let element = null;
229
+ while (!element && Date.now() < deadline) {
230
+ element = document.querySelector(action.selector);
231
+ if (!element) await delay(50);
232
+ }
233
+ if (!element || typeof element.click !== 'function') {
234
+ throw new Error(`Screenshot scenario could not click ${action.selector}`);
235
+ }
236
+ element.click();
237
+ await delay(Number(action.settle_ms) || 350);
238
+ }
239
+ }
240
+ }
241
+
130
242
  function liveRuntime() {
131
243
  return {
132
244
  ...stubRuntime(),
@@ -192,11 +304,21 @@
192
304
  window.__harness.renderStarted = true;
193
305
  createRoot(document.getElementById('root')).render(React.createElement(Root));
194
306
 
195
- requestAnimationFrame(() => requestAnimationFrame(() => {
196
- window.__harness.mounted = true;
197
- const errors = window.__harness.errors.length;
198
- const calls = window.__harness.runtimeCalls.length;
199
- setStatus(`mounted (errors=${errors}, runtimeCalls=${calls})`, errors ? '#f55' : '#0f0');
307
+ requestAnimationFrame(() => requestAnimationFrame(async () => {
308
+ try {
309
+ await delay(500);
310
+ await applyScenarioActions();
311
+ window.__harness.mounted = true;
312
+ const errors = window.__harness.errors.length;
313
+ const calls = window.__harness.runtimeCalls.length;
314
+ setStatus(`mounted (errors=${errors}, runtimeCalls=${calls})`, errors ? '#f55' : '#0f0');
315
+ } catch (error) {
316
+ window.__harness.errors.push({
317
+ type: 'scenario',
318
+ message: error && error.message ? error.message : String(error),
319
+ });
320
+ setStatus(`scenario failed: ${error && error.message ? error.message : String(error)}`, '#f55');
321
+ }
200
322
  }));
201
323
  } catch (error) {
202
324
  window.__harness.errors.push({
@@ -0,0 +1,5 @@
1
+ # App Changelog
2
+
3
+ ## [Initial Release] - {PR_MERGE_DATE}
4
+
5
+ - First Store release.