@notis_ai/cli 0.2.0-beta.156.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 (35) 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 +5168 -7271
  4. package/dist/base-skills/notis-apps/SKILL.md +108 -194
  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 +322 -1560
  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 +45 -44
  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 +20 -7
  23. package/template/packages/sdk/src/config.ts +0 -2
  24. package/template/packages/sdk/src/interactions.ts +2 -1
  25. package/template/packages/sdk/src/styles.css +28 -1
  26. package/src/runtime/app-dev-build-supervisor.js +0 -47
  27. package/src/runtime/app-dev-build.js +0 -41
  28. package/src/runtime/app-dev-consumers.js +0 -154
  29. package/src/runtime/app-dev-host-lock.js +0 -80
  30. package/src/runtime/app-dev-process-identity.js +0 -111
  31. package/src/runtime/app-dev-roots.js +0 -284
  32. package/src/runtime/app-dev-server.js +0 -1136
  33. package/src/runtime/app-dev-sessions.js +0 -185
  34. package/src/runtime/cli-mode.generated.js +0 -5
  35. 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);
@@ -578,6 +262,19 @@ function assertHarnessResult(result, route, databaseSlugs, mode = 'stub', capabi
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,
@@ -1896,8 +744,17 @@ async function appsVerifyHandler(ctx) {
1896
744
  };
1897
745
  const overallOk = summary.failed === 0;
1898
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
+ });
1899
755
  const data = {
1900
756
  status: overallOk ? (summary.manual ? 'manual' : 'passed') : 'failed',
757
+ artifact_hash: verifyStamp.artifact_hash,
1901
758
  project_dir: projectDir,
1902
759
  app_slug: appSlug,
1903
760
  mode,
@@ -1916,6 +773,7 @@ async function appsVerifyHandler(ctx) {
1916
773
  },
1917
774
  };
1918
775
 
