@notis_ai/cli 0.2.4 → 0.2.5

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.
@@ -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;
@@ -243,7 +273,7 @@ function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions }
243
273
  /**
244
274
  * Start the dev server for one or more apps.
245
275
  *
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
276
+ * @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
277
  */
248
278
  export async function startAppDevServer({
249
279
  apps,
@@ -263,6 +293,7 @@ export async function startAppDevServer({
263
293
  slug: app.slug,
264
294
  projectDir: app.projectDir,
265
295
  appId: app.appId || null,
296
+ targetAppId: app.targetAppId || null,
266
297
  userId: app.userId || null,
267
298
  canonicalBundleDir: getBundleDir(app.projectDir),
268
299
  bundleDir: null,
@@ -385,6 +416,63 @@ export async function startAppDevServer({
385
416
  }
386
417
 
387
418
  if (req.method !== 'GET' && req.method !== 'HEAD') {
419
+ if (req.method === 'POST') {
420
+ const routed = matchAppRoute(url.pathname);
421
+ const state = routed ? appState.get(routed.slug) : null;
422
+ if (state && routed.rest === 'link') {
423
+ void (async () => {
424
+ try {
425
+ const body = await readRequestJson(req);
426
+ const appId = typeof body.app_id === 'string' ? body.app_id.trim() : '';
427
+ if (!appId) {
428
+ res.writeHead(400, {
429
+ ...headers,
430
+ 'Content-Type': 'application/json; charset=utf-8',
431
+ });
432
+ res.end(JSON.stringify({ error: 'app_id is required' }));
433
+ return;
434
+ }
435
+
436
+ const currentState = readLinkedState(state.projectDir) || {};
437
+ const now = new Date().toISOString();
438
+ writeLinkedState(state.projectDir, {
439
+ ...currentState,
440
+ app_id: appId,
441
+ dev_app_id: state.appId || currentState.dev_app_id,
442
+ linked_at: currentState.linked_at || now,
443
+ dev_linked_at: currentState.dev_linked_at || now,
444
+ updated_at: now,
445
+ });
446
+ state.targetAppId = appId;
447
+ linkAppDevSessionTarget({
448
+ appId: state.appId,
449
+ devSlug: state.slug,
450
+ targetAppId: appId,
451
+ lastHeartbeatAt: now,
452
+ });
453
+ const response = {
454
+ ok: true,
455
+ app_id: appId,
456
+ dev_app_id: state.appId || null,
457
+ target_app_id: appId,
458
+ };
459
+ res.writeHead(200, {
460
+ ...headers,
461
+ 'Content-Type': 'application/json; charset=utf-8',
462
+ });
463
+ res.end(JSON.stringify(response));
464
+ } catch (error) {
465
+ const message = error instanceof Error ? error.message : String(error);
466
+ res.writeHead(400, {
467
+ ...headers,
468
+ 'Content-Type': 'application/json; charset=utf-8',
469
+ });
470
+ res.end(JSON.stringify({ error: message }));
471
+ }
472
+ })();
473
+ return;
474
+ }
475
+ }
388
476
  res.writeHead(405, headers);
389
477
  res.end('method not allowed');
390
478
  return;
@@ -394,6 +482,7 @@ export async function startAppDevServer({
394
482
  const now = new Date().toISOString();
395
483
  const sessions = Array.from(appState.values()).map((state) => ({
396
484
  appId: state.appId,
485
+ targetAppId: state.targetAppId || undefined,
397
486
  userId: state.userId,
398
487
  devSlug: state.slug,
399
488
  bundleBaseUrl: `http://127.0.0.1:${port}/a/${state.slug}`,
@@ -446,6 +535,7 @@ export async function startAppDevServer({
446
535
  const manifest = readManifest(state.projectDir);
447
536
  const payload = {
448
537
  app_id: state.appId,
538
+ target_app_id: state.targetAppId || null,
449
539
  dev_slug: state.slug,
450
540
  manifest,
451
541
  files: collectArtifactFiles(state.projectDir),
@@ -1,33 +1,78 @@
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;
9
10
 
11
+ /**
12
+ * Walk up from `startDir` looking for a workspace-scoped dev-sessions registry.
13
+ *
14
+ * The Notis desktop (and Conductor) reads a PER-WORKSPACE registry at
15
+ * `<workspace>/.context/app-dev-sessions.json` (it points its own `apps dev`
16
+ * at it via `NOTIS_APP_DEV_SESSIONS_FILE`). When a coding agent runs
17
+ * `apps dev` manually it does not inherit that env var, so its session would
18
+ * otherwise land in the global `~/.notis` registry and never appear in the
19
+ * desktop's Local development sidebar. Resolving the nearest `.context`
20
+ * registry keeps agent-run and desktop-run sessions in the same file.
21
+ *
22
+ * Returns the registry path if a `.context/` directory is found in `startDir`
23
+ * or an ancestor (stopping at the home directory / filesystem root), else null.
24
+ */
25
+ export function findWorkspaceAppDevSessionsFile(startDir = process.cwd()) {
26
+ let dir = startDir;
27
+ const home = homedir();
28
+ const { root } = parsePath(dir);
29
+ // Bounded walk; `.context` lives at the workspace root, never above $HOME.
30
+ for (let depth = 0; depth < 64; depth += 1) {
31
+ if (existsSync(join(dir, '.context'))) {
32
+ return join(dir, '.context', 'app-dev-sessions.json');
33
+ }
34
+ if (dir === home || dir === root) break;
35
+ const parent = dirname(dir);
36
+ if (parent === dir) break;
37
+ dir = parent;
38
+ }
39
+ return null;
40
+ }
41
+
10
42
  export function getAppDevSessionsFile(filePath) {
11
43
  if (filePath) {
12
44
  return filePath;
13
45
  }
14
46
  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;
47
+ if (typeof envPath === 'string' && envPath.trim()) {
48
+ return envPath.trim();
49
+ }
50
+ const workspaceFile = findWorkspaceAppDevSessionsFile();
51
+ if (workspaceFile) {
52
+ return workspaceFile;
53
+ }
54
+ return DEFAULT_APP_DEV_SESSIONS_FILE;
18
55
  }
19
56
 
20
57
  function normalizeSessions(raw) {
21
58
  if (!raw || typeof raw !== 'object' || !Array.isArray(raw.sessions)) {
22
59
  return [];
23
60
  }
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
- );
61
+ return raw.sessions
62
+ .filter((session) =>
63
+ session &&
64
+ typeof session === 'object' &&
65
+ typeof session.sessionId === 'string' &&
66
+ typeof session.appId === 'string' &&
67
+ typeof session.bundleBaseUrl === 'string',
68
+ )
69
+ .map((session) => {
70
+ if (typeof session.targetAppId === 'string' && session.targetAppId.trim()) {
71
+ return { ...session, targetAppId: session.targetAppId.trim() };
72
+ }
73
+ const { targetAppId: _targetAppId, ...rest } = session;
74
+ return rest;
75
+ });
31
76
  }
32
77
 
33
78
  export function readAppDevSessions(filePath) {
@@ -80,6 +125,27 @@ export function heartbeatAppDevSession(sessionId, lastHeartbeatAt, filePath) {
80
125
  return writeAppDevSessions(registry, filePath);
81
126
  }
82
127
 
128
+ export function linkAppDevSessionTarget({ appId, devSlug, targetAppId, lastHeartbeatAt = new Date().toISOString() }, filePath) {
129
+ const target = typeof targetAppId === 'string' ? targetAppId.trim() : '';
130
+ if (!target) {
131
+ return readAppDevSessions(filePath);
132
+ }
133
+ const registry = readAppDevSessions(filePath);
134
+ registry.sessions = registry.sessions.map((session) => {
135
+ const matchesApp = appId && session.appId === appId;
136
+ const matchesSlug = devSlug && session.devSlug === devSlug;
137
+ if (!matchesApp || !matchesSlug) {
138
+ return session;
139
+ }
140
+ return {
141
+ ...session,
142
+ targetAppId: target,
143
+ lastHeartbeatAt,
144
+ };
145
+ });
146
+ return writeAppDevSessions(registry, filePath);
147
+ }
148
+
83
149
  export function removeAppDevSession(sessionId, filePath) {
84
150
  const registry = readAppDevSessions(filePath);
85
151
  registry.sessions = registry.sessions.filter((session) => session.sessionId !== sessionId);
@@ -324,10 +324,6 @@ function imageAssetMeta(projectDir, relPath, { maxBytes, recommendedRatio = 1.6
324
324
 
325
325
  export function discoverMetadataAssets(projectDir) {
326
326
  const metadataDir = join(projectDir, METADATA_DIR);
327
- const coverPath = join(METADATA_DIR, 'cover.png');
328
- const cover = existsSync(join(projectDir, coverPath))
329
- ? imageAssetMeta(projectDir, coverPath, { maxBytes: 5 * 1024 * 1024 })
330
- : null;
331
327
  const screenshots = [];
332
328
 
333
329
  if (existsSync(metadataDir)) {
@@ -346,7 +342,6 @@ export function discoverMetadataAssets(projectDir) {
346
342
 
347
343
  screenshots.sort((a, b) => a.index - b.index);
348
344
  return {
349
- cover,
350
345
  screenshots: screenshots.slice(0, 6),
351
346
  };
352
347
  }
@@ -364,14 +359,8 @@ export function inspectListingReadiness(projectDir, appConfig = null) {
364
359
  if (categories.length === 0) {
365
360
  warnings.push(`Listing category missing in notis.config.ts. Use one of: ${NOTIS_APP_CATEGORIES.join(', ')}.`);
366
361
  }
367
- if (!metadata.cover) {
368
- warnings.push('Listing cover missing at metadata/cover.png.');
369
- } else {
370
- errors.push(...metadata.cover.errors);
371
- warnings.push(...metadata.cover.warnings);
372
- }
373
362
  if (metadata.screenshots.length < 3) {
374
- warnings.push(`Listing screenshots: ${metadata.screenshots.length}/3 minimum in metadata/screenshot-N.png.`);
363
+ warnings.push(`Listing screenshots: ${metadata.screenshots.length}/3 minimum in metadata/screenshot-N.png. Run \`notis apps screenshot\` to generate them.`);
375
364
  }
376
365
  for (const screenshot of metadata.screenshots) {
377
366
  errors.push(...screenshot.errors);
@@ -379,7 +368,7 @@ export function inspectListingReadiness(projectDir, appConfig = null) {
379
368
  }
380
369
 
381
370
  return {
382
- ready: errors.length === 0 && Boolean(config.tagline) && categories.length > 0 && Boolean(metadata.cover) && metadata.screenshots.length >= 1,
371
+ ready: errors.length === 0 && Boolean(config.tagline) && categories.length > 0 && metadata.screenshots.length >= 1,
383
372
  warnings,
384
373
  errors,
385
374
  metadata,
@@ -621,15 +610,6 @@ export function generateManifest(appConfig, projectDir) {
621
610
  const appSlug = safeKebab(appConfig.name);
622
611
  const displayTitle = appConfig.title || appConfig.displayName || appConfig.name;
623
612
  const listingMedia = {
624
- cover: metadata.cover
625
- ? {
626
- path: metadata.cover.path,
627
- content_type: metadata.cover.content_type,
628
- width: metadata.cover.width,
629
- height: metadata.cover.height,
630
- bytes: metadata.cover.bytes,
631
- }
632
- : null,
633
613
  screenshots: metadata.screenshots.map((screenshot) => ({
634
614
  path: screenshot.path,
635
615
  content_type: screenshot.content_type,
@@ -647,6 +627,7 @@ export function generateManifest(appConfig, projectDir) {
647
627
  slug: appSlug || null,
648
628
  description: appConfig.description || null,
649
629
  icon: appConfig.icon || null,
630
+ accent: appConfig.accent || null,
650
631
  title: displayTitle,
651
632
  tagline: appConfig.tagline || null,
652
633
  categories,
@@ -721,7 +702,7 @@ function copyMetadataAssets(projectDir) {
721
702
  mkdirSync(outputMetadataDir, { recursive: true });
722
703
  for (const entry of readdirSync(metadataDir, { withFileTypes: true })) {
723
704
  if (!entry.isFile()) continue;
724
- if (entry.name !== 'cover.png' && !/^screenshot-\d+\.png$/i.test(entry.name)) {
705
+ if (!/^screenshot-\d+\.png$/i.test(entry.name)) {
725
706
  continue;
726
707
  }
727
708
  cpSync(join(metadataDir, entry.name), join(outputMetadataDir, entry.name));
@@ -1259,6 +1240,7 @@ async function updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, man
1259
1240
  },
1260
1241
  body: JSON.stringify({
1261
1242
  manifest: { ...manifest, version: newVersion },
1243
+ accent: manifest?.app?.accent ?? null,
1262
1244
  updated_at: new Date().toISOString(),
1263
1245
  }),
1264
1246
  });
Binary file