@notis_ai/cli 0.2.0-beta.155.1 → 0.2.0-beta.157.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -45
- package/config/notis_app_design_rules.json +135 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +5180 -7281
- package/dist/base-skills/notis-apps/SKILL.md +141 -224
- package/dist/base-skills/notis-cli/SKILL.md +64 -131
- package/package.json +1 -2
- package/skills/notis-apps/cli.md +34 -95
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +1 -1
- package/src/command-specs/apps.js +326 -1562
- package/src/runtime/agent-browser.js +169 -1
- package/src/runtime/app-boundary-validator.js +221 -0
- package/src/runtime/app-platform.js +359 -233
- package/src/runtime/app-test-server.js +292 -0
- package/template/app/page.tsx +47 -45
- package/template/components/page-heading.tsx +23 -0
- package/template/components/ui/badge.tsx +7 -4
- package/template/components/ui/card.tsx +24 -11
- package/template/components/ui/native-select.tsx +24 -0
- package/template/notis.config.ts +0 -1
- package/template/package.json +2 -2
- package/template/packages/sdk/package.json +1 -2
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +62 -8
- package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
- package/template/packages/sdk/src/config.ts +0 -2
- package/template/packages/sdk/src/hooks/useCloudComputer.ts +15 -48
- package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +17 -53
- package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +2 -2
- package/template/packages/sdk/src/hooks/useDocument.ts +12 -47
- package/template/packages/sdk/src/hooks/useDocuments.ts +18 -58
- package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
- package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +15 -7
- package/template/packages/sdk/src/index.ts +8 -0
- package/template/packages/sdk/src/interactions.ts +2 -1
- package/template/packages/sdk/src/queryCache.ts +162 -0
- package/template/packages/sdk/src/runtime.ts +5 -0
- package/template/packages/sdk/src/styles.css +28 -1
- package/src/runtime/app-dev-build-supervisor.js +0 -47
- package/src/runtime/app-dev-build.js +0 -41
- package/src/runtime/app-dev-consumers.js +0 -154
- package/src/runtime/app-dev-host-lock.js +0 -80
- package/src/runtime/app-dev-process-identity.js +0 -111
- package/src/runtime/app-dev-roots.js +0 -284
- package/src/runtime/app-dev-server.js +0 -1136
- package/src/runtime/app-dev-sessions.js +0 -185
- package/src/runtime/cli-mode.generated.js +0 -5
- 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,
|
|
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 {
|
|
49
|
-
import { captureDesktopHostOwnership } from '../runtime/app-dev-process-identity.js';
|
|
50
|
-
import {
|
|
51
|
-
discoverRegisteredAppProjects,
|
|
52
|
-
readAppDevRoots,
|
|
53
|
-
registerAppDevRoot,
|
|
54
|
-
removeAppDevRoot,
|
|
55
|
-
} from '../runtime/app-dev-roots.js';
|
|
13
|
+
import { startAppTestServer } from '../runtime/app-test-server.js';
|
|
56
14
|
import {
|
|
57
15
|
captureHarnessScreenshot,
|
|
16
|
+
describeDesignFinding,
|
|
58
17
|
closeAgentBrowserSession,
|
|
59
18
|
isAgentBrowserAvailable,
|
|
60
19
|
runHarnessRoute,
|
|
61
20
|
} from '../runtime/agent-browser.js';
|
|
62
|
-
import {
|
|
63
|
-
getAppDevSessionsFile,
|
|
64
|
-
heartbeatAppDevSession,
|
|
65
|
-
linkAppDevSessionTarget,
|
|
66
|
-
readAppDevSessions,
|
|
67
|
-
removeAppDevSession,
|
|
68
|
-
upsertAppDevSessions,
|
|
69
|
-
} from '../runtime/app-dev-sessions.js';
|
|
70
|
-
import {
|
|
71
|
-
releaseAppDevHostLock,
|
|
72
|
-
tryAcquireAppDevHostLock,
|
|
73
|
-
} from '../runtime/app-dev-host-lock.js';
|
|
74
|
-
import {
|
|
75
|
-
heartbeatAppDevConsumer,
|
|
76
|
-
hasAppDevConsumer,
|
|
77
|
-
readAppDevConsumers,
|
|
78
|
-
removeAppDevConsumer,
|
|
79
|
-
} from '../runtime/app-dev-consumers.js';
|
|
80
|
-
import { getAvailablePort, getAvailablePortPreferring } from '../runtime/ports.js';
|
|
81
|
-
import { getCliMode } from '../runtime/cli-mode.js';
|
|
21
|
+
import { getAvailablePort } from '../runtime/ports.js';
|
|
82
22
|
import { composeStoreScreenshot } from '../runtime/store-screenshot.js';
|
|
83
23
|
import { httpRequest } from '../runtime/transport.js';
|
|
84
24
|
import { ensureFreshOAuthCredential } from '../runtime/oauth.js';
|
|
@@ -90,205 +30,6 @@ import {
|
|
|
90
30
|
} from './helpers.js';
|
|
91
31
|
|
|
92
32
|
export { appRowFieldsFromManifest } from '../runtime/app-platform.js';
|
|
93
|
-
|
|
94
|
-
const DEFAULT_DEV_PORT = 5173;
|
|
95
|
-
const DEV_HEARTBEAT_INTERVAL_MS = 10_000;
|
|
96
|
-
const DEV_CONSUMER_HEARTBEAT_INTERVAL_MS = 3_000;
|
|
97
|
-
const DEV_CONSUMER_POLL_INTERVAL_MS = 5_000;
|
|
98
|
-
const SHARED_APP_DEV_HOST_KEY = '__registered_roots__';
|
|
99
|
-
|
|
100
|
-
function projectIsWithinRoot(projectDir, rootDir) {
|
|
101
|
-
const nested = relative(rootDir, projectDir);
|
|
102
|
-
return nested === '' || (nested !== '..' && !nested.startsWith(`..${sep}`) && !isAbsolute(nested));
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function parseNotisAppVersion(value) {
|
|
106
|
-
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
|
|
107
|
-
String(value || '').trim(),
|
|
108
|
-
);
|
|
109
|
-
if (!match) return null;
|
|
110
|
-
const prerelease = match[4] ? match[4].split('.') : [];
|
|
111
|
-
if (prerelease.some((identifier) => /^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith('0'))) {
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
return {
|
|
115
|
-
major: match[1],
|
|
116
|
-
minor: match[2],
|
|
117
|
-
patch: match[3],
|
|
118
|
-
prerelease,
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function compareNumericSemverIdentifiers(left, right) {
|
|
123
|
-
if (left.length !== right.length) return left.length > right.length ? 1 : -1;
|
|
124
|
-
return left === right ? 0 : left > right ? 1 : -1;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function compareNotisPrerelease(left, right) {
|
|
128
|
-
if (left.length === 0 || right.length === 0) {
|
|
129
|
-
return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
|
|
130
|
-
}
|
|
131
|
-
const length = Math.max(left.length, right.length);
|
|
132
|
-
for (let index = 0; index < length; index += 1) {
|
|
133
|
-
const leftIdentifier = left[index];
|
|
134
|
-
const rightIdentifier = right[index];
|
|
135
|
-
if (leftIdentifier === undefined || rightIdentifier === undefined) {
|
|
136
|
-
return leftIdentifier === rightIdentifier ? 0 : leftIdentifier === undefined ? -1 : 1;
|
|
137
|
-
}
|
|
138
|
-
if (leftIdentifier === rightIdentifier) continue;
|
|
139
|
-
const leftNumeric = /^\d+$/.test(leftIdentifier);
|
|
140
|
-
const rightNumeric = /^\d+$/.test(rightIdentifier);
|
|
141
|
-
if (leftNumeric && rightNumeric) {
|
|
142
|
-
return compareNumericSemverIdentifiers(leftIdentifier, rightIdentifier);
|
|
143
|
-
}
|
|
144
|
-
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
|
|
145
|
-
return leftIdentifier > rightIdentifier ? 1 : -1;
|
|
146
|
-
}
|
|
147
|
-
return 0;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export function compareNotisAppVersions(leftValue, rightValue) {
|
|
151
|
-
const left = parseNotisAppVersion(leftValue);
|
|
152
|
-
const right = parseNotisAppVersion(rightValue);
|
|
153
|
-
if (!left || !right) return null;
|
|
154
|
-
for (const key of ['major', 'minor', 'patch']) {
|
|
155
|
-
const comparison = compareNumericSemverIdentifiers(left[key], right[key]);
|
|
156
|
-
if (comparison !== 0) return comparison;
|
|
157
|
-
}
|
|
158
|
-
return compareNotisPrerelease(left.prerelease, right.prerelease);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function readLocalNotisAppVersion(projectDir) {
|
|
162
|
-
try {
|
|
163
|
-
const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
|
|
164
|
-
const version = String(packageJson.notisAppVersion || '').trim();
|
|
165
|
-
return parseNotisAppVersion(version) ? version : null;
|
|
166
|
-
} catch {
|
|
167
|
-
return null;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* A CLI launch may add one root, but the shared host always serves the complete
|
|
173
|
-
* machine registry. Desktop sets skipRootRegistration because it is reconciling
|
|
174
|
-
* an already-registered snapshot; this must never narrow discovery to the one
|
|
175
|
-
* project path used to start the host.
|
|
176
|
-
*/
|
|
177
|
-
export function discoverAppDevLaunchProjects(rootDir, {
|
|
178
|
-
skipRootRegistration = false,
|
|
179
|
-
registerRoot = registerAppDevRoot,
|
|
180
|
-
discoverProjects = discoverRegisteredAppProjects,
|
|
181
|
-
} = {}) {
|
|
182
|
-
if (!skipRootRegistration) registerRoot(rootDir);
|
|
183
|
-
return discoverProjects();
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Collapse duplicate local sources before they reach the shared loopback host.
|
|
188
|
-
* An explicitly registered root is more intentional than the implicit
|
|
189
|
-
* ~/.notis/apps root. If two equally intentional roots claim the same dev
|
|
190
|
-
* slug, omit that slug and keep serving every unrelated app.
|
|
191
|
-
*/
|
|
192
|
-
export function selectCanonicalDevApps(candidates, rootsRegistry) {
|
|
193
|
-
const roots = Array.isArray(rootsRegistry?.roots) ? rootsRegistry.roots : [];
|
|
194
|
-
const groups = new Map();
|
|
195
|
-
for (const candidate of candidates) {
|
|
196
|
-
groups.set(candidate.devSlug, [...(groups.get(candidate.devSlug) || []), candidate]);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const selected = [];
|
|
200
|
-
const warnings = [];
|
|
201
|
-
for (const [devSlug, group] of groups) {
|
|
202
|
-
if (group.length === 1) {
|
|
203
|
-
selected.push(group[0]);
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
const ranked = group.map((candidate) => {
|
|
207
|
-
const explicitRoots = roots.filter((root) => (
|
|
208
|
-
root?.implicit !== true
|
|
209
|
-
&& typeof root?.path === 'string'
|
|
210
|
-
&& projectIsWithinRoot(candidate.projectDir, root.path)
|
|
211
|
-
));
|
|
212
|
-
const newestRegistration = explicitRoots.reduce((latest, root) => {
|
|
213
|
-
const timestamp = Date.parse(root.registeredAt || '');
|
|
214
|
-
return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
|
|
215
|
-
}, 0);
|
|
216
|
-
return {
|
|
217
|
-
candidate,
|
|
218
|
-
rank: explicitRoots.length > 0 ? 1 : 0,
|
|
219
|
-
newestRegistration,
|
|
220
|
-
};
|
|
221
|
-
}).sort((left, right) => (
|
|
222
|
-
right.rank - left.rank
|
|
223
|
-
|| right.newestRegistration - left.newestRegistration
|
|
224
|
-
));
|
|
225
|
-
const winner = ranked[0];
|
|
226
|
-
const runnerUp = ranked[1];
|
|
227
|
-
const unambiguous = winner.rank > runnerUp.rank
|
|
228
|
-
|| winner.newestRegistration > runnerUp.newestRegistration;
|
|
229
|
-
if (unambiguous) {
|
|
230
|
-
selected.push(winner.candidate);
|
|
231
|
-
warnings.push(
|
|
232
|
-
`Using ${winner.candidate.projectDir} for development slug "${devSlug}"; ignored duplicate source(s): ${ranked.slice(1).map(({ candidate }) => candidate.projectDir).join(', ')}.`,
|
|
233
|
-
);
|
|
234
|
-
continue;
|
|
235
|
-
}
|
|
236
|
-
warnings.push(
|
|
237
|
-
`Skipped ambiguous development slug "${devSlug}" because multiple equally ranked sources are registered: ${ranked.map(({ candidate }) => candidate.projectDir).join(', ')}. Remove a root or set a unique devSlug.`,
|
|
238
|
-
);
|
|
239
|
-
}
|
|
240
|
-
return { selected, warnings };
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
export function findSharedSourceBundleUrls(projectDirs, sessionsFilePath) {
|
|
244
|
-
const now = Date.now();
|
|
245
|
-
const sourceSessions = readAppDevSessions(sessionsFilePath).sessions
|
|
246
|
-
.filter((session) => {
|
|
247
|
-
if (session.sourceHost !== true) return false;
|
|
248
|
-
const heartbeatAt = Date.parse(session.lastHeartbeatAt || '');
|
|
249
|
-
if (!Number.isFinite(heartbeatAt) || now - heartbeatAt > 45_000) return false;
|
|
250
|
-
if (!Number.isInteger(session.hostPid) || session.hostPid <= 0) return false;
|
|
251
|
-
try {
|
|
252
|
-
process.kill(session.hostPid, 0);
|
|
253
|
-
return true;
|
|
254
|
-
} catch {
|
|
255
|
-
return false;
|
|
256
|
-
}
|
|
257
|
-
});
|
|
258
|
-
const groups = new Map();
|
|
259
|
-
for (const session of sourceSessions) {
|
|
260
|
-
const key = `${session.hostPid || 0}:${session.sessionId}`;
|
|
261
|
-
groups.set(key, [...(groups.get(key) || []), session]);
|
|
262
|
-
}
|
|
263
|
-
for (const sessions of groups.values()) {
|
|
264
|
-
const byProject = new Map(sessions.map((session) => [session.projectDir, session]));
|
|
265
|
-
const canonicalProjects = sessions.find((session) => (
|
|
266
|
-
Array.isArray(session.canonicalProjects)
|
|
267
|
-
))?.canonicalProjects;
|
|
268
|
-
const discoveredProjects = sessions.find((session) => (
|
|
269
|
-
Array.isArray(session.discoveredProjects)
|
|
270
|
-
))?.discoveredProjects;
|
|
271
|
-
const discoveryMatches = discoveredProjects
|
|
272
|
-
&& JSON.stringify([...discoveredProjects].sort()) === JSON.stringify([...projectDirs].sort());
|
|
273
|
-
if (
|
|
274
|
-
discoveryMatches
|
|
275
|
-
&& canonicalProjects
|
|
276
|
-
&& canonicalProjects.every((projectDir) => byProject.has(projectDir))
|
|
277
|
-
) {
|
|
278
|
-
return new Map(canonicalProjects.map((projectDir) => [
|
|
279
|
-
projectDir,
|
|
280
|
-
byProject.get(projectDir).bundleBaseUrl,
|
|
281
|
-
]));
|
|
282
|
-
}
|
|
283
|
-
if (!projectDirs.every((projectDir) => byProject.has(projectDir))) continue;
|
|
284
|
-
return new Map(projectDirs.map((projectDir) => [
|
|
285
|
-
projectDir,
|
|
286
|
-
byProject.get(projectDir).bundleBaseUrl,
|
|
287
|
-
]));
|
|
288
|
-
}
|
|
289
|
-
return null;
|
|
290
|
-
}
|
|
291
|
-
const ENSURE_DEV_APP_INSTALLATION_TOOL = 'LOCAL_NOTIS_ENSURE_DEV_APP_INSTALLATION';
|
|
292
33
|
const GET_APP_TOOL = 'LOCAL_NOTIS_GET_APP';
|
|
293
34
|
const LIST_APPS_TOOL = 'LOCAL_NOTIS_LIST_APPS';
|
|
294
35
|
const CREATE_APP_TOOL = 'LOCAL_NOTIS_CREATE_APP';
|
|
@@ -318,14 +59,6 @@ function scaffoldsTable(scaffolds) {
|
|
|
318
59
|
]);
|
|
319
60
|
}
|
|
320
61
|
|
|
321
|
-
function appDevRootsTable(roots) {
|
|
322
|
-
return formatTable(roots, [
|
|
323
|
-
{ label: 'Folder', value: (root) => root.path },
|
|
324
|
-
{ label: 'Source', value: (root) => root.implicit ? 'default' : 'registered' },
|
|
325
|
-
{ label: 'Registered', value: (root) => root.registeredAt || '' },
|
|
326
|
-
]);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
62
|
function decodeJwtSub(jwt) {
|
|
330
63
|
if (!jwt) return null;
|
|
331
64
|
try {
|
|
@@ -353,55 +86,6 @@ function slugify(value) {
|
|
|
353
86
|
.replace(/(^-|-$)+/g, '');
|
|
354
87
|
}
|
|
355
88
|
|
|
356
|
-
function buildDevInstallSlug(appConfig) {
|
|
357
|
-
const base = slugify(appConfig?.devSlug || appConfig?.name);
|
|
358
|
-
if (!base) {
|
|
359
|
-
return '';
|
|
360
|
-
}
|
|
361
|
-
return base.endsWith('-dev') ? base : `${base}-dev`;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
function timingMs(startedAt) {
|
|
365
|
-
return Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
function logAppsTiming(label, details = {}) {
|
|
369
|
-
const suffix = Object.entries(details)
|
|
370
|
-
.map(([key, value]) => `${key}=${value}`)
|
|
371
|
-
.join(' ');
|
|
372
|
-
process.stderr.write(`[notis apps timing] ${label}${suffix ? ` ${suffix}` : ''}\n`);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function nextDevInstallIdempotencyKey(globalOptions = {}, devSlug) {
|
|
376
|
-
if (globalOptions.idempotencyKey) {
|
|
377
|
-
return `${globalOptions.idempotencyKey}:${devSlug}`;
|
|
378
|
-
}
|
|
379
|
-
return nextIdempotencyKey(globalOptions);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
function pickDefaultRouteSlug(manifest) {
|
|
383
|
-
const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
|
|
384
|
-
const explicit = routes.find((route) => route && route.default && typeof route.slug === 'string');
|
|
385
|
-
if (explicit) return explicit.slug;
|
|
386
|
-
const firstWithSlug = routes.find((route) => route && typeof route.slug === 'string' && route.slug);
|
|
387
|
-
return firstWithSlug ? firstWithSlug.slug : null;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
export function buildDevelopmentAppHref({
|
|
391
|
-
appSlug,
|
|
392
|
-
appId,
|
|
393
|
-
devSlug,
|
|
394
|
-
targetAppId = null,
|
|
395
|
-
targetAppSlug = null,
|
|
396
|
-
manifest,
|
|
397
|
-
}) {
|
|
398
|
-
const routeAppId = `${targetAppId || appId}__local_dev__${devSlug}`;
|
|
399
|
-
const routeAppSlug = targetAppSlug || devSlug || appSlug;
|
|
400
|
-
const originlessBase = `/apps/${routeAppSlug}-${routeAppId}`;
|
|
401
|
-
const routeSlug = pickDefaultRouteSlug(manifest);
|
|
402
|
-
return routeSlug ? `${originlessBase}/${routeSlug}` : originlessBase;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
89
|
function parsePort(value) {
|
|
406
90
|
if (!value) return null;
|
|
407
91
|
const port = Number.parseInt(value, 10);
|
|
@@ -516,7 +200,7 @@ function runtimeCallLabel(call) {
|
|
|
516
200
|
return call?.op || 'runtime call';
|
|
517
201
|
}
|
|
518
202
|
|
|
519
|
-
function assertHarnessResult(result, route, databaseSlugs, mode = 'stub') {
|
|
203
|
+
function assertHarnessResult(result, route, databaseSlugs, mode = 'stub', capabilities = {}) {
|
|
520
204
|
const assertions = [];
|
|
521
205
|
if (result.tool_error) {
|
|
522
206
|
assertions.push({
|
|
@@ -557,7 +241,7 @@ function assertHarnessResult(result, route, databaseSlugs, mode = 'stub') {
|
|
|
557
241
|
);
|
|
558
242
|
for (const call of databaseQueries) {
|
|
559
243
|
const databaseSlug = call?.args?.arguments?.database_slug;
|
|
560
|
-
if (databaseSlug && !declaredDatabaseSet.has(databaseSlug)) {
|
|
244
|
+
if (databaseSlug && !declaredDatabaseSet.has(databaseSlug) && capabilities.workspaceDatabases !== 'read') {
|
|
561
245
|
assertions.push({
|
|
562
246
|
ok: false,
|
|
563
247
|
code: 'undeclared_database_query',
|
|
@@ -578,6 +262,19 @@ function assertHarnessResult(result, route, databaseSlugs, mode = 'stub') {
|
|
|
578
262
|
details: { databaseSlug: collectionDatabase },
|
|
579
263
|
});
|
|
580
264
|
}
|
|
265
|
+
if (result.design_tool_error) {
|
|
266
|
+
assertions.push({ ok: false, code: 'design_check_error',
|
|
267
|
+
message: `Route "${route.slug}" could not complete its automated design checks.`,
|
|
268
|
+
details: result.design_tool_error });
|
|
269
|
+
}
|
|
270
|
+
for (const finding of result.design || []) {
|
|
271
|
+
assertions.push({
|
|
272
|
+
ok: false,
|
|
273
|
+
code: 'design_rule_violation',
|
|
274
|
+
message: `Route "${route.slug}": ${describeDesignFinding(finding)}.`,
|
|
275
|
+
details: finding,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
581
278
|
if (mode === 'live') {
|
|
582
279
|
// In live mode an app that catches every failed call and renders its error
|
|
583
280
|
// state still mounts cleanly, so the render assertions above all pass. Only
|
|
@@ -633,347 +330,6 @@ function renderVerifyReport({ summary, results, noBrowser }) {
|
|
|
633
330
|
return lines.join('\n');
|
|
634
331
|
}
|
|
635
332
|
|
|
636
|
-
function buildManifestForDev(appConfig, projectDir) {
|
|
637
|
-
const routes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
|
|
638
|
-
return {
|
|
639
|
-
version: 1,
|
|
640
|
-
spec_version: 3,
|
|
641
|
-
app: {
|
|
642
|
-
name: appConfig.name,
|
|
643
|
-
description: appConfig.description || null,
|
|
644
|
-
icon: appConfig.icon || null,
|
|
645
|
-
release_version: readLocalNotisAppVersion(projectDir),
|
|
646
|
-
},
|
|
647
|
-
routes: routes.map((route) => ({
|
|
648
|
-
path: route.path,
|
|
649
|
-
slug: route.slug,
|
|
650
|
-
name: route.name,
|
|
651
|
-
icon: route.icon || null,
|
|
652
|
-
parentSlug: route.parentSlug || null,
|
|
653
|
-
default: route.default || false,
|
|
654
|
-
resourceDeepLinks: route.resourceDeepLinks === true,
|
|
655
|
-
export_name: route.exportName || route.export_name,
|
|
656
|
-
collection: route.collection || null,
|
|
657
|
-
})),
|
|
658
|
-
bundle: {
|
|
659
|
-
js: 'bundle/app.js',
|
|
660
|
-
css: 'bundle/app.css',
|
|
661
|
-
},
|
|
662
|
-
databases: appConfig.databases || [],
|
|
663
|
-
capabilities: normalizeAppCapabilities(appConfig.capabilities),
|
|
664
|
-
tools: appConfig.tools || [],
|
|
665
|
-
tool_bindings: normalizeAppToolBindings(appConfig.toolBindings),
|
|
666
|
-
skills: (appConfig.skills || []).map((skill) => ({
|
|
667
|
-
key: skill.key,
|
|
668
|
-
path: normalizeAppSkillManifestPath(skill.path),
|
|
669
|
-
name: skill.name,
|
|
670
|
-
description: skill.description || null,
|
|
671
|
-
})),
|
|
672
|
-
onboarding: appConfig.onboarding || null,
|
|
673
|
-
};
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
export function buildEnsureDevInstallArguments({
|
|
677
|
-
appConfig,
|
|
678
|
-
manifest,
|
|
679
|
-
linkedState,
|
|
680
|
-
skills,
|
|
681
|
-
useInstalledDatabases = false,
|
|
682
|
-
approvedCapabilities = null,
|
|
683
|
-
}) {
|
|
684
|
-
const devSlug = buildDevInstallSlug(appConfig);
|
|
685
|
-
const arguments_ = {
|
|
686
|
-
dev_slug: devSlug,
|
|
687
|
-
name: appConfig.name,
|
|
688
|
-
manifest,
|
|
689
|
-
skills,
|
|
690
|
-
};
|
|
691
|
-
if (linkedState?.dev_app_id) {
|
|
692
|
-
arguments_.app_id = linkedState.dev_app_id;
|
|
693
|
-
}
|
|
694
|
-
if (useInstalledDatabases && linkedState?.app_id) {
|
|
695
|
-
arguments_.installed_app_id = linkedState.app_id;
|
|
696
|
-
}
|
|
697
|
-
// Sent on every ensure call, including as `false`, so a `--scratch` session
|
|
698
|
-
// puts the app on its own dev copies and the next plain session puts it back
|
|
699
|
-
// on the installed app's databases.
|
|
700
|
-
arguments_.use_installed_databases = Boolean(useInstalledDatabases);
|
|
701
|
-
if (Array.isArray(approvedCapabilities) && approvedCapabilities.length > 0) {
|
|
702
|
-
arguments_.approved_capabilities = approvedCapabilities;
|
|
703
|
-
}
|
|
704
|
-
return arguments_;
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
const CLOUD_SHELL_CONSENT_KEY = 'cloud_computer_shell_consent';
|
|
708
|
-
|
|
709
|
-
/**
|
|
710
|
-
* Collect the developer's explicit approval for `cloudComputer: 'shell'`.
|
|
711
|
-
*
|
|
712
|
-
* The server never grants shell from authorship alone — holding an app's source
|
|
713
|
-
* (a scaffold, a pulled repo) is not consent to full-authority commands on the
|
|
714
|
-
* cloud computer — so `apps dev` asks once, records the answer in the linked
|
|
715
|
-
* state, and passes the grant with the ensure call. Runs before the parallel
|
|
716
|
-
* ensure fan-out so the prompt cannot interleave.
|
|
717
|
-
*/
|
|
718
|
-
export async function resolveCloudShellConsent({
|
|
719
|
-
appConfig,
|
|
720
|
-
projectDir,
|
|
721
|
-
grantCloudShell = false,
|
|
722
|
-
logger = console,
|
|
723
|
-
profileKey = null,
|
|
724
|
-
}) {
|
|
725
|
-
const capabilities = normalizeAppCapabilities(appConfig.capabilities);
|
|
726
|
-
if (capabilities.cloudComputer !== 'shell') {
|
|
727
|
-
return null;
|
|
728
|
-
}
|
|
729
|
-
const approved = ['cloud_computer_read', 'cloud_computer_shell'];
|
|
730
|
-
const linkedState = readLinkedState(projectDir, profileKey) || {};
|
|
731
|
-
const recordDecision = (decision) => {
|
|
732
|
-
writeLinkedState(
|
|
733
|
-
projectDir,
|
|
734
|
-
{ ...linkedState, [CLOUD_SHELL_CONSENT_KEY]: decision },
|
|
735
|
-
profileKey,
|
|
736
|
-
);
|
|
737
|
-
};
|
|
738
|
-
|
|
739
|
-
if (grantCloudShell) {
|
|
740
|
-
recordDecision('granted');
|
|
741
|
-
return approved;
|
|
742
|
-
}
|
|
743
|
-
const recorded = linkedState[CLOUD_SHELL_CONSENT_KEY];
|
|
744
|
-
if (recorded === 'granted') {
|
|
745
|
-
return approved;
|
|
746
|
-
}
|
|
747
|
-
const declineHint =
|
|
748
|
-
`${appConfig.name}: cloud computer shell stays denied for this dev session. `
|
|
749
|
-
+ 'Re-run with --grant-cloud-shell to approve it.';
|
|
750
|
-
if (recorded === 'declined') {
|
|
751
|
-
logger.warn(declineHint);
|
|
752
|
-
return null;
|
|
753
|
-
}
|
|
754
|
-
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
755
|
-
logger.warn(declineHint);
|
|
756
|
-
return null;
|
|
757
|
-
}
|
|
758
|
-
const { createInterface } = await import('node:readline/promises');
|
|
759
|
-
const prompt = createInterface({ input: process.stdin, output: process.stderr });
|
|
760
|
-
try {
|
|
761
|
-
const answer = (await prompt.question(
|
|
762
|
-
`${appConfig.name} declares cloudComputer: 'shell' - its views will run commands and `
|
|
763
|
-
+ 'read or write files on your cloud computer with the same authority as your own '
|
|
764
|
-
+ 'agent. Allow for this dev app? [y/N] ',
|
|
765
|
-
)).trim().toLowerCase();
|
|
766
|
-
const granted = answer === 'y' || answer === 'yes';
|
|
767
|
-
recordDecision(granted ? 'granted' : 'declined');
|
|
768
|
-
if (!granted) {
|
|
769
|
-
logger.warn(declineHint);
|
|
770
|
-
}
|
|
771
|
-
return granted ? approved : null;
|
|
772
|
-
} finally {
|
|
773
|
-
prompt.close();
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
export async function ensureDevInstall({
|
|
778
|
-
ctx,
|
|
779
|
-
appConfig,
|
|
780
|
-
projectDir,
|
|
781
|
-
idempotencyKey,
|
|
782
|
-
useInstalledDatabases = false,
|
|
783
|
-
approvedCapabilities = null,
|
|
784
|
-
runTool = runToolCommand,
|
|
785
|
-
}) {
|
|
786
|
-
if (!appConfig.name) {
|
|
787
|
-
throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
|
|
788
|
-
}
|
|
789
|
-
const devSlug = buildDevInstallSlug(appConfig);
|
|
790
|
-
if (!devSlug) {
|
|
791
|
-
throw usageError(`notis.config.ts devSlug or name in ${projectDir} must slugify to a non-empty value.`);
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
const manifest = buildManifestForDev(appConfig, projectDir);
|
|
795
|
-
const skills = resolveConfiguredAppSkills(appConfig, projectDir);
|
|
796
|
-
const profileKey = linkedStateProfileKey(ctx.runtime);
|
|
797
|
-
let linkedState = readLinkedState(projectDir, profileKey);
|
|
798
|
-
let linkedApp = null;
|
|
799
|
-
if (linkedState?.dev_app_id) {
|
|
800
|
-
const devApp = await getAccessibleApp(ctx.runtime, linkedState.dev_app_id, runTool);
|
|
801
|
-
if (!devApp || devApp.manifest?.is_dev !== true) {
|
|
802
|
-
const { dev_app_id: _devAppId, dev_linked_at: _devLinkedAt, ...rest } = linkedState;
|
|
803
|
-
linkedState = rest;
|
|
804
|
-
writeLinkedState(projectDir, linkedState, profileKey);
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
if (linkedState?.app_id) {
|
|
808
|
-
linkedApp = await getAccessibleApp(ctx.runtime, linkedState.app_id, runTool);
|
|
809
|
-
if (linkedApp?.manifest?.is_dev === true) {
|
|
810
|
-
const { app_id: legacyDevAppId, linked_at: _linkedAt, deployed_at: _deployedAt, version: _version, ...rest } = linkedState;
|
|
811
|
-
const devAppId = linkedState.dev_app_id || legacyDevAppId;
|
|
812
|
-
linkedState = {
|
|
813
|
-
...rest,
|
|
814
|
-
...(devAppId ? { dev_app_id: devAppId } : {}),
|
|
815
|
-
...(devAppId ? {
|
|
816
|
-
dev_linked_at: linkedState.dev_linked_at || linkedState.linked_at || new Date().toISOString(),
|
|
817
|
-
} : {}),
|
|
818
|
-
};
|
|
819
|
-
writeLinkedState(projectDir, linkedState, profileKey);
|
|
820
|
-
linkedApp = null;
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
// A checkout can be shared by Beta and a source-workspace desktop. Its
|
|
824
|
-
// installed app link is valid for the Beta account but intentionally
|
|
825
|
-
// inaccessible to the worktree's test user. Preserve that durable link for
|
|
826
|
-
// Beta, while omitting it from this run so the local dev app uses its own
|
|
827
|
-
// resources instead of sending an unauthorized installed_app_id to ensure.
|
|
828
|
-
if (!linkedState?.app_id) {
|
|
829
|
-
const installedResolution = await findInstalledLinkCandidatesForLegacyDevState(
|
|
830
|
-
ctx.runtime,
|
|
831
|
-
devSlug,
|
|
832
|
-
linkedState?.dev_app_id,
|
|
833
|
-
projectDir,
|
|
834
|
-
profileKey,
|
|
835
|
-
runTool,
|
|
836
|
-
);
|
|
837
|
-
const installedCandidates = installedResolution.candidates;
|
|
838
|
-
if (installedCandidates.length > 1) {
|
|
839
|
-
if (installedResolution.source === 'persisted-project-link') {
|
|
840
|
-
throw usageError(
|
|
841
|
-
`Multiple accessible installed apps are persisted for ${projectDir}. `
|
|
842
|
-
+ `Run \`notis apps link <app-id> ${projectDir}\` to choose one for this environment.`,
|
|
843
|
-
);
|
|
844
|
-
}
|
|
845
|
-
throw usageError(
|
|
846
|
-
`Multiple accessible installed apps use the exact slug "${devSlug.slice(0, -4)}". `
|
|
847
|
-
+ `Run \`notis apps link <app-id> ${projectDir}\` to choose one.`,
|
|
848
|
-
);
|
|
849
|
-
}
|
|
850
|
-
if (installedCandidates.length === 1) {
|
|
851
|
-
const now = new Date().toISOString();
|
|
852
|
-
linkedApp = installedCandidates[0];
|
|
853
|
-
linkedState = {
|
|
854
|
-
...(linkedState || {}),
|
|
855
|
-
app_id: linkedApp.app_id || linkedApp.id,
|
|
856
|
-
linked_at: now,
|
|
857
|
-
auto_linked_at: now,
|
|
858
|
-
};
|
|
859
|
-
writeLinkedState(projectDir, linkedState, profileKey);
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
const runtimeLinkedState = linkedState?.app_id && !linkedApp
|
|
863
|
-
? Object.fromEntries(
|
|
864
|
-
Object.entries(linkedState).filter(([key]) => ![
|
|
865
|
-
'app_id', 'linked_at', 'deployed_at', 'version',
|
|
866
|
-
].includes(key)),
|
|
867
|
-
)
|
|
868
|
-
: linkedState;
|
|
869
|
-
const localReleaseVersion = manifest.app.release_version || null;
|
|
870
|
-
const installedReleaseVersion = linkedApp?.manifest?.app?.release_version || '0.0.0';
|
|
871
|
-
const mountEligible = !linkedApp
|
|
872
|
-
|| compareNotisAppVersions(localReleaseVersion, installedReleaseVersion) === 1;
|
|
873
|
-
const ensureArguments = buildEnsureDevInstallArguments({
|
|
874
|
-
appConfig,
|
|
875
|
-
manifest,
|
|
876
|
-
linkedState: runtimeLinkedState,
|
|
877
|
-
skills,
|
|
878
|
-
useInstalledDatabases,
|
|
879
|
-
approvedCapabilities,
|
|
880
|
-
});
|
|
881
|
-
let ensureResult;
|
|
882
|
-
try {
|
|
883
|
-
ensureResult = await runTool({
|
|
884
|
-
runtime: ctx.runtime,
|
|
885
|
-
toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
|
|
886
|
-
arguments_: ensureArguments,
|
|
887
|
-
mutating: true,
|
|
888
|
-
idempotencyKey,
|
|
889
|
-
});
|
|
890
|
-
} catch (error) {
|
|
891
|
-
const backendCode = error?.details?.error?.code;
|
|
892
|
-
if (linkedState?.dev_app_id && backendCode === 'dev_app_slug_conflict') {
|
|
893
|
-
// The authenticated environment can outlive an older local API profile.
|
|
894
|
-
// If that profile remembered a different dev row while this user already
|
|
895
|
-
// owns the exact canonical dev slug, the backend refuses a duplicate.
|
|
896
|
-
// Retry without the stale row id so the backend deterministically adopts
|
|
897
|
-
// the unique slug owner; preserve the installed target and every grant.
|
|
898
|
-
const staleDevAppId = linkedState.dev_app_id;
|
|
899
|
-
const { dev_app_id: _devAppId, dev_linked_at: _devLinkedAt, ...rest } = linkedState;
|
|
900
|
-
linkedState = rest;
|
|
901
|
-
writeLinkedState(projectDir, linkedState, profileKey);
|
|
902
|
-
const retryArguments = { ...ensureArguments };
|
|
903
|
-
delete retryArguments.app_id;
|
|
904
|
-
ensureResult = await runTool({
|
|
905
|
-
runtime: ctx.runtime,
|
|
906
|
-
toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
|
|
907
|
-
arguments_: retryArguments,
|
|
908
|
-
mutating: true,
|
|
909
|
-
idempotencyKey: `${idempotencyKey}:adopt-slug-owner:${staleDevAppId}`,
|
|
910
|
-
});
|
|
911
|
-
} else {
|
|
912
|
-
throw error;
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
const appId = ensureResult.payload.app_id;
|
|
916
|
-
if (!appId) {
|
|
917
|
-
throw usageError('Dev installation tool did not return an app_id.');
|
|
918
|
-
}
|
|
919
|
-
if (linkedState?.dev_app_id !== appId) {
|
|
920
|
-
const now = new Date().toISOString();
|
|
921
|
-
writeLinkedState(projectDir, {
|
|
922
|
-
...(linkedState || {}),
|
|
923
|
-
dev_app_id: appId,
|
|
924
|
-
dev_linked_at: linkedState?.dev_linked_at || now,
|
|
925
|
-
}, profileKey);
|
|
926
|
-
}
|
|
927
|
-
return {
|
|
928
|
-
slug: ensureResult.payload.slug || devSlug,
|
|
929
|
-
devSlug,
|
|
930
|
-
name: appConfig.name,
|
|
931
|
-
appId,
|
|
932
|
-
projectDir,
|
|
933
|
-
manifest,
|
|
934
|
-
created: ensureResult.payload.created || false,
|
|
935
|
-
linkedAppId: runtimeLinkedState?.app_id || null,
|
|
936
|
-
targetAppId: runtimeLinkedState?.app_id || null,
|
|
937
|
-
targetAppSlug: linkedApp?.slug || null,
|
|
938
|
-
localReleaseVersion,
|
|
939
|
-
installedReleaseVersion: linkedApp ? installedReleaseVersion : null,
|
|
940
|
-
mountEligible,
|
|
941
|
-
databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
|
|
942
|
-
liveData: ensureResult.payload.live_data || null,
|
|
943
|
-
};
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
function databaseMaterializationWarnings(apps) {
|
|
947
|
-
const warnings = [];
|
|
948
|
-
for (const app of apps) {
|
|
949
|
-
const unresolved = app.databaseMaterialization?.unresolved || [];
|
|
950
|
-
if (!unresolved.length) {
|
|
951
|
-
continue;
|
|
952
|
-
}
|
|
953
|
-
warnings.push(
|
|
954
|
-
`${app.name}: database slug${unresolved.length === 1 ? '' : 's'} ${unresolved.join(', ')} ` +
|
|
955
|
-
'could not be created because no store snapshot schema exists. Create them manually or link to a store-installed app.',
|
|
956
|
-
);
|
|
957
|
-
}
|
|
958
|
-
return warnings;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
function liveDataWarnings(apps) {
|
|
962
|
-
return apps
|
|
963
|
-
.filter((app) => app.liveData?.warning)
|
|
964
|
-
.map((app) => `${app.name}: ${app.liveData.warning}`);
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
function versionPrecedenceWarnings(apps) {
|
|
968
|
-
return apps
|
|
969
|
-
.filter((app) => app.targetAppId && app.mountEligible === false)
|
|
970
|
-
.map((app) => {
|
|
971
|
-
const localVersion = app.localReleaseVersion || 'missing or invalid';
|
|
972
|
-
const pullCommand = `npx --package @notis_ai/cli@latest -- notis apps pull ${app.targetAppId} ${JSON.stringify(app.projectDir)} --force`;
|
|
973
|
-
return `${app.name}: local version ${localVersion} is not strictly newer than installed version ${app.installedReleaseVersion}; Workspace keeps serving the online bundle. Preserve any local edits, pull latest with \`${pullCommand}\`, then bump package.json notisAppVersion before development.`;
|
|
974
|
-
});
|
|
975
|
-
}
|
|
976
|
-
|
|
977
333
|
async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
|
|
978
334
|
const result = await runTool({
|
|
979
335
|
runtime,
|
|
@@ -997,93 +353,11 @@ async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
|
|
|
997
353
|
throw usageError(`Could not verify access to app ${appId}${message ? `: ${message}` : '.'}`);
|
|
998
354
|
}
|
|
999
355
|
|
|
1000
|
-
async function findInstalledLinkCandidatesForLegacyDevState(
|
|
1001
|
-
runtime,
|
|
1002
|
-
devSlug,
|
|
1003
|
-
devAppId,
|
|
1004
|
-
projectDir,
|
|
1005
|
-
profileKey,
|
|
1006
|
-
runTool = runToolCommand,
|
|
1007
|
-
) {
|
|
1008
|
-
const installedSlug = devSlug.endsWith('-dev') ? devSlug.slice(0, -4) : '';
|
|
1009
|
-
if (!installedSlug) return { candidates: [], source: null };
|
|
1010
|
-
|
|
1011
|
-
const result = await runTool({
|
|
1012
|
-
runtime,
|
|
1013
|
-
toolName: LIST_APPS_TOOL,
|
|
1014
|
-
});
|
|
1015
|
-
const accessibleInstalledApps = (result.payload?.apps || []).filter((app) => (
|
|
1016
|
-
(app?.app_id || app?.id)
|
|
1017
|
-
&& (app.app_id || app.id) !== devAppId
|
|
1018
|
-
&& app?.manifest?.is_dev !== true
|
|
1019
|
-
));
|
|
1020
|
-
|
|
1021
|
-
// A project-local app id is stronger evidence than a slug. Older CLI runs
|
|
1022
|
-
// could persist that explicit link under another API/user profile before
|
|
1023
|
-
// the machine-local discovery host mounted the same source here. Migrate it
|
|
1024
|
-
// only when exactly one such id is accessible in the current environment;
|
|
1025
|
-
// multiple accessible ids remain ambiguous and fail closed below.
|
|
1026
|
-
const rawState = readLinkedState(projectDir) || {};
|
|
1027
|
-
const persistedAppIds = new Set();
|
|
1028
|
-
if (rawState.app_id) persistedAppIds.add(rawState.app_id);
|
|
1029
|
-
const profiles = rawState.profiles && typeof rawState.profiles === 'object'
|
|
1030
|
-
? rawState.profiles
|
|
1031
|
-
: {};
|
|
1032
|
-
for (const [storedProfileKey, state] of Object.entries(profiles)) {
|
|
1033
|
-
if (storedProfileKey === profileKey || !state || typeof state !== 'object') continue;
|
|
1034
|
-
if (state.app_id) persistedAppIds.add(state.app_id);
|
|
1035
|
-
}
|
|
1036
|
-
const persistedCandidates = accessibleInstalledApps.filter((app) => (
|
|
1037
|
-
persistedAppIds.has(app.app_id || app.id)
|
|
1038
|
-
));
|
|
1039
|
-
if (persistedCandidates.length) {
|
|
1040
|
-
return { candidates: persistedCandidates, source: 'persisted-project-link' };
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
// The database row slug can change when a Store app is installed or an old
|
|
1044
|
-
// development app is promoted. The shipped source manifest keeps the
|
|
1045
|
-
// canonical app slug, so accept either exact field. Display names are never
|
|
1046
|
-
// considered.
|
|
1047
|
-
const exactSlugCandidates = accessibleInstalledApps.filter((app) => {
|
|
1048
|
-
const canonicalSlugs = new Set([
|
|
1049
|
-
app?.slug,
|
|
1050
|
-
app?.manifest?.app?.slug,
|
|
1051
|
-
].map(slugify).filter(Boolean));
|
|
1052
|
-
return canonicalSlugs.has(installedSlug);
|
|
1053
|
-
});
|
|
1054
|
-
return { candidates: exactSlugCandidates, source: 'exact-slug' };
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
export async function assertDirectDeployAccess(runtime, appId, runTool = runToolCommand) {
|
|
1058
|
-
const app = await getAccessibleApp(runtime, appId, runTool);
|
|
1059
|
-
if (!app) {
|
|
1060
|
-
throw usageError(`Direct deploy requires access to app ${appId}.`);
|
|
1061
|
-
}
|
|
1062
|
-
if (app.apps_access?.has_access !== true) {
|
|
1063
|
-
throw usageError('Notis Apps require a PRO+ or ULTRA plan after your trial.');
|
|
1064
|
-
}
|
|
1065
|
-
if (app.can_edit !== true) {
|
|
1066
|
-
throw usageError(`Direct deploy requires edit access to app ${appId}.`);
|
|
1067
|
-
}
|
|
1068
|
-
if (app.manifest?.is_dev === true) {
|
|
1069
|
-
throw usageError(
|
|
1070
|
-
'A development app cannot be deployed directly. Retry without --direct so first deploy can promote it safely.',
|
|
1071
|
-
);
|
|
1072
|
-
}
|
|
1073
|
-
return app;
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
356
|
export async function assertLinkTarget(runtime, appId, runTool = runToolCommand) {
|
|
1077
|
-
const
|
|
1078
|
-
|
|
1079
|
-
|
|
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
|
|
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
|
|
1151
|
-
const
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
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
|
-
?
|
|
1186
|
-
:
|
|
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
|
|
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
|
-
|
|
633
|
+
testServer = await startAppTestServer({
|
|
1785
634
|
apps: [{
|
|
1786
635
|
slug: appSlug,
|
|
1787
636
|
projectDir,
|
|
1788
637
|
appId: linkedState?.app_id || 'harness-app',
|
|
1789
638
|
}],
|
|
1790
639
|
port,
|
|
1791
|
-
watch: false,
|
|
1792
640
|
harness: {
|
|
1793
641
|
mode,
|
|
1794
642
|
apiBase: ctx.runtime.apiBase,
|
|
@@ -1842,6 +690,7 @@ async function appsVerifyHandler(ctx) {
|
|
|
1842
690
|
route,
|
|
1843
691
|
declaredDatabaseSlugs(appConfig, manifest, route),
|
|
1844
692
|
mode,
|
|
693
|
+
manifest.capabilities || appConfig.capabilities || {},
|
|
1845
694
|
);
|
|
1846
695
|
return {
|
|
1847
696
|
...result,
|
|
@@ -1867,6 +716,7 @@ async function appsVerifyHandler(ctx) {
|
|
|
1867
716
|
route,
|
|
1868
717
|
declaredDatabaseSlugs(appConfig, manifest, route),
|
|
1869
718
|
mode,
|
|
719
|
+
manifest.capabilities || appConfig.capabilities || {},
|
|
1870
720
|
);
|
|
1871
721
|
results.push({
|
|
1872
722
|
route: route.slug,
|
|
@@ -1894,8 +744,17 @@ async function appsVerifyHandler(ctx) {
|
|
|
1894
744
|
};
|
|
1895
745
|
const overallOk = summary.failed === 0;
|
|
1896
746
|
const exitCode = overallOk ? EXIT_CODES.ok : EXIT_CODES.unexpected;
|
|
747
|
+
// Keep standalone verification diagnostics. Deploy always verifies its own
|
|
748
|
+
// frozen snapshot; this report is never authority to skip that check.
|
|
749
|
+
const verifyStamp = writeVerifyStamp(projectDir, {
|
|
750
|
+
ok: overallOk && summary.manual === 0 && summary.total > 0,
|
|
751
|
+
mode,
|
|
752
|
+
summary,
|
|
753
|
+
results,
|
|
754
|
+
});
|
|
1897
755
|
const data = {
|
|
1898
756
|
status: overallOk ? (summary.manual ? 'manual' : 'passed') : 'failed',
|
|
757
|
+
artifact_hash: verifyStamp.artifact_hash,
|
|
1899
758
|
project_dir: projectDir,
|
|
1900
759
|
app_slug: appSlug,
|
|
1901
760
|
mode,
|
|
@@ -1914,6 +773,7 @@ async function appsVerifyHandler(ctx) {
|
|
|
1914
773
|
},
|
|
1915
774
|
};
|
|
1916
775
|
|
|
776
|
+
if (!keepOpen) await cleanup();
|
|
1917
777
|
ctx.output.emitSuccess({
|
|
1918
778
|
ok: overallOk,
|
|
1919
779
|
command: ctx.spec.command_path.join(' '),
|
|
@@ -1932,22 +792,8 @@ async function appsVerifyHandler(ctx) {
|
|
|
1932
792
|
|
|
1933
793
|
return exitCode;
|
|
1934
794
|
} finally {
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
try {
|
|
1938
|
-
await closeAgentBrowserSession(browserSessionName);
|
|
1939
|
-
} catch {
|
|
1940
|
-
// Ignore browser cleanup failures.
|
|
1941
|
-
}
|
|
1942
|
-
}
|
|
1943
|
-
if (devServer) {
|
|
1944
|
-
try {
|
|
1945
|
-
await devServer.close();
|
|
1946
|
-
} catch {
|
|
1947
|
-
// Ignore server cleanup failures.
|
|
1948
|
-
}
|
|
1949
|
-
}
|
|
1950
|
-
}
|
|
795
|
+
try { await cleanup(); }
|
|
796
|
+
finally { removeSignalHandlers(); }
|
|
1951
797
|
}
|
|
1952
798
|
}
|
|
1953
799
|
|
|
@@ -2042,14 +888,19 @@ async function appsScreenshotHandler(ctx) {
|
|
|
2042
888
|
const rawOutputDir = ctx.options.raw
|
|
2043
889
|
? null
|
|
2044
890
|
: mkdtempSync(join(tmpdir(), 'notis-store-screenshots-'));
|
|
2045
|
-
let
|
|
891
|
+
let testServer = null;
|
|
2046
892
|
let browserTouched = false;
|
|
2047
893
|
|
|
894
|
+
let cleanupPromise;
|
|
895
|
+
const cleanup = () => (cleanupPromise ||= closeHarnessResources(
|
|
896
|
+
browserTouched ? browserSessionNames : [], testServer, rawOutputDir,
|
|
897
|
+
));
|
|
898
|
+
const removeSignalHandlers = installHarnessSignalCleanup(cleanup);
|
|
899
|
+
|
|
2048
900
|
try {
|
|
2049
|
-
|
|
901
|
+
testServer = await startAppTestServer({
|
|
2050
902
|
apps: [{ slug: appSlug, projectDir, appId: linkedState?.app_id || 'harness-app' }],
|
|
2051
903
|
port,
|
|
2052
|
-
watch: false,
|
|
2053
904
|
harness: {
|
|
2054
905
|
mode,
|
|
2055
906
|
apiBase: ctx.runtime.apiBase,
|
|
@@ -2136,6 +987,7 @@ async function appsScreenshotHandler(ctx) {
|
|
|
2136
987
|
warnings.push(`${failed.length}/${results.length} routes failed to capture; see results.`);
|
|
2137
988
|
}
|
|
2138
989
|
|
|
990
|
+
await cleanup();
|
|
2139
991
|
ctx.output.emitSuccess({
|
|
2140
992
|
ok: failed.length === 0,
|
|
2141
993
|
command: ctx.spec.command_path.join(' '),
|
|
@@ -2155,61 +1007,40 @@ async function appsScreenshotHandler(ctx) {
|
|
|
2155
1007
|
});
|
|
2156
1008
|
return screenshotExitCode(failed.length);
|
|
2157
1009
|
} finally {
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
try {
|
|
2161
|
-
await closeAgentBrowserSession(sessionName);
|
|
2162
|
-
} catch {
|
|
2163
|
-
// Ignore browser cleanup failures.
|
|
2164
|
-
}
|
|
2165
|
-
}));
|
|
2166
|
-
}
|
|
2167
|
-
if (devServer) {
|
|
2168
|
-
try {
|
|
2169
|
-
await devServer.close();
|
|
2170
|
-
} catch {
|
|
2171
|
-
// Ignore server cleanup failures.
|
|
2172
|
-
}
|
|
2173
|
-
}
|
|
2174
|
-
if (rawOutputDir) {
|
|
2175
|
-
rmSync(rawOutputDir, { recursive: true, force: true });
|
|
2176
|
-
}
|
|
1010
|
+
try { await cleanup(); }
|
|
1011
|
+
finally { removeSignalHandlers(); }
|
|
2177
1012
|
}
|
|
2178
1013
|
}
|
|
2179
1014
|
|
|
2180
|
-
export function buildLinkedAppState(
|
|
2181
|
-
existingState,
|
|
2182
|
-
appId,
|
|
2183
|
-
linkedAt = new Date().toISOString(),
|
|
2184
|
-
) {
|
|
2185
|
-
const sameIdPromotion = existingState?.dev_app_id === appId;
|
|
2186
|
-
return {
|
|
2187
|
-
...(!sameIdPromotion && existingState?.dev_app_id ? { dev_app_id: existingState.dev_app_id } : {}),
|
|
2188
|
-
...(!sameIdPromotion && existingState?.dev_linked_at ? { dev_linked_at: existingState.dev_linked_at } : {}),
|
|
2189
|
-
app_id: appId,
|
|
2190
|
-
linked_at: linkedAt,
|
|
2191
|
-
};
|
|
1015
|
+
export function buildLinkedAppState(existingState, appId, linkedAt = new Date().toISOString()) {
|
|
1016
|
+
return { ...(existingState?.app_id === appId ? existingState : {}), app_id: appId, linked_at: linkedAt };
|
|
2192
1017
|
}
|
|
2193
1018
|
|
|
2194
1019
|
async function appsLinkHandler(ctx) {
|
|
2195
1020
|
const projectDir = resolveProjectDir(ctx.args.dir || '.');
|
|
2196
1021
|
const appId = ctx.args.appId;
|
|
1022
|
+
const expectedVersion = ctx.options.expectedVersion === undefined ? null : Number(ctx.options.expectedVersion);
|
|
1023
|
+
if (expectedVersion !== null && (!/^\d+$/.test(String(ctx.options.expectedVersion)) || !Number.isSafeInteger(expectedVersion))) {
|
|
1024
|
+
throw usageError('--expected-version must be a non-negative integer.');
|
|
1025
|
+
}
|
|
2197
1026
|
|
|
2198
|
-
await assertLinkTarget(ctx.runtime, appId);
|
|
1027
|
+
const app = await assertLinkTarget(ctx.runtime, appId);
|
|
2199
1028
|
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
1029
|
+
const profileKey = linkedStateProfileKey(ctx.runtime);
|
|
1030
|
+
const state = buildLinkedAppState(readLinkedState(projectDir, profileKey), appId);
|
|
1031
|
+
const version = deployedAppVersion(app);
|
|
1032
|
+
if (expectedVersion !== null && version !== expectedVersion) {
|
|
1033
|
+
throw usageError('The app release changed before linking. Preserve local source, pull the current release into a fresh directory, and reapply changes before deploying.');
|
|
1034
|
+
}
|
|
1035
|
+
if (state.version !== undefined && state.version !== version) {
|
|
1036
|
+
throw usageError('A different release exists. Pull current source into a fresh directory and reapply local changes before deploying.');
|
|
1037
|
+
}
|
|
1038
|
+
if (!app.updated_at) throw usageError('App revision is unavailable; the directory was not relinked.');
|
|
1039
|
+
writeLinkedState(projectDir, { ...state, version, expected_updated_at: app.updated_at }, profileKey);
|
|
2209
1040
|
|
|
2210
1041
|
return ctx.output.emitSuccess({
|
|
2211
1042
|
command: ctx.spec.command_path.join(' '),
|
|
2212
|
-
data: { app_id: appId, project_dir: projectDir },
|
|
1043
|
+
data: { app_id: appId, project_dir: projectDir, version, expected_updated_at: app.updated_at },
|
|
2213
1044
|
humanSummary: `Linked to app ${appId}`,
|
|
2214
1045
|
hints: [
|
|
2215
1046
|
{ command: 'notis apps deploy .', reason: 'Deploy the app' },
|
|
@@ -2251,6 +1082,7 @@ async function appsPullHandler(ctx) {
|
|
|
2251
1082
|
version,
|
|
2252
1083
|
force: Boolean(ctx.options.force),
|
|
2253
1084
|
profileKey: linkedStateProfileKey(ctx.runtime),
|
|
1085
|
+
expectedUpdatedAt: app.updated_at,
|
|
2254
1086
|
});
|
|
2255
1087
|
|
|
2256
1088
|
const versionLabel = pulled.version === 'latest' ? 'latest version' : `v${pulled.version}`;
|
|
@@ -2261,11 +1093,11 @@ async function appsPullHandler(ctx) {
|
|
|
2261
1093
|
project_dir: pulled.projectDir,
|
|
2262
1094
|
version: pulled.version,
|
|
2263
1095
|
},
|
|
2264
|
-
humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}.
|
|
1096
|
+
humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}. Run npm install, edit the source, then build, verify and deploy the update.`,
|
|
2265
1097
|
});
|
|
2266
1098
|
}
|
|
2267
1099
|
|
|
2268
|
-
function updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey = null) {
|
|
1100
|
+
function updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey = null, updatedAt = null) {
|
|
2269
1101
|
if (!linkedState || linkedState.app_id !== appId || !Number.isFinite(version)) {
|
|
2270
1102
|
return;
|
|
2271
1103
|
}
|
|
@@ -2275,33 +1107,24 @@ function updateLinkedDeployState(projectDir, linkedState, appId, version, profil
|
|
|
2275
1107
|
version,
|
|
2276
1108
|
linked_at: linkedState.linked_at || new Date().toISOString(),
|
|
2277
1109
|
deployed_at: new Date().toISOString(),
|
|
1110
|
+
expected_updated_at: updatedAt,
|
|
2278
1111
|
}, profileKey);
|
|
2279
1112
|
}
|
|
2280
1113
|
|
|
2281
|
-
// Resolve first deploy without mutating. Promotion is part of the final
|
|
2282
|
-
// SAVE_APP_FILES update, after build and uploads succeed, so a failed deploy
|
|
2283
|
-
// cannot leave an empty installed app behind.
|
|
2284
|
-
function resolveDeployTarget(ctx, projectDir) {
|
|
2285
|
-
if (ctx.options.appId) return { appId: ctx.options.appId, needsPromotion: false };
|
|
2286
|
-
const profileKey = linkedStateProfileKey(ctx.runtime);
|
|
2287
|
-
const state = readLinkedState(projectDir, profileKey);
|
|
2288
|
-
if (state?.app_id) return { appId: state.app_id, needsPromotion: false };
|
|
2289
|
-
if (!state?.dev_app_id) {
|
|
2290
|
-
return { appId: requireLinkedAppId(projectDir, ctx.options.appId, profileKey), needsPromotion: false };
|
|
2291
|
-
}
|
|
2292
|
-
return { appId: state.dev_app_id, needsPromotion: true };
|
|
2293
|
-
}
|
|
2294
|
-
|
|
2295
1114
|
async function appsDeployHandler(ctx) {
|
|
2296
1115
|
const projectDir = resolveProjectDir(ctx.args.dir || '.');
|
|
2297
1116
|
const profileKey = linkedStateProfileKey(ctx.runtime);
|
|
2298
|
-
const
|
|
1117
|
+
const appId = requireLinkedAppId(projectDir, ctx.options.appId, profileKey);
|
|
2299
1118
|
const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
|
|
2300
1119
|
const linkedState = readLinkedState(projectDir, profileKey);
|
|
2301
1120
|
const baseVersion = linkedState?.app_id === appId && Number.isFinite(linkedState?.version)
|
|
2302
1121
|
? linkedState.version
|
|
2303
1122
|
: undefined;
|
|
2304
1123
|
|
|
1124
|
+
if (!Number.isInteger(baseVersion) || baseVersion < 0 || !linkedState?.expected_updated_at) {
|
|
1125
|
+
throw usageError('Deploy requires a current profile-scoped app link and deployment base. Pull the current release, or link an unreleased app first.');
|
|
1126
|
+
}
|
|
1127
|
+
|
|
2305
1128
|
// Build if needed
|
|
2306
1129
|
if (!ctx.options.skipBuild) {
|
|
2307
1130
|
await buildArtifact(projectDir, {
|
|
@@ -2309,133 +1132,149 @@ async function appsDeployHandler(ctx) {
|
|
|
2309
1132
|
});
|
|
2310
1133
|
}
|
|
2311
1134
|
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
1135
|
+
const release = prepareAppRelease(projectDir);
|
|
1136
|
+
const { files, sourceFiles, manifest } = release;
|
|
1137
|
+
let cleanupVerification = async () => {};
|
|
1138
|
+
let uploadStarted = false;
|
|
1139
|
+
let cancelled = false;
|
|
1140
|
+
const removeDeploySignalHandlers = installHarnessSignalCleanup(async () => {
|
|
1141
|
+
cancelled = true;
|
|
1142
|
+
const cleanupErrors = [];
|
|
1143
|
+
try { await cleanupVerification(); }
|
|
1144
|
+
catch (error) { cleanupErrors.push(error.message); }
|
|
1145
|
+
finally {
|
|
1146
|
+
try { release.close(); } catch (error) { cleanupErrors.push(error.message); }
|
|
2316
1147
|
}
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
let result;
|
|
1148
|
+
ctx.output.emitError({ command: 'apps deploy', error: new CliError({
|
|
1149
|
+
code: uploadStarted ? 'app_deploy_outcome_unknown' : 'app_deploy_cancelled',
|
|
1150
|
+
message: uploadStarted
|
|
1151
|
+
? 'Deployment interrupted. Read back the exact app/version before retrying.'
|
|
1152
|
+
: 'Deployment interrupted before upload; no update was deployed.',
|
|
1153
|
+
retryable: false, exitCode: EXIT_CODES.network,
|
|
1154
|
+
details: { app_id: appId, base_version: baseVersion,
|
|
1155
|
+
target_version: baseVersion + 1, idempotency_key: idempotencyKey,
|
|
1156
|
+
activation_outcome: uploadStarted ? 'unknown' : 'not_started',
|
|
1157
|
+
...(cleanupErrors.length ? { cleanup_errors: cleanupErrors } : {}) },
|
|
1158
|
+
hints: uploadStarted
|
|
1159
|
+
? [{ command: 'notis apps list --json', reason: 'Reconcile the interrupted deployment' }]
|
|
1160
|
+
: [],
|
|
1161
|
+
}) });
|
|
1162
|
+
});
|
|
2334
1163
|
try {
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
},
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
files,
|
|
2348
|
-
source_files: sourceFiles,
|
|
2349
|
-
manifest,
|
|
2350
|
-
...appRowFieldsFromManifest(manifest),
|
|
2351
|
-
...(baseVersion !== undefined ? { base_version: baseVersion } : {}),
|
|
2352
|
-
...(needsPromotion ? { promote_dev_app: true } : {}),
|
|
1164
|
+
let verification;
|
|
1165
|
+
const verifyOutput = {
|
|
1166
|
+
...ctx.output,
|
|
1167
|
+
emitSuccess: (result) => { verification = result; },
|
|
1168
|
+
isMachineMode: () => true,
|
|
1169
|
+
};
|
|
1170
|
+
const verified = await appsVerifyHandler({
|
|
1171
|
+
...ctx, args: { dir: release.projectDir },
|
|
1172
|
+
options: { skipBuild: true, mode: 'stub' }, output: verifyOutput,
|
|
1173
|
+
registerSignalCleanup: (cleanup) => {
|
|
1174
|
+
cleanupVerification = cleanup;
|
|
1175
|
+
return () => { cleanupVerification = async () => {}; };
|
|
2353
1176
|
},
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
if (error.code === 'conflict') {
|
|
2359
|
-
throw toolConflictToError(error.details, 'Deploy conflict');
|
|
2360
|
-
}
|
|
2361
|
-
|
|
2362
|
-
// A timed-out mutation may already have committed on the backend. A direct
|
|
2363
|
-
// upload here would create a second revision, so fail closed and let the
|
|
2364
|
-
// caller inspect/pull the app before deciding whether to retry.
|
|
2365
|
-
if (error.code === 'network_timeout') {
|
|
2366
|
-
error.message = `${error.message}. The backend may still complete this deploy; direct fallback was not attempted to avoid creating a duplicate revision. Inspect the app version, then pull before retrying if needed.`;
|
|
1177
|
+
}).catch(async (error) => {
|
|
1178
|
+
// The signal handler also awaits verification cleanup. If that shared
|
|
1179
|
+
// promise rejects, it still owns the single structured terminal outcome.
|
|
1180
|
+
if (cancelled) return await new Promise(() => {});
|
|
2367
1181
|
throw error;
|
|
1182
|
+
});
|
|
1183
|
+
if (verified !== EXIT_CODES.ok || verification?.data?.status !== 'passed' || verification?.data?.summary?.passed < 1) {
|
|
1184
|
+
throw usageError('App verification failed; no update was deployed. Run notis apps verify for details.');
|
|
2368
1185
|
}
|
|
2369
1186
|
|
|
2370
|
-
// A network failure is ambiguous for a mutation. It can happen after the
|
|
2371
|
-
// backend committed but before undici finished reading the response (for
|
|
2372
|
-
// example ECONNRESET / UND_ERR_SOCKET). Never infer pre-dispatch from a
|
|
2373
|
-
// generic network_error or its message: an automatic direct upload could
|
|
2374
|
-
// create a second revision. Operators who have proved the backend is down
|
|
2375
|
-
// can still choose the explicit --direct mode.
|
|
2376
|
-
if (error.code === 'network_error') {
|
|
2377
|
-
error.message = `${error.message}. The backend may have committed this deploy; direct fallback was not attempted to avoid creating a duplicate revision. Inspect the app version, then pull before retrying, or use --direct only after proving the backend mutation did not land.`;
|
|
2378
|
-
throw error;
|
|
2379
|
-
}
|
|
2380
1187
|
|
|
2381
|
-
|
|
2382
|
-
|
|
1188
|
+
// The signal handler owns terminal reporting and exit. A cancellation
|
|
1189
|
+
// during verification cleanup must never continue into the mutation.
|
|
1190
|
+
if (cancelled) return await new Promise(() => {});
|
|
2383
1191
|
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
code: 'network_error',
|
|
2388
|
-
message: 'The backend returned an incomplete deploy response. The deploy may have committed; inspect the app version and pull before retrying. Direct fallback was not attempted to avoid creating a duplicate revision.',
|
|
2389
|
-
exitCode: EXIT_CODES.network,
|
|
2390
|
-
retryable: true,
|
|
2391
|
-
});
|
|
2392
|
-
}
|
|
1192
|
+
// Upload uses captured bytes only. Fail closed on a staging identity swap
|
|
1193
|
+
// before any remote mutation, and finish local cleanup before activation.
|
|
1194
|
+
release.close();
|
|
2393
1195
|
|
|
2394
|
-
|
|
2395
|
-
const now = new Date().toISOString();
|
|
2396
|
-
writeLinkedState(projectDir, {
|
|
2397
|
-
...linkedState,
|
|
2398
|
-
app_id: appId,
|
|
2399
|
-
linked_at: linkedState?.linked_at || now,
|
|
2400
|
-
version: deployedVersion,
|
|
2401
|
-
deployed_at: now,
|
|
2402
|
-
dev_app_id: undefined,
|
|
2403
|
-
dev_linked_at: undefined,
|
|
2404
|
-
}, profileKey);
|
|
1196
|
+
let result;
|
|
2405
1197
|
try {
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
1198
|
+
uploadStarted = true;
|
|
1199
|
+
result = await runToolCommand({
|
|
1200
|
+
// App deploys upload both the built artifact and the editable source
|
|
1201
|
+
// snapshot. The ordinary 30s CLI timeout is too short for larger apps,
|
|
1202
|
+
// and timing out a mutation is ambiguous: the backend may commit after
|
|
1203
|
+
// the client disconnects. Give this operation its real completion window.
|
|
1204
|
+
runtime: {
|
|
1205
|
+
...ctx.runtime,
|
|
1206
|
+
timeoutMs: Math.max(ctx.runtime.timeoutMs || 0, APP_DEPLOY_TIMEOUT_MS),
|
|
1207
|
+
},
|
|
1208
|
+
toolName: SAVE_APP_FILES_TOOL,
|
|
1209
|
+
arguments_: {
|
|
1210
|
+
app_id: appId,
|
|
1211
|
+
files,
|
|
1212
|
+
source_files: sourceFiles,
|
|
1213
|
+
manifest,
|
|
1214
|
+
...appRowFieldsFromManifest(manifest),
|
|
1215
|
+
base_version: baseVersion,
|
|
1216
|
+
expected_updated_at: linkedState.expected_updated_at,
|
|
1217
|
+
},
|
|
1218
|
+
mutating: true,
|
|
1219
|
+
idempotencyKey,
|
|
1220
|
+
});
|
|
2410
1221
|
} catch (error) {
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
1222
|
+
if (error.code === 'conflict') {
|
|
1223
|
+
throw toolConflictToError(error.details, 'Deploy conflict');
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// Transport failure may arrive after commit. Never replay an uncertain release.
|
|
1227
|
+
if (error.code === 'network_timeout' || error.code === 'network_error') {
|
|
1228
|
+
error.message = `${error.message}. Deployment outcome is unknown; read back the exact app/version before any retry.`;
|
|
1229
|
+
error.retryable = false;
|
|
1230
|
+
error.details = { ...error.details, app_id: appId, base_version: baseVersion,
|
|
1231
|
+
target_version: baseVersion + 1, idempotency_key: idempotencyKey };
|
|
1232
|
+
error.hints = [{ command: 'notis apps list --json', reason: `Read back app ${appId} and reconcile the deployment outcome` }];
|
|
1233
|
+
}
|
|
1234
|
+
throw error;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
const deployedVersion = Number(result?.payload?.version);
|
|
1238
|
+
if (!Number.isInteger(deployedVersion) || deployedVersion !== baseVersion + 1 || result.payload.app_id !== appId || !result.payload.updated_at) {
|
|
1239
|
+
throw new CliError({
|
|
1240
|
+
code: 'network_error',
|
|
1241
|
+
message: 'The backend returned an incomplete deploy response. The deploy may have committed; inspect the app version and pull before retrying.',
|
|
1242
|
+
exitCode: EXIT_CODES.network,
|
|
1243
|
+
retryable: false,
|
|
1244
|
+
details: { app_id: appId, base_version: baseVersion, target_version: baseVersion + 1, idempotency_key: idempotencyKey },
|
|
1245
|
+
hints: [{ command: 'notis apps list --json', reason: 'Reconcile the incomplete deployment response' }],
|
|
2414
1246
|
});
|
|
2415
1247
|
}
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
1248
|
+
|
|
1249
|
+
const warnings = [];
|
|
1250
|
+
try { updateLinkedDeployState(projectDir, linkedState, appId, deployedVersion, profileKey, result.payload.updated_at); }
|
|
1251
|
+
catch { warnings.push('The app was updated, but the local link could not be saved. Pull the installed version before editing again.'); }
|
|
1252
|
+
try { release.close(); }
|
|
1253
|
+
catch { warnings.push('The app was updated, but the temporary release directory needs local cleanup.'); }
|
|
1254
|
+
|
|
1255
|
+
return ctx.output.emitSuccess({
|
|
1256
|
+
command: ctx.spec.command_path.join(' '),
|
|
1257
|
+
data: {
|
|
1258
|
+
app_id: appId,
|
|
1259
|
+
version: deployedVersion,
|
|
1260
|
+
idempotency_key: idempotencyKey,
|
|
1261
|
+
},
|
|
1262
|
+
warnings,
|
|
1263
|
+
humanSummary: `Deployed to app ${appId} (version ${deployedVersion})`,
|
|
1264
|
+
meta: { mutating: true, idempotency_key: idempotencyKey },
|
|
2419
1265
|
});
|
|
2420
|
-
}
|
|
2421
|
-
|
|
1266
|
+
} finally {
|
|
1267
|
+
removeDeploySignalHandlers();
|
|
1268
|
+
try { release.close(); } catch { /* Do not mask a committed or unknown release. */ }
|
|
2422
1269
|
}
|
|
2423
|
-
return ctx.output.emitSuccess({
|
|
2424
|
-
command: ctx.spec.command_path.join(' '),
|
|
2425
|
-
data: {
|
|
2426
|
-
app_id: appId,
|
|
2427
|
-
version: deployedVersion,
|
|
2428
|
-
idempotency_key: idempotencyKey,
|
|
2429
|
-
},
|
|
2430
|
-
humanSummary: `Deployed to app ${appId} (version ${deployedVersion})`,
|
|
2431
|
-
meta: { mutating: true, idempotency_key: idempotencyKey },
|
|
2432
|
-
});
|
|
2433
1270
|
}
|
|
2434
1271
|
|
|
2435
1272
|
function deployedAppVersion(app) {
|
|
2436
1273
|
const value = app?.current_version ?? app?.manifest?.version;
|
|
1274
|
+
if (value === null || value === undefined) return 0;
|
|
2437
1275
|
const parsed = Number(value);
|
|
2438
|
-
|
|
1276
|
+
if (!Number.isInteger(parsed) || parsed < 0) throw usageError('The app returned an invalid deployment version.');
|
|
1277
|
+
return parsed;
|
|
2439
1278
|
}
|
|
2440
1279
|
|
|
2441
1280
|
async function appsDuplicateHandler(ctx) {
|
|
@@ -2620,9 +1459,6 @@ export function doctorLinkSummary(linkedState) {
|
|
|
2620
1459
|
if (linkedState?.app_id) {
|
|
2621
1460
|
return ` Linked to app ${linkedState.app_id}.`;
|
|
2622
1461
|
}
|
|
2623
|
-
if (linkedState?.dev_app_id) {
|
|
2624
|
-
return ` Local development app ${linkedState.dev_app_id} is active.`;
|
|
2625
|
-
}
|
|
2626
1462
|
return ' Not linked.';
|
|
2627
1463
|
}
|
|
2628
1464
|
|
|
@@ -2697,89 +1533,17 @@ export const appsCommandSpecs = [
|
|
|
2697
1533
|
{ token: '<name>', description: 'Display name for the remote app.' },
|
|
2698
1534
|
{ token: '[dir]', key: 'dir', description: 'Project directory to link after creation (default: do not link).' },
|
|
2699
1535
|
],
|
|
2700
|
-
options: [],
|
|
1536
|
+
options: [{ flags: '--team-id <id>', description: 'Create or reuse the exact team-scoped app (default: personal).' }],
|
|
2701
1537
|
},
|
|
2702
1538
|
examples: [
|
|
2703
1539
|
'notis apps create "My App"',
|
|
2704
1540
|
'notis apps create "My App" .',
|
|
2705
1541
|
],
|
|
2706
1542
|
mutates: true,
|
|
2707
|
-
idempotent:
|
|
1543
|
+
idempotent: true,
|
|
2708
1544
|
backend_call: { type: 'tool', name: CREATE_APP_TOOL },
|
|
2709
1545
|
handler: appsCreateHandler,
|
|
2710
1546
|
},
|
|
2711
|
-
{
|
|
2712
|
-
command_path: ['apps', 'dev'],
|
|
2713
|
-
summary: 'Register a development root and connect its apps to the shared local development host.',
|
|
2714
|
-
when_to_use:
|
|
2715
|
-
'Run this once for any folder that should be watched permanently. The folder itself, direct child apps, and apps/* are discovered automatically by every signed-in Notis Desktop instance. A linked app substitutes its online bundle only when local notisAppVersion is strictly greater than the installed release.',
|
|
2716
|
-
args_schema: {
|
|
2717
|
-
arguments: [
|
|
2718
|
-
{ token: '[dir]', key: 'dir', description: 'Project directory or monorepo root (default: current dir).' },
|
|
2719
|
-
],
|
|
2720
|
-
options: [
|
|
2721
|
-
{ flags: '--port <number>', description: `Local bundle server port (default: ${DEFAULT_DEV_PORT}).` },
|
|
2722
|
-
{
|
|
2723
|
-
flags: '--scratch',
|
|
2724
|
-
description:
|
|
2725
|
-
'Use isolated empty databases, bundled skills, and bundled automations for this session '
|
|
2726
|
-
+ "instead of the installed app's resources. For fixture work and destructive experiments.",
|
|
2727
|
-
},
|
|
2728
|
-
{
|
|
2729
|
-
flags: '--live-data',
|
|
2730
|
-
description:
|
|
2731
|
-
'Deprecated: using the installed app\'s real resources is now the default. '
|
|
2732
|
-
+ 'Accepted as a no-op; use `--scratch` for the old isolated behavior.',
|
|
2733
|
-
},
|
|
2734
|
-
{
|
|
2735
|
-
flags: '--grant-cloud-shell',
|
|
2736
|
-
description:
|
|
2737
|
-
"Approve a cloudComputer: 'shell' declaration without the interactive prompt. "
|
|
2738
|
-
+ 'The grant persists for this dev app; authorship alone never grants it.',
|
|
2739
|
-
},
|
|
2740
|
-
],
|
|
2741
|
-
},
|
|
2742
|
-
examples: [
|
|
2743
|
-
'notis apps dev',
|
|
2744
|
-
'notis apps dev ./my-app',
|
|
2745
|
-
'notis apps dev ./workspace --port 5200',
|
|
2746
|
-
'notis apps dev --scratch # isolated resources for fixture or schema experiments',
|
|
2747
|
-
],
|
|
2748
|
-
mutates: true,
|
|
2749
|
-
idempotent: true,
|
|
2750
|
-
require_auth: true,
|
|
2751
|
-
backend_call: { type: 'tool', name: ENSURE_DEV_APP_INSTALLATION_TOOL },
|
|
2752
|
-
handler: appsDevHandler,
|
|
2753
|
-
},
|
|
2754
|
-
{
|
|
2755
|
-
command_path: ['apps', 'roots', 'list'],
|
|
2756
|
-
summary: 'List persistent machine-local Notis app development roots.',
|
|
2757
|
-
when_to_use: 'See which folders every local Notis Desktop instance watches for development apps.',
|
|
2758
|
-
args_schema: { arguments: [], options: [] },
|
|
2759
|
-
examples: ['notis apps roots list'],
|
|
2760
|
-
mutates: false,
|
|
2761
|
-
idempotent: true,
|
|
2762
|
-
require_auth: false,
|
|
2763
|
-
backend_call: { type: 'local', name: 'list_app_development_roots' },
|
|
2764
|
-
handler: appsRootsListHandler,
|
|
2765
|
-
},
|
|
2766
|
-
{
|
|
2767
|
-
command_path: ['apps', 'roots', 'remove'],
|
|
2768
|
-
summary: 'Stop watching a registered Notis app development root.',
|
|
2769
|
-
when_to_use: 'Remove a persistent development root. The built-in ~/.notis/apps root cannot be removed.',
|
|
2770
|
-
args_schema: {
|
|
2771
|
-
arguments: [
|
|
2772
|
-
{ token: '<folder>', key: 'dir', description: 'Previously registered development root.' },
|
|
2773
|
-
],
|
|
2774
|
-
options: [],
|
|
2775
|
-
},
|
|
2776
|
-
examples: ['notis apps roots remove ./old-apps'],
|
|
2777
|
-
mutates: true,
|
|
2778
|
-
idempotent: true,
|
|
2779
|
-
require_auth: false,
|
|
2780
|
-
backend_call: { type: 'local', name: 'remove_app_development_root' },
|
|
2781
|
-
handler: appsRootsRemoveHandler,
|
|
2782
|
-
},
|
|
2783
1547
|
{
|
|
2784
1548
|
command_path: ['apps', 'build'],
|
|
2785
1549
|
summary: 'Build and package the app into .notis/output/.',
|
|
@@ -2875,9 +1639,11 @@ export const appsCommandSpecs = [
|
|
|
2875
1639
|
{ token: '<app-id>', description: 'Remote app ID to link to.' },
|
|
2876
1640
|
{ token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
|
|
2877
1641
|
],
|
|
2878
|
-
options: [
|
|
1642
|
+
options: [
|
|
1643
|
+
{ flags: '--expected-version <version>', description: 'Link only if the remote deployment version still matches this non-negative integer.' },
|
|
1644
|
+
],
|
|
2879
1645
|
},
|
|
2880
|
-
examples: ['notis apps link abc123', 'notis apps link abc123 ./my-app'],
|
|
1646
|
+
examples: ['notis apps link abc123', 'notis apps link abc123 ./my-app', 'notis apps link abc123 ./recovered-app --expected-version 0'],
|
|
2881
1647
|
mutates: true,
|
|
2882
1648
|
idempotent: true,
|
|
2883
1649
|
require_auth: false,
|
|
@@ -2888,7 +1654,7 @@ export const appsCommandSpecs = [
|
|
|
2888
1654
|
command_path: ['apps', 'pull'],
|
|
2889
1655
|
summary: 'Download a Notis app source snapshot into a local project folder.',
|
|
2890
1656
|
when_to_use:
|
|
2891
|
-
'Edit an installed app
|
|
1657
|
+
'Edit an installed app. Preserve local edits, pull and link its persisted source, then build, verify and deploy.',
|
|
2892
1658
|
args_schema: {
|
|
2893
1659
|
arguments: [
|
|
2894
1660
|
{ token: '<app-id>', description: 'Remote app ID to pull.' },
|
|
@@ -2911,20 +1677,18 @@ export const appsCommandSpecs = [
|
|
|
2911
1677
|
},
|
|
2912
1678
|
{
|
|
2913
1679
|
command_path: ['apps', 'deploy'],
|
|
2914
|
-
summary: 'Build and
|
|
1680
|
+
summary: 'Build, verify and release the linked Workspace app.',
|
|
2915
1681
|
when_to_use:
|
|
2916
|
-
'
|
|
1682
|
+
'Build, verify and release the linked personal or team Workspace app. This command does not publish to the Store.',
|
|
2917
1683
|
args_schema: {
|
|
2918
1684
|
arguments: [
|
|
2919
1685
|
{ token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
|
|
2920
1686
|
],
|
|
2921
1687
|
options: [
|
|
2922
1688
|
{ flags: '--app-id <id>', description: 'Override linked app ID.' },
|
|
2923
|
-
{ flags: '--skip-build', description: '
|
|
2924
|
-
{ flags: '--direct', description: 'Explicitly upload to Supabase storage, bypassing the backend server.' },
|
|
1689
|
+
{ flags: '--skip-build', description: 'Reuse unchanged build output; automated verification still runs.' },
|
|
2925
1690
|
],
|
|
2926
1691
|
},
|
|
2927
|
-
examples: ['notis apps deploy', 'notis apps deploy --skip-build', 'notis apps deploy --app-id abc123', 'notis apps deploy --direct'],
|
|
2928
1692
|
mutates: true,
|
|
2929
1693
|
idempotent: true,
|
|
2930
1694
|
backend_call: { type: 'tool', name: SAVE_APP_FILES_TOOL },
|