@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,712 @@
1
+ /**
2
+ * Dev server for `notis apps dev`.
3
+ *
4
+ * Hosts one or more app bundles on a single HTTP server bound to 127.0.0.1.
5
+ * Each app lives at `/a/<slug>/...`:
6
+ * - `/a/<slug>/bundle/app.js` — watched Vite output
7
+ * - `/a/<slug>/bundle/app.css` — watched Vite output
8
+ * - `/a/<slug>/events` — SSE push on rebuild
9
+ *
10
+ * A `vite build --watch` is spawned per app so the monorepo case (one CLI
11
+ * invocation at the repo root, many apps under `apps/*`) stays cheap to run.
12
+ */
13
+
14
+ import { createServer } from 'node:http';
15
+ import { spawn } from 'node:child_process';
16
+ import { existsSync, readFileSync, statSync, watch as fsWatch } from 'node:fs';
17
+ import { dirname, join, resolve } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ import {
21
+ collectArtifactFiles,
22
+ collectSourceFiles,
23
+ exportNameFromPath,
24
+ getBundleDir,
25
+ loadAppConfig,
26
+ prepareArtifactBuild,
27
+ readManifest,
28
+ readLinkedState,
29
+ writeLinkedState,
30
+ } from './app-platform.js';
31
+ import { linkAppDevSessionTarget } from './app-dev-sessions.js';
32
+
33
+ const CONTENT_TYPES = {
34
+ '.js': 'application/javascript; charset=utf-8',
35
+ '.css': 'text/css; charset=utf-8',
36
+ '.map': 'application/json; charset=utf-8',
37
+ };
38
+ const MAX_JSON_BODY_BYTES = 64 * 1024;
39
+ const RUNTIME_DIR = dirname(fileURLToPath(import.meta.url));
40
+ const CLI_ROOT = resolve(RUNTIME_DIR, '../..');
41
+ const REPO_ROOT = resolve(RUNTIME_DIR, '../../../..');
42
+ const HARNESS_TEMPLATE_PATH = join(CLI_ROOT, 'template', '.harness', 'index.html.tmpl');
43
+ const FALLBACK_REACT_VERSION = '19.0.0';
44
+
45
+ function extFor(pathname) {
46
+ const idx = pathname.lastIndexOf('.');
47
+ return idx === -1 ? '' : pathname.slice(idx);
48
+ }
49
+
50
+ function isAllowedOrigin(origin) {
51
+ if (!origin) return true;
52
+ try {
53
+ const parsed = new URL(origin);
54
+ if (parsed.protocol === 'notis-app:') return true;
55
+ if (!['http:', 'https:'].includes(parsed.protocol)) return false;
56
+ return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(parsed.hostname);
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ function corsHeaders(origin) {
63
+ const allowOrigin = origin && isAllowedOrigin(origin) ? origin : '*';
64
+ return {
65
+ 'Access-Control-Allow-Origin': allowOrigin,
66
+ 'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS',
67
+ 'Access-Control-Allow-Headers': 'Content-Type, Cache-Control',
68
+ 'Cache-Control': 'no-store',
69
+ };
70
+ }
71
+
72
+ function safeJoin(baseDir, relPath) {
73
+ const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, '');
74
+ if (normalized.includes('..')) return null;
75
+ return join(baseDir, normalized);
76
+ }
77
+
78
+ function scriptJson(value) {
79
+ return JSON.stringify(value)
80
+ .replace(/</g, '\\u003c')
81
+ .replace(/\u2028/g, '\\u2028')
82
+ .replace(/\u2029/g, '\\u2029');
83
+ }
84
+
85
+ function timingMs(startedAt) {
86
+ return Number(process.hrtime.bigint() - startedAt) / 1_000_000;
87
+ }
88
+
89
+ function formatTimingMs(value) {
90
+ return value.toFixed(value < 10 ? 2 : 1);
91
+ }
92
+
93
+ function readJsonFile(path) {
94
+ if (!existsSync(path)) {
95
+ return null;
96
+ }
97
+ try {
98
+ return JSON.parse(readFileSync(path, 'utf-8'));
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ function readRequestJson(req) {
105
+ return new Promise((resolvePromise, rejectPromise) => {
106
+ let body = '';
107
+ req.setEncoding('utf8');
108
+ req.on('data', (chunk) => {
109
+ body += chunk;
110
+ if (Buffer.byteLength(body, 'utf8') > MAX_JSON_BODY_BYTES) {
111
+ rejectPromise(new Error('request body too large'));
112
+ req.destroy();
113
+ }
114
+ });
115
+ req.on('end', () => {
116
+ if (!body.trim()) {
117
+ resolvePromise({});
118
+ return;
119
+ }
120
+ try {
121
+ resolvePromise(JSON.parse(body));
122
+ } catch {
123
+ rejectPromise(new Error('invalid JSON body'));
124
+ }
125
+ });
126
+ req.on('error', rejectPromise);
127
+ });
128
+ }
129
+
130
+ function reactVersionFromPeer(peerRange) {
131
+ if (typeof peerRange !== 'string' || !peerRange) {
132
+ return FALLBACK_REACT_VERSION;
133
+ }
134
+ const exact = peerRange.match(/\d+\.\d+\.\d+/);
135
+ if (exact && !/[<>=~^*x]/i.test(peerRange.replace(exact[0], ''))) {
136
+ return exact[0];
137
+ }
138
+ if (peerRange.includes('19') || peerRange.includes('18')) {
139
+ return FALLBACK_REACT_VERSION;
140
+ }
141
+ return FALLBACK_REACT_VERSION;
142
+ }
143
+
144
+ function resolveHarnessReactVersion(projectDir) {
145
+ const candidates = [
146
+ join(projectDir, 'node_modules', '@notis', 'sdk', 'package.json'),
147
+ join(REPO_ROOT, 'packages', 'sdk', 'package.json'),
148
+ join(CLI_ROOT, 'template', 'packages', 'sdk', 'package.json'),
149
+ ];
150
+ for (const candidate of candidates) {
151
+ const pkg = readJsonFile(candidate);
152
+ const peer = pkg?.peerDependencies?.react;
153
+ if (peer) {
154
+ return reactVersionFromPeer(peer);
155
+ }
156
+ }
157
+ return FALLBACK_REACT_VERSION;
158
+ }
159
+
160
+ function titleFromSlug(slug) {
161
+ return String(slug || '')
162
+ .replace(/[-_]+/g, ' ')
163
+ .replace(/\b\w/g, (char) => char.toUpperCase());
164
+ }
165
+
166
+ function normalizeDatabaseDescriptors(databases) {
167
+ return (Array.isArray(databases) ? databases : [])
168
+ .map((entry) => {
169
+ if (typeof entry === 'string') {
170
+ return {
171
+ slug: entry,
172
+ title: titleFromSlug(entry),
173
+ description: null,
174
+ icon: null,
175
+ properties: [],
176
+ };
177
+ }
178
+ if (entry && typeof entry === 'object' && typeof entry.slug === 'string') {
179
+ return {
180
+ slug: entry.slug,
181
+ title: entry.title || titleFromSlug(entry.slug),
182
+ description: entry.description || null,
183
+ icon: entry.icon || null,
184
+ properties: Array.isArray(entry.properties) ? entry.properties : [],
185
+ };
186
+ }
187
+ return null;
188
+ })
189
+ .filter(Boolean);
190
+ }
191
+
192
+ function normalizeToolDescriptors(tools) {
193
+ return (Array.isArray(tools) ? tools : [])
194
+ .map((entry) => {
195
+ if (typeof entry === 'string') {
196
+ return { name: entry };
197
+ }
198
+ if (entry && typeof entry === 'object' && typeof entry.name === 'string') {
199
+ return entry;
200
+ }
201
+ return null;
202
+ })
203
+ .filter(Boolean);
204
+ }
205
+
206
+ function defaultRouteForManifest(manifest) {
207
+ const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
208
+ return routes.find((route) => route?.default) || routes[0] || null;
209
+ }
210
+
211
+ function findHarnessRoute(manifest, routeSlug) {
212
+ const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
213
+ if (!routeSlug) {
214
+ return defaultRouteForManifest(manifest);
215
+ }
216
+ return routes.find((route) => route?.slug === routeSlug) || null;
217
+ }
218
+
219
+ function buildHarnessDescriptor({ state, manifest, appConfig, route, scenario = null }) {
220
+ const databases = normalizeDatabaseDescriptors(
221
+ Array.isArray(appConfig?.databases) && appConfig.databases.length
222
+ ? appConfig.databases
223
+ : manifest.databases,
224
+ );
225
+ const tools = normalizeToolDescriptors(
226
+ Array.isArray(appConfig?.tools) && appConfig.tools.length
227
+ ? appConfig.tools
228
+ : manifest.tools,
229
+ );
230
+
231
+ return {
232
+ app: {
233
+ id: state.appId || 'harness-app',
234
+ slug: state.slug,
235
+ name: manifest.app?.name || appConfig?.name || state.slug,
236
+ icon: manifest.app?.icon || appConfig?.icon || null,
237
+ description: manifest.app?.description || appConfig?.description || null,
238
+ },
239
+ route: {
240
+ slug: route.slug,
241
+ path: route.path || '/',
242
+ name: route.name || titleFromSlug(route.slug),
243
+ icon: route.icon || null,
244
+ parentSlug: route.parentSlug || null,
245
+ default: Boolean(route.default),
246
+ collection: route.collection || null,
247
+ },
248
+ databases,
249
+ context: { collectionItem: null, screenshotScenario: scenario },
250
+ tools,
251
+ };
252
+ }
253
+
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 }) {
269
+ const template = readFileSync(HARNESS_TEMPLATE_PATH, 'utf-8');
270
+ const descriptor = buildHarnessDescriptor({ state, manifest, appConfig, route, scenario });
271
+ const routeExport = route.export_name || route.exportName || exportNameFromPath(route.path || '/');
272
+ const replacements = {
273
+ '{{REACT_VERSION}}': resolveHarnessReactVersion(state.projectDir),
274
+ '{{ROUTE_EXPORT}}': scriptJson(routeExport),
275
+ '{{RUNTIME_DESCRIPTOR}}': scriptJson(descriptor),
276
+ '{{MODE}}': scriptJson(harnessOptions.mode || 'stub'),
277
+ '{{API_BASE}}': scriptJson(harnessOptions.apiBase || null),
278
+ '{{JWT}}': scriptJson(harnessOptions.jwt || null),
279
+ '{{FIXTURES}}': scriptJson(harnessFixtures(state.projectDir, scenario)),
280
+ };
281
+ let html = template;
282
+ for (const [token, value] of Object.entries(replacements)) {
283
+ html = html.replaceAll(token, value);
284
+ }
285
+ return html;
286
+ }
287
+
288
+ /**
289
+ * Start the dev server for one or more apps.
290
+ *
291
+ * @param {{apps: Array<{slug: string, projectDir: string, appId?: string, targetAppId?: string, userId?: string}>, port: number, watch?: boolean, harness?: { mode?: string, apiBase?: string, jwt?: string }, log?: (m: string) => void, logError?: (m: string) => void}} options
292
+ */
293
+ export async function startAppDevServer({
294
+ apps,
295
+ port,
296
+ watch = true,
297
+ harness = {},
298
+ log = (msg) => process.stdout.write(`${msg}\n`),
299
+ logError = (msg) => process.stderr.write(`${msg}\n`),
300
+ }) {
301
+ if (!Array.isArray(apps) || apps.length === 0) {
302
+ throw new Error('startAppDevServer requires at least one app.');
303
+ }
304
+
305
+ const appState = new Map();
306
+ for (const app of apps) {
307
+ appState.set(app.slug, {
308
+ slug: app.slug,
309
+ projectDir: app.projectDir,
310
+ appId: app.appId || null,
311
+ targetAppId: app.targetAppId || null,
312
+ userId: app.userId || null,
313
+ canonicalBundleDir: getBundleDir(app.projectDir),
314
+ bundleDir: null,
315
+ jsPath: null,
316
+ sseClients: new Set(),
317
+ watcher: null,
318
+ buildProcess: null,
319
+ lastMtimeMs: 0,
320
+ watchPollTimer: null,
321
+ });
322
+ }
323
+
324
+ function broadcastReload(slug) {
325
+ const state = appState.get(slug);
326
+ if (!state) return;
327
+ const payload = `event: reload\ndata: ${Date.now()}\n\n`;
328
+ for (const res of state.sseClients) {
329
+ try {
330
+ res.write(payload);
331
+ } catch {
332
+ state.sseClients.delete(res);
333
+ }
334
+ }
335
+ }
336
+
337
+ function matchAppRoute(pathname) {
338
+ // Matches `/a/<slug>/<rest>` — returns { slug, rest } or null.
339
+ const match = pathname.match(/^\/a\/([^/]+)(?:\/(.*))?$/);
340
+ if (!match) return null;
341
+ return { slug: decodeURIComponent(match[1]), rest: match[2] || '' };
342
+ }
343
+
344
+ function resolveBundleDir(state) {
345
+ if (existsSync(join(state.canonicalBundleDir, 'app.js'))) {
346
+ return state.canonicalBundleDir;
347
+ }
348
+ return null;
349
+ }
350
+
351
+ function updateBundleDir(state, bundleDir) {
352
+ if (!bundleDir || state.bundleDir === bundleDir) {
353
+ return;
354
+ }
355
+ if (state.watcher) {
356
+ try {
357
+ state.watcher.close();
358
+ } catch {
359
+ // ignore
360
+ }
361
+ state.watcher = null;
362
+ }
363
+ state.bundleDir = bundleDir;
364
+ state.jsPath = join(bundleDir, 'app.js');
365
+ try {
366
+ state.lastMtimeMs = statSync(state.jsPath).mtimeMs;
367
+ } catch {
368
+ state.lastMtimeMs = 0;
369
+ }
370
+ }
371
+
372
+ async function serveHarness(req, res, url, state, headers) {
373
+ let manifest;
374
+ let appConfig;
375
+ try {
376
+ manifest = readManifest(state.projectDir);
377
+ appConfig = await loadAppConfig(state.projectDir);
378
+ } catch (error) {
379
+ res.writeHead(500, {
380
+ ...headers,
381
+ 'Content-Type': 'text/plain; charset=utf-8',
382
+ });
383
+ res.end(error instanceof Error ? error.message : String(error));
384
+ return;
385
+ }
386
+
387
+ const routeSlug = url.searchParams.get('route') || '';
388
+ const scenario = url.searchParams.get('scenario') || null;
389
+ const route = findHarnessRoute(manifest, routeSlug);
390
+ if (!route) {
391
+ res.writeHead(404, {
392
+ ...headers,
393
+ 'Content-Type': 'text/plain; charset=utf-8',
394
+ });
395
+ res.end(routeSlug ? `unknown route: ${routeSlug}` : 'no default route');
396
+ return;
397
+ }
398
+
399
+ const html = renderHarnessHtml({
400
+ state,
401
+ manifest,
402
+ appConfig,
403
+ route,
404
+ harnessOptions: harness,
405
+ scenario,
406
+ });
407
+ res.writeHead(200, {
408
+ ...headers,
409
+ 'Content-Type': 'text/html; charset=utf-8',
410
+ 'Content-Length': String(Buffer.byteLength(html)),
411
+ });
412
+ if (req.method === 'HEAD') {
413
+ res.end();
414
+ } else {
415
+ res.end(html);
416
+ }
417
+ }
418
+
419
+ const server = createServer((req, res) => {
420
+ const url = new URL(req.url || '/', `http://127.0.0.1:${port}`);
421
+ const origin = req.headers.origin || '';
422
+ const headers = corsHeaders(origin);
423
+ if (origin && !isAllowedOrigin(origin)) {
424
+ res.writeHead(403, headers);
425
+ res.end('origin not allowed');
426
+ return;
427
+ }
428
+
429
+ if (req.method === 'OPTIONS') {
430
+ res.writeHead(204, headers);
431
+ res.end();
432
+ return;
433
+ }
434
+
435
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
436
+ if (req.method === 'POST') {
437
+ const routed = matchAppRoute(url.pathname);
438
+ const state = routed ? appState.get(routed.slug) : null;
439
+ if (state && routed.rest === 'link') {
440
+ void (async () => {
441
+ try {
442
+ const body = await readRequestJson(req);
443
+ const appId = typeof body.app_id === 'string' ? body.app_id.trim() : '';
444
+ if (!appId) {
445
+ res.writeHead(400, {
446
+ ...headers,
447
+ 'Content-Type': 'application/json; charset=utf-8',
448
+ });
449
+ res.end(JSON.stringify({ error: 'app_id is required' }));
450
+ return;
451
+ }
452
+
453
+ const currentState = readLinkedState(state.projectDir) || {};
454
+ const now = new Date().toISOString();
455
+ writeLinkedState(state.projectDir, {
456
+ ...currentState,
457
+ app_id: appId,
458
+ dev_app_id: state.appId || currentState.dev_app_id,
459
+ linked_at: currentState.linked_at || now,
460
+ dev_linked_at: currentState.dev_linked_at || now,
461
+ updated_at: now,
462
+ });
463
+ state.targetAppId = appId;
464
+ linkAppDevSessionTarget({
465
+ appId: state.appId,
466
+ devSlug: state.slug,
467
+ targetAppId: appId,
468
+ lastHeartbeatAt: now,
469
+ });
470
+ const response = {
471
+ ok: true,
472
+ app_id: appId,
473
+ dev_app_id: state.appId || null,
474
+ target_app_id: appId,
475
+ };
476
+ res.writeHead(200, {
477
+ ...headers,
478
+ 'Content-Type': 'application/json; charset=utf-8',
479
+ });
480
+ res.end(JSON.stringify(response));
481
+ } catch (error) {
482
+ const message = error instanceof Error ? error.message : String(error);
483
+ res.writeHead(400, {
484
+ ...headers,
485
+ 'Content-Type': 'application/json; charset=utf-8',
486
+ });
487
+ res.end(JSON.stringify({ error: message }));
488
+ }
489
+ })();
490
+ return;
491
+ }
492
+ }
493
+ res.writeHead(405, headers);
494
+ res.end('method not allowed');
495
+ return;
496
+ }
497
+
498
+ if (url.pathname === '/healthz') {
499
+ const now = new Date().toISOString();
500
+ const sessions = Array.from(appState.values()).map((state) => ({
501
+ appId: state.appId,
502
+ targetAppId: state.targetAppId || undefined,
503
+ userId: state.userId,
504
+ devSlug: state.slug,
505
+ bundleBaseUrl: `http://127.0.0.1:${port}/a/${state.slug}`,
506
+ projectDir: state.projectDir,
507
+ lastHeartbeatAt: now,
508
+ status: 'connected',
509
+ }));
510
+ res.writeHead(200, {
511
+ ...headers,
512
+ 'Content-Type': 'application/json; charset=utf-8',
513
+ });
514
+ res.end(JSON.stringify({ ok: true, apps: Array.from(appState.keys()), sessions }));
515
+ return;
516
+ }
517
+
518
+ const routed = matchAppRoute(url.pathname);
519
+ if (!routed) {
520
+ res.writeHead(404, headers);
521
+ res.end('not found');
522
+ return;
523
+ }
524
+
525
+ const state = appState.get(routed.slug);
526
+ if (!state) {
527
+ res.writeHead(404, headers);
528
+ res.end(`unknown app: ${routed.slug}`);
529
+ return;
530
+ }
531
+
532
+ if (routed.rest === 'events') {
533
+ res.writeHead(200, {
534
+ ...headers,
535
+ 'Content-Type': 'text/event-stream; charset=utf-8',
536
+ Connection: 'keep-alive',
537
+ });
538
+ res.write(': connected\n\n');
539
+ state.sseClients.add(res);
540
+ req.on('close', () => state.sseClients.delete(res));
541
+ return;
542
+ }
543
+
544
+ if (routed.rest === 'harness') {
545
+ void serveHarness(req, res, url, state, headers);
546
+ return;
547
+ }
548
+
549
+ if (routed.rest === 'snapshot') {
550
+ try {
551
+ updateBundleDir(state, resolveBundleDir(state));
552
+ const manifest = readManifest(state.projectDir);
553
+ const payload = {
554
+ app_id: state.appId,
555
+ target_app_id: state.targetAppId || null,
556
+ dev_slug: state.slug,
557
+ manifest,
558
+ files: collectArtifactFiles(state.projectDir),
559
+ source_files: collectSourceFiles(state.projectDir),
560
+ };
561
+ const body = JSON.stringify(payload);
562
+ res.writeHead(200, {
563
+ ...headers,
564
+ 'Content-Type': 'application/json; charset=utf-8',
565
+ 'Content-Length': String(Buffer.byteLength(body)),
566
+ });
567
+ if (req.method === 'HEAD') {
568
+ res.end();
569
+ } else {
570
+ res.end(body);
571
+ }
572
+ } catch (error) {
573
+ const message = error instanceof Error ? error.message : String(error);
574
+ res.writeHead(500, {
575
+ ...headers,
576
+ 'Content-Type': 'application/json; charset=utf-8',
577
+ });
578
+ res.end(JSON.stringify({ error: message }));
579
+ }
580
+ return;
581
+ }
582
+
583
+ if (routed.rest.startsWith('bundle/')) {
584
+ const startedAt = process.hrtime.bigint();
585
+ updateBundleDir(state, resolveBundleDir(state));
586
+ const rel = routed.rest.slice('bundle/'.length);
587
+ if (!state.bundleDir) {
588
+ res.writeHead(404, headers);
589
+ res.end('not found');
590
+ log(`[notis apps timing] ${state.slug}: GET bundle/${rel} -> 404 in ${formatTimingMs(timingMs(startedAt))}ms`);
591
+ return;
592
+ }
593
+ const full = safeJoin(state.bundleDir, rel);
594
+ if (!full || !existsSync(full)) {
595
+ res.writeHead(404, headers);
596
+ res.end('not found');
597
+ log(`[notis apps timing] ${state.slug}: GET bundle/${rel} -> 404 in ${formatTimingMs(timingMs(startedAt))}ms`);
598
+ return;
599
+ }
600
+ const ext = extFor(rel);
601
+ const contentType = CONTENT_TYPES[ext] || 'application/octet-stream';
602
+ const content = readFileSync(full);
603
+ res.writeHead(200, {
604
+ ...headers,
605
+ 'Content-Type': contentType,
606
+ 'Content-Length': String(content.byteLength),
607
+ });
608
+ if (req.method === 'HEAD') {
609
+ res.end();
610
+ } else {
611
+ res.end(content);
612
+ }
613
+ log(
614
+ `[notis apps timing] ${state.slug}: GET bundle/${rel} -> 200 ` +
615
+ `${content.byteLength}B in ${formatTimingMs(timingMs(startedAt))}ms`,
616
+ );
617
+ return;
618
+ }
619
+
620
+ res.writeHead(404, headers);
621
+ res.end('not found');
622
+ });
623
+
624
+ await new Promise((resolvePromise, rejectPromise) => {
625
+ server.once('error', rejectPromise);
626
+ server.listen(port, '127.0.0.1', () => {
627
+ server.off('error', rejectPromise);
628
+ resolvePromise();
629
+ });
630
+ });
631
+
632
+ function startBundleWatch(state) {
633
+ if (state.watcher || !state.bundleDir) return;
634
+ try {
635
+ state.watcher = fsWatch(state.bundleDir, { persistent: false }, (_event, filename) => {
636
+ if (!filename || filename !== 'app.js') return;
637
+ try {
638
+ const stat = statSync(state.jsPath);
639
+ if (stat.mtimeMs === state.lastMtimeMs) return;
640
+ state.lastMtimeMs = stat.mtimeMs;
641
+ log(`[notis apps dev] ${state.slug}: bundle updated — reloading portal`);
642
+ broadcastReload(state.slug);
643
+ } catch {
644
+ // Bundle file may be missing mid-write; ignore.
645
+ }
646
+ });
647
+ } catch (error) {
648
+ logError(`[notis apps dev] ${state.slug}: watch failed: ${error.message}`);
649
+ }
650
+ }
651
+
652
+ function pollForBundleAndWatch(state) {
653
+ const bundleDir = resolveBundleDir(state);
654
+ if (bundleDir) {
655
+ updateBundleDir(state, bundleDir);
656
+ startBundleWatch(state);
657
+ return;
658
+ }
659
+ state.watchPollTimer = setTimeout(() => pollForBundleAndWatch(state), 300);
660
+ }
661
+
662
+ for (const state of appState.values()) {
663
+ if (watch) {
664
+ await prepareArtifactBuild(state.projectDir);
665
+ pollForBundleAndWatch(state);
666
+
667
+ state.buildProcess = spawn('npm', ['run', 'build', '--', '--watch'], {
668
+ cwd: state.projectDir,
669
+ stdio: 'inherit',
670
+ env: { ...process.env, NOTIS_DEV: '1' },
671
+ });
672
+
673
+ state.buildProcess.on('exit', (code) => {
674
+ if (code !== 0 && code !== null) {
675
+ logError(`[notis apps dev] ${state.slug}: vite build --watch exited with code ${code}`);
676
+ }
677
+ });
678
+ } else {
679
+ updateBundleDir(state, resolveBundleDir(state));
680
+ }
681
+
682
+ log(`[notis apps dev] ${state.slug}: serving bundle at http://127.0.0.1:${port}/a/${state.slug}/bundle/app.js`);
683
+ }
684
+
685
+ return {
686
+ port,
687
+ async close() {
688
+ for (const state of appState.values()) {
689
+ if (state.watchPollTimer) clearTimeout(state.watchPollTimer);
690
+ if (state.watcher) {
691
+ try {
692
+ state.watcher.close();
693
+ } catch {
694
+ // ignore
695
+ }
696
+ }
697
+ for (const res of state.sseClients) {
698
+ try {
699
+ res.end();
700
+ } catch {
701
+ // ignore
702
+ }
703
+ }
704
+ state.sseClients.clear();
705
+ if (state.buildProcess && state.buildProcess.exitCode === null) {
706
+ state.buildProcess.kill('SIGTERM');
707
+ }
708
+ }
709
+ await new Promise((resolvePromise) => server.close(() => resolvePromise()));
710
+ },
711
+ };
712
+ }