@notis_ai/cli 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +47 -5
  2. package/dist/scaffolds/notis-database/CHANGELOG.md +5 -0
  3. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  4. package/dist/scaffolds/notis-database/notis.config.ts +0 -1
  5. package/dist/scaffolds/notis-database/src/mock-runtime.ts +1 -0
  6. package/dist/scaffolds/notis-journal/CHANGELOG.md +25 -0
  7. package/dist/scaffolds/notis-journal/app/globals.css +37 -0
  8. package/dist/scaffolds/notis-journal/app/insights/page.tsx +513 -0
  9. package/dist/scaffolds/notis-journal/app/journal-core.tsx +362 -0
  10. package/dist/scaffolds/notis-journal/app/journal-ui.tsx +337 -0
  11. package/dist/scaffolds/notis-journal/app/layout.tsx +6 -0
  12. package/dist/scaffolds/notis-journal/app/page.tsx +485 -0
  13. package/dist/scaffolds/notis-journal/components/ui/badge.tsx +28 -0
  14. package/dist/scaffolds/notis-journal/components/ui/button.tsx +53 -0
  15. package/dist/scaffolds/notis-journal/components/ui/card.tsx +56 -0
  16. package/dist/scaffolds/notis-journal/components.json +20 -0
  17. package/dist/scaffolds/notis-journal/index.html +12 -0
  18. package/dist/scaffolds/notis-journal/lib/utils.ts +6 -0
  19. package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
  20. package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
  21. package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
  22. package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
  23. package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
  24. package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
  25. package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +132 -0
  26. package/dist/scaffolds/notis-journal/notis.config.ts +93 -0
  27. package/dist/scaffolds/notis-journal/package.json +34 -0
  28. package/dist/scaffolds/notis-journal/packages/sdk/package.json +36 -0
  29. package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  30. package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +60 -0
  31. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
  32. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  33. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  34. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +145 -0
  35. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +229 -0
  36. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +41 -0
  37. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  38. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +78 -0
  39. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +121 -0
  40. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
  41. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +34 -0
  42. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  43. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +64 -0
  44. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +56 -0
  45. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  46. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  47. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +83 -0
  48. package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +43 -0
  49. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +220 -0
  50. package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +186 -0
  51. package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +15 -0
  52. package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +56 -0
  53. package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +15 -0
  54. package/dist/scaffolds/notis-journal/postcss.config.mjs +8 -0
  55. package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +120 -0
  56. package/dist/scaffolds/notis-journal/src/dev-main.tsx +58 -0
  57. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +197 -0
  58. package/dist/scaffolds/notis-journal/tailwind.config.ts +58 -0
  59. package/dist/scaffolds/notis-journal/tsconfig.json +23 -0
  60. package/dist/scaffolds/notis-journal/vite.config.ts +10 -0
  61. package/dist/scaffolds/notis-notes/CHANGELOG.md +5 -0
  62. package/dist/scaffolds/notis-notes/app/page.tsx +325 -543
  63. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  64. package/dist/scaffolds/notis-notes/notis.config.ts +0 -1
  65. package/dist/scaffolds/notis-random/CHANGELOG.md +5 -0
  66. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  67. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  68. package/dist/scaffolds/notis-random/notis.config.ts +0 -1
  69. package/dist/scaffolds/notis-random/src/mock-runtime.ts +1 -0
  70. package/dist/scaffolds.json +11 -0
  71. package/package.json +3 -3
  72. package/skills/notis-apps/SKILL.md +64 -6
  73. package/skills/notis-apps/cli.md +38 -2
  74. package/skills/notis-cli/SKILL.md +20 -2
  75. package/skills/notis-query/cli.md +1 -1
  76. package/src/command-specs/apps.js +529 -16
  77. package/src/command-specs/index.js +1 -1
  78. package/src/command-specs/meta.js +8 -2
  79. package/src/command-specs/tools.js +50 -37
  80. package/src/runtime/agent-browser.js +273 -1
  81. package/src/runtime/app-changelog.js +79 -0
  82. package/src/runtime/app-dev-server.js +113 -6
  83. package/src/runtime/app-dev-sessions.js +176 -12
  84. package/src/runtime/app-platform.js +200 -39
  85. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  86. package/src/runtime/desktop-auth.js +93 -0
  87. package/src/runtime/profiles.js +37 -15
  88. package/src/runtime/store-screenshot.js +138 -0
  89. package/src/runtime/transport.js +21 -82
  90. package/template/.harness/index.html.tmpl +128 -6
  91. package/template/CHANGELOG.md +5 -0
  92. package/template/app/page.tsx +11 -41
  93. package/template/notis.config.ts +0 -1
  94. package/template/package-lock.json +4137 -0
  95. package/template/package.json +1 -0
  96. package/template/packages/sdk/package.json +4 -0
  97. package/template/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  98. package/template/packages/sdk/src/components/Markdown.tsx +60 -0
  99. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +7 -2
  100. package/template/packages/sdk/src/config.ts +74 -0
  101. package/template/packages/sdk/src/documents.ts +229 -0
  102. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  103. package/template/packages/sdk/src/hooks/useDocument.ts +78 -0
  104. package/template/packages/sdk/src/hooks/useDocuments.ts +121 -0
  105. package/template/packages/sdk/src/hooks/useMultiSelect.ts +38 -2
  106. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +54 -9
  107. package/template/packages/sdk/src/index.ts +30 -1
  108. package/template/packages/sdk/src/runtime.ts +76 -17
  109. package/template/packages/sdk/src/styles.css +148 -0
  110. package/dist/scaffolds/notis-database/metadata/cover.png +0 -0
  111. package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
  112. package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
  113. package/dist/scaffolds/notis-notes/metadata/cover.png +0 -0
  114. package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
  115. package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
  116. package/dist/scaffolds/notis-random/metadata/cover.png +0 -0
  117. package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
  118. package/template/metadata/cover.png +0 -0
  119. package/template/packages/sdk/src/hooks/useDatabase.ts +0 -76