776
+ if (!keepOpen) await cleanup();
1919
777
  ctx.output.emitSuccess({
1920
778
  ok: overallOk,
1921
779
  command: ctx.spec.command_path.join(' '),
@@ -1934,22 +792,8 @@ async function appsVerifyHandler(ctx) {
1934
792
 
1935
793
  return exitCode;
1936
794
  } finally {
1937
- if (!keepOpen) {
1938
- if (browserTouched) {
1939
- try {
1940
- await closeAgentBrowserSession(browserSessionName);
1941
- } catch {
1942
- // Ignore browser cleanup failures.
1943
- }
1944
- }
1945
- if (devServer) {
1946
- try {
1947
- await devServer.close();
1948
- } catch {
1949
- // Ignore server cleanup failures.
1950
- }
1951
- }
1952
- }
795
+ try { await cleanup(); }
796
+ finally { removeSignalHandlers(); }
1953
797
  }
1954
798
  }
1955
799
 
@@ -2044,14 +888,19 @@ async function appsScreenshotHandler(ctx) {
2044
888
  const rawOutputDir = ctx.options.raw
2045
889
  ? null
2046
890
  : mkdtempSync(join(tmpdir(), 'notis-store-screenshots-'));
2047
- let devServer = null;
891
+ let testServer = null;
2048
892
  let browserTouched = false;
2049
893
 
894
+ let cleanupPromise;
895
+ const cleanup = () => (cleanupPromise ||= closeHarnessResources(
896
+ browserTouched ? browserSessionNames : [], testServer, rawOutputDir,
897
+ ));
898
+ const removeSignalHandlers = installHarnessSignalCleanup(cleanup);
899
+
2050
900
  try {
2051
- devServer = await startAppDevServer({
901
+ testServer = await startAppTestServer({
2052
902
  apps: [{ slug: appSlug, projectDir, appId: linkedState?.app_id || 'harness-app' }],
2053
903
  port,
2054
- watch: false,
2055
904
  harness: {
2056
905
  mode,
2057
906
  apiBase: ctx.runtime.apiBase,
@@ -2138,6 +987,7 @@ async function appsScreenshotHandler(ctx) {
2138
987
  warnings.push(`${failed.length}/${results.length} routes failed to capture; see results.`);
2139
988
  }
2140
989
 
990
+ await cleanup();
2141
991
  ctx.output.emitSuccess({
2142
992
  ok: failed.length === 0,
2143
993
  command: ctx.spec.command_path.join(' '),
@@ -2157,61 +1007,40 @@ async function appsScreenshotHandler(ctx) {
2157
1007
  });
2158
1008
  return screenshotExitCode(failed.length);
2159
1009
  } finally {
2160
- if (browserTouched) {
2161
- await Promise.all(browserSessionNames.map(async (sessionName) => {
2162
- try {
2163
- await closeAgentBrowserSession(sessionName);
2164
- } catch {
2165
- // Ignore browser cleanup failures.
2166
- }
2167
- }));
2168
- }
2169
- if (devServer) {
2170
- try {
2171
- await devServer.close();
2172
- } catch {
2173
- // Ignore server cleanup failures.
2174
- }
2175
- }
2176
- if (rawOutputDir) {
2177
- rmSync(rawOutputDir, { recursive: true, force: true });
2178
- }
1010
+ try { await cleanup(); }
1011
+ finally { removeSignalHandlers(); }
2179
1012
  }
2180
1013
  }
2181
1014
 
2182
- export function buildLinkedAppState(
2183
- existingState,
2184
- appId,
2185
- linkedAt = new Date().toISOString(),
2186
- ) {
2187
- const sameIdPromotion = existingState?.dev_app_id === appId;
2188
- return {
2189
- ...(!sameIdPromotion && existingState?.dev_app_id ? { dev_app_id: existingState.dev_app_id } : {}),
2190
- ...(!sameIdPromotion && existingState?.dev_linked_at ? { dev_linked_at: existingState.dev_linked_at } : {}),
2191
- app_id: appId,
2192
- linked_at: linkedAt,
2193
- };
1015
+ export function buildLinkedAppState(existingState, appId, linkedAt = new Date().toISOString()) {
1016
+ return { ...(existingState?.app_id === appId ? existingState : {}), app_id: appId, linked_at: linkedAt };
2194
1017
  }
2195
1018
 
2196
1019
  async function appsLinkHandler(ctx) {
2197
1020
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
2198
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
+ }
2199
1026
 
2200
- await assertLinkTarget(ctx.runtime, appId);
1027
+ const app = await assertLinkTarget(ctx.runtime, appId);
2201
1028
 
2202
- writeLinkedState(
2203
- projectDir,
2204
- buildLinkedAppState(
2205
- readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime)),
2206
- appId,
2207
- new Date().toISOString(),
2208
- ),
2209
- linkedStateProfileKey(ctx.runtime),
2210
- );
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);
2211
1040
 
2212
1041
  return ctx.output.emitSuccess({
2213
1042
  command: ctx.spec.command_path.join(' '),
2214
- data: { app_id: appId, project_dir: projectDir },
1043
+ data: { app_id: appId, project_dir: projectDir, version, expected_updated_at: app.updated_at },
2215
1044
  humanSummary: `Linked to app ${appId}`,
2216
1045
  hints: [
2217
1046
  { command: 'notis apps deploy .', reason: 'Deploy the app' },
@@ -2253,6 +1082,7 @@ async function appsPullHandler(ctx) {
2253
1082
  version,
2254
1083
  force: Boolean(ctx.options.force),
2255
1084
  profileKey: linkedStateProfileKey(ctx.runtime),
1085
+ expectedUpdatedAt: app.updated_at,
2256
1086
  });
2257
1087
 
2258
1088
  const versionLabel = pulled.version === 'latest' ? 'latest version' : `v${pulled.version}`;
@@ -2263,11 +1093,11 @@ async function appsPullHandler(ctx) {
2263
1093
  project_dir: pulled.projectDir,
2264
1094
  version: pulled.version,
2265
1095
  },
2266
- 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.`,
2267
1097
  });
2268
1098
  }
2269
1099
 
2270
- function updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey = null) {
1100
+ function updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey = null, updatedAt = null) {
2271
1101
  if (!linkedState || linkedState.app_id !== appId || !Number.isFinite(version)) {
2272
1102
  return;
2273
1103
  }
@@ -2277,33 +1107,24 @@ function updateLinkedDeployState(projectDir, linkedState, appId, version, profil
2277
1107
  version,
2278
1108
  linked_at: linkedState.linked_at || new Date().toISOString(),
2279
1109
  deployed_at: new Date().toISOString(),
1110
+ expected_updated_at: updatedAt,
2280
1111
  }, profileKey);
2281
1112
  }
2282
1113
 
2283
- // Resolve first deploy without mutating. Promotion is part of the final
2284
- // SAVE_APP_FILES update, after build and uploads succeed, so a failed deploy
2285
- // cannot leave an empty installed app behind.
2286
- function resolveDeployTarget(ctx, projectDir) {
2287
- if (ctx.options.appId) return { appId: ctx.options.appId, needsPromotion: false };
2288
- const profileKey = linkedStateProfileKey(ctx.runtime);
2289
- const state = readLinkedState(projectDir, profileKey);
2290
- if (state?.app_id) return { appId: state.app_id, needsPromotion: false };
2291
- if (!state?.dev_app_id) {
2292
- return { appId: requireLinkedAppId(projectDir, ctx.options.appId, profileKey), needsPromotion: false };
2293
- }
2294
- return { appId: state.dev_app_id, needsPromotion: true };
2295
- }
2296
-
2297
1114
  async function appsDeployHandler(ctx) {
2298
1115
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
2299
1116
  const profileKey = linkedStateProfileKey(ctx.runtime);
2300
- const { appId, needsPromotion } = resolveDeployTarget(ctx, projectDir);
1117
+ const appId = requireLinkedAppId(projectDir, ctx.options.appId, profileKey);
2301
1118
  const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
2302
1119
  const linkedState = readLinkedState(projectDir, profileKey);
2303
1120
  const baseVersion = linkedState?.app_id === appId && Number.isFinite(linkedState?.version)
2304
1121
  ? linkedState.version
2305
1122
  : undefined;
2306
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
+
2307
1128
  // Build if needed
2308
1129
  if (!ctx.options.skipBuild) {
2309
1130
  await buildArtifact(projectDir, {
@@ -2311,133 +1132,149 @@ async function appsDeployHandler(ctx) {
2311
1132
  });
2312
1133
  }
2313
1134
 
2314
- // Direct deploy mode: upload to Supabase storage directly
2315
- if (ctx.options.direct) {
2316
- if (needsPromotion) {
2317
- 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); }
2318
1147
  }
2319
- await assertDirectDeployAccess(ctx.runtime, appId);
2320
- const { version } = await directDeploy(projectDir, appId);
2321
- updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey);
2322
- return ctx.output.emitSuccess({
2323
- command: ctx.spec.command_path.join(' '),
2324
- data: { app_id: appId, version, mode: 'direct' },
2325
- humanSummary: `Deployed to app ${appId} (version ${version}) via direct upload`,
2326
- meta: { mutating: true },
2327
- });
2328
- }
2329
-
2330
- // Standard deploy via backend server, with auto-fallback to direct
2331
- const files = collectArtifactFiles(projectDir);
2332
- const sourceFiles = collectSourceFiles(projectDir);
2333
- const manifest = readManifest(projectDir);
2334
-
2335
- 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
+ });
2336
1163
  try {
2337
- result = await runToolCommand({
2338
- // App deploys upload both the built artifact and the editable source
2339
- // snapshot. The ordinary 30s CLI timeout is too short for larger apps,
2340
- // and timing out a mutation is ambiguous: the backend may commit after
2341
- // the client disconnects. Give this operation its real completion window.
2342
- runtime: {
2343
- ...ctx.runtime,
2344
- timeoutMs: Math.max(ctx.runtime.timeoutMs || 0, APP_DEPLOY_TIMEOUT_MS),
2345
- },
2346
- toolName: SAVE_APP_FILES_TOOL,
2347
- arguments_: {
2348
- app_id: appId,
2349
- files,
2350
- source_files: sourceFiles,
2351
- manifest,
2352
- ...appRowFieldsFromManifest(manifest),
2353
- ...(baseVersion !== undefined ? { base_version: baseVersion } : {}),
2354
- ...(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 () => {}; };
2355
1176
  },
2356
- mutating: true,
2357
- idempotencyKey,
2358
- });
2359
- } catch (error) {
2360
- if (error.code === 'conflict') {
2361
- throw toolConflictToError(error.details, 'Deploy conflict');
2362
- }
2363
-
2364
- // A timed-out mutation may already have committed on the backend. A direct
2365
- // upload here would create a second revision, so fail closed and let the
2366
- // caller inspect/pull the app before deciding whether to retry.
2367
- if (error.code === 'network_timeout') {
2368
- 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(() => {});
2369
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.');
2370
1185
  }
2371
1186
 
2372
- // A network failure is ambiguous for a mutation. It can happen after the
2373
- // backend committed but before undici finished reading the response (for
2374
- // example ECONNRESET / UND_ERR_SOCKET). Never infer pre-dispatch from a
2375
- // generic network_error or its message: an automatic direct upload could
2376
- // create a second revision. Operators who have proved the backend is down
2377
- // can still choose the explicit --direct mode.
2378
- if (error.code === 'network_error') {
2379
- 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.`;
2380
- throw error;
2381
- }
2382
1187
 
2383
- throw error;
2384
- }
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(() => {});
2385
1191
 
2386
- const deployedVersion = Number(result?.payload?.version);
2387
- if (!Number.isFinite(deployedVersion) || deployedVersion <= 0) {
2388
- throw new CliError({
2389
- code: 'network_error',
2390
- 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.',
2391
- exitCode: EXIT_CODES.network,
2392
- retryable: true,
2393
- });
2394
- }
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();
2395
1195
 
2396
- if (needsPromotion) {
2397
- const now = new Date().toISOString();
2398
- writeLinkedState(projectDir, {
2399
- ...linkedState,
2400
- app_id: appId,
2401
- linked_at: linkedState?.linked_at || now,
2402
- version: deployedVersion,
2403
- deployed_at: now,
2404
- dev_app_id: undefined,
2405
- dev_linked_at: undefined,
2406
- }, profileKey);
1196
+ let result;
2407
1197
  try {
2408
- linkAppDevSessionTarget(
2409
- { appId, targetAppId: appId },
2410
- getAppDevSessionsFile(),
2411
- );
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
+ });
2412
1221
  } catch (error) {
2413
- ctx.output.emitProgress({
2414
- phase: 'warning',
2415
- 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' }],
2416
1246
  });
2417
1247
  }
2418
- ctx.output.emitProgress({
2419
- phase: 'promote',
2420
- 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 },
2421
1265
  });
2422
- } else {
2423
- updateLinkedDeployState(projectDir, linkedState, appId, deployedVersion, profileKey);
1266
+ } finally {
1267
+ removeDeploySignalHandlers();
1268
+ try { release.close(); } catch { /* Do not mask a committed or unknown release. */ }
2424
1269
  }
2425
- return ctx.output.emitSuccess({
2426
- command: ctx.spec.command_path.join(' '),
2427
- data: {
2428
- app_id: appId,
2429
- version: deployedVersion,
2430
- idempotency_key: idempotencyKey,
2431
- },
2432
- humanSummary: `Deployed to app ${appId} (version ${deployedVersion})`,
2433
- meta: { mutating: true, idempotency_key: idempotencyKey },
2434
- });
2435
1270
  }
2436
1271
 
2437
1272
  function deployedAppVersion(app) {
2438
1273
  const value = app?.current_version ?? app?.manifest?.version;
1274
+ if (value === null || value === undefined) return 0;
2439
1275
  const parsed = Number(value);
2440
- 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;
2441
1278
  }
2442
1279
 
2443
1280
  async function appsDuplicateHandler(ctx) {
@@ -2622,9 +1459,6 @@ export function doctorLinkSummary(linkedState) {
2622
1459
  if (linkedState?.app_id) {
2623
1460
  return ` Linked to app ${linkedState.app_id}.`;
2624
1461
  }
2625
- if (linkedState?.dev_app_id) {
2626
- return ` Local development app ${linkedState.dev_app_id} is active.`;
2627
- }
2628
1462
  return ' Not linked.';
2629
1463
  }
2630
1464
 
@@ -2699,89 +1533,17 @@ export const appsCommandSpecs = [
2699
1533
  { token: '<name>', description: 'Display name for the remote app.' },
2700
1534
  { token: '[dir]', key: 'dir', description: 'Project directory to link after creation (default: do not link).' },
2701
1535
  ],
2702
- options: [],
1536
+ options: [{ flags: '--team-id <id>', description: 'Create or reuse the exact team-scoped app (default: personal).' }],
2703
1537
  },
2704
1538
  examples: [
2705
1539
  'notis apps create "My App"',
2706
1540
  'notis apps create "My App" .',
2707
1541
  ],
2708
1542
  mutates: true,
2709
- idempotent: false,
1543
+ idempotent: true,
2710
1544
  backend_call: { type: 'tool', name: CREATE_APP_TOOL },
2711
1545
  handler: appsCreateHandler,
2712
1546
  },
