@notis_ai/cli 0.2.0-beta.102.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/README.md +334 -0
  2. package/bin/notis.js +2 -0
  3. package/config/notis_app_boundary_rules.json +50 -0
  4. package/dist/scaffolds/notis-database/CHANGELOG.md +5 -0
  5. package/dist/scaffolds/notis-database/app/globals.css +44 -0
  6. package/dist/scaffolds/notis-database/app/layout.tsx +6 -0
  7. package/dist/scaffolds/notis-database/app/page.tsx +1091 -0
  8. package/dist/scaffolds/notis-database/components/ui/badge.tsx +28 -0
  9. package/dist/scaffolds/notis-database/components/ui/button.tsx +53 -0
  10. package/dist/scaffolds/notis-database/components/ui/card.tsx +56 -0
  11. package/dist/scaffolds/notis-database/components/ui/table.tsx +120 -0
  12. package/dist/scaffolds/notis-database/components.json +20 -0
  13. package/dist/scaffolds/notis-database/index.html +12 -0
  14. package/dist/scaffolds/notis-database/lib/types.ts +134 -0
  15. package/dist/scaffolds/notis-database/lib/utils.ts +6 -0
  16. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  17. package/dist/scaffolds/notis-database/notis.config.ts +26 -0
  18. package/dist/scaffolds/notis-database/package.json +31 -0
  19. package/dist/scaffolds/notis-database/postcss.config.mjs +8 -0
  20. package/dist/scaffolds/notis-database/src/dev-main.tsx +23 -0
  21. package/dist/scaffolds/notis-database/src/mock-runtime.ts +558 -0
  22. package/dist/scaffolds/notis-database/tailwind.config.ts +59 -0
  23. package/dist/scaffolds/notis-database/tsconfig.json +23 -0
  24. package/dist/scaffolds/notis-database/vite.config.ts +22 -0
  25. package/dist/scaffolds/notis-journal/CHANGELOG.md +25 -0
  26. package/dist/scaffolds/notis-journal/app/globals.css +37 -0
  27. package/dist/scaffolds/notis-journal/app/insights/page.tsx +513 -0
  28. package/dist/scaffolds/notis-journal/app/journal-core.tsx +362 -0
  29. package/dist/scaffolds/notis-journal/app/journal-ui.tsx +337 -0
  30. package/dist/scaffolds/notis-journal/app/layout.tsx +6 -0
  31. package/dist/scaffolds/notis-journal/app/page.tsx +485 -0
  32. package/dist/scaffolds/notis-journal/components/ui/badge.tsx +28 -0
  33. package/dist/scaffolds/notis-journal/components/ui/button.tsx +53 -0
  34. package/dist/scaffolds/notis-journal/components/ui/card.tsx +56 -0
  35. package/dist/scaffolds/notis-journal/components.json +20 -0
  36. package/dist/scaffolds/notis-journal/index.html +12 -0
  37. package/dist/scaffolds/notis-journal/lib/utils.ts +6 -0
  38. package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
  39. package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
  40. package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
  41. package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
  42. package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
  43. package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
  44. package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +132 -0
  45. package/dist/scaffolds/notis-journal/notis.config.ts +93 -0
  46. package/dist/scaffolds/notis-journal/package.json +34 -0
  47. package/dist/scaffolds/notis-journal/packages/sdk/package.json +36 -0
  48. package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  49. package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +60 -0
  50. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
  51. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  52. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  53. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +145 -0
  54. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +229 -0
  55. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +41 -0
  56. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  57. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +78 -0
  58. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +121 -0
  59. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
  60. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +34 -0
  61. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  62. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +64 -0
  63. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +56 -0
  64. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  65. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  66. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +83 -0
  67. package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +43 -0
  68. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +220 -0
  69. package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +186 -0
  70. package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +15 -0
  71. package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +56 -0
  72. package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +15 -0
  73. package/dist/scaffolds/notis-journal/postcss.config.mjs +8 -0
  74. package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +120 -0
  75. package/dist/scaffolds/notis-journal/src/dev-main.tsx +58 -0
  76. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +197 -0
  77. package/dist/scaffolds/notis-journal/tailwind.config.ts +58 -0
  78. package/dist/scaffolds/notis-journal/tsconfig.json +23 -0
  79. package/dist/scaffolds/notis-journal/vite.config.ts +10 -0
  80. package/dist/scaffolds/notis-notes/CHANGELOG.md +5 -0
  81. package/dist/scaffolds/notis-notes/app/globals.css +3 -0
  82. package/dist/scaffolds/notis-notes/app/layout.tsx +6 -0
  83. package/dist/scaffolds/notis-notes/app/page.tsx +1950 -0
  84. package/dist/scaffolds/notis-notes/components/ui/badge.tsx +28 -0
  85. package/dist/scaffolds/notis-notes/components/ui/button.tsx +53 -0
  86. package/dist/scaffolds/notis-notes/components/ui/card.tsx +56 -0
  87. package/dist/scaffolds/notis-notes/components.json +20 -0
  88. package/dist/scaffolds/notis-notes/lib/utils.ts +6 -0
  89. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  90. package/dist/scaffolds/notis-notes/notis.config.ts +35 -0
  91. package/dist/scaffolds/notis-notes/package.json +31 -0
  92. package/dist/scaffolds/notis-notes/postcss.config.mjs +8 -0
  93. package/dist/scaffolds/notis-notes/tailwind.config.ts +58 -0
  94. package/dist/scaffolds/notis-notes/tsconfig.json +23 -0
  95. package/dist/scaffolds/notis-notes/vite.config.ts +10 -0
  96. package/dist/scaffolds/notis-random/CHANGELOG.md +5 -0
  97. package/dist/scaffolds/notis-random/README.md +33 -0
  98. package/dist/scaffolds/notis-random/app/globals.css +11 -0
  99. package/dist/scaffolds/notis-random/app/history/page.tsx +66 -0
  100. package/dist/scaffolds/notis-random/app/layout.tsx +7 -0
  101. package/dist/scaffolds/notis-random/app/page.tsx +222 -0
  102. package/dist/scaffolds/notis-random/index.html +12 -0
  103. package/dist/scaffolds/notis-random/lib/notis-tools.ts +109 -0
  104. package/dist/scaffolds/notis-random/lib/rng.ts +42 -0
  105. package/dist/scaffolds/notis-random/lib/roll-record.ts +102 -0
  106. package/dist/scaffolds/notis-random/lib/utils.ts +25 -0
  107. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  108. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  109. package/dist/scaffolds/notis-random/notis.config.ts +42 -0
  110. package/dist/scaffolds/notis-random/package.json +32 -0
  111. package/dist/scaffolds/notis-random/postcss.config.mjs +6 -0
  112. package/dist/scaffolds/notis-random/src/dev-main.tsx +70 -0
  113. package/dist/scaffolds/notis-random/src/mock-runtime.ts +129 -0
  114. package/dist/scaffolds/notis-random/tailwind.config.ts +43 -0
  115. package/dist/scaffolds/notis-random/tsconfig.json +23 -0
  116. package/dist/scaffolds/notis-random/vite.config.ts +11 -0
  117. package/dist/scaffolds.json +47 -0
  118. package/package.json +45 -0
  119. package/skills/notis-apps/SKILL.md +219 -0
  120. package/skills/notis-apps/cli.md +244 -0
  121. package/skills/notis-cli/SKILL.md +269 -0
  122. package/skills/notis-query/cli.md +39 -0
  123. package/src/cli.js +175 -0
  124. package/src/command-specs/apps.js +1939 -0
  125. package/src/command-specs/helpers.js +186 -0
  126. package/src/command-specs/index.js +14 -0
  127. package/src/command-specs/meta.js +161 -0
  128. package/src/command-specs/tools.js +499 -0
  129. package/src/runtime/agent-browser.js +464 -0
  130. package/src/runtime/app-boundary-validator.js +183 -0
  131. package/src/runtime/app-changelog.js +79 -0
  132. package/src/runtime/app-dev-server.js +712 -0
  133. package/src/runtime/app-dev-sessions.js +251 -0
  134. package/src/runtime/app-platform.js +1500 -0
  135. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  136. package/src/runtime/cli-mode.generated.js +4 -0
  137. package/src/runtime/cli-mode.js +29 -0
  138. package/src/runtime/desktop-auth.js +93 -0
  139. package/src/runtime/errors.js +55 -0
  140. package/src/runtime/help.js +60 -0
  141. package/src/runtime/output.js +180 -0
  142. package/src/runtime/ports.js +15 -0
  143. package/src/runtime/profiles.js +261 -0
  144. package/src/runtime/store-screenshot.js +138 -0
  145. package/src/runtime/transport.js +347 -0
  146. package/template/.harness/index.html.tmpl +333 -0
  147. package/template/CHANGELOG.md +5 -0
  148. package/template/app/globals.css +3 -0
  149. package/template/app/layout.tsx +6 -0
  150. package/template/app/page.tsx +60 -0
  151. package/template/components/ui/badge.tsx +28 -0
  152. package/template/components/ui/button.tsx +53 -0
  153. package/template/components/ui/card.tsx +56 -0
  154. package/template/components.json +20 -0
  155. package/template/lib/utils.ts +6 -0
  156. package/template/metadata/screenshot-1.png +0 -0
  157. package/template/metadata/screenshot-2.png +0 -0
  158. package/template/metadata/screenshot-3.png +0 -0
  159. package/template/notis.config.ts +36 -0
  160. package/template/package-lock.json +4137 -0
  161. package/template/package.json +32 -0
  162. package/template/packages/sdk/package.json +36 -0
  163. package/template/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  164. package/template/packages/sdk/src/components/Markdown.tsx +60 -0
  165. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
  166. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  167. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  168. package/template/packages/sdk/src/config.ts +145 -0
  169. package/template/packages/sdk/src/documents.ts +229 -0
  170. package/template/packages/sdk/src/hooks/useBackend.ts +41 -0
  171. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  172. package/template/packages/sdk/src/hooks/useDocument.ts +78 -0
  173. package/template/packages/sdk/src/hooks/useDocuments.ts +121 -0
  174. package/template/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
  175. package/template/packages/sdk/src/hooks/useNotis.ts +34 -0
  176. package/template/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  177. package/template/packages/sdk/src/hooks/useTool.ts +64 -0
  178. package/template/packages/sdk/src/hooks/useTools.ts +56 -0
  179. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  180. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  181. package/template/packages/sdk/src/index.ts +83 -0
  182. package/template/packages/sdk/src/provider.tsx +43 -0
  183. package/template/packages/sdk/src/runtime.ts +220 -0
  184. package/template/packages/sdk/src/styles.css +186 -0
  185. package/template/packages/sdk/src/ui.ts +15 -0
  186. package/template/packages/sdk/src/vite.ts +56 -0
  187. package/template/packages/sdk/tsconfig.json +15 -0
  188. package/template/postcss.config.mjs +8 -0
  189. package/template/tailwind.config.ts +58 -0
  190. package/template/tsconfig.json +22 -0
  191. package/template/vite.config.ts +10 -0