@@ -25,13 +25,17 @@ import {
25
25
  loadAppConfig,
26
26
  prepareArtifactBuild,
27
27
  readManifest,
28
+ readLinkedState,
29
+ writeLinkedState,
28
30
  } from './app-platform.js';
31
+ import { linkAppDevSessionTarget } from './app-dev-sessions.js';
29
32
 
30
33
  const CONTENT_TYPES = {
31
34
  '.js': 'application/javascript; charset=utf-8',
32
35
  '.css': 'text/css; charset=utf-8',
33
36
  '.map': 'application/json; charset=utf-8',
34
37
  };
38
+ const MAX_JSON_BODY_BYTES = 64 * 1024;
35
39
  const RUNTIME_DIR = dirname(fileURLToPath(import.meta.url));
36
40
  const CLI_ROOT = resolve(RUNTIME_DIR, '../..');
37
41
  const REPO_ROOT = resolve(RUNTIME_DIR, '../../../..');
@@ -59,7 +63,7 @@ function corsHeaders(origin) {
59
63
  const allowOrigin = origin && isAllowedOrigin(origin) ? origin : '*';
60
64
  return {
61
65
  'Access-Control-Allow-Origin': allowOrigin,
62
- 'Access-Control-Allow-Methods': 'GET, OPTIONS',
66
+ 'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS',
63
67
  'Access-Control-Allow-Headers': 'Content-Type, Cache-Control',
64
68
  'Cache-Control': 'no-store',
65
69
  };
@@ -97,6 +101,32 @@ function readJsonFile(path) {
97
101
  }
98
102
  }
