@notis_ai/cli 0.2.0-beta.155.1 → 0.2.0-beta.157.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 (47) hide show
  1. package/README.md +11 -45
  2. package/config/notis_app_design_rules.json +135 -0
  3. package/dist/agent-hooks/notis-agent-hook.mjs +5180 -7281
  4. package/dist/base-skills/notis-apps/SKILL.md +141 -224
  5. package/dist/base-skills/notis-cli/SKILL.md +64 -131
  6. package/package.json +1 -2
  7. package/skills/notis-apps/cli.md +34 -95
  8. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +1 -1
  9. package/src/command-specs/apps.js +326 -1562
  10. package/src/runtime/agent-browser.js +169 -1
  11. package/src/runtime/app-boundary-validator.js +221 -0
  12. package/src/runtime/app-platform.js +359 -233
  13. package/src/runtime/app-test-server.js +292 -0
  14. package/template/app/page.tsx +47 -45
  15. package/template/components/page-heading.tsx +23 -0
  16. package/template/components/ui/badge.tsx +7 -4
  17. package/template/components/ui/card.tsx +24 -11
  18. package/template/components/ui/native-select.tsx +24 -0
  19. package/template/notis.config.ts +0 -1
  20. package/template/package.json +2 -2
  21. package/template/packages/sdk/package.json +1 -2
  22. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +62 -8
  23. package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
  24. package/template/packages/sdk/src/config.ts +0 -2
  25. package/template/packages/sdk/src/hooks/useCloudComputer.ts +15 -48
  26. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +17 -53
  27. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +2 -2
  28. package/template/packages/sdk/src/hooks/useDocument.ts +12 -47
  29. package/template/packages/sdk/src/hooks/useDocuments.ts +18 -58
  30. package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
  31. package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
  32. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +15 -7
  33. package/template/packages/sdk/src/index.ts +8 -0
  34. package/template/packages/sdk/src/interactions.ts +2 -1
  35. package/template/packages/sdk/src/queryCache.ts +162 -0
  36. package/template/packages/sdk/src/runtime.ts +5 -0
  37. package/template/packages/sdk/src/styles.css +28 -1
  38. package/src/runtime/app-dev-build-supervisor.js +0 -47
  39. package/src/runtime/app-dev-build.js +0 -41
  40. package/src/runtime/app-dev-consumers.js +0 -154
  41. package/src/runtime/app-dev-host-lock.js +0 -80
  42. package/src/runtime/app-dev-process-identity.js +0 -111
  43. package/src/runtime/app-dev-roots.js +0 -284
  44. package/src/runtime/app-dev-server.js +0 -1136
  45. package/src/runtime/app-dev-sessions.js +0 -185
  46. package/src/runtime/cli-mode.generated.js +0 -5
  47. package/src/runtime/cli-mode.js +0 -34
@@ -1,84 +1,24 @@
1
- /**
2
- * Notis apps CLI commands.
3
- *
4
- * Canonical Notis app workflow:
5
- * init -> dev -> build -> deploy -> publish (after explicit approval)
6
- *
7
- * Supporting commands: list, link, doctor, pull.
8
- */
9
-
10
- import { randomUUID } from 'node:crypto';
11
- import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
1
+ import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
12
2
  import { tmpdir } from 'node:os';
13
- import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
+ import { basename, join, relative } from 'node:path';
14
4
 
15
5
  import { CliError, EXIT_CODES, usageError } from '../runtime/errors.js';
16
6
  import { formatTable } from '../runtime/output.js';
17
- import {
18
- defaultAppProjectDir,
19
- resolveProjectDir,
20
- loadAppConfig,
21
- detectProjectProblems,
22
- detectProjectWarnings,
23
- buildArtifact,
24
- appLinkedStateProfileKey,
25
- readManifest,
26
- readLinkedState,
27
- writeLinkedState,
28
- requireLinkedAppId,
29
- scaffoldProject,
30
- findUnknownScreenshotScenarios,
31
- inspectListingReadiness,
32
- resolveListingScreenshots,
33
- collectArtifactFiles,
34
- collectSourceFiles,
35
- resolveConfiguredAppSkills,
36
- normalizeAppCapabilities,
37
- normalizeAppToolBindings,
38
- normalizeAppSkillManifestPath,
39
- appRowFieldsFromManifest,
40
- directDeploy,
41
- pullAppSource,
42
- } from '../runtime/app-platform.js';
7
+ import { defaultAppProjectDir, resolveProjectDir, loadAppConfig, detectProjectProblems, detectProjectWarnings, buildArtifact, prepareAppRelease, beginAppCreateIntent, appLinkedStateProfileKey, readManifest, readLinkedState, writeLinkedState, requireLinkedAppId, scaffoldProject, findUnknownScreenshotScenarios, inspectListingReadiness, resolveListingScreenshots, collectArtifactFiles, collectSourceFiles, appRowFieldsFromManifest, pullAppSource, writeVerifyStamp } from '../runtime/app-platform.js';
43
8
  import {
44
9
  filterScaffoldCatalog,
45
10
  loadScaffoldCatalog,
46
11
  scaffoldRegistryLabel,
47
12
  } from '../runtime/app-registry-scaffolds.js';
48
- import { startAppDevServer } from '../runtime/app-dev-server.js';
49
- import { captureDesktopHostOwnership } from '../runtime/app-dev-process-identity.js';
50
- import {
51
- discoverRegisteredAppProjects,
52
- readAppDevRoots,
53
- registerAppDevRoot,
54
- removeAppDevRoot,
55
- } from '../runtime/app-dev-roots.js';
13
+ import { startAppTestServer } from '../runtime/app-test-server.js';
56
14
  import {
57
15
  captureHarnessScreenshot,
16
+ describeDesignFinding,
58
17
  closeAgentBrowserSession,
59
18
  isAgentBrowserAvailable,
60
19
  runHarnessRoute,
61
20
  } from '../runtime/agent-browser.js';
62
- import {
63
- getAppDevSessionsFile,
64
- heartbeatAppDevSession,
65
- linkAppDevSessionTarget,
66
- readAppDevSessions,
67
- removeAppDevSession,
68
- upsertAppDevSessions,
69
- } from '../runtime/app-dev-sessions.js';
70
- import {
71
- releaseAppDevHostLock,
72
- tryAcquireAppDevHostLock,
73
- } from '../runtime/app-dev-host-lock.js';
74
- import {
75
- heartbeatAppDevConsumer,
76
- hasAppDevConsumer,
77
- readAppDevConsumers,
78
- removeAppDevConsumer,
79
- } from '../runtime/app-dev-consumers.js';
80
- import { getAvailablePort, getAvailablePortPreferring } from '../runtime/ports.js';
81
- import { getCliMode } from '../runtime/cli-mode.js';
21
+ import { getAvailablePort } from '../runtime/ports.js';
82
22
  import { composeStoreScreenshot } from '../runtime/store-screenshot.js';
83
23
  import { httpRequest } from '../runtime/transport.js';
84
24
  import { ensureFreshOAuthCredential } from '../runtime/oauth.js';
@@ -90,205 +30,6 @@ import {
90
30
  } from './helpers.js';
91
31
 
92
32
  export { appRowFieldsFromManifest } from '../runtime/app-platform.js';
93
-
94
- const DEFAULT_DEV_PORT = 5173;
95
- const DEV_HEARTBEAT_INTERVAL_MS = 10_000;
96
- const DEV_CONSUMER_HEARTBEAT_INTERVAL_MS = 3_000;
97
- const DEV_CONSUMER_POLL_INTERVAL_MS = 5_000;
98
- const SHARED_APP_DEV_HOST_KEY = '__registered_roots__';
99
-
100
- function projectIsWithinRoot(projectDir, rootDir) {
101
- const nested = relative(rootDir, projectDir);
102
- return nested === '' || (nested !== '..' && !nested.startsWith(`..${sep}`) && !isAbsolute(nested));
103
- }
104
-
105
- function parseNotisAppVersion(value) {
106
- const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
107
- String(value || '').trim(),
108
- );
109
- if (!match) return null;
110
- const prerelease = match[4] ? match[4].split('.') : [];
111
- if (prerelease.some((identifier) => /^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith('0'))) {
112
- return null;
113
- }
114
- return {
115
- major: match[1],
116
- minor: match[2],
117
- patch: match[3],
118
- prerelease,
119
- };
120
- }
121
-
122
- function compareNumericSemverIdentifiers(left, right) {
123
- if (left.length !== right.length) return left.length > right.length ? 1 : -1;
124
- return left === right ? 0 : left > right ? 1 : -1;
125
- }
126
-
127
- function compareNotisPrerelease(left, right) {
128
- if (left.length === 0 || right.length === 0) {
129
- return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
130
- }
131
- const length = Math.max(left.length, right.length);
132
- for (let index = 0; index < length; index += 1) {
133
- const leftIdentifier = left[index];
134
- const rightIdentifier = right[index];
135
- if (leftIdentifier === undefined || rightIdentifier === undefined) {
136
- return leftIdentifier === rightIdentifier ? 0 : leftIdentifier === undefined ? -1 : 1;
137
- }
138
- if (leftIdentifier === rightIdentifier) continue;
139
- const leftNumeric = /^\d+$/.test(leftIdentifier);
140
- const rightNumeric = /^\d+$/.test(rightIdentifier);
141
- if (leftNumeric && rightNumeric) {
142
- return compareNumericSemverIdentifiers(leftIdentifier, rightIdentifier);
143
- }
144
- if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
145
- return leftIdentifier > rightIdentifier ? 1 : -1;
146
- }
147
- return 0;
148
- }
149
-
150
- export function compareNotisAppVersions(leftValue, rightValue) {
151
- const left = parseNotisAppVersion(leftValue);
152
- const right = parseNotisAppVersion(rightValue);
153
- if (!left || !right) return null;
154
- for (const key of ['major', 'minor', 'patch']) {
155
- const comparison = compareNumericSemverIdentifiers(left[key], right[key]);
156
- if (comparison !== 0) return comparison;
157
- }
158
- return compareNotisPrerelease(left.prerelease, right.prerelease);
159
- }
160
-
161
- function readLocalNotisAppVersion(projectDir) {
162
- try {
163
- const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
164
- const version = String(packageJson.notisAppVersion || '').trim();
165
- return parseNotisAppVersion(version) ? version : null;
166
- } catch {
167
- return null;
168
- }
169
- }
170
-
171
- /**
172
- * A CLI launch may add one root, but the shared host always serves the complete
173
- * machine registry. Desktop sets skipRootRegistration because it is reconciling
174
- * an already-registered snapshot; this must never narrow discovery to the one
175
- * project path used to start the host.
176
- */
177
- export function discoverAppDevLaunchProjects(rootDir, {
178
- skipRootRegistration = false,
179
- registerRoot = registerAppDevRoot,
180
- discoverProjects = discoverRegisteredAppProjects,
181
- } = {}) {
182
- if (!skipRootRegistration) registerRoot(rootDir);
183
- return discoverProjects();
184
- }
185
-
186
- /**
187
- * Collapse duplicate local sources before they reach the shared loopback host.
188
- * An explicitly registered root is more intentional than the implicit
189
- * ~/.notis/apps root. If two equally intentional roots claim the same dev
190
- * slug, omit that slug and keep serving every unrelated app.
191
- */
192
- export function selectCanonicalDevApps(candidates, rootsRegistry) {
193
- const roots = Array.isArray(rootsRegistry?.roots) ? rootsRegistry.roots : [];
194
- const groups = new Map();
195
- for (const candidate of candidates) {
196
- groups.set(candidate.devSlug, [...(groups.get(candidate.devSlug) || []), candidate]);
197
- }
198
-
199
- const selected = [];
200
- const warnings = [];
201
- for (const [devSlug, group] of groups) {
202
- if (group.length === 1) {
203
- selected.push(group[0]);
204
- continue;
205
- }
206
- const ranked = group.map((candidate) => {
207
- const explicitRoots = roots.filter((root) => (
208
- root?.implicit !== true
209
- && typeof root?.path === 'string'
210
- && projectIsWithinRoot(candidate.projectDir, root.path)
211
- ));
212
- const newestRegistration = explicitRoots.reduce((latest, root) => {
213
- const timestamp = Date.parse(root.registeredAt || '');
214
- return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
215
- }, 0);
216
- return {
217
- candidate,
218
- rank: explicitRoots.length > 0 ? 1 : 0,
219
- newestRegistration,
220
- };
221
- }).sort((left, right) => (
222
- right.rank - left.rank
223
- || right.newestRegistration - left.newestRegistration
224
- ));
225
- const winner = ranked[0];
226
- const runnerUp = ranked[1];
227
- const unambiguous = winner.rank > runnerUp.rank
228
- || winner.newestRegistration > runnerUp.newestRegistration;
229
- if (unambiguous) {
230
- selected.push(winner.candidate);
231
- warnings.push(
232
- `Using ${winner.candidate.projectDir} for development slug "${devSlug}"; ignored duplicate source(s): ${ranked.slice(1).map(({ candidate }) => candidate.projectDir).join(', ')}.`,
233
- );
234
- continue;
235
- }
236
- warnings.push(
237
- `Skipped ambiguous development slug "${devSlug}" because multiple equally ranked sources are registered: ${ranked.map(({ candidate }) => candidate.projectDir).join(', ')}. Remove a root or set a unique devSlug.`,
238
- );
239
- }
240
- return { selected, warnings };
241
- }
242
-
243
- export function findSharedSourceBundleUrls(projectDirs, sessionsFilePath) {
244
- const now = Date.now();
245
- const sourceSessions = readAppDevSessions(sessionsFilePath).sessions
246
- .filter((session) => {
247
- if (session.sourceHost !== true) return false;
248
- const heartbeatAt = Date.parse(session.lastHeartbeatAt || '');
249
- if (!Number.isFinite(heartbeatAt) || now - heartbeatAt > 45_000) return false;
250
- if (!Number.isInteger(session.hostPid) || session.hostPid <= 0) return false;
251
- try {
252
- process.kill(session.hostPid, 0);
253
- return true;
254
- } catch {
255
- return false;
256
- }
257
- });
258
- const groups = new Map();
259
- for (const session of sourceSessions) {
260
- const key = `${session.hostPid || 0}:${session.sessionId}`;
261
- groups.set(key, [...(groups.get(key) || []), session]);
262
- }
263
- for (const sessions of groups.values()) {
264
- const byProject = new Map(sessions.map((session) => [session.projectDir, session]));
265
- const canonicalProjects = sessions.find((session) => (
266
- Array.isArray(session.canonicalProjects)
267
- ))?.canonicalProjects;
268
- const discoveredProjects = sessions.find((session) => (
269
- Array.isArray(session.discoveredProjects)
270
- ))?.discoveredProjects;
271
- const discoveryMatches = discoveredProjects
272
- && JSON.stringify([...discoveredProjects].sort()) === JSON.stringify([...projectDirs].sort());
273
- if (
274
- discoveryMatches
275
- && canonicalProjects
276
- && canonicalProjects.every((projectDir) => byProject.has(projectDir))
277
- ) {
278
- return new Map(canonicalProjects.map((projectDir) => [
279
- projectDir,
280
- byProject.get(projectDir).bundleBaseUrl,
281
- ]));
282
- }
283
- if (!projectDirs.every((projectDir) => byProject.has(projectDir))) continue;
284
- return new Map(projectDirs.map((projectDir) => [
285
- projectDir,
286
- byProject.get(projectDir).bundleBaseUrl,
287
- ]));
288
- }
289
- return null;
290
- }
291
- const ENSURE_DEV_APP_INSTALLATION_TOOL = 'LOCAL_NOTIS_ENSURE_DEV_APP_INSTALLATION';
292
33
  const GET_APP_TOOL = 'LOCAL_NOTIS_GET_APP';