@@ -0,0 +1,464 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+
5
+ export const HIDE_HARNESS_STATUS_SCRIPT =
6
+ "(() => { const el = document.getElementById('harness-status'); if (el) el.style.opacity = '0'; return true; })()";
7
+
8
+ /**
9
+ * Prepare the mounted harness page for a listing capture: remove the debug
10
+ * banner and the layout the harness adds around the app (top padding for the
11
+ * banner, 100vh min-height) so content height can be measured and framed
12
+ * without artificial whitespace.
13
+ */
14
+ const PREPARE_CAPTURE_SCRIPT =
15
+ "(() => { const s = document.getElementById('harness-status'); if (s) s.style.display = 'none';" +
16
+ " const r = document.getElementById('root'); if (r) { r.style.paddingTop = '0'; r.style.minHeight = '0'; }" +
17
+ " document.body.style.minHeight = '0'; document.documentElement.style.minHeight = '0'; return true; })()";
18
+
19
+ const FONTS_STATUS_SCRIPT =
20
+ "(() => (document.fonts ? document.fonts.status : 'loaded'))()";
21
+
22
+ const CONTENT_HEIGHT_SCRIPT =
23
+ '(() => { const r = document.getElementById(\'root\');' +
24
+ ' if (!r) return Math.ceil(document.documentElement.scrollHeight);' +
25
+ ' return Math.ceil(Math.max(r.scrollHeight, r.getBoundingClientRect().height)); })()';
26
+
27
+ // CSS viewport bounds for content-aware framing. Content shorter than the
28
+ // minimum gets a little breathing room; content taller than the maximum is
29
+ // cropped at the frame rather than shrunk into unreadably small text.
30
+ const MIN_CSS_VIEWPORT_HEIGHT = 720;
31
+ const MAX_CSS_VIEWPORT_HEIGHT = 1500;
32
+
33
+ function pngDimensions(path) {
34
+ try {
35
+ const buffer = readFileSync(path);
36
+ if (buffer.length < 24 || buffer.readUInt32BE(12) !== 0x49484452) {
37
+ return null;
38
+ }
39
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Pick a CSS viewport that frames `contentHeight` at the output aspect ratio,
47
+ * with a deviceScaleFactor that maps it back to exactly outputWidth x
48
+ * outputHeight physical pixels. Width snaps to a multiple of 8 so the height
49
+ * stays integral for the common 16:10 target.
50
+ */
51
+ function fitCssViewport(contentHeight, outputWidth, outputHeight) {
52
+ const aspect = outputWidth / outputHeight;
53
+ const cssH = Math.min(
54
+ MAX_CSS_VIEWPORT_HEIGHT,
55
+ Math.max(MIN_CSS_VIEWPORT_HEIGHT, Math.round(contentHeight)),
56
+ );
57
+ const cssW = Math.round((cssH * aspect) / 8) * 8;
58
+ return {
59
+ width: cssW,
60
+ height: Math.round(cssW / aspect),
61
+ scale: outputWidth / cssW,
62
+ };
63
+ }
64
+
65
+ async function evalNumber(sessionName, script, fallback = null) {
66
+ const result = await runAgentBrowser(
67
+ ['--session', sessionName, '--json', 'eval', script],
68
+ { timeoutMs: 5000 },
69
+ );
70
+ if (result.exitCode !== 0) {
71
+ return fallback;
72
+ }
73
+ try {
74
+ const payload = parseAgentBrowserJson(result.stdout);
75
+ const raw = payload?.data?.result ?? payload?.result ?? payload?.data ?? null;
76
+ const value = typeof raw === 'string' ? JSON.parse(raw) : raw;
77
+ return Number.isFinite(value) ? value : fallback;
78
+ } catch {
79
+ return fallback;
80
+ }
81
+ }
82
+
83
+ async function waitForFonts(sessionName, timeoutMs = 3000) {
84
+ const deadline = Date.now() + timeoutMs;
85
+ while (Date.now() < deadline) {
86
+ const result = await runAgentBrowser(
87
+ ['--session', sessionName, '--json', 'eval', FONTS_STATUS_SCRIPT],
88
+ { timeoutMs: 5000 },
89
+ );
90
+ if (result.exitCode !== 0) {
91
+ return;
92
+ }
93
+ try {
94
+ const payload = parseAgentBrowserJson(result.stdout);
95
+ const raw = payload?.data?.result ?? payload?.result ?? payload?.data ?? null;
96
+ const status = typeof raw === 'string' && raw.startsWith('"') ? JSON.parse(raw) : raw;
97
+ if (status !== 'loading') {
98
+ return;
99
+ }
100
+ } catch {
101
+ return;
102
+ }
103
+ await delay(150);
104
+ }
105
+ }
106
+
107
+ async function setViewport(sessionName, width, height, scale = null) {
108
+ const args = ['--session', sessionName, 'set', 'viewport', String(width), String(height)];
109
+ if (scale != null) {
110
+ args.push(String(scale));
111
+ }
112
+ const result = await runAgentBrowser(args, { timeoutMs: 5000 });
113
+ return result.exitCode === 0;
114
+ }
115
+
116
+ function delay(ms) {
117
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
118
+ }
119
+
120
+ function commandError(phase, result) {
121
+ return {
122
+ phase,
123
+ exit_code: result.exitCode,
124
+ stdout: result.stdout.trim(),
125
+ stderr: result.stderr.trim(),
126
+ };
127
+ }
128
+
129
+ function runAgentBrowser(args, { timeoutMs = 30_000 } = {}) {
130
+ return new Promise((resolvePromise) => {
131
+ const child = spawn('agent-browser', args, {
132
+ stdio: ['ignore', 'pipe', 'pipe'],
133
+ env: process.env,
134
+ });
135
+ let stdout = '';
136
+ let stderr = '';
137
+ let timedOut = false;
138
+ const timer = setTimeout(() => {
139
+ timedOut = true;
140
+ child.kill('SIGTERM');
141
+ }, timeoutMs);
142
+
143
+ child.stdout.on('data', (chunk) => {
144
+ stdout += chunk.toString();
145
+ });
146
+ child.stderr.on('data', (chunk) => {
147
+ stderr += chunk.toString();
148
+ });
149
+ child.on('error', (error) => {
150
+ clearTimeout(timer);
151
+ resolvePromise({
152
+ exitCode: 1,
153
+ stdout,
154
+ stderr: stderr || error.message,
155
+ timedOut,
156
+ });
157
+ });
158
+ child.on('close', (code) => {
159
+ clearTimeout(timer);
160
+ resolvePromise({
161
+ exitCode: timedOut ? 124 : (code ?? 1),
162
+ stdout,
163
+ stderr,
164
+ timedOut,
165
+ });
166
+ });
167
+ });
168
+ }
169
+
170
+ function parseAgentBrowserJson(stdout) {
171
+ const trimmed = stdout.trim();
172
+ if (!trimmed) {
173
+ return null;
174
+ }
175
+ return JSON.parse(trimmed);
176
+ }
177
+
178
+ function parseHarnessFromEval(stdout) {
179
+ const payload = parseAgentBrowserJson(stdout);
180
+ const rawResult = payload?.data?.result ?? payload?.result ?? payload?.data ?? null;
181
+ if (rawResult == null || rawResult === 'null') {
182
+ return null;
183
+ }
184
+ return typeof rawResult === 'string' ? JSON.parse(rawResult) : rawResult;
185
+ }
186
+
187
+ async function readHarness(sessionName, timeoutMs) {
188
+ const result = await runAgentBrowser(
189
+ [
190
+ '--session',
191
+ sessionName,
192
+ '--json',
193
+ 'eval',
194
+ 'JSON.stringify(window.__harness || null)',
195
+ ],
196
+ { timeoutMs },
197
+ );
198
+ if (result.exitCode !== 0) {
199
+ return { ok: false, toolError: commandError('eval', result) };
200
+ }
201
+
202
+ try {
203
+ return { ok: true, harness: parseHarnessFromEval(result.stdout) };
204
+ } catch (error) {
205
+ return {
206
+ ok: false,
207
+ toolError: {
208
+ phase: 'eval_parse',
209
+ message: error instanceof Error ? error.message : String(error),
210
+ stdout: result.stdout.trim(),
211
+ stderr: result.stderr.trim(),
212
+ },
213
+ };
214
+ }
215
+ }
216
+
217
+ export function isAgentBrowserAvailable() {
218
+ const result = spawnSync('agent-browser', ['--version'], {
219
+ stdio: 'ignore',
220
+ env: process.env,
221
+ });
222
+ return result.status === 0;
223
+ }
224
+
225
+ export async function runHarnessRoute({
226
+ url,
227
+ sessionName,
228
+ timeoutMs = 10_000,
229
+ snapshotPath = null,
230
+ }) {
231
+ const opened = await runAgentBrowser(['--session', sessionName, 'open', url], {
232
+ timeoutMs: Math.min(Math.max(timeoutMs, 5000), 30_000),
233
+ });
234
+ if (opened.exitCode !== 0) {
235
+ return {
236
+ mounted: false,
237
+ renderStarted: false,
238
+ errors: [],
239
+ runtimeCalls: [],
240
+ snapshotPath: null,
241
+ tool_error: commandError('open', opened),
242
+ };
243
+ }
244
+
245
+ const deadline = Date.now() + timeoutMs;
246
+ while (Date.now() < deadline) {
247
+ const lastRead = await readHarness(sessionName, 5000);
248
+ if (!lastRead.ok) {
249
+ await delay(250);
250
+ continue;
251
+ }
252
+ const harness = lastRead.harness;
253
+ if (harness?.mounted || (Array.isArray(harness?.errors) && harness.errors.length > 0)) {
254
+ await delay(250);
255
+ break;
256
+ }
257
+ await delay(250);
258
+ }
259
+
260
+ const finalRead = await readHarness(sessionName, 5000);
261
+ if (!finalRead.ok) {
262
+ return {
263
+ mounted: false,
264
+ renderStarted: false,
265
+ errors: [],
266
+ runtimeCalls: [],
267
+ snapshotPath: null,
268
+ tool_error: finalRead.toolError,
269
+ };
270
+ }
271
+
272
+ const harness = finalRead.harness || {};
273
+ let savedSnapshotPath = null;
274
+ if (snapshotPath) {
275
+ const snapshot = await runAgentBrowser(['--session', sessionName, 'snapshot', '-i'], {
276
+ timeoutMs: 10_000,
277
+ });
278
+ if (snapshot.exitCode === 0) {
279
+ mkdirSync(dirname(snapshotPath), { recursive: true });
280
+ writeFileSync(snapshotPath, snapshot.stdout);
281
+ savedSnapshotPath = snapshotPath;
282
+ }
283
+ }
284
+
285
+ const timedOut = !harness.mounted && Date.now() >= deadline;
286
+ return {
287
+ mounted: Boolean(harness.mounted),
288
+ renderStarted: Boolean(harness.renderStarted),
289
+ errors: Array.isArray(harness.errors) ? harness.errors : [],
290
+ runtimeCalls: Array.isArray(harness.runtimeCalls) ? harness.runtimeCalls : [],
291
+ snapshotPath: savedSnapshotPath,
292
+ timed_out: timedOut,
293
+ tool_error: null,
294
+ raw: harness,
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Open a harness route, wait for it to mount, then capture a PNG screenshot of
300
+ * the rendered app. Output is always exactly `width` x `height` physical
301
+ * pixels (default 2000x1250, the 16:10 the listing validator expects), but the
302
+ * CSS viewport is fitted to the rendered content height: short pages render
303
+ * larger (no dead whitespace below the app), tall pages get more room before
304
+ * cropping. The deviceScaleFactor maps the fitted CSS viewport back onto the
305
+ * fixed output frame. Used by `notis apps screenshot`.
306
+ */
307
+ export async function captureHarnessScreenshot({
308
+ url,
309
+ sessionName,
310
+ screenshotPath,
311
+ focusSelector = null,
312
+ width = 2000,
313
+ height = 1250,
314
+ timeoutMs = 15_000,
315
+ }) {
316
+ const opened = await runAgentBrowser(['--session', sessionName, 'open', url], {
317
+ timeoutMs: Math.min(Math.max(timeoutMs, 5000), 30_000),
318
+ });
319
+ if (opened.exitCode !== 0) {
320
+ return { ok: false, mounted: false, screenshotPath: null, tool_error: commandError('open', opened) };
321
+ }
322
+
323
+ // Initial fixed frame so mount/layout happen at a deterministic size; the
324
+ // scale keeps physical output at width x height from the very first paint.
325
+ const initial = fitCssViewport(900, width, height);
326
+ let scaledViewport = await setViewport(
327
+ sessionName,
328
+ initial.width,
329
+ initial.height,
330
+ initial.scale,
331
+ );
332
+ if (!scaledViewport) {
333
+ // Older agent-browser without deviceScaleFactor support: fall back to a
334
+ // plain fixed viewport at output size.
335
+ await setViewport(sessionName, width, height);
336
+ }
337
+
338
+ const deadline = Date.now() + timeoutMs;
339
+ let lastErrors = [];
340
+ let lastReadError = null;
341
+ let mounted = false;
342
+ while (Date.now() < deadline) {
343
+ const read = await readHarness(sessionName, 5000);
344
+ if (read.ok) {
345
+ const harness = read.harness || {};
346
+ mounted = Boolean(harness.mounted);
347
+ lastErrors = Array.isArray(harness.errors) ? harness.errors : [];
348
+ if (mounted || lastErrors.length > 0) {
349
+ break;
350
+ }
351
+ } else {
352
+ lastReadError = read.toolError;
353
+ }
354
+ await delay(250);
355
+ }
356
+
357
+ if (lastErrors.length > 0) {
358
+ return { ok: false, mounted, screenshotPath: null, errors: lastErrors, tool_error: null };
359
+ }
360
+ if (!mounted) {
361
+ return {
362
+ ok: false,
363
+ mounted: false,
364
+ screenshotPath: null,
365
+ errors: [],
366
+ timed_out: true,
367
+ tool_error: lastReadError || {
368
+ phase: 'harness',
369
+ message: 'Timed out waiting for window.__harness.mounted.',
370
+ },
371
+ };
372
+ }
373
+
374
+ // Strip harness chrome (debug banner, banner padding, 100vh min-height) so
375
+ // the frame contains only the app, then wait for webfonts before measuring.
376
+ await runAgentBrowser(
377
+ ['--session', sessionName, 'eval', PREPARE_CAPTURE_SCRIPT],
378
+ { timeoutMs: 5000 },
379
+ );
380
+ await waitForFonts(sessionName);
381
+
382
+ // Fit the CSS viewport to the rendered content. Content height depends on
383
+ // viewport width, so refine once after the first resize reflows the page.
384
+ let framing = null;
385
+ if (scaledViewport) {
386
+ let contentHeight = await evalNumber(sessionName, CONTENT_HEIGHT_SCRIPT);
387
+ for (let pass = 0; contentHeight != null && pass < 2; pass += 1) {
388
+ const fitted = fitCssViewport(contentHeight, width, height);
389
+ if (framing && fitted.width === framing.width) {
390
+ break;
391
+ }
392
+ scaledViewport = await setViewport(sessionName, fitted.width, fitted.height, fitted.scale);
393
+ if (!scaledViewport) {
394
+ framing = null;
395
+ await setViewport(sessionName, width, height);
396
+ break;
397
+ }
398
+ framing = { ...fitted, content_height: contentHeight };
399
+ await delay(150);
400
+ const remeasured = await evalNumber(sessionName, CONTENT_HEIGHT_SCRIPT);
401
+ if (remeasured == null || Math.abs(remeasured - contentHeight) <= 32) {
402
+ framing.content_height = remeasured ?? contentHeight;
403
+ break;
404
+ }
405
+ contentHeight = remeasured;
406
+ }
407
+ }
408
+
409
+ // Let the route settle (async data, resize transitions) before the capture.
410
+ await delay(500);
411
+
412
+ mkdirSync(dirname(screenshotPath), { recursive: true });
413
+ const screenshotArgs = ['--session', sessionName, 'screenshot'];
414
+ if (focusSelector) {
415
+ screenshotArgs.push(focusSelector);
416
+ }
417
+ screenshotArgs.push(screenshotPath, '--screenshot-format', 'png');
418
+ const shot = await runAgentBrowser(screenshotArgs, { timeoutMs: 15_000 });
419
+ if (shot.exitCode !== 0) {
420
+ return { ok: false, mounted: true, screenshotPath: null, errors: lastErrors, timed_out: false, tool_error: commandError('screenshot', shot) };
421
+ }
422
+
423
+ // The listing validator requires exact output dimensions. If the fitted
424
+ // capture came out wrong (e.g. the browser ignored the scale factor),
425
+ // recapture once at a plain fixed viewport.
426
+ const dimensions = pngDimensions(screenshotPath);
427
+ if (!focusSelector && framing && (!dimensions || dimensions.width !== width || dimensions.height !== height)) {
428
+ await setViewport(sessionName, width, height);
429
+ await delay(300);
430
+ const retry = await runAgentBrowser(
431
+ ['--session', sessionName, 'screenshot', screenshotPath, '--screenshot-format', 'png'],
432
+ { timeoutMs: 15_000 },
433
+ );
434
+ if (retry.exitCode !== 0) {
435
+ return { ok: false, mounted: true, screenshotPath: null, errors: lastErrors, timed_out: false, tool_error: commandError('screenshot', retry) };
436
+ }
437
+ framing = null;
438
+ }
439
+
440
+ if (focusSelector) {
441
+ framing = {
442
+ ...(framing || {}),
443
+ focus_selector: focusSelector,
444
+ source_dimensions: dimensions,
445
+ };
446
+ }
447
+
448
+ return {
449
+ ok: true,
450
+ mounted: true,
451
+ screenshotPath,
452
+ errors: lastErrors,
453
+ timed_out: false,
454
+ tool_error: null,
455
+ framing,
456
+ };
457
+ }
458
+
459
+ export async function closeAgentBrowserSession(sessionName) {
460
+ const result = await runAgentBrowser(['--session', sessionName, 'close'], {
461
+ timeoutMs: 5000,
462
+ });
463
+ return result.exitCode === 0;
464
+ }
@@ -0,0 +1,183 @@
1
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
2
+ import { dirname, extname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ import { usageError } from './errors.js';
6
+
7
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
8
+
9
+ // Candidate locations for the app boundary rules, in priority order:
10
+ // 1. The monorepo server source (always fresh during local development).
11
+ // 2. The copy bundled into the published package at build time (see
12
+ // scripts/copy-boundary-rules.js + package.json "files": config/). When the
13
+ // CLI is installed via npm, candidate 1 escapes the package and is absent,
14
+ // so the bundled copy is used. This is what was missing before: the package
15
+ // shipped only the escaping path and crashed with ENOENT on any command.
16
+ export const RULES_PATH_CANDIDATES = [
17
+ resolve(moduleDir, '../../../../server/config/notis_app_boundary_rules.json'),
18
+ resolve(moduleDir, '../../config/notis_app_boundary_rules.json'),
19
+ ];
20
+
21
+ export function resolveRulesPath() {
22
+ for (const candidate of RULES_PATH_CANDIDATES) {
23
+ if (existsSync(candidate)) {
24
+ return candidate;
25
+ }
26
+ }
27
+ // Fall back to the bundled-copy path so any error message points inside the
28
+ // package rather than at an escaping monorepo path.
29
+ return RULES_PATH_CANDIDATES[RULES_PATH_CANDIDATES.length - 1];
30
+ }
31
+
32
+ const SOURCE_FILE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.css', '.scss', '.sass', '.pcss']);
33
+ const IGNORED_SOURCE_DIRS = new Set([
34
+ 'node_modules',
35
+ '.git',
36
+ '.next',
37
+ '.notis',
38
+ 'dist',
39
+ 'build',
40
+ 'coverage',
41
+ ]);
42
+
43
+ function compileRules(entries) {
44
+ // Tolerate a rules object whose javascript/css field is a non-array (a corrupt
45
+ // file can parse to {"javascript":"x"}); coerce anything non-array to [] so
46
+ // getCompiledRules degrades to an empty rule set instead of throwing on .map.
47
+ return (Array.isArray(entries) ? entries : []).map((entry) => ({
48
+ regex: new RegExp(entry.pattern, 'm'),
49
+ message: entry.message,
50
+ }));
51
+ }
52
+
53
+ // Loaded lazily on first validation rather than at module import time: a
54
+ // missing rules file must not prevent the whole CLI from booting (every
55
+ // command imports this module transitively). The server re-validates on
56
+ // save/deploy, so an empty client-side rule set degrades gracefully.
57
+ let compiledRules = null;
58
+
59
+ function getCompiledRules() {
60
+ if (compiledRules) {
61
+ return compiledRules;
62
+ }
63
+ let boundaryRules = { javascript: [], css: [] };
64
+ try {
65
+ const parsed = JSON.parse(readFileSync(resolveRulesPath(), 'utf-8'));
66
+ // Keep the safe default unless the file is a real rules object: a valid but
67
+ // non-object payload (null, a number, an array) would otherwise throw on the
68
+ // `.javascript`/`.css` deref below, reintroducing the boot crash this guards.
69
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
70
+ boundaryRules = parsed;
71
+ } else {
72
+ process.stderr.write(
73
+ 'Warning: Notis app boundary rules are not a rules object; skipping local boundary checks.\n',
74
+ );
75
+ }
76
+ } catch (error) {
77
+ process.stderr.write(
78
+ `Warning: could not load Notis app boundary rules (${error.message}); skipping local boundary checks.\n`,
79
+ );
80
+ }
81
+ compiledRules = {
82
+ javascript: compileRules(boundaryRules.javascript),
83
+ css: compileRules(boundaryRules.css),
84
+ };
85
+ return compiledRules;
86
+ }
87
+
88
+ function collectProjectFiles(projectDir, dir, results) {
89
+ if (!existsSync(dir)) {
90
+ return;
91
+ }
92
+
93
+ const entries = readdirSync(dir, { withFileTypes: true });
94
+ for (const entry of entries) {
95
+ if (entry.name.startsWith('.')) {
96
+ if (entry.name !== '.storybook') {
97
+ continue;
98
+ }
99
+ }
100
+
101
+ const fullPath = join(dir, entry.name);
102
+ if (entry.isDirectory()) {
103
+ if (IGNORED_SOURCE_DIRS.has(entry.name)) {
104
+ continue;
105
+ }
106
+ collectProjectFiles(projectDir, fullPath, results);
107
+ continue;
108
+ }
109
+
110
+ if (!SOURCE_FILE_EXTENSIONS.has(extname(entry.name))) {
111
+ continue;
112
+ }
113
+
114
+ results.push({
115
+ path: fullPath,
116
+ relPath: fullPath.slice(projectDir.length + 1),
117
+ content: readFileSync(fullPath, 'utf-8'),
118
+ });
119
+ }
120
+ }
121
+
122
+ function applyRules(content, rules, relPath) {
123
+ const errors = [];
124
+ for (const rule of rules) {
125
+ if (rule.regex.test(content)) {
126
+ errors.push(`${relPath}: ${rule.message}`);
127
+ }
128
+ }
129
+ return errors;
130
+ }
131
+
132
+ function validateTextFile(relPath, content) {
133
+ const extension = extname(relPath);
134
+ const rules = getCompiledRules();
135
+ if (extension === '.css' || extension === '.scss' || extension === '.sass' || extension === '.pcss') {
136
+ return applyRules(content, rules.css, relPath);
137
+ }
138
+ if (extension === '.js' || extension === '.jsx' || extension === '.ts' || extension === '.tsx') {
139
+ return applyRules(content, rules.javascript, relPath);
140
+ }
141
+ return [];
142
+ }
143
+
144
+ export function collectProjectBoundaryViolations(projectDir) {
145
+ const files = [];
146
+ collectProjectFiles(projectDir, projectDir, files);
147
+ return files.flatMap((file) => validateTextFile(file.relPath, file.content));
148
+ }
149
+
150
+ export function collectArtifactBoundaryViolations(files) {
151
+ return Object.entries(files).flatMap(([relPath, rawContent]) => {
152
+ const extension = extname(relPath);
153
+ if (!['.js', '.css', '.scss', '.sass', '.pcss'].includes(extension)) {
154
+ return [];
155
+ }
156
+
157
+ const content = Buffer.isBuffer(rawContent)
158
+ ? rawContent.toString('utf-8')
159
+ : typeof rawContent === 'string'
160
+ ? rawContent
161
+ : String(rawContent ?? '');
162
+
163
+ return validateTextFile(relPath, content);
164
+ });
165
+ }
166
+
167
+ export function validateProjectBoundary(projectDir) {
168
+ const violations = collectProjectBoundaryViolations(projectDir);
169
+ if (violations.length > 0) {
170
+ throw usageError(
171
+ `Project violates the Notis app portal boundary:\n${violations.map((error) => ` - ${error}`).join('\n')}`,
172
+ );
173
+ }
174
+ }
175
+
176
+ export function validateArtifactBoundary(files) {
177
+ const violations = collectArtifactBoundaryViolations(files);
178
+ if (violations.length > 0) {
179
+ throw usageError(
180
+ `Built app violates the Notis app portal boundary:\n${violations.map((error) => ` - ${error}`).join('\n')}`,
181
+ );
182
+ }
183
+ }