99
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
+
100
130
  function reactVersionFromPeer(peerRange) {
101
131
  if (typeof peerRange !== 'string' || !peerRange) {
102
132
  return FALLBACK_REACT_VERSION;
@@ -186,7 +216,7 @@ function findHarnessRoute(manifest, routeSlug) {
186
216
  return routes.find((route) => route?.slug === routeSlug) || null;
187
217
  }
188
218
 
189
- function buildHarnessDescriptor({ state, manifest, appConfig, route }) {
219
+ function buildHarnessDescriptor({ state, manifest, appConfig, route, scenario = null }) {
190
220
  const databases = normalizeDatabaseDescriptors(
191
221
  Array.isArray(appConfig?.databases) && appConfig.databases.length
192
222
  ? appConfig.databases
@@ -216,14 +246,28 @@ function buildHarnessDescriptor({ state, manifest, appConfig, route }) {
216
246
  collection: route.collection || null,
217
247
  },
218
248
  databases,
219
- context: { collectionItem: null },
249
+ context: { collectionItem: null, screenshotScenario: scenario },
220
250
  tools,
221
251
  };
222
252
  }
223
253
 
224
- function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions }) {
254
+ function harnessFixtures(projectDir, scenario) {
255
+ const fixtureConfig = readJsonFile(join(projectDir, 'metadata', 'screenshot-fixtures.json')) || {};
256
+ const scenarios = fixtureConfig.scenarios && typeof fixtureConfig.scenarios === 'object'
257
+ ? fixtureConfig.scenarios
258
+ : {};
259
+ return {
260
+ tools: fixtureConfig.tools && typeof fixtureConfig.tools === 'object' ? fixtureConfig.tools : {},
261
+ requests: fixtureConfig.requests && typeof fixtureConfig.requests === 'object' ? fixtureConfig.requests : {},
262
+ scenario: scenario && scenarios[scenario] && typeof scenarios[scenario] === 'object'
263
+ ? scenarios[scenario]
264
+ : null,
265
+ };
266
+ }
267
+
268
+ function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions, scenario = null }) {
225
269
  const template = readFileSync(HARNESS_TEMPLATE_PATH, 'utf-8');
226
- const descriptor = buildHarnessDescriptor({ state, manifest, appConfig, route });
270
+ const descriptor = buildHarnessDescriptor({ state, manifest, appConfig, route, scenario });
227
271
  const routeExport = route.export_name || route.exportName || exportNameFromPath(route.path || '/');
228
272
  const replacements = {
229
273
  '{{REACT_VERSION}}': resolveHarnessReactVersion(state.projectDir),
@@ -232,6 +276,7 @@ function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions }
232
276
  '{{MODE}}': scriptJson(harnessOptions.mode || 'stub'),
233
277
  '{{API_BASE}}': scriptJson(harnessOptions.apiBase || null),
234
278
  '{{JWT}}': scriptJson(harnessOptions.jwt || null),
279
+ '{{FIXTURES}}': scriptJson(harnessFixtures(state.projectDir, scenario)),
235
280
  };
236
281
  let html = template;
237
282
  for (const [token, value] of Object.entries(replacements)) {
@@ -243,7 +288,7 @@ function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions }
243
288
  /**
244
289
  * Start the dev server for one or more apps.
245
290
  *
246
- * @param {{apps: Array<{slug: string, projectDir: string, appId?: string, userId?: string}>, port: number, watch?: boolean, harness?: { mode?: string, apiBase?: string, jwt?: string }, log?: (m: string) => void, logError?: (m: string) => void}} options
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
247
292
  */