293
34
  const LIST_APPS_TOOL = 'LOCAL_NOTIS_LIST_APPS';
294
35
  const CREATE_APP_TOOL = 'LOCAL_NOTIS_CREATE_APP';
@@ -318,14 +59,6 @@ function scaffoldsTable(scaffolds) {
318
59
  ]);
319
60
  }
320
61
 
321
- function appDevRootsTable(roots) {
322
- return formatTable(roots, [
323
- { label: 'Folder', value: (root) => root.path },
324
- { label: 'Source', value: (root) => root.implicit ? 'default' : 'registered' },
325
- { label: 'Registered', value: (root) => root.registeredAt || '' },
326
- ]);
327
- }
328
-
329
62
  function decodeJwtSub(jwt) {
330
63
  if (!jwt) return null;
331
64
  try {
@@ -353,55 +86,6 @@ function slugify(value) {
353
86
  .replace(/(^-|-$)+/g, '');
354
87
  }
355
88
 
356
- function buildDevInstallSlug(appConfig) {
357
- const base = slugify(appConfig?.devSlug || appConfig?.name);
358
- if (!base) {
359
- return '';
360
- }
361
- return base.endsWith('-dev') ? base : `${base}-dev`;
362
- }
363
-
364
- function timingMs(startedAt) {
365
- return Number(process.hrtime.bigint() - startedAt) / 1_000_000;
366
- }
367
-
368
- function logAppsTiming(label, details = {}) {
369
- const suffix = Object.entries(details)
370
- .map(([key, value]) => `${key}=${value}`)
371
- .join(' ');
372
- process.stderr.write(`[notis apps timing] ${label}${suffix ? ` ${suffix}` : ''}\n`);
373
- }
374
-
375
- function nextDevInstallIdempotencyKey(globalOptions = {}, devSlug) {
376
- if (globalOptions.idempotencyKey) {
377
- return `${globalOptions.idempotencyKey}:${devSlug}`;
378
- }
379
- return nextIdempotencyKey(globalOptions);
380
- }
381
-
382
- function pickDefaultRouteSlug(manifest) {
383
- const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
384
- const explicit = routes.find((route) => route && route.default && typeof route.slug === 'string');
385
- if (explicit) return explicit.slug;
386
- const firstWithSlug = routes.find((route) => route && typeof route.slug === 'string' && route.slug);
387
- return firstWithSlug ? firstWithSlug.slug : null;
388
- }
389
-
390
- export function buildDevelopmentAppHref({
391
- appSlug,
392
- appId,
393
- devSlug,
394
- targetAppId = null,
395
- targetAppSlug = null,
396
- manifest,
397
- }) {
398
- const routeAppId = `${targetAppId || appId}__local_dev__${devSlug}`;
399
- const routeAppSlug = targetAppSlug || devSlug || appSlug;
400
- const originlessBase = `/apps/${routeAppSlug}-${routeAppId}`;
401
- const routeSlug = pickDefaultRouteSlug(manifest);
402
- return routeSlug ? `${originlessBase}/${routeSlug}` : originlessBase;
403
- }
404
-
405
89
  function parsePort(value) {
406
90
  if (!value) return null;
407
91
  const port = Number.parseInt(value, 10);
@@ -516,7 +200,7 @@ function runtimeCallLabel(call) {
516
200
  return call?.op || 'runtime call';
517
201
  }
518
202
 
519
- function assertHarnessResult(result, route, databaseSlugs, mode = 'stub') {
203
+ function assertHarnessResult(result, route, databaseSlugs, mode = 'stub', capabilities = {}) {
520
204
  const assertions = [];
521
205
  if (result.tool_error) {
522
206
  assertions.push({
@@ -557,7 +241,7 @@ function assertHarnessResult(result, route, databaseSlugs, mode = 'stub') {
557
241
  );
558
242
  for (const call of databaseQueries) {
559
243
  const databaseSlug = call?.args?.arguments?.database_slug;
560
- if (databaseSlug && !declaredDatabaseSet.has(databaseSlug)) {
244
+ if (databaseSlug && !declaredDatabaseSet.has(databaseSlug) && capabilities.workspaceDatabases !== 'read') {
561
245
  assertions.push({
562
246
  ok: false,
563
247
  code: 'undeclared_database_query',
@@ -578,6 +262,19 @@ function assertHarnessResult(result, route, databaseSlugs, mode = 'stub') {
578
262
  details: { databaseSlug: collectionDatabase },
579
263
  });
580
264
  }
265
+ if (result.design_tool_error) {
266
+ assertions.push({ ok: false, code: 'design_check_error',
267
+ message: `Route "${route.slug}" could not complete its automated design checks.`,
268
+ details: result.design_tool_error });
269
+ }
270
+ for (const finding of result.design || []) {
271
+ assertions.push({
272
+ ok: false,
273
+ code: 'design_rule_violation',
274
+ message: `Route "${route.slug}": ${describeDesignFinding(finding)}.`,
275
+ details: finding,
276
+ });
277
+ }
581
278
  if (mode === 'live') {
582
279
  // In live mode an app that catches every failed call and renders its error
583
280
  // state still mounts cleanly, so the render assertions above all pass. Only
@@ -633,347 +330,6 @@ function renderVerifyReport({ summary, results, noBrowser }) {
633
330
  return lines.join('\n');
634
331
  }
635
332
 
636
- function buildManifestForDev(appConfig, projectDir) {
637
- const routes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
638
- return {
639
- version: 1,
640
- spec_version: 3,
641
- app: {
642
- name: appConfig.name,
643
- description: appConfig.description || null,
644
- icon: appConfig.icon || null,
645
- release_version: readLocalNotisAppVersion(projectDir),
646
- },
647
- routes: routes.map((route) => ({
648
- path: route.path,
649
- slug: route.slug,
650
- name: route.name,
651
- icon: route.icon || null,
652
- parentSlug: route.parentSlug || null,
653
- default: route.default || false,
654
- resourceDeepLinks: route.resourceDeepLinks === true,
655
- export_name: route.exportName || route.export_name,
656
- collection: route.collection || null,
657
- })),
658
- bundle: {
659
- js: 'bundle/app.js',
660
- css: 'bundle/app.css',
661
- },
662
- databases: appConfig.databases || [],
663
- capabilities: normalizeAppCapabilities(appConfig.capabilities),
664
- tools: appConfig.tools || [],
665
- tool_bindings: normalizeAppToolBindings(appConfig.toolBindings),
666
- skills: (appConfig.skills || []).map((skill) => ({
667
- key: skill.key,
668
- path: normalizeAppSkillManifestPath(skill.path),
669
- name: skill.name,
670
- description: skill.description || null,
671
- })),
672
- onboarding: appConfig.onboarding || null,
673
- };
674
- }
675
-
676
- export function buildEnsureDevInstallArguments({
677
- appConfig,
678
- manifest,
679
- linkedState,
680
- skills,
681
- useInstalledDatabases = false,
682
- approvedCapabilities = null,
683
- }) {
684
- const devSlug = buildDevInstallSlug(appConfig);
685
- const arguments_ = {
686
- dev_slug: devSlug,
687
- name: appConfig.name,
688
- manifest,
689
- skills,
690
- };
691
- if (linkedState?.dev_app_id) {
692
- arguments_.app_id = linkedState.dev_app_id;
693
- }
694
- if (useInstalledDatabases && linkedState?.app_id) {
695
- arguments_.installed_app_id = linkedState.app_id;
696
- }
697
- // Sent on every ensure call, including as `false`, so a `--scratch` session
698
- // puts the app on its own dev copies and the next plain session puts it back
699
- // on the installed app's databases.
700
- arguments_.use_installed_databases = Boolean(useInstalledDatabases);
701
- if (Array.isArray(approvedCapabilities) && approvedCapabilities.length > 0) {
702
- arguments_.approved_capabilities = approvedCapabilities;
703
- }
704
- return arguments_;
705
- }
706
-
707
- const CLOUD_SHELL_CONSENT_KEY = 'cloud_computer_shell_consent';
708
-
709
- /**
710
- * Collect the developer's explicit approval for `cloudComputer: 'shell'`.
711
- *
712
- * The server never grants shell from authorship alone — holding an app's source
713
- * (a scaffold, a pulled repo) is not consent to full-authority commands on the
714
- * cloud computer — so `apps dev` asks once, records the answer in the linked
715
- * state, and passes the grant with the ensure call. Runs before the parallel
716
- * ensure fan-out so the prompt cannot interleave.
717
- */
718
- export async function resolveCloudShellConsent({
719
- appConfig,
720
- projectDir,
721
- grantCloudShell = false,
722
- logger = console,
723
- profileKey = null,
724
- }) {
725
- const capabilities = normalizeAppCapabilities(appConfig.capabilities);
726
- if (capabilities.cloudComputer !== 'shell') {
727
- return null;
728
- }
729
- const approved = ['cloud_computer_read', 'cloud_computer_shell'];
730
- const linkedState = readLinkedState(projectDir, profileKey) || {};
731
- const recordDecision = (decision) => {
732
- writeLinkedState(
733
- projectDir,
734
- { ...linkedState, [CLOUD_SHELL_CONSENT_KEY]: decision },
735
- profileKey,
736
- );
737
- };
738
-
739
- if (grantCloudShell) {
740
- recordDecision('granted');
741
- return approved;
742
- }
743
- const recorded = linkedState[CLOUD_SHELL_CONSENT_KEY];
744
- if (recorded === 'granted') {
745
- return approved;
746
- }
747
- const declineHint =
748
- `${appConfig.name}: cloud computer shell stays denied for this dev session. `
749
- + 'Re-run with --grant-cloud-shell to approve it.';
750
- if (recorded === 'declined') {
751
- logger.warn(declineHint);
752
- return null;
753
- }
754
- if (!process.stdin.isTTY || !process.stderr.isTTY) {
755
- logger.warn(declineHint);
756
- return null;
757
- }
758
- const { createInterface } = await import('node:readline/promises');
759
- const prompt = createInterface({ input: process.stdin, output: process.stderr });
760
- try {
761
- const answer = (await prompt.question(
762
- `${appConfig.name} declares cloudComputer: 'shell' - its views will run commands and `
763
- + 'read or write files on your cloud computer with the same authority as your own '
764
- + 'agent. Allow for this dev app? [y/N] ',
765
- )).trim().toLowerCase();
766
- const granted = answer === 'y' || answer === 'yes';
767
- recordDecision(granted ? 'granted' : 'declined');
768
- if (!granted) {
769
- logger.warn(declineHint);
770
- }
771
- return granted ? approved : null;
772
- } finally {
773
- prompt.close();
774
- }
775
- }
776
-
777
- export async function ensureDevInstall({
778
- ctx,
779
- appConfig,
780
- projectDir,
781
- idempotencyKey,
782
- useInstalledDatabases = false,
783
- approvedCapabilities = null,
784
- runTool = runToolCommand,
785
- }) {
786
- if (!appConfig.name) {
787
- throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
788
- }
789
- const devSlug = buildDevInstallSlug(appConfig);
790
- if (!devSlug) {
791
- throw usageError(`notis.config.ts devSlug or name in ${projectDir} must slugify to a non-empty value.`);
792
- }
793
-
794
- const manifest = buildManifestForDev(appConfig, projectDir);
795
- const skills = resolveConfiguredAppSkills(appConfig, projectDir);
796
- const profileKey = linkedStateProfileKey(ctx.runtime);
797
- let linkedState = readLinkedState(projectDir, profileKey);
798
- let linkedApp = null;
799
- if (linkedState?.dev_app_id) {
800
- const devApp = await getAccessibleApp(ctx.runtime, linkedState.dev_app_id, runTool);
801
- if (!devApp || devApp.manifest?.is_dev !== true) {
802
- const { dev_app_id: _devAppId, dev_linked_at: _devLinkedAt, ...rest } = linkedState;
803
- linkedState = rest;
804
- writeLinkedState(projectDir, linkedState, profileKey);
805
- }
806
- }
807
- if (linkedState?.app_id) {
808
- linkedApp = await getAccessibleApp(ctx.runtime, linkedState.app_id, runTool);
809
- if (linkedApp?.manifest?.is_dev === true) {
810
- const { app_id: legacyDevAppId, linked_at: _linkedAt, deployed_at: _deployedAt, version: _version, ...rest } = linkedState;
811
- const devAppId = linkedState.dev_app_id || legacyDevAppId;
812
- linkedState = {
813
- ...rest,
814
- ...(devAppId ? { dev_app_id: devAppId } : {}),
815
- ...(devAppId ? {
816
- dev_linked_at: linkedState.dev_linked_at || linkedState.linked_at || new Date().toISOString(),
817
- } : {}),
818
- };
819
- writeLinkedState(projectDir, linkedState, profileKey);
820
- linkedApp = null;
821
- }
822
- }
823
- // A checkout can be shared by Beta and a source-workspace desktop. Its
824
- // installed app link is valid for the Beta account but intentionally
825
- // inaccessible to the worktree's test user. Preserve that durable link for
826
- // Beta, while omitting it from this run so the local dev app uses its own
827
- // resources instead of sending an unauthorized installed_app_id to ensure.
828
- if (!linkedState?.app_id) {
829
- const installedResolution = await findInstalledLinkCandidatesForLegacyDevState(
830
- ctx.runtime,
831
- devSlug,
832
- linkedState?.dev_app_id,
833
- projectDir,
834
- profileKey,
835
- runTool,
836
- );
837
- const installedCandidates = installedResolution.candidates;
838
- if (installedCandidates.length > 1) {
839
- if (installedResolution.source === 'persisted-project-link') {
840
- throw usageError(
841
- `Multiple accessible installed apps are persisted for ${projectDir}. `
842
- + `Run \`notis apps link <app-id> ${projectDir}\` to choose one for this environment.`,
843
- );
844
- }
845
- throw usageError(
846
- `Multiple accessible installed apps use the exact slug "${devSlug.slice(0, -4)}". `
847
- + `Run \`notis apps link <app-id> ${projectDir}\` to choose one.`,
848
- );
849
- }
850
- if (installedCandidates.length === 1) {
851
- const now = new Date().toISOString();
852
- linkedApp = installedCandidates[0];
853
- linkedState = {
854
- ...(linkedState || {}),
855
- app_id: linkedApp.app_id || linkedApp.id,
856
- linked_at: now,
857
- auto_linked_at: now,
858
- };
859
- writeLinkedState(projectDir, linkedState, profileKey);
860
- }
861
- }
862
- const runtimeLinkedState = linkedState?.app_id && !linkedApp
863
- ? Object.fromEntries(
864
- Object.entries(linkedState).filter(([key]) => ![
865
- 'app_id', 'linked_at', 'deployed_at', 'version',
866
- ].includes(key)),
867
- )
868
- : linkedState;
869
- const localReleaseVersion = manifest.app.release_version || null;
870
- const installedReleaseVersion = linkedApp?.manifest?.app?.release_version || '0.0.0';
871
- const mountEligible = !linkedApp
872
- || compareNotisAppVersions(localReleaseVersion, installedReleaseVersion) === 1;
873
- const ensureArguments = buildEnsureDevInstallArguments({
874
- appConfig,
875
- manifest,
876
- linkedState: runtimeLinkedState,
877
- skills,
878
- useInstalledDatabases,
879
- approvedCapabilities,
880
- });
881
- let ensureResult;
882
- try {
883
- ensureResult = await runTool({
884
- runtime: ctx.runtime,
885
- toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
886
- arguments_: ensureArguments,
887
- mutating: true,
888
- idempotencyKey,
889
- });
890
- } catch (error) {
891
- const backendCode = error?.details?.error?.code;
892
- if (linkedState?.dev_app_id && backendCode === 'dev_app_slug_conflict') {
893
- // The authenticated environment can outlive an older local API profile.
894
- // If that profile remembered a different dev row while this user already
895
- // owns the exact canonical dev slug, the backend refuses a duplicate.
896
- // Retry without the stale row id so the backend deterministically adopts
897
- // the unique slug owner; preserve the installed target and every grant.
898
- const staleDevAppId = linkedState.dev_app_id;
899
- const { dev_app_id: _devAppId, dev_linked_at: _devLinkedAt, ...rest } = linkedState;
900
- linkedState = rest;
901
- writeLinkedState(projectDir, linkedState, profileKey);
902
- const retryArguments = { ...ensureArguments };
903
- delete retryArguments.app_id;
904
- ensureResult = await runTool({
905
- runtime: ctx.runtime,
906
- toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
907
- arguments_: retryArguments,
908
- mutating: true,
909
- idempotencyKey: `${idempotencyKey}:adopt-slug-owner:${staleDevAppId}`,
910
- });
911
- } else {
912
- throw error;
913
- }
914
- }
915
- const appId = ensureResult.payload.app_id;
916
- if (!appId) {
917
- throw usageError('Dev installation tool did not return an app_id.');
918
- }
919
- if (linkedState?.dev_app_id !== appId) {
920
- const now = new Date().toISOString();
921
- writeLinkedState(projectDir, {
922
- ...(linkedState || {}),
923
- dev_app_id: appId,
924
- dev_linked_at: linkedState?.dev_linked_at || now,
925
- }, profileKey);
926
- }
927
- return {
928
- slug: ensureResult.payload.slug || devSlug,
929
- devSlug,
930
- name: appConfig.name,
931
- appId,
932
- projectDir,
933
- manifest,
934
- created: ensureResult.payload.created || false,
935
- linkedAppId: runtimeLinkedState?.app_id || null,
936
- targetAppId: runtimeLinkedState?.app_id || null,
937
- targetAppSlug: linkedApp?.slug || null,
938
- localReleaseVersion,
939
- installedReleaseVersion: linkedApp ? installedReleaseVersion : null,
940
- mountEligible,
941
- databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
942
- liveData: ensureResult.payload.live_data || null,
943
- };
944
- }
945
-
946
- function databaseMaterializationWarnings(apps) {
947
- const warnings = [];
948
- for (const app of apps) {
949
- const unresolved = app.databaseMaterialization?.unresolved || [];
950
- if (!unresolved.length) {
951
- continue;
952
- }
953
- warnings.push(
954
- `${app.name}: database slug${unresolved.length === 1 ? '' : 's'} ${unresolved.join(', ')} ` +
955
- 'could not be created because no store snapshot schema exists. Create them manually or link to a store-installed app.',
956
- );
957
- }
958
- return warnings;
959
- }
960
-
961
- function liveDataWarnings(apps) {
962
- return apps
963
- .filter((app) => app.liveData?.warning)
964
- .map((app) => `${app.name}: ${app.liveData.warning}`);
965
- }
966
-
967
- function versionPrecedenceWarnings(apps) {
968
- return apps
969
- .filter((app) => app.targetAppId && app.mountEligible === false)
970
- .map((app) => {
971
- const localVersion = app.localReleaseVersion || 'missing or invalid';
972
- const pullCommand = `npx --package @notis_ai/cli@latest -- notis apps pull ${app.targetAppId} ${JSON.stringify(app.projectDir)} --force`;
973
- return `${app.name}: local version ${localVersion} is not strictly newer than installed version ${app.installedReleaseVersion}; Workspace keeps serving the online bundle. Preserve any local edits, pull latest with \`${pullCommand}\`, then bump package.json notisAppVersion before development.`;
974
- });
975
- }
976
-
977
333
  async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
978
334
  const result = await runTool({
979
335
  runtime,
@@ -997,93 +353,11 @@ async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
997
353
  throw usageError(`Could not verify access to app ${appId}${message ? `: ${message}` : '.'}`);
998
354
  }
999
355
 
1000
- async function findInstalledLinkCandidatesForLegacyDevState(
1001
- runtime,
1002
- devSlug,
1003
- devAppId,
1004
- projectDir,
1005
- profileKey,
1006
- runTool = runToolCommand,
1007
- ) {
1008
- const installedSlug = devSlug.endsWith('-dev') ? devSlug.slice(0, -4) : '';
1009
- if (!installedSlug) return { candidates: [], source: null };
1010
-
1011
- const result = await runTool({
1012
- runtime,
1013
- toolName: LIST_APPS_TOOL,
1014
- });
1015
- const accessibleInstalledApps = (result.payload?.apps || []).filter((app) => (
1016
- (app?.app_id || app?.id)
1017
- && (app.app_id || app.id) !== devAppId
1018
- && app?.manifest?.is_dev !== true
1019
- ));
1020
-
1021
- // A project-local app id is stronger evidence than a slug. Older CLI runs
1022
- // could persist that explicit link under another API/user profile before
1023
- // the machine-local discovery host mounted the same source here. Migrate it
1024
- // only when exactly one such id is accessible in the current environment;
1025
- // multiple accessible ids remain ambiguous and fail closed below.
1026
- const rawState = readLinkedState(projectDir) || {};
1027
- const persistedAppIds = new Set();
1028
- if (rawState.app_id) persistedAppIds.add(rawState.app_id);
1029
- const profiles = rawState.profiles && typeof rawState.profiles === 'object'
1030
- ? rawState.profiles
1031
- : {};
1032
- for (const [storedProfileKey, state] of Object.entries(profiles)) {
1033
- if (storedProfileKey === profileKey || !state || typeof state !== 'object') continue;
1034
- if (state.app_id) persistedAppIds.add(state.app_id);
1035
- }
1036
- const persistedCandidates = accessibleInstalledApps.filter((app) => (
1037
- persistedAppIds.has(app.app_id || app.id)
1038
- ));
1039
- if (persistedCandidates.length) {
1040
- return { candidates: persistedCandidates, source: 'persisted-project-link' };
1041
- }
1042
-
1043
- // The database row slug can change when a Store app is installed or an old
1044
- // development app is promoted. The shipped source manifest keeps the
1045
- // canonical app slug, so accept either exact field. Display names are never
1046
- // considered.
1047
- const exactSlugCandidates = accessibleInstalledApps.filter((app) => {
1048
- const canonicalSlugs = new Set([
1049
- app?.slug,
1050
- app?.manifest?.app?.slug,
1051
- ].map(slugify).filter(Boolean));
1052
- return canonicalSlugs.has(installedSlug);
1053
- });
1054
- return { candidates: exactSlugCandidates, source: 'exact-slug' };
1055
- }
1056
-
1057
- export async function assertDirectDeployAccess(runtime, appId, runTool = runToolCommand) {
1058
- const app = await getAccessibleApp(runtime, appId, runTool);
1059
- if (!app) {
1060
- throw usageError(`Direct deploy requires access to app ${appId}.`);
1061
- }
1062
- if (app.apps_access?.has_access !== true) {
1063
- throw usageError('Notis Apps require a PRO+ or ULTRA plan after your trial.');
1064
- }
1065
- if (app.can_edit !== true) {
1066
- throw usageError(`Direct deploy requires edit access to app ${appId}.`);
1067
- }
1068
- if (app.manifest?.is_dev === true) {
1069
- throw usageError(
1070
- 'A development app cannot be deployed directly. Retry without --direct so first deploy can promote it safely.',
1071
- );
1072
- }
1073
- return app;
1074
- }
1075
-
1076
356
  export async function assertLinkTarget(runtime, appId, runTool = runToolCommand) {
1077
- const app = await getAccessibleApp(runtime, appId, runTool);
1078
- if (!app) {
1079
- throw usageError(`Cannot link to inaccessible app ${appId}.`);
1080
- }
1081
- if (app.manifest?.is_dev === true) {
1082
- throw usageError(
1083
- `Cannot link to development runtime app ${appId}. ` +
1084
- 'Link to an installed workspace app, or run `notis apps dev` without a target.',
1085
- );
1086
- }
357
+ const result = await runTool({ runtime, toolName: LIST_APPS_TOOL });
358
+ const app = (result.payload?.apps || []).find(app => (app.app_id || app.id) === appId);
359
+ if (!app || app.can_edit !== true) throw usageError(`Cannot edit app ${appId} in this profile.`);
360
+
1087
361
  return app;
1088
362
  }
1089
363
 
@@ -1121,7 +395,7 @@ async function appsInitHandler(ctx) {
1121
395
  : `Scaffolded "${ctx.args.name}" in ${projectDir}`,
1122
396
  hints: [
1123
397
  { command: `cd ${projectDir} && npm install`, reason: 'Install dependencies' },
1124
- { command: `cd ${projectDir} && notis apps dev`, reason: 'Start the real-portal dev workflow' },
398
+ { command: `cd ${projectDir} && notis apps build`, reason: 'Build and verify the app' },
1125
399
  ],
1126
400
  });
1127
401
  }
@@ -1147,43 +421,82 @@ async function appsScaffoldsListHandler(ctx) {
1147
421
  async function appsCreateHandler(ctx) {
1148
422
  const projectDir = ctx.args.dir ? resolveProjectDir(ctx.args.dir) : null;
1149
423
  const appConfig = projectDir ? await loadAppConfig(projectDir) : null;
1150
- const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
1151
- const result = await runToolCommand({
1152
- runtime: ctx.runtime,
1153
- toolName: CREATE_APP_TOOL,
1154
- arguments_: {
1155
- name: ctx.args.name,
1156
- description: appConfig?.description || undefined,
1157
- icon: appConfig?.icon || undefined,
1158
- accent: appConfig?.accent ?? undefined,
1159
- },
1160
- mutating: true,
1161
- idempotencyKey,
1162
- });
1163
-
1164
- const app = result.payload.app || result.payload;
1165
- if (!app?.id) {
1166
- throw usageError('Create app did not return an app id.');
424
+ const profileKey = linkedStateProfileKey(ctx.runtime);
425
+ const teamId = ctx.options.teamId || null;
426
+ const name = ctx.args.name.trim();
427
+ const slug = appConfig?.name || slugify(name);
428
+ if (appConfig && (appConfig.title || name) !== name) {
429
+ throw usageError('The app name must match the config display title. Keep the config machine name unchanged.');
430
+ }
431
+ // Capture the intent before listing: overlapping invocations share the same
432
+ // durable key even when neither can yet see the pending remote creation.
433
+ const intent = beginAppCreateIntent([profileKey, name, teamId, slug], ctx.globalOptions.idempotencyKey);
434
+ const idempotencyKey = intent.key;
435
+ const listed = await runToolCommand({ runtime: ctx.runtime, toolName: LIST_APPS_TOOL });
436
+ const validInventory = result => Array.isArray(result.payload?.apps)
437
+ && result.payload.status !== 'error' && result.payload.successful !== false;
438
+ if (!validInventory(listed)) throw usageError('App inventory is unavailable; absence is not proven. No app was created.');
439
+ const apps = listed.payload.apps;
440
+ const linked = projectDir ? readLinkedState(projectDir, profileKey) : null;
441
+ const matchesIdentity = app => app.name === name && app.slug === slug && (app.team_id || null) === teamId;
442
+ let app;
443
+ if (linked?.app_id) {
444
+ app = apps.find(app => (app.app_id || app.id) === linked.app_id);
445
+ if (!app || !matchesIdentity(app)) throw usageError('This directory is linked to a different app identity. Use its exact name, slug and scope.');
446
+ } else {
447
+ const candidates = apps.filter(app => (app.team_id || null) === teamId && (app.name === name || app.slug === slug));
448
+ if (candidates.length > 1 || (candidates.length === 1 && !matchesIdentity(candidates[0]))) {
449
+ throw usageError('Conflicting app name or slug in this scope. Inspect and link the exact intended identity.');
450
+ }
451
+ app = candidates[0];
1167
452
  }
1168
-
453
+ if (app && app.can_edit !== true) throw usageError('The matching app is not editable in this profile.');
454
+ const reused = Boolean(app);
455
+ if (!app) {
456
+ const result = await runToolCommand({
457
+ runtime: ctx.runtime, toolName: CREATE_APP_TOOL,
458
+ arguments_: { name, slug, description: appConfig?.description || undefined,
459
+ icon: appConfig?.icon || undefined, accent: appConfig?.accent ?? undefined,
460
+ ...(teamId ? { team_id: teamId } : {}) },
461
+ mutating: true, idempotencyKey,
462
+ });
463
+ if (result.payload?.status === 'error' && result.payload.outcome === 'rejected') {
464
+ // Only this typed, pre-insert rejection proves that the cached key is
465
+ // finished without side effects. Unknown outcomes retain their intent.
466
+ intent.complete();
467
+ throw usageError(result.payload.message || 'App creation was rejected before insertion. Correct the request before retrying.');
468
+ }
469
+ const created = result.payload.app || result.payload;
470
+ const appId = created?.id || created?.app_id;
471
+ const readback = await runToolCommand({ runtime: ctx.runtime, toolName: LIST_APPS_TOOL });
472
+ if (!validInventory(readback)) throw usageError('Creation readback is unavailable. Reconcile the pending creation before retrying.');
473
+ app = readback.payload.apps.find(row => (row.id || row.app_id) === appId);
474
+ if (!app || !matchesIdentity(app) || app.can_edit !== true) {
475
+ throw usageError('Creation outcome could not be reconciled to the exact editable identity. Do not retry blindly.');
476
+ }
477
+ }
478
+ app = { ...app, id: app.id || app.app_id };
1169
479
  if (projectDir) {
1170
- writeLinkedState(projectDir, {
1171
- app_id: app.id,
1172
- linked_at: new Date().toISOString(),
1173
- }, linkedStateProfileKey(ctx.runtime));
480
+ const state = buildLinkedAppState(linked, app.id);
481
+ writeLinkedState(projectDir, { ...state,
482
+ version: state.version ?? deployedAppVersion(app),
483
+ expected_updated_at: state.version === 0 && deployedAppVersion(app) === 0 ? app.updated_at : state.expected_updated_at ?? app.updated_at,
484
+ }, profileKey);
1174
485
  }
1175
486
 
487
+ intent.complete();
1176
488
  return ctx.output.emitSuccess({
1177
489
  command: ctx.spec.command_path.join(' '),
1178
490
  data: {
1179
491
  app,
1180
492
  project_dir: projectDir,
1181
493
  linked: Boolean(projectDir),
494
+ reused,
1182
495
  idempotency_key: idempotencyKey,
1183
496
  },
1184
497
  humanSummary: projectDir
1185
- ? `Created app ${app.name || ctx.args.name} (${app.id}) and linked ${projectDir}`
1186
- : `Created app ${app.name || ctx.args.name} (${app.id})`,
498
+ ? `${reused ? 'Reused' : 'Created'} app ${app.name || ctx.args.name} (${app.id}) and linked ${projectDir}`
499
+ : `${reused ? 'Reused' : 'Created'} app ${app.name || ctx.args.name} (${app.id})`,
1187
500
  hints: projectDir
1188
501
  ? [{ command: `cd ${projectDir} && notis apps deploy .`, reason: 'Deploy the linked project' }]
1189
502
  : [{ command: `notis apps link ${app.id} .`, reason: 'Link a local project before deploying' }],
@@ -1191,519 +504,6 @@ async function appsCreateHandler(ctx) {
1191
504
  });
1192
505
  }
1193
506
 
1194
- async function appsDevHandler(ctx) {
1195
- const rootDir = resolveProjectDir(ctx.args.dir || '.');
1196
- const skipRootRegistration = process.env.NOTIS_APPS_DEV_ALL_REGISTERED_ROOTS === '1';
1197
- let appDirs = discoverAppDevLaunchProjects(rootDir, { skipRootRegistration });
1198
- const discoveredAppDirs = [...appDirs];
1199
- const sessionsFilePath = getAppDevSessionsFile();
1200
- let sharedBundleBaseUrls = null;
1201
- const sharedBundlesRaw = process.env.NOTIS_APPS_DEV_SHARED_BUNDLE_URLS;
1202
- if (sharedBundlesRaw) {
1203
- let parsed;
1204
- try {
1205
- parsed = JSON.parse(sharedBundlesRaw);
1206
- } catch {
1207
- throw usageError('The shared app development bundle map is invalid.');
1208
- }
1209
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1210
- throw usageError('The shared app development bundle map must be an object.');
1211
- }
1212
- const sharedProjectDirs = Object.keys(parsed);
1213
- const discoveredProjects = new Set(appDirs);
1214
- for (const projectDir of sharedProjectDirs) {
1215
- if (!discoveredProjects.has(projectDir)) {
1216
- throw usageError(`The shared bundle map references an unregistered app: ${projectDir}.`);
1217
- }
1218
- }
1219
- appDirs = sharedProjectDirs;
1220
- sharedBundleBaseUrls = new Map();
1221
- for (const projectDir of appDirs) {
1222
- const value = parsed[projectDir];
1223
- let url;
1224
- try {
1225
- url = new URL(String(value || ''));
1226
- } catch {
1227
- throw usageError(`The shared bundle URL is missing or invalid for ${projectDir}.`);
1228
- }
1229
- if (
1230
- url.protocol !== 'http:'
1231
- || !['127.0.0.1', 'localhost', '::1', '[::1]'].includes(url.hostname)
1232
- ) {
1233
- throw usageError(`The shared bundle URL must use loopback HTTP for ${projectDir}.`);
1234
- }
1235
- sharedBundleBaseUrls.set(projectDir, url.toString().replace(/\/$/, ''));
1236
- }
1237
- }
1238
-
1239
- if (appDirs.length === 0) {
1240
- return ctx.output.emitSuccess({
1241
- command: ctx.spec.command_path.join(' '),
1242
- data: {
1243
- registered_root: skipRootRegistration ? null : rootDir,
1244
- apps: [],
1245
- watching_on_desktop_launch: true,
1246
- },
1247
- humanSummary: [
1248
- skipRootRegistration
1249
- ? 'No registered Notis apps are present yet.'
1250
- : `Registered app development root: ${rootDir}`,
1251
- 'No Notis apps are present yet. A running Notis Desktop will discover and mount apps created here automatically.',
1252
- ].join('\n'),
1253
- hints: [
1254
- { command: 'notis apps roots list', reason: 'Show every persistent development root' },
1255
- ],
1256
- meta: { mutating: true },
1257
- });
1258
- }
1259
-
1260
- if (!sharedBundleBaseUrls) {
1261
- sharedBundleBaseUrls = findSharedSourceBundleUrls(appDirs, sessionsFilePath);
1262
- }
1263
- let sourceHostLock = null;
1264
- if (
1265
- !sharedBundleBaseUrls
1266
- && process.env.NOTIS_APPS_DEV_HOST_LOCK_HELD !== '1'
1267
- ) {
1268
- sourceHostLock = tryAcquireAppDevHostLock({
1269
- identity: '__mac_user__',
1270
- apiBase: 'loopback-source',
1271
- projectDir: SHARED_APP_DEV_HOST_KEY,
1272
- });
1273
- if (!sourceHostLock) {
1274
- const startedAt = Date.now();
1275
- while (!sharedBundleBaseUrls && Date.now() - startedAt < 45_000) {
1276
- await new Promise((resolvePromise) => setTimeout(resolvePromise, 100));
1277
- sharedBundleBaseUrls = findSharedSourceBundleUrls(appDirs, sessionsFilePath);
1278
- }
1279
- if (!sharedBundleBaseUrls) {
1280
- throw usageError('The shared app development host is busy but did not become ready. Retry after checking ~/.notis/app-dev-host.log.');
1281
- }
1282
- }
1283
- }
1284
-
1285
- for (const projectDir of appDirs) {
1286
- const problems = detectProjectProblems(projectDir);
1287
- if (problems.length) {
1288
- throw usageError(`Project ${projectDir} has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
1289
- }
1290
- }
1291
-
1292
- let port = null;
1293
- if (sharedBundleBaseUrls) {
1294
- // The source host owns the only loopback listener and build watchers.
1295
- } else if (ctx.options.port) {
1296
- port = Number.parseInt(ctx.options.port, 10);
1297
- if (!Number.isInteger(port) || port <= 0 || port > 65535) {
1298
- throw usageError('Port must be between 1 and 65535.');
1299
- }
1300
- } else {
1301
- // Concurrent sessions must not fight over the default port: the desktop
1302
- // auto-starts every known project at launch, so only the first one can
1303
- // have 5173 and the rest fall back to an ephemeral port.
1304
- port = await getAvailablePortPreferring(DEFAULT_DEV_PORT);
1305
- }
1306
-
1307
- const mode = getCliMode();
1308
- const identity = decodeJwtSub(ctx.runtime.jwt);
1309
- if (!identity) {
1310
- throw usageError('Could not determine the current user from the CLI credential. Run notis login and retry.');
1311
- }
1312
- const apiBase = String(ctx.runtime.apiBase || '').replace(/\/$/, '');
1313
- const sessionId = randomUUID();
1314
- const profileKey = linkedStateProfileKey(ctx.runtime);
1315
- const consumerMode = process.env.NOTIS_APPS_DEV_CONSUMER_MODE;
1316
- const manualConsumerInstanceId = sharedBundleBaseUrls && !consumerMode
1317
- ? `cli.${process.pid}.${sessionId}`
1318
- : null;
1319
- let manualConsumerTimer = null;
1320
- if (manualConsumerInstanceId) {
1321
- const heartbeatManualConsumer = () => heartbeatAppDevConsumer({
1322
- instanceId: manualConsumerInstanceId,
1323
- userId: identity,
1324
- apiBase,
1325
- pid: process.pid,
1326
- });
1327
- heartbeatManualConsumer();
1328
- manualConsumerTimer = setInterval(() => {
1329
- try {
1330
- heartbeatManualConsumer();
1331
- } catch (error) {
1332
- const message = error instanceof Error ? error.message : String(error);
1333
- process.stderr.write(`[notis apps dev] consumer heartbeat failed: ${message}\n`);
1334
- }
1335
- }, DEV_CONSUMER_HEARTBEAT_INTERVAL_MS);
1336
- }
1337
- // One app, one dataset: a dev session runs the app from local source against
1338
- // the same databases the installed app uses, the way `npm run dev` serves the
1339
- // same database as the deployed site. `--scratch` is the marked case; the
1340
- // old `--live-data` flag now names the default and is kept as a no-op so
1341
- // muscle memory and scripts keep working.
1342
- if (ctx.options?.scratch && ctx.options?.liveData) {
1343
- throw usageError('--scratch and --live-data contradict each other; pass at most one.');
1344
- }
1345
- const useInstalledDatabases = !ctx.options?.scratch;
1346
-
1347
- const candidates = [];
1348
- for (const projectDir of appDirs) {
1349
- const appConfig = await loadAppConfig(projectDir);
1350
- if (!appConfig.name) {
1351
- throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
1352
- }
1353
- const devSlug = buildDevInstallSlug(appConfig);
1354
- if (!devSlug) {
1355
- throw usageError(`notis.config.ts devSlug or name in ${projectDir} must slugify to a non-empty value.`);
1356
- }
1357
- candidates.push({ appConfig, devSlug, projectDir });
1358
- }
1359
-
1360
- const canonicalSelection = selectCanonicalDevApps(candidates, readAppDevRoots());
1361
- const canonicalCandidates = [];
1362
- for (const { appConfig, devSlug, projectDir } of canonicalSelection.selected) {
1363
- // Sequential on purpose: the shell-consent prompt must never interleave
1364
- // with another app's, and the ensure fan-out below runs in parallel.
1365
- const approvedCapabilities = await resolveCloudShellConsent({
1366
- appConfig,
1367
- projectDir,
1368
- grantCloudShell: Boolean(ctx.options?.grantCloudShell),
1369
- profileKey,
1370
- });
1371
- canonicalCandidates.push({
1372
- appConfig,
1373
- devSlug,
1374
- projectDir,
1375
- approvedCapabilities,
1376
- mountNonce: randomUUID(),
1377
- });
1378
- }
1379
-
1380
- // Start every build watcher before the backend registrations finish. A
1381
- // machine with several apps must not show an empty sidebar for minutes while
1382
- // unrelated registrations run sequentially. Each successful registration is
1383
- // published below as soon as its own last-known bundle is ready.
1384
- const devServer = sharedBundleBaseUrls
1385
- ? {
1386
- close: async () => {},
1387
- isBundleReady: () => true,
1388
- updateApp: () => {},
1389
- waitForBundle: async () => {},
1390
- getWatcherOwnership: () => null,
1391
- }
1392
- : await startAppDevServer({
1393
- apps: canonicalCandidates.map((app) => ({
1394
- slug: app.devSlug,
1395
- projectDir: app.projectDir,
1396
- appId: null,
1397
- targetAppId: null,
1398
- userId: identity,
1399
- profileKey,
1400
- sessionId,
1401
- mountNonce: app.mountNonce,
1402
- })),
1403
- port,
1404
- sessionsFilePath,
1405
- });
1406
- const desktopHostOwnership = captureDesktopHostOwnership({
1407
- desktopOwnerId: process.env.NOTIS_APPS_DEV_DESKTOP_OWNER_ID,
1408
- desktopOwnerScope: process.env.NOTIS_APPS_DEV_DESKTOP_OWNER_SCOPE,
1409
- });
1410
-
1411
- let heartbeatTimer = null;
1412
- let consumerTimer = null;
1413
- let shuttingDown = false;
1414
- const shutdown = async (signal) => {
1415
- if (shuttingDown) return;
1416
- shuttingDown = true;
1417
- process.stdout.write(`\n[notis apps dev] stopping (${signal})...\n`);
1418
- if (heartbeatTimer) {
1419
- clearInterval(heartbeatTimer);
1420
- heartbeatTimer = null;
1421
- }
1422
- if (consumerTimer) {
1423
- clearInterval(consumerTimer);
1424
- consumerTimer = null;
1425
- }
1426
- if (manualConsumerTimer) {
1427
- clearInterval(manualConsumerTimer);
1428
- manualConsumerTimer = null;
1429
- }
1430
- if (manualConsumerInstanceId) {
1431
- try {
1432
- removeAppDevConsumer(manualConsumerInstanceId);
1433
- } catch {
1434
- // A crashed CLI lease expires automatically after the heartbeat window.
1435
- }
1436
- }
1437
- try {
1438
- await devServer.close();
1439
- } catch {
1440
- // ignore cleanup failures during shutdown
1441
- }
1442
- // Keep ownership records until every watcher group has stopped. If the
1443
- // Desktop must force this host down, the next launch can still recover a
1444
- // verified orphan instead of losing its only ownership proof.
1445
- try {
1446
- removeAppDevSession(sessionId, sessionsFilePath);
1447
- } catch {
1448
- // ignore cleanup failures during shutdown
1449
- }
1450
- if (sourceHostLock) {
1451
- releaseAppDevHostLock(sourceHostLock);
1452
- sourceHostLock = null;
1453
- }
1454
- process.exit(EXIT_CODES.ok);
1455
- };
1456
- const handleSigint = () => {
1457
- void shutdown('SIGINT');
1458
- };
1459
- const handleSigterm = () => {
1460
- void shutdown('SIGTERM');
1461
- };
1462
-
1463
- // Electron can stop a partially registered host. Install cleanup before the
1464
- // first remote registration so every watcher group is still terminated when
1465
- // registration is slow or stuck.
1466
- process.on('SIGINT', handleSigint);
1467
- process.on('SIGTERM', handleSigterm);
1468
-
1469
- heartbeatTimer = setInterval(() => {
1470
- try {
1471
- heartbeatAppDevSession(sessionId, new Date().toISOString(), sessionsFilePath);
1472
- } catch (error) {
1473
- const message = error instanceof Error ? error.message : String(error);
1474
- process.stderr.write(`[notis apps dev] heartbeat failed: ${message}\n`);
1475
- }
1476
- }, DEV_HEARTBEAT_INTERVAL_MS);
1477
-
1478
- const registrationStartedAt = process.hrtime.bigint();
1479
- const apps = [];
1480
- const registrationWarnings = [];
1481
- let firstRegistrationError = null;
1482
- // The local development backend is deliberately a single worker. Register
1483
- // apps sequentially so a large root cannot queue many long mutations behind
1484
- // one another. Unlike the old all-or-nothing startup, publish each healthy
1485
- // app immediately while later registrations continue in the background.
1486
- for (const {
1487
- appConfig,
1488
- devSlug,
1489
- projectDir,
1490
- approvedCapabilities,
1491
- mountNonce,
1492
- } of canonicalCandidates) {
1493
- const appStartedAt = process.hrtime.bigint();
1494
- try {
1495
- const registeredApp = await ensureDevInstall({
1496
- ctx,
1497
- appConfig,
1498
- projectDir,
1499
- idempotencyKey: nextDevInstallIdempotencyKey(ctx.globalOptions, devSlug),
1500
- useInstalledDatabases,
1501
- approvedCapabilities,
1502
- });
1503
- const bundleBaseUrl = sharedBundleBaseUrls?.get(registeredApp.projectDir)
1504
- || `http://127.0.0.1:${port}/a/${registeredApp.devSlug}`;
1505
- const app = {
1506
- ...registeredApp,
1507
- bundleBaseUrl,
1508
- mountNonce,
1509
- appHref: buildDevelopmentAppHref({
1510
- appSlug: registeredApp.slug,
1511
- appId: registeredApp.appId,
1512
- devSlug: registeredApp.devSlug,
1513
- targetAppId: registeredApp.targetAppId,
1514
- targetAppSlug: registeredApp.targetAppSlug,
1515
- manifest: registeredApp.manifest,
1516
- }),
1517
- };
1518
- devServer.updateApp(app.devSlug, {
1519
- appId: app.appId,
1520
- targetAppId: app.targetAppId || null,
1521
- });
1522
- const now = new Date().toISOString();
1523
- const sessionRecord = {
1524
- sessionId,
1525
- hostPid: process.pid,
1526
- sourceHost: !sharedBundleBaseUrls,
1527
- ...(desktopHostOwnership || {}),
1528
- ...(devServer.getWatcherOwnership(app.devSlug) || {}),
1529
- bundleReady: devServer.isBundleReady(app.devSlug),
1530
- ...(!sharedBundleBaseUrls ? {
1531
- discoveredProjects: discoveredAppDirs,
1532
- canonicalProjects: canonicalCandidates.map((candidate) => candidate.projectDir),
1533
- } : {}),
1534
- userId: identity,
1535
- apiBase,
1536
- profileKey,
1537
- appId: app.appId,
1538
- targetAppId: app.targetAppId || undefined,
1539
- mountNonce: app.mountNonce,
1540
- devSlug: app.devSlug,
1541
- bundleBaseUrl: app.bundleBaseUrl,
1542
- projectDir: app.projectDir,
1543
- startedAt: now,
1544
- lastHeartbeatAt: now,
1545
- };
1546
- upsertAppDevSessions(sessionRecord, sessionsFilePath);
1547
- apps.push(app);
1548
- if (!sessionRecord.bundleReady) {
1549
- void devServer.waitForBundle(app.devSlug).then(() => {
1550
- upsertAppDevSessions({
1551
- ...sessionRecord,
1552
- bundleReady: true,
1553
- lastHeartbeatAt: new Date().toISOString(),
1554
- }, sessionsFilePath);
1555
- }).catch((error) => {
1556
- const message = error instanceof Error ? error.message : String(error);
1557
- process.stderr.write(`[notis apps dev] ${app.devSlug}: initial bundle failed: ${message}\n`);
1558
- });
1559
- }
1560
- } catch (error) {
1561
- firstRegistrationError ||= error;
1562
- const message = error instanceof Error ? error.message : String(error);
1563
- registrationWarnings.push(
1564
- `${projectDir} could not be mounted: ${message}. It will be retried when the shared host reconciles again.`,
1565
- );
1566
- process.stderr.write(`[notis apps dev] ${devSlug}: registration failed: ${message}\n`);
1567
- } finally {
1568
- logAppsTiming('ensure-dev-install', {
1569
- slug: devSlug,
1570
- ms: timingMs(appStartedAt).toFixed(1),
1571
- });
1572
- }
1573
- }
1574
- if (apps.length === 0) {
1575
- process.off('SIGINT', handleSigint);
1576
- process.off('SIGTERM', handleSigterm);
1577
- clearInterval(heartbeatTimer);
1578
- heartbeatTimer = null;
1579
- try {
1580
- await devServer.close();
1581
- } catch {
1582
- // preserve the original registration error
1583
- }
1584
- if (sourceHostLock) {
1585
- releaseAppDevHostLock(sourceHostLock);
1586
- sourceHostLock = null;
1587
- }
1588
- if (manualConsumerTimer) clearInterval(manualConsumerTimer);
1589
- if (manualConsumerInstanceId) {
1590
- try {
1591
- removeAppDevConsumer(manualConsumerInstanceId);
1592
- } catch {
1593
- // Preserve the registration error; a failed lease cleanup expires.
1594
- }
1595
- }
1596
- throw firstRegistrationError || usageError('No unambiguous development apps could be mounted.');
1597
- }
1598
- logAppsTiming('ensure-dev-install:all', {
1599
- apps: apps.length,
1600
- ms: timingMs(registrationStartedAt).toFixed(1),
1601
- });
1602
-
1603
- const warnings = [
1604
- ...canonicalSelection.warnings,
1605
- ...registrationWarnings,
1606
- ...databaseMaterializationWarnings(apps),
1607
- ...liveDataWarnings(apps),
1608
- ...versionPrecedenceWarnings(apps),
1609
- ];
1610
-
1611
- ctx.output.emitSuccess({
1612
- command: ctx.spec.command_path.join(' '),
1613
- data: {
1614
- mode,
1615
- api_base: apiBase,
1616
- session_id: sessionId,
1617
- mount_status: 'serving',
1618
- source_host: !sharedBundleBaseUrls,
1619
- identity,
1620
- apps: apps.map((app) => ({
1621
- slug: app.devSlug,
1622
- app_id: app.appId,
1623
- target_app_id: app.targetAppId,
1624
- name: app.name,
1625
- project_dir: app.projectDir,
1626
- bundle_base_url: app.bundleBaseUrl,
1627
- app_href: app.appHref,
1628
- created: app.created,
1629
- linked_app_id: app.linkedAppId,
1630
- database_materialization: app.databaseMaterialization,
1631
- live_data: app.liveData,
1632
- local_release_version: app.localReleaseVersion,
1633
- installed_release_version: app.installedReleaseVersion,
1634
- mount_eligible: app.mountEligible,
1635
- })),
1636
- },
1637
- warnings,
1638
- humanSummary: [
1639
- `Running apps dev against ${apiBase} as ${identity} (mode: ${mode})`,
1640
- ...(!skipRootRegistration ? [`Registered development root: ${rootDir}`] : []),
1641
- `Watching ${readAppDevRoots().roots.length} persistent development root(s).`,
1642
- ...(useInstalledDatabases
1643
- ? [`Databases: ${apps.filter((app) => app.liveData?.enabled).length}/${apps.length} app(s) reading the installed app's live rows`]
1644
- : []),
1645
- '',
1646
- ...apps.map((app) => (
1647
- app.mountEligible
1648
- ? ` ${app.name.padEnd(24)} ${app.bundleBaseUrl} -> ${app.appHref}`
1649
- : ` ${app.name.padEnd(24)} online v${app.installedReleaseVersion} (local ${app.localReleaseVersion || 'version missing'})`
1650
- )),
1651
- '',
1652
- sharedBundleBaseUrls
1653
- ? `Attached to the shared source host: ${apps.filter((app) => app.mountEligible).length}/${apps.length} app${apps.length === 1 ? '' : 's'} eligible to substitute.`
1654
- : `Serving one shared loopback host for ${apps.length} app${apps.length === 1 ? '' : 's'}; ${apps.filter((app) => app.mountEligible).length} eligible to substitute.`,
1655
- '',
1656
- 'Press Ctrl-C to stop.',
1657
- ].join('\n'),
1658
- });
1659
-
1660
- if (consumerMode === 'machine' || consumerMode === 'environment') {
1661
- consumerTimer = setInterval(() => {
1662
- if (!hasAppDevConsumer(readAppDevConsumers(), {
1663
- mode: consumerMode,
1664
- userId: identity,
1665
- apiBase,
1666
- })) {
1667
- void shutdown('last consumer detached');
1668
- }
1669
- }, DEV_CONSUMER_POLL_INTERVAL_MS);
1670
- }
1671
-
1672
- await new Promise(() => {});
1673
-
1674
- return EXIT_CODES.ok;
1675
- }
1676
-
1677
- async function appsRootsListHandler(ctx) {
1678
- const registry = readAppDevRoots();
1679
- return ctx.output.emitSuccess({
1680
- command: ctx.spec.command_path.join(' '),
1681
- data: { roots: registry.roots },
1682
- humanSummary: appDevRootsTable(registry.roots),
1683
- meta: { mutating: false },
1684
- });
1685
- }
1686
-
1687
- async function appsRootsRemoveHandler(ctx) {
1688
- const rootDir = resolve(ctx.args.dir);
1689
- const result = removeAppDevRoot(rootDir);
1690
- return ctx.output.emitSuccess({
1691
- command: ctx.spec.command_path.join(' '),
1692
- data: {
1693
- removed: result.removed,
1694
- root: result.path,
1695
- roots: result.registry.roots,
1696
- },
1697
- humanSummary: result.removed
1698
- ? `Removed app development root: ${result.path}`
1699
- : `App development root was not registered: ${result.path}`,
1700
- hints: result.removed
1701
- ? []
1702
- : [{ command: 'notis apps roots list', reason: 'Show registered roots' }],
1703
- meta: { mutating: result.removed },
1704
- });
1705
- }
1706
-
1707
507
  async function appsBuildHandler(ctx) {
1708
508
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
1709
509
  const problems = detectProjectProblems(projectDir);
@@ -1722,6 +522,47 @@ async function appsBuildHandler(ctx) {
1722
522
  });
1723
523
  }
1724
524
 
525
+ function installHarnessSignalCleanup(cleanup) {
526
+ let signalOwned = false;
527
+ const handlers = new Map(['SIGINT', 'SIGTERM'].map((signal) => [signal, () => {
528
+ // The first signal owns cleanup and terminal reporting. Keep both listeners
529
+ // installed while it runs so repeated signals cannot bypass or duplicate it.
530
+ if (signalOwned) return;
531
+ signalOwned = true;
532
+ void cleanup().catch((error) => {
533
+ process.stderr.write(`[notis apps] ${error.message}\n`);
534
+ }).finally(() => {
535
+ removeHandlers();
536
+ process.exit(signal === 'SIGINT' ? 130 : 143);
537
+ });
538
+ }]));
539
+ const removeHandlers = () => {
540
+ for (const [signal, handler] of handlers) process.removeListener(signal, handler);
541
+ };
542
+ for (const [signal, handler] of handlers) process.on(signal, handler);
543
+ return () => { if (!signalOwned) removeHandlers(); };
544
+ }
545
+
546
+ async function closeHarnessResources(sessionNames, testServer, rawOutputDir = null) {
547
+ const outcomes = await Promise.allSettled(sessionNames.map(async (name) => {
548
+ // Closing a named session is idempotent. Retry once, but never silently
549
+ // certify a release when its temporary browser could not be stopped.
550
+ for (let attempt = 0; attempt < 2; attempt += 1) {
551
+ if (await closeAgentBrowserSession(name).catch(() => false)) return;
552
+ }
553
+ throw new Error(`Browser session ${name} could not be closed`);
554
+ }));
555
+ if (testServer) outcomes.push(...await Promise.allSettled([testServer.close()]));
556
+ if (rawOutputDir) {
557
+ try { rmSync(rawOutputDir, { recursive: true, force: true }); }
558
+ catch (error) { outcomes.push({ status: 'rejected', reason: error }); }
559
+ }
560
+ const errors = outcomes.filter(result => result.status === 'rejected').map(result => result.reason);
561
+ if (errors.length) {
562
+ throw new AggregateError(errors, `Temporary app harness cleanup failed: ${errors.map(error => error.message).join('; ')}`);
563
+ }
564
+ }
565
+
1725
566
  async function appsVerifyHandler(ctx) {
1726
567
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
1727
568
  const problems = detectProjectProblems(projectDir);
@@ -1777,18 +618,25 @@ async function appsVerifyHandler(ctx) {
1777
618
  const browserSessionName = `notis-verify-${process.pid}`;
1778
619
  const noBrowser = ctx.options.browser === false;
1779
620
  const keepOpen = Boolean(ctx.options.keepOpen);
1780
- let devServer = null;
621
+ let testServer = null;
1781
622
  let browserTouched = false;
1782
623
 
624
+ let cleanupPromise;
625
+ const cleanup = () => (cleanupPromise ||= closeHarnessResources(
626
+ browserTouched ? [browserSessionName] : [], testServer,
627
+ ));
628
+ const removeSignalHandlers = ctx.registerSignalCleanup
629
+ ? ctx.registerSignalCleanup(cleanup)
630
+ : installHarnessSignalCleanup(cleanup);
631
+
1783
632
  try {
1784
- devServer = await startAppDevServer({
633
+ testServer = await startAppTestServer({
1785
634
  apps: [{
1786
635
  slug: appSlug,
1787
636
  projectDir,
1788
637
  appId: linkedState?.app_id || 'harness-app',
1789
638
  }],
1790
639
  port,
1791
- watch: false,
1792
640
  harness: {
1793
641
  mode,
1794
642
  apiBase: ctx.runtime.apiBase,
@@ -1842,6 +690,7 @@ async function appsVerifyHandler(ctx) {
1842
690
  route,
1843
691
  declaredDatabaseSlugs(appConfig, manifest, route),
1844
692
  mode,
693
+ manifest.capabilities || appConfig.capabilities || {},
1845
694
  );
1846
695
  return {
1847
696
  ...result,
@@ -1867,6 +716,7 @@ async function appsVerifyHandler(ctx) {
1867
716
  route,
1868
717
  declaredDatabaseSlugs(appConfig, manifest, route),
1869
718
  mode,
719
+ manifest.capabilities || appConfig.capabilities || {},
1870
720
  );
1871
721
  results.push({
1872
722
  route: route.slug,
@@ -1894,8 +744,17 @@ async function appsVerifyHandler(ctx) {
1894
744
  };
1895
745
  const overallOk = summary.failed === 0;
1896
746
  const exitCode = overallOk ? EXIT_CODES.ok : EXIT_CODES.unexpected;
747
+ // Keep standalone verification diagnostics. Deploy always verifies its own
748
+ // frozen snapshot; this report is never authority to skip that check.
749
+ const verifyStamp = writeVerifyStamp(projectDir, {
750
+ ok: overallOk && summary.manual === 0 && summary.total > 0,
751
+ mode,
752
+ summary,
753
+ results,
754
+ });
1897
755
  const data = {
1898
756
  status: overallOk ? (summary.manual ? 'manual' : 'passed') : 'failed',
757
+ artifact_hash: verifyStamp.artifact_hash,
1899
758
  project_dir: projectDir,
1900
759
  app_slug: appSlug,
1901
760
  mode,
@@ -1914,6 +773,7 @@ async function appsVerifyHandler(ctx) {
1914
773
  },
1915
774
  };
1916
775
 
776
+ if (!keepOpen) await cleanup();
1917
777
  ctx.output.emitSuccess({
1918
778
  ok: overallOk,
1919
779
  command: ctx.spec.command_path.join(' '),
@@ -1932,22 +792,8 @@ async function appsVerifyHandler(ctx) {
1932
792
 
1933
793
  return exitCode;
1934
794
  } finally {
1935
- if (!keepOpen) {
1936
- if (browserTouched) {
1937
- try {
1938
- await closeAgentBrowserSession(browserSessionName);
1939
- } catch {
1940
- // Ignore browser cleanup failures.
1941
- }
1942
- }
1943
- if (devServer) {
1944
- try {
1945
- await devServer.close();
1946
- } catch {
1947
- // Ignore server cleanup failures.
1948
- }
1949
- }
1950
- }
795
+ try { await cleanup(); }
796
+ finally { removeSignalHandlers(); }
1951
797
  }
1952
798
  }
1953
799
 
@@ -2042,14 +888,19 @@ async function appsScreenshotHandler(ctx) {
2042
888
  const rawOutputDir = ctx.options.raw
2043
889
  ? null
2044
890
  : mkdtempSync(join(tmpdir(), 'notis-store-screenshots-'));
2045
- let devServer = null;
891
+ let testServer = null;
2046
892
  let browserTouched = false;
2047
893
 
894
+ let cleanupPromise;
895
+ const cleanup = () => (cleanupPromise ||= closeHarnessResources(
896
+ browserTouched ? browserSessionNames : [], testServer, rawOutputDir,
897
+ ));
898
+ const removeSignalHandlers = installHarnessSignalCleanup(cleanup);
899
+
2048
900
  try {
2049
- devServer = await startAppDevServer({
901
+ testServer = await startAppTestServer({
2050
902
  apps: [{ slug: appSlug, projectDir, appId: linkedState?.app_id || 'harness-app' }],
2051
903
  port,
2052
- watch: false,
2053
904
  harness: {
2054
905
  mode,
2055
906
  apiBase: ctx.runtime.apiBase,
@@ -2136,6 +987,7 @@ async function appsScreenshotHandler(ctx) {
2136
987
  warnings.push(`${failed.length}/${results.length} routes failed to capture; see results.`);
2137
988
  }
2138
989
 
990
+ await cleanup();
2139
991
  ctx.output.emitSuccess({
2140
992
  ok: failed.length === 0,
2141
993
  command: ctx.spec.command_path.join(' '),
@@ -2155,61 +1007,40 @@ async function appsScreenshotHandler(ctx) {
2155
1007
  });
2156
1008
  return screenshotExitCode(failed.length);
2157
1009
  } finally {
2158
- if (browserTouched) {
2159
- await Promise.all(browserSessionNames.map(async (sessionName) => {
2160
- try {
2161
- await closeAgentBrowserSession(sessionName);
2162
- } catch {
2163
- // Ignore browser cleanup failures.
2164
- }
2165
- }));
2166
- }
2167
- if (devServer) {
2168
- try {
2169
- await devServer.close();
2170
- } catch {
2171
- // Ignore server cleanup failures.
2172
- }
2173
- }
2174
- if (rawOutputDir) {
2175
- rmSync(rawOutputDir, { recursive: true, force: true });
2176
- }
1010
+ try { await cleanup(); }
1011
+ finally { removeSignalHandlers(); }
2177
1012
  }
2178
1013
  }
2179
1014
 
2180
- export function buildLinkedAppState(
2181
- existingState,
2182
- appId,
2183
- linkedAt = new Date().toISOString(),
2184
- ) {
2185
- const sameIdPromotion = existingState?.dev_app_id === appId;
2186
- return {
2187
- ...(!sameIdPromotion && existingState?.dev_app_id ? { dev_app_id: existingState.dev_app_id } : {}),
2188
- ...(!sameIdPromotion && existingState?.dev_linked_at ? { dev_linked_at: existingState.dev_linked_at } : {}),
2189
- app_id: appId,
2190
- linked_at: linkedAt,
2191
- };
1015
+ export function buildLinkedAppState(existingState, appId, linkedAt = new Date().toISOString()) {
1016
+ return { ...(existingState?.app_id === appId ? existingState : {}), app_id: appId, linked_at: linkedAt };
2192
1017
  }
2193
1018
 
2194
1019
  async function appsLinkHandler(ctx) {
2195
1020
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
2196
1021
  const appId = ctx.args.appId;
1022
+ const expectedVersion = ctx.options.expectedVersion === undefined ? null : Number(ctx.options.expectedVersion);
1023
+ if (expectedVersion !== null && (!/^\d+$/.test(String(ctx.options.expectedVersion)) || !Number.isSafeInteger(expectedVersion))) {
1024
+ throw usageError('--expected-version must be a non-negative integer.');
1025
+ }
2197
1026
 
2198
- await assertLinkTarget(ctx.runtime, appId);
1027
+ const app = await assertLinkTarget(ctx.runtime, appId);
2199
1028
 
2200
- writeLinkedState(
2201
- projectDir,
2202
- buildLinkedAppState(
2203
- readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime)),
2204
- appId,
2205
- new Date().toISOString(),
2206
- ),
2207
- linkedStateProfileKey(ctx.runtime),
2208
- );
1029
+ const profileKey = linkedStateProfileKey(ctx.runtime);
1030
+ const state = buildLinkedAppState(readLinkedState(projectDir, profileKey), appId);
1031
+ const version = deployedAppVersion(app);
1032
+ if (expectedVersion !== null && version !== expectedVersion) {
1033
+ throw usageError('The app release changed before linking. Preserve local source, pull the current release into a fresh directory, and reapply changes before deploying.');
1034
+ }
1035
+ if (state.version !== undefined && state.version !== version) {
1036
+ throw usageError('A different release exists. Pull current source into a fresh directory and reapply local changes before deploying.');
1037
+ }
1038
+ if (!app.updated_at) throw usageError('App revision is unavailable; the directory was not relinked.');
1039
+ writeLinkedState(projectDir, { ...state, version, expected_updated_at: app.updated_at }, profileKey);
2209
1040
 
2210
1041
  return ctx.output.emitSuccess({
2211
1042
  command: ctx.spec.command_path.join(' '),
2212
- data: { app_id: appId, project_dir: projectDir },
1043
+ data: { app_id: appId, project_dir: projectDir, version, expected_updated_at: app.updated_at },
2213
1044
  humanSummary: `Linked to app ${appId}`,
2214
1045
  hints: [
2215
1046
  { command: 'notis apps deploy .', reason: 'Deploy the app' },
@@ -2251,6 +1082,7 @@ async function appsPullHandler(ctx) {
2251
1082
  version,
2252
1083
  force: Boolean(ctx.options.force),
2253
1084
  profileKey: linkedStateProfileKey(ctx.runtime),
1085
+ expectedUpdatedAt: app.updated_at,
2254
1086
  });
2255
1087
 
2256
1088
  const versionLabel = pulled.version === 'latest' ? 'latest version' : `v${pulled.version}`;
@@ -2261,11 +1093,11 @@ async function appsPullHandler(ctx) {
2261
1093
  project_dir: pulled.projectDir,
2262
1094
  version: pulled.version,
2263
1095
  },
2264
- humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}. Increment package.json notisAppVersion above the pulled release, then run \`cd ${pulled.projectDir} && npm install && notis apps dev\` to substitute the online bundle.`,
1096
+ humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}. Run npm install, edit the source, then build, verify and deploy the update.`,
2265
1097
  });
2266
1098
  }
2267
1099
 
2268
- function updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey = null) {
1100
+ function updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey = null, updatedAt = null) {
2269
1101
  if (!linkedState || linkedState.app_id !== appId || !Number.isFinite(version)) {
2270
1102
  return;
2271
1103
  }
@@ -2275,33 +1107,24 @@ function updateLinkedDeployState(projectDir, linkedState, appId, version, profil
2275
1107
  version,
2276
1108
  linked_at: linkedState.linked_at || new Date().toISOString(),
2277
1109
  deployed_at: new Date().toISOString(),
1110
+ expected_updated_at: updatedAt,
2278
1111
  }, profileKey);
2279
1112
  }
2280
1113
 
2281
- // Resolve first deploy without mutating. Promotion is part of the final
2282
- // SAVE_APP_FILES update, after build and uploads succeed, so a failed deploy
2283
- // cannot leave an empty installed app behind.
2284
- function resolveDeployTarget(ctx, projectDir) {
2285
- if (ctx.options.appId) return { appId: ctx.options.appId, needsPromotion: false };
2286
- const profileKey = linkedStateProfileKey(ctx.runtime);
2287
- const state = readLinkedState(projectDir, profileKey);
2288
- if (state?.app_id) return { appId: state.app_id, needsPromotion: false };
2289
- if (!state?.dev_app_id) {
2290
- return { appId: requireLinkedAppId(projectDir, ctx.options.appId, profileKey), needsPromotion: false };
2291
- }
2292
- return { appId: state.dev_app_id, needsPromotion: true };
2293
- }
2294
-
2295
1114
  async function appsDeployHandler(ctx) {
2296
1115
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
2297
1116
  const profileKey = linkedStateProfileKey(ctx.runtime);
2298
- const { appId, needsPromotion } = resolveDeployTarget(ctx, projectDir);
1117
+ const appId = requireLinkedAppId(projectDir, ctx.options.appId, profileKey);
2299
1118
  const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
2300
1119
  const linkedState = readLinkedState(projectDir, profileKey);
2301
1120
  const baseVersion = linkedState?.app_id === appId && Number.isFinite(linkedState?.version)
2302
1121
  ? linkedState.version
2303
1122
  : undefined;
2304
1123
 
1124
+ if (!Number.isInteger(baseVersion) || baseVersion < 0 || !linkedState?.expected_updated_at) {
1125
+ throw usageError('Deploy requires a current profile-scoped app link and deployment base. Pull the current release, or link an unreleased app first.');
1126
+ }
1127
+
2305
1128
  // Build if needed
2306
1129
  if (!ctx.options.skipBuild) {
2307
1130
  await buildArtifact(projectDir, {
@@ -2309,133 +1132,149 @@ async function appsDeployHandler(ctx) {
2309
1132
  });
2310
1133
  }
2311
1134
 
2312
- // Direct deploy mode: upload to Supabase storage directly
2313
- if (ctx.options.direct) {
2314
- if (needsPromotion) {
2315
- throw usageError('The first deploy must use the backend so promotion and save are atomic. Retry without --direct.');
1135
+ const release = prepareAppRelease(projectDir);
1136
+ const { files, sourceFiles, manifest } = release;
1137
+ let cleanupVerification = async () => {};
1138
+ let uploadStarted = false;
1139
+ let cancelled = false;
1140
+ const removeDeploySignalHandlers = installHarnessSignalCleanup(async () => {
1141
+ cancelled = true;
1142
+ const cleanupErrors = [];
1143
+ try { await cleanupVerification(); }
1144
+ catch (error) { cleanupErrors.push(error.message); }
1145
+ finally {
1146
+ try { release.close(); } catch (error) { cleanupErrors.push(error.message); }
2316
1147
  }
2317
- await assertDirectDeployAccess(ctx.runtime, appId);
2318
- const { version } = await directDeploy(projectDir, appId);
2319
- updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey);
2320
- return ctx.output.emitSuccess({
2321
- command: ctx.spec.command_path.join(' '),
2322
- data: { app_id: appId, version, mode: 'direct' },
2323
- humanSummary: `Deployed to app ${appId} (version ${version}) via direct upload`,
2324
- meta: { mutating: true },
2325
- });
2326
- }
2327
-
2328
- // Standard deploy via backend server, with auto-fallback to direct
2329
- const files = collectArtifactFiles(projectDir);
2330
- const sourceFiles = collectSourceFiles(projectDir);
2331
- const manifest = readManifest(projectDir);
2332
-
2333
- let result;
1148
+ ctx.output.emitError({ command: 'apps deploy', error: new CliError({
1149
+ code: uploadStarted ? 'app_deploy_outcome_unknown' : 'app_deploy_cancelled',
1150
+ message: uploadStarted
1151
+ ? 'Deployment interrupted. Read back the exact app/version before retrying.'
1152
+ : 'Deployment interrupted before upload; no update was deployed.',
1153
+ retryable: false, exitCode: EXIT_CODES.network,
1154
+ details: { app_id: appId, base_version: baseVersion,
1155
+ target_version: baseVersion + 1, idempotency_key: idempotencyKey,
1156
+ activation_outcome: uploadStarted ? 'unknown' : 'not_started',
1157
+ ...(cleanupErrors.length ? { cleanup_errors: cleanupErrors } : {}) },
1158
+ hints: uploadStarted
1159
+ ? [{ command: 'notis apps list --json', reason: 'Reconcile the interrupted deployment' }]
1160
+ : [],
1161
+ }) });
1162
+ });
2334
1163
  try {
2335
- result = await runToolCommand({
2336
- // App deploys upload both the built artifact and the editable source
2337
- // snapshot. The ordinary 30s CLI timeout is too short for larger apps,
2338
- // and timing out a mutation is ambiguous: the backend may commit after
2339
- // the client disconnects. Give this operation its real completion window.
2340
- runtime: {
2341
- ...ctx.runtime,
2342
- timeoutMs: Math.max(ctx.runtime.timeoutMs || 0, APP_DEPLOY_TIMEOUT_MS),
2343
- },
2344
- toolName: SAVE_APP_FILES_TOOL,
2345
- arguments_: {
2346
- app_id: appId,
2347
- files,
2348
- source_files: sourceFiles,
2349
- manifest,
2350
- ...appRowFieldsFromManifest(manifest),
2351
- ...(baseVersion !== undefined ? { base_version: baseVersion } : {}),
2352
- ...(needsPromotion ? { promote_dev_app: true } : {}),
1164
+ let verification;
1165
+ const verifyOutput = {
1166
+ ...ctx.output,
1167
+ emitSuccess: (result) => { verification = result; },
1168
+ isMachineMode: () => true,
1169
+ };
1170
+ const verified = await appsVerifyHandler({
1171
+ ...ctx, args: { dir: release.projectDir },
1172
+ options: { skipBuild: true, mode: 'stub' }, output: verifyOutput,
1173
+ registerSignalCleanup: (cleanup) => {
1174
+ cleanupVerification = cleanup;
1175
+ return () => { cleanupVerification = async () => {}; };
2353
1176
  },
2354
- mutating: true,
2355
- idempotencyKey,
2356
- });
2357
- } catch (error) {
2358
- if (error.code === 'conflict') {
2359
- throw toolConflictToError(error.details, 'Deploy conflict');
2360
- }
2361
-
2362
- // A timed-out mutation may already have committed on the backend. A direct
2363
- // upload here would create a second revision, so fail closed and let the
2364
- // caller inspect/pull the app before deciding whether to retry.
2365
- if (error.code === 'network_timeout') {
2366
- error.message = `${error.message}. The backend may still complete this deploy; direct fallback was not attempted to avoid creating a duplicate revision. Inspect the app version, then pull before retrying if needed.`;
1177
+ }).catch(async (error) => {
1178
+ // The signal handler also awaits verification cleanup. If that shared
1179
+ // promise rejects, it still owns the single structured terminal outcome.
1180
+ if (cancelled) return await new Promise(() => {});
2367
1181
  throw error;
1182
+ });
1183
+ if (verified !== EXIT_CODES.ok || verification?.data?.status !== 'passed' || verification?.data?.summary?.passed < 1) {
1184
+ throw usageError('App verification failed; no update was deployed. Run notis apps verify for details.');
2368
1185
  }
2369
1186
 
2370
- // A network failure is ambiguous for a mutation. It can happen after the
2371
- // backend committed but before undici finished reading the response (for
2372
- // example ECONNRESET / UND_ERR_SOCKET). Never infer pre-dispatch from a
2373
- // generic network_error or its message: an automatic direct upload could
2374
- // create a second revision. Operators who have proved the backend is down
2375
- // can still choose the explicit --direct mode.
2376
- if (error.code === 'network_error') {
2377
- error.message = `${error.message}. The backend may have committed this deploy; direct fallback was not attempted to avoid creating a duplicate revision. Inspect the app version, then pull before retrying, or use --direct only after proving the backend mutation did not land.`;
2378
- throw error;
2379
- }
2380
1187
 
2381
- throw error;
2382
- }
1188
+ // The signal handler owns terminal reporting and exit. A cancellation
1189
+ // during verification cleanup must never continue into the mutation.
1190
+ if (cancelled) return await new Promise(() => {});
2383
1191
 
2384
- const deployedVersion = Number(result?.payload?.version);
2385
- if (!Number.isFinite(deployedVersion) || deployedVersion <= 0) {
2386
- throw new CliError({
2387
- code: 'network_error',
2388
- message: 'The backend returned an incomplete deploy response. The deploy may have committed; inspect the app version and pull before retrying. Direct fallback was not attempted to avoid creating a duplicate revision.',
2389
- exitCode: EXIT_CODES.network,
2390
- retryable: true,
2391
- });
2392
- }
1192
+ // Upload uses captured bytes only. Fail closed on a staging identity swap
1193
+ // before any remote mutation, and finish local cleanup before activation.
1194
+ release.close();
2393
1195
 
2394
- if (needsPromotion) {
2395
- const now = new Date().toISOString();
2396
- writeLinkedState(projectDir, {
2397
- ...linkedState,
2398
- app_id: appId,
2399
- linked_at: linkedState?.linked_at || now,
2400
- version: deployedVersion,
2401
- deployed_at: now,
2402
- dev_app_id: undefined,
2403
- dev_linked_at: undefined,
2404
- }, profileKey);
1196
+ let result;
2405
1197
  try {
2406
- linkAppDevSessionTarget(
2407
- { appId, targetAppId: appId },
2408
- getAppDevSessionsFile(),
2409
- );
1198
+ uploadStarted = true;
1199
+ result = await runToolCommand({
1200
+ // App deploys upload both the built artifact and the editable source
1201
+ // snapshot. The ordinary 30s CLI timeout is too short for larger apps,
1202
+ // and timing out a mutation is ambiguous: the backend may commit after
1203
+ // the client disconnects. Give this operation its real completion window.
1204
+ runtime: {
1205
+ ...ctx.runtime,
1206
+ timeoutMs: Math.max(ctx.runtime.timeoutMs || 0, APP_DEPLOY_TIMEOUT_MS),
1207
+ },
1208
+ toolName: SAVE_APP_FILES_TOOL,
1209
+ arguments_: {
1210
+ app_id: appId,
1211
+ files,
1212
+ source_files: sourceFiles,
1213
+ manifest,
1214
+ ...appRowFieldsFromManifest(manifest),
1215
+ base_version: baseVersion,
1216
+ expected_updated_at: linkedState.expected_updated_at,
1217
+ },
1218
+ mutating: true,
1219
+ idempotencyKey,
1220
+ });
2410
1221
  } catch (error) {
2411
- ctx.output.emitProgress({
2412
- phase: 'warning',
2413
- message: `Deploy succeeded, but the local development session could not be retargeted: ${error.message}`,
1222
+ if (error.code === 'conflict') {
1223
+ throw toolConflictToError(error.details, 'Deploy conflict');
1224
+ }
1225
+
1226
+ // Transport failure may arrive after commit. Never replay an uncertain release.
1227
+ if (error.code === 'network_timeout' || error.code === 'network_error') {
1228
+ error.message = `${error.message}. Deployment outcome is unknown; read back the exact app/version before any retry.`;
1229
+ error.retryable = false;
1230
+ error.details = { ...error.details, app_id: appId, base_version: baseVersion,
1231
+ target_version: baseVersion + 1, idempotency_key: idempotencyKey };
1232
+ error.hints = [{ command: 'notis apps list --json', reason: `Read back app ${appId} and reconcile the deployment outcome` }];
1233
+ }
1234
+ throw error;
1235
+ }
1236
+
1237
+ const deployedVersion = Number(result?.payload?.version);
1238
+ if (!Number.isInteger(deployedVersion) || deployedVersion !== baseVersion + 1 || result.payload.app_id !== appId || !result.payload.updated_at) {
1239
+ throw new CliError({
1240
+ code: 'network_error',
1241
+ message: 'The backend returned an incomplete deploy response. The deploy may have committed; inspect the app version and pull before retrying.',
1242
+ exitCode: EXIT_CODES.network,
1243
+ retryable: false,
1244
+ details: { app_id: appId, base_version: baseVersion, target_version: baseVersion + 1, idempotency_key: idempotencyKey },
1245
+ hints: [{ command: 'notis apps list --json', reason: 'Reconcile the incomplete deployment response' }],
2414
1246
  });
2415
1247
  }
2416
- ctx.output.emitProgress({
2417
- phase: 'promote',
2418
- message: `Promoted development app to installed app ${appId}`,
1248
+
1249
+ const warnings = [];
1250
+ try { updateLinkedDeployState(projectDir, linkedState, appId, deployedVersion, profileKey, result.payload.updated_at); }
1251
+ catch { warnings.push('The app was updated, but the local link could not be saved. Pull the installed version before editing again.'); }
1252
+ try { release.close(); }
1253
+ catch { warnings.push('The app was updated, but the temporary release directory needs local cleanup.'); }
1254
+
1255
+ return ctx.output.emitSuccess({
1256
+ command: ctx.spec.command_path.join(' '),
1257
+ data: {
1258
+ app_id: appId,
1259
+ version: deployedVersion,
1260
+ idempotency_key: idempotencyKey,
1261
+ },
1262
+ warnings,
1263
+ humanSummary: `Deployed to app ${appId} (version ${deployedVersion})`,
1264
+ meta: { mutating: true, idempotency_key: idempotencyKey },
2419
1265
  });
2420
- } else {
2421
- updateLinkedDeployState(projectDir, linkedState, appId, deployedVersion, profileKey);
1266
+ } finally {
1267
+ removeDeploySignalHandlers();
1268
+ try { release.close(); } catch { /* Do not mask a committed or unknown release. */ }
2422
1269
  }
2423
- return ctx.output.emitSuccess({
2424
- command: ctx.spec.command_path.join(' '),
2425
- data: {
2426
- app_id: appId,
2427
- version: deployedVersion,
2428
- idempotency_key: idempotencyKey,
2429
- },
2430
- humanSummary: `Deployed to app ${appId} (version ${deployedVersion})`,
2431
- meta: { mutating: true, idempotency_key: idempotencyKey },
2432
- });
2433
1270
  }
2434
1271
 
2435
1272
  function deployedAppVersion(app) {
2436
1273
  const value = app?.current_version ?? app?.manifest?.version;
1274
+ if (value === null || value === undefined) return 0;
2437
1275
  const parsed = Number(value);
2438
- return Number.isFinite(parsed) ? parsed : 0;
1276
+ if (!Number.isInteger(parsed) || parsed < 0) throw usageError('The app returned an invalid deployment version.');
1277
+ return parsed;
2439
1278
  }
2440
1279
 
2441
1280
  async function appsDuplicateHandler(ctx) {
@@ -2620,9 +1459,6 @@ export function doctorLinkSummary(linkedState) {
2620
1459
  if (linkedState?.app_id) {
2621
1460
  return ` Linked to app ${linkedState.app_id}.`;
2622
1461
  }
2623
- if (linkedState?.dev_app_id) {
2624
- return ` Local development app ${linkedState.dev_app_id} is active.`;
2625
- }
2626
1462
  return ' Not linked.';
2627
1463
  }
2628
1464
 
@@ -2697,89 +1533,17 @@ export const appsCommandSpecs = [
2697
1533
  { token: '<name>', description: 'Display name for the remote app.' },
2698
1534
  { token: '[dir]', key: 'dir', description: 'Project directory to link after creation (default: do not link).' },
2699
1535
  ],
2700
- options: [],
1536
+ options: [{ flags: '--team-id <id>', description: 'Create or reuse the exact team-scoped app (default: personal).' }],
2701
1537
  },
2702
1538
  examples: [
2703
1539
  'notis apps create "My App"',
2704
1540
  'notis apps create "My App" .',
2705
1541
  ],
2706
1542
  mutates: true,
2707
- idempotent: false,
1543
+ idempotent: true,
2708
1544
  backend_call: { type: 'tool', name: CREATE_APP_TOOL },
2709
1545
  handler: appsCreateHandler,
2710
1546
  },
2711
- {
2712
- command_path: ['apps', 'dev'],
2713
- summary: 'Register a development root and connect its apps to the shared local development host.',
2714
- when_to_use:
2715
- 'Run this once for any folder that should be watched permanently. The folder itself, direct child apps, and apps/* are discovered automatically by every signed-in Notis Desktop instance. A linked app substitutes its online bundle only when local notisAppVersion is strictly greater than the installed release.',
2716
- args_schema: {
2717
- arguments: [
2718
- { token: '[dir]', key: 'dir', description: 'Project directory or monorepo root (default: current dir).' },
2719
- ],
2720
- options: [
2721
- { flags: '--port <number>', description: `Local bundle server port (default: ${DEFAULT_DEV_PORT}).` },
2722
- {
2723
- flags: '--scratch',
2724
- description:
2725
- 'Use isolated empty databases, bundled skills, and bundled automations for this session '
2726
- + "instead of the installed app's resources. For fixture work and destructive experiments.",
2727
- },
2728
- {
2729
- flags: '--live-data',
2730
- description:
2731
- 'Deprecated: using the installed app\'s real resources is now the default. '
2732
- + 'Accepted as a no-op; use `--scratch` for the old isolated behavior.',
2733
- },
2734
- {
2735
- flags: '--grant-cloud-shell',
2736
- description:
2737
- "Approve a cloudComputer: 'shell' declaration without the interactive prompt. "
2738
- + 'The grant persists for this dev app; authorship alone never grants it.',
2739
- },
2740
- ],
2741
- },
2742
- examples: [
2743
- 'notis apps dev',
2744
- 'notis apps dev ./my-app',
2745
- 'notis apps dev ./workspace --port 5200',
2746
- 'notis apps dev --scratch # isolated resources for fixture or schema experiments',
2747
- ],
2748
- mutates: true,
2749
- idempotent: true,
2750
- require_auth: true,
2751
- backend_call: { type: 'tool', name: ENSURE_DEV_APP_INSTALLATION_TOOL },
2752
- handler: appsDevHandler,
2753
- },
2754
- {
2755
- command_path: ['apps', 'roots', 'list'],
2756
- summary: 'List persistent machine-local Notis app development roots.',
2757
- when_to_use: 'See which folders every local Notis Desktop instance watches for development apps.',
2758
- args_schema: { arguments: [], options: [] },
2759
- examples: ['notis apps roots list'],
2760
- mutates: false,
2761
- idempotent: true,
2762
- require_auth: false,
2763
- backend_call: { type: 'local', name: 'list_app_development_roots' },
2764
- handler: appsRootsListHandler,
2765
- },
2766
- {
2767
- command_path: ['apps', 'roots', 'remove'],
2768
- summary: 'Stop watching a registered Notis app development root.',
2769
- when_to_use: 'Remove a persistent development root. The built-in ~/.notis/apps root cannot be removed.',
2770
- args_schema: {
2771
- arguments: [
2772
- { token: '<folder>', key: 'dir', description: 'Previously registered development root.' },
2773
- ],
2774
- options: [],
2775
- },
2776
- examples: ['notis apps roots remove ./old-apps'],
2777
- mutates: true,
2778
- idempotent: true,
2779
- require_auth: false,
2780
- backend_call: { type: 'local', name: 'remove_app_development_root' },
2781
- handler: appsRootsRemoveHandler,
2782
- },
2783
1547
  {
2784
1548
  command_path: ['apps', 'build'],
2785
1549
  summary: 'Build and package the app into .notis/output/.',
@@ -2875,9 +1639,11 @@ export const appsCommandSpecs = [
2875
1639
  { token: '<app-id>', description: 'Remote app ID to link to.' },
2876
1640
  { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
2877
1641
  ],
2878
- options: [],
1642
+ options: [
1643
+ { flags: '--expected-version <version>', description: 'Link only if the remote deployment version still matches this non-negative integer.' },
1644
+ ],
2879
1645
  },
2880
- examples: ['notis apps link abc123', 'notis apps link abc123 ./my-app'],
1646
+ examples: ['notis apps link abc123', 'notis apps link abc123 ./my-app', 'notis apps link abc123 ./recovered-app --expected-version 0'],
2881
1647
  mutates: true,
2882
1648
  idempotent: true,
2883
1649
  require_auth: false,
@@ -2888,7 +1654,7 @@ export const appsCommandSpecs = [
2888
1654
  command_path: ['apps', 'pull'],
2889
1655
  summary: 'Download a Notis app source snapshot into a local project folder.',
2890
1656
  when_to_use:
2891
- 'Edit an installed app locally. Preserve any local edits, pull and link the latest persisted source, then increment package.json notisAppVersion above that release before notis apps dev; continue with build and deploy.',
1657
+ 'Edit an installed app. Preserve local edits, pull and link its persisted source, then build, verify and deploy.',
2892
1658
  args_schema: {
2893
1659
  arguments: [
2894
1660
  { token: '<app-id>', description: 'Remote app ID to pull.' },
@@ -2911,20 +1677,18 @@ export const appsCommandSpecs = [
2911
1677
  },
2912
1678
  {
2913
1679
  command_path: ['apps', 'deploy'],
2914
- summary: 'Build and upload the app to the linked Notis app.',
1680
+ summary: 'Build, verify and release the linked Workspace app.',
2915
1681
  when_to_use:
2916
- 'Ship the installed app to production for the linked user/team app. A project that has only a development app is promoted in place on first deploy: same app id, same databases, dev markers removed. This command does not publish to the app store.',
1682
+ 'Build, verify and release the linked personal or team Workspace app. This command does not publish to the Store.',
2917
1683
  args_schema: {
2918
1684
  arguments: [
2919
1685
  { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
2920
1686
  ],
2921
1687
  options: [
2922
1688
  { flags: '--app-id <id>', description: 'Override linked app ID.' },
2923
- { flags: '--skip-build', description: 'Skip the build step (use existing .notis/output/).' },
2924
- { flags: '--direct', description: 'Explicitly upload to Supabase storage, bypassing the backend server.' },
1689
+ { flags: '--skip-build', description: 'Reuse unchanged build output; automated verification still runs.' },
2925
1690
  ],
2926
1691
  },
2927
- examples: ['notis apps deploy', 'notis apps deploy --skip-build', 'notis apps deploy --app-id abc123', 'notis apps deploy --direct'],
2928
1692
  mutates: true,
2929
1693
  idempotent: true,
2930
1694
  backend_call: { type: 'tool', name: SAVE_APP_FILES_TOOL },