2713
- {
2714
- command_path: ['apps', 'dev'],
2715
- summary: 'Register a development root and connect its apps to the shared local development host.',
2716
- when_to_use:
2717
- '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.',
2718
- args_schema: {
2719
- arguments: [
2720
- { token: '[dir]', key: 'dir', description: 'Project directory or monorepo root (default: current dir).' },
2721
- ],
2722
- options: [
2723
- { flags: '--port <number>', description: `Local bundle server port (default: ${DEFAULT_DEV_PORT}).` },
2724
- {
2725
- flags: '--scratch',
2726
- description:
2727
- 'Use isolated empty databases, bundled skills, and bundled automations for this session '
2728
- + "instead of the installed app's resources. For fixture work and destructive experiments.",
2729
- },
2730
- {
2731
- flags: '--live-data',
2732
- description:
2733
- 'Deprecated: using the installed app\'s real resources is now the default. '
2734
- + 'Accepted as a no-op; use `--scratch` for the old isolated behavior.',
2735
- },
2736
- {
2737
- flags: '--grant-cloud-shell',
2738
- description:
2739
- "Approve a cloudComputer: 'shell' declaration without the interactive prompt. "
2740
- + 'The grant persists for this dev app; authorship alone never grants it.',
2741
- },
2742
- ],
2743
- },
2744
- examples: [
2745
- 'notis apps dev',
2746
- 'notis apps dev ./my-app',
2747
- 'notis apps dev ./workspace --port 5200',
2748
- 'notis apps dev --scratch # isolated resources for fixture or schema experiments',
2749
- ],
2750
- mutates: true,
2751
- idempotent: true,
2752
- require_auth: true,
2753
- backend_call: { type: 'tool', name: ENSURE_DEV_APP_INSTALLATION_TOOL },
2754
- handler: appsDevHandler,
2755
- },
2756
- {
2757
- command_path: ['apps', 'roots', 'list'],
2758
- summary: 'List persistent machine-local Notis app development roots.',
2759
- when_to_use: 'See which folders every local Notis Desktop instance watches for development apps.',
2760
- args_schema: { arguments: [], options: [] },
2761
- examples: ['notis apps roots list'],
2762
- mutates: false,
2763
- idempotent: true,
2764
- require_auth: false,
2765
- backend_call: { type: 'local', name: 'list_app_development_roots' },
2766
- handler: appsRootsListHandler,
2767
- },
2768
- {
2769
- command_path: ['apps', 'roots', 'remove'],
2770
- summary: 'Stop watching a registered Notis app development root.',
2771
- when_to_use: 'Remove a persistent development root. The built-in ~/.notis/apps root cannot be removed.',
2772
- args_schema: {
2773
- arguments: [
2774
- { token: '<folder>', key: 'dir', description: 'Previously registered development root.' },
2775
- ],
2776
- options: [],
2777
- },
2778
- examples: ['notis apps roots remove ./old-apps'],
2779
- mutates: true,
2780
- idempotent: true,
2781
- require_auth: false,
2782
- backend_call: { type: 'local', name: 'remove_app_development_root' },
2783
- handler: appsRootsRemoveHandler,
2784
- },
2785
1547
  {
2786
1548
  command_path: ['apps', 'build'],
2787
1549
  summary: 'Build and package the app into .notis/output/.',
@@ -2877,9 +1639,11 @@ export const appsCommandSpecs = [
2877
1639
  { token: '<app-id>', description: 'Remote app ID to link to.' },
2878
1640
  { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
2879
1641
  ],
2880
- options: [],
1642
+ options: [
1643
+ { flags: '--expected-version <version>', description: 'Link only if the remote deployment version still matches this non-negative integer.' },
1644
+ ],
2881
1645
  },
2882
- 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'],
2883
1647
  mutates: true,
2884
1648
  idempotent: true,
2885
1649
  require_auth: false,
@@ -2890,7 +1654,7 @@ export const appsCommandSpecs = [
2890
1654
  command_path: ['apps', 'pull'],
2891
1655
  summary: 'Download a Notis app source snapshot into a local project folder.',
2892
1656
  when_to_use:
2893
- '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.',
2894
1658
  args_schema: {
2895
1659
  arguments: [
2896
1660
  { token: '<app-id>', description: 'Remote app ID to pull.' },
@@ -2913,20 +1677,18 @@ export const appsCommandSpecs = [
2913
1677
  },
2914
1678
  {
2915
1679
  command_path: ['apps', 'deploy'],
2916
- summary: 'Build and upload the app to the linked Notis app.',
1680
+ summary: 'Build, verify and release the linked Workspace app.',
2917
1681
  when_to_use:
2918
- '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.',
2919
1683
  args_schema: {
2920
1684
  arguments: [
2921
1685
  { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
2922
1686
  ],
2923
1687
  options: [
2924
1688
  { flags: '--app-id <id>', description: 'Override linked app ID.' },
2925
- { flags: '--skip-build', description: 'Skip the build step (use existing .notis/output/).' },
2926
- { 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.' },
2927
1690
  ],
2928
1691
  },
2929
- examples: ['notis apps deploy', 'notis apps deploy --skip-build', 'notis apps deploy --app-id abc123', 'notis apps deploy --direct'],
2930
1692
  mutates: true,
2931
1693
  idempotent: true,
2932
1694
  backend_call: { type: 'tool', name: SAVE_APP_FILES_TOOL },