248
293
  export async function startAppDevServer({
249
294
  apps,
@@ -263,6 +308,7 @@ export async function startAppDevServer({
263
308
  slug: app.slug,
264
309
  projectDir: app.projectDir,
265
310
  appId: app.appId || null,
311
+ targetAppId: app.targetAppId || null,
266
312
  userId: app.userId || null,
267
313
  canonicalBundleDir: getBundleDir(app.projectDir),
268
314
  bundleDir: null,
@@ -339,6 +385,7 @@ export async function startAppDevServer({
339
385
  }
340
386
 
341
387
  const routeSlug = url.searchParams.get('route') || '';
388
+ const scenario = url.searchParams.get('scenario') || null;
342
389
  const route = findHarnessRoute(manifest, routeSlug);
343
390
  if (!route) {
344
391
  res.writeHead(404, {
@@ -355,6 +402,7 @@ export async function startAppDevServer({
355
402
  appConfig,
356
403
  route,
357
404
  harnessOptions: harness,
405
+ scenario,
358
406
  });
359
407
  res.writeHead(200, {
360
408
  ...headers,
@@ -385,6 +433,63 @@ export async function startAppDevServer({
385
433
  }
386
434
 
387
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
+ }
388
493
  res.writeHead(405, headers);
389
494
  res.end('method not allowed');
390
495
  return;
@@ -394,6 +499,7 @@ export async function startAppDevServer({
394
499
  const now = new Date().toISOString();
395
500
  const sessions = Array.from(appState.values()).map((state) => ({
396
501
  appId: state.appId,
502
+ targetAppId: state.targetAppId || undefined,
397
503
  userId: state.userId,
398
504
  devSlug: state.slug,
399
505
  bundleBaseUrl: `http://127.0.0.1:${port}/a/${state.slug}`,
@@ -446,6 +552,7 @@ export async function startAppDevServer({
446
552
  const manifest = readManifest(state.projectDir);
447
553
  const payload = {
448
554
  app_id: state.appId,
555
+ target_app_id: state.targetAppId || null,
449
556
  dev_slug: state.slug,
450
557
  manifest,
451
558
  files: collectArtifactFiles(state.projectDir),
@@ -1,33 +1,85 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join, parse as parsePath } from 'node:path';
4
5
  import { CONFIG_DIR } from './profiles.js';
5
6
 
6
7
  export const DEFAULT_APP_DEV_SESSIONS_FILE = join(CONFIG_DIR, 'app-dev-sessions.json');
7
8
  export const APP_DEV_SESSIONS_FILE = DEFAULT_APP_DEV_SESSIONS_FILE;
8
9
  export const APP_DEV_SESSIONS_VERSION = 1;
10
+ export const APP_DEV_SESSION_MOUNT_ACKS_VERSION = 1;
11
+
12
+ /**
13
+ * Walk up from `startDir` looking for a workspace-scoped dev-sessions registry.
14
+ *
15
+ * The Notis desktop (and Conductor) reads a PER-WORKSPACE registry at
16
+ * `<workspace>/.context/app-dev-sessions.json` (it points its own `apps dev`
17
+ * at it via `NOTIS_APP_DEV_SESSIONS_FILE`). When a coding agent runs
18
+ * `apps dev` manually it does not inherit that env var, so its session would
19
+ * otherwise land in the global `~/.notis` registry and never appear in the
20
+ * desktop's Local development sidebar. Resolving the nearest `.context`
21
+ * registry keeps agent-run and desktop-run sessions in the same file.
22
+ *
23
+ * Returns the registry path if a `.context/` directory is found in `startDir`
24
+ * or an ancestor (stopping at the home directory / filesystem root), else null.
25
+ */
26
+ export function findWorkspaceAppDevSessionsFile(startDir = process.cwd()) {
27
+ let dir = startDir;
28
+ const home = homedir();
29
+ const { root } = parsePath(dir);
30
+ // Bounded walk; `.context` lives at the workspace root, never above $HOME.
31
+ for (let depth = 0; depth < 64; depth += 1) {
32
+ if (existsSync(join(dir, '.context'))) {
33
+ return join(dir, '.context', 'app-dev-sessions.json');
34
+ }
35
+ if (dir === home || dir === root) break;
36
+ const parent = dirname(dir);
37
+ if (parent === dir) break;
38
+ dir = parent;
39
+ }
40
+ return null;
41
+ }
9
42
 
10
43
  export function getAppDevSessionsFile(filePath) {
11
44
  if (filePath) {
12
45
  return filePath;
13
46
  }
14
47
  const envPath = process.env.NOTIS_APP_DEV_SESSIONS_FILE;
15
- return typeof envPath === 'string' && envPath.trim()
16
- ? envPath.trim()
17
- : DEFAULT_APP_DEV_SESSIONS_FILE;
48
+ if (typeof envPath === 'string' && envPath.trim()) {
49
+ return envPath.trim();
50
+ }
51
+ const workspaceFile = findWorkspaceAppDevSessionsFile();
52
+ if (workspaceFile) {
53
+ return workspaceFile;
54
+ }
55
+ return DEFAULT_APP_DEV_SESSIONS_FILE;
56
+ }
57
+
58
+ export function getAppDevSessionMountAcksFile(sessionsFilePath) {
59
+ const resolvedSessionsFile = getAppDevSessionsFile(sessionsFilePath);
60
+ const parsed = parsePath(resolvedSessionsFile);
61
+ return join(parsed.dir, `${parsed.name}-mount-acks${parsed.ext || '.json'}`);
18
62
  }
19
63
 
20
64
  function normalizeSessions(raw) {
21
65
  if (!raw || typeof raw !== 'object' || !Array.isArray(raw.sessions)) {
22
66
  return [];
23
67
  }
24
- return raw.sessions.filter((session) =>
25
- session &&
26
- typeof session === 'object' &&
27
- typeof session.sessionId === 'string' &&
28
- typeof session.appId === 'string' &&
29
- typeof session.bundleBaseUrl === 'string',
30
- );
68
+ return raw.sessions
69
+ .filter((session) =>
70
+ session &&
71
+ typeof session === 'object' &&
72
+ typeof session.sessionId === 'string' &&
73
+ typeof session.appId === 'string' &&
74
+ typeof session.bundleBaseUrl === 'string',
75
+ )
76
+ .map((session) => {
77
+ if (typeof session.targetAppId === 'string' && session.targetAppId.trim()) {
78
+ return { ...session, targetAppId: session.targetAppId.trim() };
79
+ }
80
+ const { targetAppId: _targetAppId, ...rest } = session;
81
+ return rest;
82
+ });
31
83
  }
32
84
 
33
85
  export function readAppDevSessions(filePath) {
@@ -80,8 +132,120 @@ export function heartbeatAppDevSession(sessionId, lastHeartbeatAt, filePath) {
80
132
  return writeAppDevSessions(registry, filePath);
81
133
  }
82
134
 
135
+ export function linkAppDevSessionTarget({ appId, devSlug, targetAppId, lastHeartbeatAt = new Date().toISOString() }, filePath) {
136
+ const target = typeof targetAppId === 'string' ? targetAppId.trim() : '';
137
+ if (!target) {
138
+ return readAppDevSessions(filePath);
139
+ }
140
+ const registry = readAppDevSessions(filePath);
141
+ registry.sessions = registry.sessions.map((session) => {
142
+ const matchesApp = appId && session.appId === appId;
143
+ const matchesSlug = devSlug && session.devSlug === devSlug;
144
+ if (!matchesApp || !matchesSlug) {
145
+ return session;
146
+ }
147
+ return {
148
+ ...session,
149
+ targetAppId: target,
150
+ lastHeartbeatAt,
151
+ };
152
+ });
153
+ return writeAppDevSessions(registry, filePath);
154
+ }
155
+
83
156
  export function removeAppDevSession(sessionId, filePath) {
84
157
  const registry = readAppDevSessions(filePath);
85
158
  registry.sessions = registry.sessions.filter((session) => session.sessionId !== sessionId);
86
- return writeAppDevSessions(registry, filePath);
159
+ const result = writeAppDevSessions(registry, filePath);
160
+ removeAppDevSessionMountAcknowledgements(sessionId, filePath);
161
+ return result;
162
+ }
163
+
164
+ function normalizeMountAcknowledgements(raw) {
165
+ if (!raw || typeof raw !== 'object' || !Array.isArray(raw.acknowledgements)) {
166
+ return [];
167
+ }
168
+ return raw.acknowledgements.filter((acknowledgement) =>
169
+ acknowledgement &&
170
+ typeof acknowledgement === 'object' &&
171
+ typeof acknowledgement.sessionId === 'string' &&
172
+ typeof acknowledgement.appId === 'string' &&
173
+ typeof acknowledgement.devSlug === 'string' &&
174
+ typeof acknowledgement.mountNonce === 'string' &&
175
+ typeof acknowledgement.acknowledgedAt === 'string' &&
176
+ acknowledgement.stage === 'listed',
177
+ );
178
+ }
179
+
180
+ export function readAppDevSessionMountAcknowledgements(sessionsFilePath) {
181
+ const filePath = getAppDevSessionMountAcksFile(sessionsFilePath);
182
+ if (!existsSync(filePath)) {
183
+ return { version: APP_DEV_SESSION_MOUNT_ACKS_VERSION, acknowledgements: [] };
184
+ }
185
+ try {
186
+ const raw = JSON.parse(readFileSync(filePath, 'utf8'));
187
+ return {
188
+ version: APP_DEV_SESSION_MOUNT_ACKS_VERSION,
189
+ acknowledgements: normalizeMountAcknowledgements(raw),
190
+ };
191
+ } catch {
192
+ return { version: APP_DEV_SESSION_MOUNT_ACKS_VERSION, acknowledgements: [] };
193
+ }
194
+ }
195
+
196
+ function writeAppDevSessionMountAcknowledgements(registry, sessionsFilePath) {
197
+ const filePath = getAppDevSessionMountAcksFile(sessionsFilePath);
198
+ mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
199
+ const normalized = {
200
+ version: APP_DEV_SESSION_MOUNT_ACKS_VERSION,
201
+ acknowledgements: normalizeMountAcknowledgements(registry),
202
+ };
203
+ const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
204
+ writeFileSync(tempPath, JSON.stringify(normalized, null, 2), { mode: 0o600 });
205
+ renameSync(tempPath, filePath);
206
+ return normalized;
207
+ }
208
+
209
+ export function removeAppDevSessionMountAcknowledgements(sessionId, sessionsFilePath) {
210
+ const filePath = getAppDevSessionMountAcksFile(sessionsFilePath);
211
+ if (!existsSync(filePath)) {
212
+ return { version: APP_DEV_SESSION_MOUNT_ACKS_VERSION, acknowledgements: [] };
213
+ }
214
+ const registry = readAppDevSessionMountAcknowledgements(sessionsFilePath);
215
+ registry.acknowledgements = registry.acknowledgements.filter(
216
+ (acknowledgement) => acknowledgement.sessionId !== sessionId,
217
+ );
218
+ return writeAppDevSessionMountAcknowledgements(registry, sessionsFilePath);
219
+ }
220
+
221
+ function mountAcknowledgementKey(value) {
222
+ return `${value.sessionId}:${value.appId}:${value.devSlug}:${value.mountNonce}`;
223
+ }
224
+
225
+ export async function waitForAppDevSessionMountAcknowledgements(expectedSessions, options = {}) {
226
+ const expected = Array.isArray(expectedSessions) ? expectedSessions : [expectedSessions];
227
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(0, options.timeoutMs) : 15_000;
228
+ const pollIntervalMs = Number.isFinite(options.pollIntervalMs)
229
+ ? Math.max(1, options.pollIntervalMs)
230
+ : 100;
231
+ const now = typeof options.now === 'function' ? options.now : Date.now;
232
+ const sleep = typeof options.sleep === 'function'
233
+ ? options.sleep
234
+ : (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs));
235
+ const startedAt = now();
236
+
237
+ while (true) {
238
+ const registry = readAppDevSessionMountAcknowledgements(options.sessionsFilePath);
239
+ const acknowledgedKeys = new Set(registry.acknowledgements.map(mountAcknowledgementKey));
240
+ const missing = expected.filter((session) => !acknowledgedKeys.has(mountAcknowledgementKey(session)));
241
+ if (missing.length === 0) {
242
+ return { mounted: true, missing: [], acknowledgements: registry.acknowledgements };
243
+ }
244
+
245
+ const elapsedMs = now() - startedAt;
246
+ if (elapsedMs >= timeoutMs) {
247
+ return { mounted: false, missing, acknowledgements: registry.acknowledgements };
248
+ }
249
+ await sleep(Math.min(pollIntervalMs, timeoutMs - elapsedMs));
250
+ }
87
251
  }