@notis_ai/cli 0.2.0-beta.156.1 → 0.2.0-beta.158.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 +8893 -10612
- package/dist/base-skills/notis-apps/SKILL.md +25 -573
- package/dist/base-skills/notis-apps/references/architecture.md +147 -0
- package/dist/base-skills/notis-apps/references/design.md +154 -0
- package/dist/base-skills/notis-apps/references/release.md +93 -0
- package/dist/base-skills/notis-apps/references/sdk.md +60 -0
- package/dist/base-skills/notis-apps/references/troubleshooting.md +26 -0
- package/dist/base-skills/notis-cli/SKILL.md +19 -267
- package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
- package/dist/base-skills/notis-cli/references/native-databases.md +20 -0
- package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
- package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
- package/dist/base-skills/notis-query/SKILL.md +13 -651
- package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
- package/dist/base-skills/notis-query/references/documents.md +50 -0
- package/dist/base-skills/notis-query/references/query.md +543 -0
- package/dist/skill-sync/index.js +24 -7
- package/dist/skill-sync/index.js.map +4 -4
- package/dist/skill-sync-worker.mjs +2989 -0
- package/package.json +1 -2
- package/skills/notis-apps/cli.md +34 -95
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +1 -1
- package/src/cli.js +4 -0
- package/src/command-specs/apps.js +322 -1560
- package/src/command-specs/diagnostics.js +37 -0
- package/src/command-specs/skills.js +23 -5
- 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/src/runtime/profiles.js +5 -2
- package/src/runtime/skill-sync/cloud-client.ts +2 -1
- package/src/runtime/skill-sync/index.ts +24 -6
- package/src/runtime/skill-sync/types.ts +2 -0
- package/src/runtime/skill-sync-service.js +109 -0
- package/src/skill-sync-worker-entry.js +2 -0
- package/src/skill-sync-worker.js +50 -0
- package/template/app/page.tsx +45 -44
- 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 +55 -13
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +3 -1
- package/template/packages/sdk/src/config.ts +0 -2
- package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +138 -28
- package/template/packages/sdk/src/hooks/useLongPressSelection.ts +79 -0
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +2 -8
- package/template/packages/sdk/src/index.ts +3 -0
- package/template/packages/sdk/src/interactions/actions.ts +14 -1
- package/template/packages/sdk/src/interactions/shortcuts.tsx +79 -19
- package/template/packages/sdk/src/interactions/visibility.ts +13 -0
- package/template/packages/sdk/src/interactions.ts +5 -1
- 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);
|
|
@@ -578,6 +262,19 @@ function assertHarnessResult(result, route, databaseSlugs, mode = 'stub', capabi
|
|
|
578
262
|
details: { databaseSlug: collectionDatabase },
|
|
579
263
|
});
|
|
580
264
|
}
|
|
265
|
+
if (result.design_tool_error) {
|
|
266
|
+
assertions.push({ ok: false, code: 'design_check_error',
|
|
267
|
+
message: `Route "${route.slug}" could not complete its automated design checks.`,
|
|
268
|
+
details: result.design_tool_error });
|
|
269
|
+
}
|
|
270
|
+
for (const finding of result.design || []) {
|
|
271
|
+
assertions.push({
|
|
272
|
+
ok: false,
|
|
273
|
+
code: 'design_rule_violation',
|
|
274
|
+
message: `Route "${route.slug}": ${describeDesignFinding(finding)}.`,
|
|
275
|
+
details: finding,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
581
278
|
if (mode === 'live') {
|
|
582
279
|
// In live mode an app that catches every failed call and renders its error
|
|
583
280
|
// state still mounts cleanly, so the render assertions above all pass. Only
|
|
@@ -633,347 +330,6 @@ function renderVerifyReport({ summary, results, noBrowser }) {
|
|
|
633
330
|
return lines.join('\n');
|
|
634
331
|
}
|
|
635
332
|
|
|
636
|
-
function buildManifestForDev(appConfig, projectDir) {
|
|
637
|
-
const routes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
|
|
638
|
-
return {
|
|
639
|
-
version: 1,
|
|
640
|
-
spec_version: 3,
|
|
641
|
-
app: {
|
|
642
|
-
name: appConfig.name,
|
|
643
|
-
description: appConfig.description || null,
|
|
644
|
-
icon: appConfig.icon || null,
|
|
645
|
-
release_version: readLocalNotisAppVersion(projectDir),
|
|
646
|
-
},
|
|
647
|
-
routes: routes.map((route) => ({
|
|
648
|
-
path: route.path,
|
|
649
|
-
slug: route.slug,
|
|
650
|
-
name: route.name,
|
|
651
|
-
icon: route.icon || null,
|
|
652
|
-
parentSlug: route.parentSlug || null,
|
|
653
|
-
default: route.default || false,
|
|
654
|
-
resourceDeepLinks: route.resourceDeepLinks === true,
|
|
655
|
-
export_name: route.exportName || route.export_name,
|
|
656
|
-
collection: route.collection || null,
|
|
657
|
-
})),
|
|
658
|
-
bundle: {
|
|
659
|
-
js: 'bundle/app.js',
|
|
660
|
-
css: 'bundle/app.css',
|
|
661
|
-
},
|
|
662
|
-
databases: appConfig.databases || [],
|
|
663
|
-
capabilities: normalizeAppCapabilities(appConfig.capabilities),
|
|
664
|
-
tools: appConfig.tools || [],
|
|
665
|
-
tool_bindings: normalizeAppToolBindings(appConfig.toolBindings),
|
|
666
|
-
skills: (appConfig.skills || []).map((skill) => ({
|
|
667
|
-
key: skill.key,
|
|
668
|
-
path: normalizeAppSkillManifestPath(skill.path),
|
|
669
|
-
name: skill.name,
|
|
670
|
-
description: skill.description || null,
|
|
671
|
-
})),
|
|
672
|
-
onboarding: appConfig.onboarding || null,
|
|
673
|
-
};
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
export function buildEnsureDevInstallArguments({
|
|
677
|
-
appConfig,
|
|
678
|
-
manifest,
|
|
679
|
-
linkedState,
|
|
680
|
-
skills,
|
|
681
|
-
useInstalledDatabases = false,
|
|
682
|
-
approvedCapabilities = null,
|
|
683
|
-
}) {
|
|
684
|
-
const devSlug = buildDevInstallSlug(appConfig);
|
|
685
|
-
const arguments_ = {
|
|
686
|
-
dev_slug: devSlug,
|
|
687
|
-
name: appConfig.name,
|
|
688
|
-
manifest,
|
|
689
|
-
skills,
|
|
690
|
-
};
|
|
691
|
-
if (linkedState?.dev_app_id) {
|
|
692
|
-
arguments_.app_id = linkedState.dev_app_id;
|
|
693
|
-
}
|
|
694
|
-
if (useInstalledDatabases && linkedState?.app_id) {
|
|
695
|
-
arguments_.installed_app_id = linkedState.app_id;
|
|
696
|
-
}
|
|
697
|
-
// Sent on every ensure call, including as `false`, so a `--scratch` session
|
|
698
|
-
// puts the app on its own dev copies and the next plain session puts it back
|
|
699
|
-
// on the installed app's databases.
|
|
700
|
-
arguments_.use_installed_databases = Boolean(useInstalledDatabases);
|
|
701
|
-
if (Array.isArray(approvedCapabilities) && approvedCapabilities.length > 0) {
|
|
702
|
-
arguments_.approved_capabilities = approvedCapabilities;
|
|
703
|
-
}
|
|
704
|
-
return arguments_;
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
const CLOUD_SHELL_CONSENT_KEY = 'cloud_computer_shell_consent';
|
|
708
|
-
|
|
709
|
-
/**
|
|
710
|
-
* Collect the developer's explicit approval for `cloudComputer: 'shell'`.
|
|
711
|
-
*
|
|
712
|
-
* The server never grants shell from authorship alone — holding an app's source
|
|
713
|
-
* (a scaffold, a pulled repo) is not consent to full-authority commands on the
|
|
714
|
-
* cloud computer — so `apps dev` asks once, records the answer in the linked
|
|
715
|
-
* state, and passes the grant with the ensure call. Runs before the parallel
|
|
716
|
-
* ensure fan-out so the prompt cannot interleave.
|
|
717
|
-
*/
|
|
718
|
-
export async function resolveCloudShellConsent({
|
|
719
|
-
appConfig,
|
|
720
|
-
projectDir,
|
|
721
|
-
grantCloudShell = false,
|
|
722
|
-
logger = console,
|
|
723
|
-
profileKey = null,
|
|
724
|
-
}) {
|
|
725
|
-
const capabilities = normalizeAppCapabilities(appConfig.capabilities);
|
|
726
|
-
if (capabilities.cloudComputer !== 'shell') {
|
|
727
|
-
return null;
|
|
728
|
-
}
|
|
729
|
-
const approved = ['cloud_computer_read', 'cloud_computer_shell'];
|
|
730
|
-
const linkedState = readLinkedState(projectDir, profileKey) || {};
|
|
731
|
-
const recordDecision = (decision) => {
|
|
732
|
-
writeLinkedState(
|
|
733
|
-
projectDir,
|
|
734
|
-
{ ...linkedState, [CLOUD_SHELL_CONSENT_KEY]: decision },
|
|
735
|
-
profileKey,
|
|
736
|
-
);
|
|
737
|
-
};
|
|
738
|
-
|
|
739
|
-
if (grantCloudShell) {
|
|
740
|
-
recordDecision('granted');
|
|
741
|
-
return approved;
|
|
742
|
-
}
|
|
743
|
-
const recorded = linkedState[CLOUD_SHELL_CONSENT_KEY];
|
|
744
|
-
if (recorded === 'granted') {
|
|
745
|
-
return approved;
|
|
746
|
-
}
|
|
747
|
-
const declineHint =
|
|
748
|
-
`${appConfig.name}: cloud computer shell stays denied for this dev session. `
|
|
749
|
-
+ 'Re-run with --grant-cloud-shell to approve it.';
|
|
750
|
-
if (recorded === 'declined') {
|
|
751
|
-
logger.warn(declineHint);
|
|
752
|
-
return null;
|
|
753
|
-
}
|
|
754
|
-
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
755
|
-
logger.warn(declineHint);
|
|
756
|
-
return null;
|
|
757
|
-
}
|
|
758
|
-
const { createInterface } = await import('node:readline/promises');
|
|
759
|
-
const prompt = createInterface({ input: process.stdin, output: process.stderr });
|
|
760
|
-
try {
|
|
761
|
-
const answer = (await prompt.question(
|
|
762
|
-
`${appConfig.name} declares cloudComputer: 'shell' - its views will run commands and `
|
|
763
|
-
+ 'read or write files on your cloud computer with the same authority as your own '
|
|
764
|
-
+ 'agent. Allow for this dev app? [y/N] ',
|
|
765
|
-
)).trim().toLowerCase();
|
|
766
|
-
const granted = answer === 'y' || answer === 'yes';
|
|
767
|
-
recordDecision(granted ? 'granted' : 'declined');
|
|
768
|
-
if (!granted) {
|
|
769
|
-
logger.warn(declineHint);
|
|
770
|
-
}
|
|
771
|
-
return granted ? approved : null;
|
|
772
|
-
} finally {
|
|
773
|
-
prompt.close();
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
export async function ensureDevInstall({
|
|
778
|
-
ctx,
|
|
779
|
-
appConfig,
|
|
780
|
-
projectDir,
|
|
781
|
-
idempotencyKey,
|
|
782
|
-
useInstalledDatabases = false,
|
|
783
|
-
approvedCapabilities = null,
|
|
784
|
-
runTool = runToolCommand,
|
|
785
|
-
}) {
|
|
786
|
-
if (!appConfig.name) {
|
|
787
|
-
throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
|
|
788
|
-
}
|
|
789
|
-
const devSlug = buildDevInstallSlug(appConfig);
|
|
790
|
-
if (!devSlug) {
|
|
791
|
-
throw usageError(`notis.config.ts devSlug or name in ${projectDir} must slugify to a non-empty value.`);
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
const manifest = buildManifestForDev(appConfig, projectDir);
|
|
795
|
-
const skills = resolveConfiguredAppSkills(appConfig, projectDir);
|
|
796
|
-
const profileKey = linkedStateProfileKey(ctx.runtime);
|
|
797
|
-
let linkedState = readLinkedState(projectDir, profileKey);
|
|
798
|
-
let linkedApp = null;
|
|
799
|
-
if (linkedState?.dev_app_id) {
|
|
800
|
-
const devApp = await getAccessibleApp(ctx.runtime, linkedState.dev_app_id, runTool);
|
|
801
|
-
if (!devApp || devApp.manifest?.is_dev !== true) {
|
|
802
|
-
const { dev_app_id: _devAppId, dev_linked_at: _devLinkedAt, ...rest } = linkedState;
|
|
803
|
-
linkedState = rest;
|
|
804
|
-
writeLinkedState(projectDir, linkedState, profileKey);
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
if (linkedState?.app_id) {
|
|
808
|
-
linkedApp = await getAccessibleApp(ctx.runtime, linkedState.app_id, runTool);
|
|
809
|
-
if (linkedApp?.manifest?.is_dev === true) {
|
|
810
|
-
const { app_id: legacyDevAppId, linked_at: _linkedAt, deployed_at: _deployedAt, version: _version, ...rest } = linkedState;
|
|
811
|
-
const devAppId = linkedState.dev_app_id || legacyDevAppId;
|
|
812
|
-
linkedState = {
|
|
813
|
-
...rest,
|
|
814
|
-
...(devAppId ? { dev_app_id: devAppId } : {}),
|
|
815
|
-
...(devAppId ? {
|
|
816
|
-
dev_linked_at: linkedState.dev_linked_at || linkedState.linked_at || new Date().toISOString(),
|
|
817
|
-
} : {}),
|
|
818
|
-
};
|
|
819
|
-
writeLinkedState(projectDir, linkedState, profileKey);
|
|
820
|
-
linkedApp = null;
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
// A checkout can be shared by Beta and a source-workspace desktop. Its
|
|
824
|
-
// installed app link is valid for the Beta account but intentionally
|
|
825
|
-
// inaccessible to the worktree's test user. Preserve that durable link for
|
|
826
|
-
// Beta, while omitting it from this run so the local dev app uses its own
|
|
827
|
-
// resources instead of sending an unauthorized installed_app_id to ensure.
|
|
828
|
-
if (!linkedState?.app_id) {
|
|
829
|
-
const installedResolution = await findInstalledLinkCandidatesForLegacyDevState(
|
|
830
|
-
ctx.runtime,
|
|
831
|
-
devSlug,
|
|
832
|
-
linkedState?.dev_app_id,
|
|
833
|
-
projectDir,
|
|
834
|
-
profileKey,
|
|
835
|
-
runTool,
|
|
836
|
-
);
|
|
837
|
-
const installedCandidates = installedResolution.candidates;
|
|
838
|
-
if (installedCandidates.length > 1) {
|
|
839
|
-
if (installedResolution.source === 'persisted-project-link') {
|
|
840
|
-
throw usageError(
|
|
841
|
-
`Multiple accessible installed apps are persisted for ${projectDir}. `
|
|
842
|
-
+ `Run \`notis apps link <app-id> ${projectDir}\` to choose one for this environment.`,
|
|
843
|
-
);
|
|
844
|
-
}
|
|
845
|
-
throw usageError(
|
|
846
|
-
`Multiple accessible installed apps use the exact slug "${devSlug.slice(0, -4)}". `
|
|
847
|
-
+ `Run \`notis apps link <app-id> ${projectDir}\` to choose one.`,
|
|
848
|
-
);
|
|
849
|
-
}
|
|
850
|
-
if (installedCandidates.length === 1) {
|
|
851
|
-
const now = new Date().toISOString();
|
|
852
|
-
linkedApp = installedCandidates[0];
|
|
853
|
-
linkedState = {
|
|
854
|
-
...(linkedState || {}),
|
|
855
|
-
app_id: linkedApp.app_id || linkedApp.id,
|
|
856
|
-
linked_at: now,
|
|
857
|
-
auto_linked_at: now,
|
|
858
|
-
};
|
|
859
|
-
writeLinkedState(projectDir, linkedState, profileKey);
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
const runtimeLinkedState = linkedState?.app_id && !linkedApp
|
|
863
|
-
? Object.fromEntries(
|
|
864
|
-
Object.entries(linkedState).filter(([key]) => ![
|
|
865
|
-
'app_id', 'linked_at', 'deployed_at', 'version',
|
|
866
|
-
].includes(key)),
|
|
867
|
-
)
|
|
868
|
-
: linkedState;
|
|
869
|
-
const localReleaseVersion = manifest.app.release_version || null;
|
|
870
|
-
const installedReleaseVersion = linkedApp?.manifest?.app?.release_version || '0.0.0';
|
|
871
|
-
const mountEligible = !linkedApp
|
|
872
|
-
|| compareNotisAppVersions(localReleaseVersion, installedReleaseVersion) === 1;
|
|
873
|
-
const ensureArguments = buildEnsureDevInstallArguments({
|
|
874
|
-
appConfig,
|
|
875
|
-
manifest,
|
|
876
|
-
linkedState: runtimeLinkedState,
|
|
877
|
-
skills,
|
|
878
|
-
useInstalledDatabases,
|
|
879
|
-
approvedCapabilities,
|
|
880
|
-
});
|
|
881
|
-
let ensureResult;
|
|
882
|
-
try {
|
|
883
|
-
ensureResult = await runTool({
|
|
884
|
-
runtime: ctx.runtime,
|
|
885
|
-
toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
|
|
886
|
-
arguments_: ensureArguments,
|
|
887
|
-
mutating: true,
|
|
888
|
-
idempotencyKey,
|
|
889
|
-
});
|
|
890
|
-
} catch (error) {
|
|
891
|
-
const backendCode = error?.details?.error?.code;
|
|
892
|
-
if (linkedState?.dev_app_id && backendCode === 'dev_app_slug_conflict') {
|
|
893
|
-
// The authenticated environment can outlive an older local API profile.
|
|
894
|
-
// If that profile remembered a different dev row while this user already
|
|
895
|
-
// owns the exact canonical dev slug, the backend refuses a duplicate.
|
|
896
|
-
// Retry without the stale row id so the backend deterministically adopts
|
|
897
|
-
// the unique slug owner; preserve the installed target and every grant.
|
|
898
|
-
const staleDevAppId = linkedState.dev_app_id;
|
|
899
|
-
const { dev_app_id: _devAppId, dev_linked_at: _devLinkedAt, ...rest } = linkedState;
|
|
900
|
-
linkedState = rest;
|
|
901
|
-
writeLinkedState(projectDir, linkedState, profileKey);
|
|
902
|
-
const retryArguments = { ...ensureArguments };
|
|
903
|
-
delete retryArguments.app_id;
|
|
904
|
-
ensureResult = await runTool({
|
|
905
|
-
runtime: ctx.runtime,
|
|
906
|
-
toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
|
|
907
|
-
arguments_: retryArguments,
|
|
908
|
-
mutating: true,
|
|
909
|
-
idempotencyKey: `${idempotencyKey}:adopt-slug-owner:${staleDevAppId}`,
|
|
910
|
-
});
|
|
911
|
-
} else {
|
|
912
|
-
throw error;
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
const appId = ensureResult.payload.app_id;
|
|
916
|
-
if (!appId) {
|
|
917
|
-
throw usageError('Dev installation tool did not return an app_id.');
|
|
918
|
-
}
|
|
919
|
-
if (linkedState?.dev_app_id !== appId) {
|
|
920
|
-
const now = new Date().toISOString();
|
|
921
|
-
writeLinkedState(projectDir, {
|
|
922
|
-
...(linkedState || {}),
|
|
923
|
-
dev_app_id: appId,
|
|
924
|
-
dev_linked_at: linkedState?.dev_linked_at || now,
|
|
925
|
-
}, profileKey);
|
|
926
|
-
}
|
|
927
|
-
return {
|
|
928
|
-
slug: ensureResult.payload.slug || devSlug,
|
|
929
|
-
devSlug,
|
|
930
|
-
name: appConfig.name,
|
|
931
|
-
appId,
|
|
932
|
-
projectDir,
|
|
933
|
-
manifest,
|
|
934
|
-
created: ensureResult.payload.created || false,
|
|
935
|
-
linkedAppId: runtimeLinkedState?.app_id || null,
|
|
936
|
-
targetAppId: runtimeLinkedState?.app_id || null,
|
|
937
|
-
targetAppSlug: linkedApp?.slug || null,
|
|
938
|
-
localReleaseVersion,
|
|
939
|
-
installedReleaseVersion: linkedApp ? installedReleaseVersion : null,
|
|
940
|
-
mountEligible,
|
|
941
|
-
databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
|
|
942
|
-
liveData: ensureResult.payload.live_data || null,
|
|
943
|
-
};
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
function databaseMaterializationWarnings(apps) {
|
|
947
|
-
const warnings = [];
|
|
948
|
-
for (const app of apps) {
|
|
949
|
-
const unresolved = app.databaseMaterialization?.unresolved || [];
|
|
950
|
-
if (!unresolved.length) {
|
|
951
|
-
continue;
|
|
952
|
-
}
|
|
953
|
-
warnings.push(
|
|
954
|
-
`${app.name}: database slug${unresolved.length === 1 ? '' : 's'} ${unresolved.join(', ')} ` +
|
|
955
|
-
'could not be created because no store snapshot schema exists. Create them manually or link to a store-installed app.',
|
|
956
|
-
);
|
|
957
|
-
}
|
|
958
|
-
return warnings;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
function liveDataWarnings(apps) {
|
|
962
|
-
return apps
|
|
963
|
-
.filter((app) => app.liveData?.warning)
|
|
964
|
-
.map((app) => `${app.name}: ${app.liveData.warning}`);
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
function versionPrecedenceWarnings(apps) {
|
|
968
|
-
return apps
|
|
969
|
-
.filter((app) => app.targetAppId && app.mountEligible === false)
|
|
970
|
-
.map((app) => {
|
|
971
|
-
const localVersion = app.localReleaseVersion || 'missing or invalid';
|
|
972
|
-
const pullCommand = `npx --package @notis_ai/cli@latest -- notis apps pull ${app.targetAppId} ${JSON.stringify(app.projectDir)} --force`;
|
|
973
|
-
return `${app.name}: local version ${localVersion} is not strictly newer than installed version ${app.installedReleaseVersion}; Workspace keeps serving the online bundle. Preserve any local edits, pull latest with \`${pullCommand}\`, then bump package.json notisAppVersion before development.`;
|
|
974
|
-
});
|
|
975
|
-
}
|
|
976
|
-
|
|
977
333
|
async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
|
|
978
334
|
const result = await runTool({
|
|
979
335
|
runtime,
|
|
@@ -997,93 +353,11 @@ async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
|
|
|
997
353
|
throw usageError(`Could not verify access to app ${appId}${message ? `: ${message}` : '.'}`);
|
|
998
354
|
}
|
|
999
355
|
|
|
1000
|
-
async function findInstalledLinkCandidatesForLegacyDevState(
|
|
1001
|
-
runtime,
|
|
1002
|
-
devSlug,
|
|
1003
|
-
devAppId,
|
|
1004
|
-
projectDir,
|
|
1005
|
-
profileKey,
|
|
1006
|
-
runTool = runToolCommand,
|
|
1007
|
-
) {
|
|
1008
|
-
const installedSlug = devSlug.endsWith('-dev') ? devSlug.slice(0, -4) : '';
|
|
1009
|
-
if (!installedSlug) return { candidates: [], source: null };
|
|
1010
|
-
|
|
1011
|
-
const result = await runTool({
|
|
1012
|
-
runtime,
|
|
1013
|
-
toolName: LIST_APPS_TOOL,
|
|
1014
|
-
});
|
|
1015
|
-
const accessibleInstalledApps = (result.payload?.apps || []).filter((app) => (
|
|
1016
|
-
(app?.app_id || app?.id)
|
|
1017
|
-
&& (app.app_id || app.id) !== devAppId
|
|
1018
|
-
&& app?.manifest?.is_dev !== true
|
|
1019
|
-
));
|
|
1020
|
-
|
|
1021
|
-
// A project-local app id is stronger evidence than a slug. Older CLI runs
|
|
1022
|
-
// could persist that explicit link under another API/user profile before
|
|
1023
|
-
// the machine-local discovery host mounted the same source here. Migrate it
|
|
1024
|
-
// only when exactly one such id is accessible in the current environment;
|
|
1025
|
-
// multiple accessible ids remain ambiguous and fail closed below.
|
|
1026
|
-
const rawState = readLinkedState(projectDir) || {};
|
|
1027
|
-
const persistedAppIds = new Set();
|
|
1028
|
-
if (rawState.app_id) persistedAppIds.add(rawState.app_id);
|
|
1029
|
-
const profiles = rawState.profiles && typeof rawState.profiles === 'object'
|
|
1030
|
-
? rawState.profiles
|
|
1031
|
-
: {};
|
|
1032
|
-
for (const [storedProfileKey, state] of Object.entries(profiles)) {
|
|
1033
|
-
if (storedProfileKey === profileKey || !state || typeof state !== 'object') continue;
|
|
1034
|
-
if (state.app_id) persistedAppIds.add(state.app_id);
|
|
1035
|
-
}
|
|
1036
|
-
const persistedCandidates = accessibleInstalledApps.filter((app) => (
|
|
1037
|
-
persistedAppIds.has(app.app_id || app.id)
|
|
1038
|
-
));
|
|
1039
|
-
if (persistedCandidates.length) {
|
|
1040
|
-
return { candidates: persistedCandidates, source: 'persisted-project-link' };
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
// The database row slug can change when a Store app is installed or an old
|
|
1044
|
-
// development app is promoted. The shipped source manifest keeps the
|
|
1045
|
-
// canonical app slug, so accept either exact field. Display names are never
|
|
1046
|
-
// considered.
|
|
1047
|
-
const exactSlugCandidates = accessibleInstalledApps.filter((app) => {
|
|
1048
|
-
const canonicalSlugs = new Set([
|
|
1049
|
-
app?.slug,
|
|
1050
|
-
app?.manifest?.app?.slug,
|
|
1051
|
-
].map(slugify).filter(Boolean));
|
|
1052
|
-
return canonicalSlugs.has(installedSlug);
|
|
1053
|
-
});
|
|
1054
|
-
return { candidates: exactSlugCandidates, source: 'exact-slug' };
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
export async function assertDirectDeployAccess(runtime, appId, runTool = runToolCommand) {
|
|
1058
|
-
const app = await getAccessibleApp(runtime, appId, runTool);
|
|
1059
|
-
if (!app) {
|
|
1060
|
-
throw usageError(`Direct deploy requires access to app ${appId}.`);
|
|
1061
|
-
}
|
|
1062
|
-
if (app.apps_access?.has_access !== true) {
|
|
1063
|
-
throw usageError('Notis Apps require a PRO+ or ULTRA plan after your trial.');
|
|
1064
|
-
}
|
|
1065
|
-
if (app.can_edit !== true) {
|
|
1066
|
-
throw usageError(`Direct deploy requires edit access to app ${appId}.`);
|
|
1067
|
-
}
|
|
1068
|
-
if (app.manifest?.is_dev === true) {
|
|
1069
|
-
throw usageError(
|
|
1070
|
-
'A development app cannot be deployed directly. Retry without --direct so first deploy can promote it safely.',
|
|
1071
|
-
);
|
|
1072
|
-
}
|
|
1073
|
-
return app;
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
356
|
export async function assertLinkTarget(runtime, appId, runTool = runToolCommand) {
|
|
1077
|
-
const
|
|
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,
|
|
@@ -1896,8 +744,17 @@ async function appsVerifyHandler(ctx) {
|
|
|
1896
744
|
};
|
|
1897
745
|
const overallOk = summary.failed === 0;
|
|
1898
746
|
const exitCode = overallOk ? EXIT_CODES.ok : EXIT_CODES.unexpected;
|
|
747
|
+
// Keep standalone verification diagnostics. Deploy always verifies its own
|
|
748
|
+
// frozen snapshot; this report is never authority to skip that check.
|
|
749
|
+
const verifyStamp = writeVerifyStamp(projectDir, {
|
|
750
|
+
ok: overallOk && summary.manual === 0 && summary.total > 0,
|
|
751
|
+
mode,
|
|
752
|
+
summary,
|
|
753
|
+
results,
|
|
754
|
+
});
|
|
1899
755
|
const data = {
|
|
1900
756
|
status: overallOk ? (summary.manual ? 'manual' : 'passed') : 'failed',
|
|
757
|
+
artifact_hash: verifyStamp.artifact_hash,
|
|
1901
758
|
project_dir: projectDir,
|
|
1902
759
|
app_slug: appSlug,
|
|
1903
760
|
mode,
|
|
@@ -1916,6 +773,7 @@ async function appsVerifyHandler(ctx) {
|
|
|
1916
773
|
},
|
|
1917
774
|
};
|
|
1918
775
|
|
|
776
|
+
if (!keepOpen) await cleanup();
|
|
1919
777
|
ctx.output.emitSuccess({
|
|
1920
778
|
ok: overallOk,
|
|
1921
779
|
command: ctx.spec.command_path.join(' '),
|
|
@@ -1934,22 +792,8 @@ async function appsVerifyHandler(ctx) {
|
|
|
1934
792
|
|
|
1935
793
|
return exitCode;
|
|
1936
794
|
} finally {
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
try {
|
|
1940
|
-
await closeAgentBrowserSession(browserSessionName);
|
|
1941
|
-
} catch {
|
|
1942
|
-
// Ignore browser cleanup failures.
|
|
1943
|
-
}
|
|
1944
|
-
}
|
|
1945
|
-
if (devServer) {
|
|
1946
|
-
try {
|
|
1947
|
-
await devServer.close();
|
|
1948
|
-
} catch {
|
|
1949
|
-
// Ignore server cleanup failures.
|
|
1950
|
-
}
|
|
1951
|
-
}
|
|
1952
|
-
}
|
|
795
|
+
try { await cleanup(); }
|
|
796
|
+
finally { removeSignalHandlers(); }
|
|
1953
797
|
}
|
|
1954
798
|
}
|
|
1955
799
|
|
|
@@ -2044,14 +888,19 @@ async function appsScreenshotHandler(ctx) {
|
|
|
2044
888
|
const rawOutputDir = ctx.options.raw
|
|
2045
889
|
? null
|
|
2046
890
|
: mkdtempSync(join(tmpdir(), 'notis-store-screenshots-'));
|
|
2047
|
-
let
|
|
891
|
+
let testServer = null;
|
|
2048
892
|
let browserTouched = false;
|
|
2049
893
|
|
|
894
|
+
let cleanupPromise;
|
|
895
|
+
const cleanup = () => (cleanupPromise ||= closeHarnessResources(
|
|
896
|
+
browserTouched ? browserSessionNames : [], testServer, rawOutputDir,
|
|
897
|
+
));
|
|
898
|
+
const removeSignalHandlers = installHarnessSignalCleanup(cleanup);
|
|
899
|
+
|
|
2050
900
|
try {
|
|
2051
|
-
|
|
901
|
+
testServer = await startAppTestServer({
|
|
2052
902
|
apps: [{ slug: appSlug, projectDir, appId: linkedState?.app_id || 'harness-app' }],
|
|
2053
903
|
port,
|
|
2054
|
-
watch: false,
|
|
2055
904
|
harness: {
|
|
2056
905
|
mode,
|
|
2057
906
|
apiBase: ctx.runtime.apiBase,
|
|
@@ -2138,6 +987,7 @@ async function appsScreenshotHandler(ctx) {
|
|
|
2138
987
|
warnings.push(`${failed.length}/${results.length} routes failed to capture; see results.`);
|
|
2139
988
|
}
|
|
2140
989
|
|
|
990
|
+
await cleanup();
|
|
2141
991
|
ctx.output.emitSuccess({
|
|
2142
992
|
ok: failed.length === 0,
|
|
2143
993
|
command: ctx.spec.command_path.join(' '),
|
|
@@ -2157,61 +1007,40 @@ async function appsScreenshotHandler(ctx) {
|
|
|
2157
1007
|
});
|
|
2158
1008
|
return screenshotExitCode(failed.length);
|
|
2159
1009
|
} finally {
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
try {
|
|
2163
|
-
await closeAgentBrowserSession(sessionName);
|
|
2164
|
-
} catch {
|
|
2165
|
-
// Ignore browser cleanup failures.
|
|
2166
|
-
}
|
|
2167
|
-
}));
|
|
2168
|
-
}
|
|
2169
|
-
if (devServer) {
|
|
2170
|
-
try {
|
|
2171
|
-
await devServer.close();
|
|
2172
|
-
} catch {
|
|
2173
|
-
// Ignore server cleanup failures.
|
|
2174
|
-
}
|
|
2175
|
-
}
|
|
2176
|
-
if (rawOutputDir) {
|
|
2177
|
-
rmSync(rawOutputDir, { recursive: true, force: true });
|
|
2178
|
-
}
|
|
1010
|
+
try { await cleanup(); }
|
|
1011
|
+
finally { removeSignalHandlers(); }
|
|
2179
1012
|
}
|
|
2180
1013
|
}
|
|
2181
1014
|
|
|
2182
|
-
export function buildLinkedAppState(
|
|
2183
|
-
existingState,
|
|
2184
|
-
appId,
|
|
2185
|
-
linkedAt = new Date().toISOString(),
|
|
2186
|
-
) {
|
|
2187
|
-
const sameIdPromotion = existingState?.dev_app_id === appId;
|
|
2188
|
-
return {
|
|
2189
|
-
...(!sameIdPromotion && existingState?.dev_app_id ? { dev_app_id: existingState.dev_app_id } : {}),
|
|
2190
|
-
...(!sameIdPromotion && existingState?.dev_linked_at ? { dev_linked_at: existingState.dev_linked_at } : {}),
|
|
2191
|
-
app_id: appId,
|
|
2192
|
-
linked_at: linkedAt,
|
|
2193
|
-
};
|
|
1015
|
+
export function buildLinkedAppState(existingState, appId, linkedAt = new Date().toISOString()) {
|
|
1016
|
+
return { ...(existingState?.app_id === appId ? existingState : {}), app_id: appId, linked_at: linkedAt };
|
|
2194
1017
|
}
|
|
2195
1018
|
|
|
2196
1019
|
async function appsLinkHandler(ctx) {
|
|
2197
1020
|
const projectDir = resolveProjectDir(ctx.args.dir || '.');
|
|
2198
1021
|
const appId = ctx.args.appId;
|
|
1022
|
+
const expectedVersion = ctx.options.expectedVersion === undefined ? null : Number(ctx.options.expectedVersion);
|
|
1023
|
+
if (expectedVersion !== null && (!/^\d+$/.test(String(ctx.options.expectedVersion)) || !Number.isSafeInteger(expectedVersion))) {
|
|
1024
|
+
throw usageError('--expected-version must be a non-negative integer.');
|
|
1025
|
+
}
|
|
2199
1026
|
|
|
2200
|
-
await assertLinkTarget(ctx.runtime, appId);
|
|
1027
|
+
const app = await assertLinkTarget(ctx.runtime, appId);
|
|
2201
1028
|
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
1029
|
+
const profileKey = linkedStateProfileKey(ctx.runtime);
|
|
1030
|
+
const state = buildLinkedAppState(readLinkedState(projectDir, profileKey), appId);
|
|
1031
|
+
const version = deployedAppVersion(app);
|
|
1032
|
+
if (expectedVersion !== null && version !== expectedVersion) {
|
|
1033
|
+
throw usageError('The app release changed before linking. Preserve local source, pull the current release into a fresh directory, and reapply changes before deploying.');
|
|
1034
|
+
}
|
|
1035
|
+
if (state.version !== undefined && state.version !== version) {
|
|
1036
|
+
throw usageError('A different release exists. Pull current source into a fresh directory and reapply local changes before deploying.');
|
|
1037
|
+
}
|
|
1038
|
+
if (!app.updated_at) throw usageError('App revision is unavailable; the directory was not relinked.');
|
|
1039
|
+
writeLinkedState(projectDir, { ...state, version, expected_updated_at: app.updated_at }, profileKey);
|
|
2211
1040
|
|
|
2212
1041
|
return ctx.output.emitSuccess({
|
|
2213
1042
|
command: ctx.spec.command_path.join(' '),
|
|
2214
|
-
data: { app_id: appId, project_dir: projectDir },
|
|
1043
|
+
data: { app_id: appId, project_dir: projectDir, version, expected_updated_at: app.updated_at },
|
|
2215
1044
|
humanSummary: `Linked to app ${appId}`,
|
|
2216
1045
|
hints: [
|
|
2217
1046
|
{ command: 'notis apps deploy .', reason: 'Deploy the app' },
|
|
@@ -2253,6 +1082,7 @@ async function appsPullHandler(ctx) {
|
|
|
2253
1082
|
version,
|
|
2254
1083
|
force: Boolean(ctx.options.force),
|
|
2255
1084
|
profileKey: linkedStateProfileKey(ctx.runtime),
|
|
1085
|
+
expectedUpdatedAt: app.updated_at,
|
|
2256
1086
|
});
|
|
2257
1087
|
|
|
2258
1088
|
const versionLabel = pulled.version === 'latest' ? 'latest version' : `v${pulled.version}`;
|
|
@@ -2263,11 +1093,11 @@ async function appsPullHandler(ctx) {
|
|
|
2263
1093
|
project_dir: pulled.projectDir,
|
|
2264
1094
|
version: pulled.version,
|
|
2265
1095
|
},
|
|
2266
|
-
humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}.
|
|
1096
|
+
humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}. Run npm install, edit the source, then build, verify and deploy the update.`,
|
|
2267
1097
|
});
|
|
2268
1098
|
}
|
|
2269
1099
|
|
|
2270
|
-
function updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey = null) {
|
|
1100
|
+
function updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey = null, updatedAt = null) {
|
|
2271
1101
|
if (!linkedState || linkedState.app_id !== appId || !Number.isFinite(version)) {
|
|
2272
1102
|
return;
|
|
2273
1103
|
}
|
|
@@ -2277,33 +1107,24 @@ function updateLinkedDeployState(projectDir, linkedState, appId, version, profil
|
|
|
2277
1107
|
version,
|
|
2278
1108
|
linked_at: linkedState.linked_at || new Date().toISOString(),
|
|
2279
1109
|
deployed_at: new Date().toISOString(),
|
|
1110
|
+
expected_updated_at: updatedAt,
|
|
2280
1111
|
}, profileKey);
|
|
2281
1112
|
}
|
|
2282
1113
|
|
|
2283
|
-
// Resolve first deploy without mutating. Promotion is part of the final
|
|
2284
|
-
// SAVE_APP_FILES update, after build and uploads succeed, so a failed deploy
|
|
2285
|
-
// cannot leave an empty installed app behind.
|
|
2286
|
-
function resolveDeployTarget(ctx, projectDir) {
|
|
2287
|
-
if (ctx.options.appId) return { appId: ctx.options.appId, needsPromotion: false };
|
|
2288
|
-
const profileKey = linkedStateProfileKey(ctx.runtime);
|
|
2289
|
-
const state = readLinkedState(projectDir, profileKey);
|
|
2290
|
-
if (state?.app_id) return { appId: state.app_id, needsPromotion: false };
|
|
2291
|
-
if (!state?.dev_app_id) {
|
|
2292
|
-
return { appId: requireLinkedAppId(projectDir, ctx.options.appId, profileKey), needsPromotion: false };
|
|
2293
|
-
}
|
|
2294
|
-
return { appId: state.dev_app_id, needsPromotion: true };
|
|
2295
|
-
}
|
|
2296
|
-
|
|
2297
1114
|
async function appsDeployHandler(ctx) {
|
|
2298
1115
|
const projectDir = resolveProjectDir(ctx.args.dir || '.');
|
|
2299
1116
|
const profileKey = linkedStateProfileKey(ctx.runtime);
|
|
2300
|
-
const
|
|
1117
|
+
const appId = requireLinkedAppId(projectDir, ctx.options.appId, profileKey);
|
|
2301
1118
|
const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
|
|
2302
1119
|
const linkedState = readLinkedState(projectDir, profileKey);
|
|
2303
1120
|
const baseVersion = linkedState?.app_id === appId && Number.isFinite(linkedState?.version)
|
|
2304
1121
|
? linkedState.version
|
|
2305
1122
|
: undefined;
|
|
2306
1123
|
|
|
1124
|
+
if (!Number.isInteger(baseVersion) || baseVersion < 0 || !linkedState?.expected_updated_at) {
|
|
1125
|
+
throw usageError('Deploy requires a current profile-scoped app link and deployment base. Pull the current release, or link an unreleased app first.');
|
|
1126
|
+
}
|
|
1127
|
+
|
|
2307
1128
|
// Build if needed
|
|
2308
1129
|
if (!ctx.options.skipBuild) {
|
|
2309
1130
|
await buildArtifact(projectDir, {
|
|
@@ -2311,133 +1132,149 @@ async function appsDeployHandler(ctx) {
|
|
|
2311
1132
|
});
|
|
2312
1133
|
}
|
|
2313
1134
|
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
1135
|
+
const release = prepareAppRelease(projectDir);
|
|
1136
|
+
const { files, sourceFiles, manifest } = release;
|
|
1137
|
+
let cleanupVerification = async () => {};
|
|
1138
|
+
let uploadStarted = false;
|
|
1139
|
+
let cancelled = false;
|
|
1140
|
+
const removeDeploySignalHandlers = installHarnessSignalCleanup(async () => {
|
|
1141
|
+
cancelled = true;
|
|
1142
|
+
const cleanupErrors = [];
|
|
1143
|
+
try { await cleanupVerification(); }
|
|
1144
|
+
catch (error) { cleanupErrors.push(error.message); }
|
|
1145
|
+
finally {
|
|
1146
|
+
try { release.close(); } catch (error) { cleanupErrors.push(error.message); }
|
|
2318
1147
|
}
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
let result;
|
|
1148
|
+
ctx.output.emitError({ command: 'apps deploy', error: new CliError({
|
|
1149
|
+
code: uploadStarted ? 'app_deploy_outcome_unknown' : 'app_deploy_cancelled',
|
|
1150
|
+
message: uploadStarted
|
|
1151
|
+
? 'Deployment interrupted. Read back the exact app/version before retrying.'
|
|
1152
|
+
: 'Deployment interrupted before upload; no update was deployed.',
|
|
1153
|
+
retryable: false, exitCode: EXIT_CODES.network,
|
|
1154
|
+
details: { app_id: appId, base_version: baseVersion,
|
|
1155
|
+
target_version: baseVersion + 1, idempotency_key: idempotencyKey,
|
|
1156
|
+
activation_outcome: uploadStarted ? 'unknown' : 'not_started',
|
|
1157
|
+
...(cleanupErrors.length ? { cleanup_errors: cleanupErrors } : {}) },
|
|
1158
|
+
hints: uploadStarted
|
|
1159
|
+
? [{ command: 'notis apps list --json', reason: 'Reconcile the interrupted deployment' }]
|
|
1160
|
+
: [],
|
|
1161
|
+
}) });
|
|
1162
|
+
});
|
|
2336
1163
|
try {
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
},
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
files,
|
|
2350
|
-
source_files: sourceFiles,
|
|
2351
|
-
manifest,
|
|
2352
|
-
...appRowFieldsFromManifest(manifest),
|
|
2353
|
-
...(baseVersion !== undefined ? { base_version: baseVersion } : {}),
|
|
2354
|
-
...(needsPromotion ? { promote_dev_app: true } : {}),
|
|
1164
|
+
let verification;
|
|
1165
|
+
const verifyOutput = {
|
|
1166
|
+
...ctx.output,
|
|
1167
|
+
emitSuccess: (result) => { verification = result; },
|
|
1168
|
+
isMachineMode: () => true,
|
|
1169
|
+
};
|
|
1170
|
+
const verified = await appsVerifyHandler({
|
|
1171
|
+
...ctx, args: { dir: release.projectDir },
|
|
1172
|
+
options: { skipBuild: true, mode: 'stub' }, output: verifyOutput,
|
|
1173
|
+
registerSignalCleanup: (cleanup) => {
|
|
1174
|
+
cleanupVerification = cleanup;
|
|
1175
|
+
return () => { cleanupVerification = async () => {}; };
|
|
2355
1176
|
},
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
if (error.code === 'conflict') {
|
|
2361
|
-
throw toolConflictToError(error.details, 'Deploy conflict');
|
|
2362
|
-
}
|
|
2363
|
-
|
|
2364
|
-
// A timed-out mutation may already have committed on the backend. A direct
|
|
2365
|
-
// upload here would create a second revision, so fail closed and let the
|
|
2366
|
-
// caller inspect/pull the app before deciding whether to retry.
|
|
2367
|
-
if (error.code === 'network_timeout') {
|
|
2368
|
-
error.message = `${error.message}. The backend may still complete this deploy; direct fallback was not attempted to avoid creating a duplicate revision. Inspect the app version, then pull before retrying if needed.`;
|
|
1177
|
+
}).catch(async (error) => {
|
|
1178
|
+
// The signal handler also awaits verification cleanup. If that shared
|
|
1179
|
+
// promise rejects, it still owns the single structured terminal outcome.
|
|
1180
|
+
if (cancelled) return await new Promise(() => {});
|
|
2369
1181
|
throw error;
|
|
1182
|
+
});
|
|
1183
|
+
if (verified !== EXIT_CODES.ok || verification?.data?.status !== 'passed' || verification?.data?.summary?.passed < 1) {
|
|
1184
|
+
throw usageError('App verification failed; no update was deployed. Run notis apps verify for details.');
|
|
2370
1185
|
}
|
|
2371
1186
|
|
|
2372
|
-
// A network failure is ambiguous for a mutation. It can happen after the
|
|
2373
|
-
// backend committed but before undici finished reading the response (for
|
|
2374
|
-
// example ECONNRESET / UND_ERR_SOCKET). Never infer pre-dispatch from a
|
|
2375
|
-
// generic network_error or its message: an automatic direct upload could
|
|
2376
|
-
// create a second revision. Operators who have proved the backend is down
|
|
2377
|
-
// can still choose the explicit --direct mode.
|
|
2378
|
-
if (error.code === 'network_error') {
|
|
2379
|
-
error.message = `${error.message}. The backend may have committed this deploy; direct fallback was not attempted to avoid creating a duplicate revision. Inspect the app version, then pull before retrying, or use --direct only after proving the backend mutation did not land.`;
|
|
2380
|
-
throw error;
|
|
2381
|
-
}
|
|
2382
1187
|
|
|
2383
|
-
|
|
2384
|
-
|
|
1188
|
+
// The signal handler owns terminal reporting and exit. A cancellation
|
|
1189
|
+
// during verification cleanup must never continue into the mutation.
|
|
1190
|
+
if (cancelled) return await new Promise(() => {});
|
|
2385
1191
|
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
code: 'network_error',
|
|
2390
|
-
message: 'The backend returned an incomplete deploy response. The deploy may have committed; inspect the app version and pull before retrying. Direct fallback was not attempted to avoid creating a duplicate revision.',
|
|
2391
|
-
exitCode: EXIT_CODES.network,
|
|
2392
|
-
retryable: true,
|
|
2393
|
-
});
|
|
2394
|
-
}
|
|
1192
|
+
// Upload uses captured bytes only. Fail closed on a staging identity swap
|
|
1193
|
+
// before any remote mutation, and finish local cleanup before activation.
|
|
1194
|
+
release.close();
|
|
2395
1195
|
|
|
2396
|
-
|
|
2397
|
-
const now = new Date().toISOString();
|
|
2398
|
-
writeLinkedState(projectDir, {
|
|
2399
|
-
...linkedState,
|
|
2400
|
-
app_id: appId,
|
|
2401
|
-
linked_at: linkedState?.linked_at || now,
|
|
2402
|
-
version: deployedVersion,
|
|
2403
|
-
deployed_at: now,
|
|
2404
|
-
dev_app_id: undefined,
|
|
2405
|
-
dev_linked_at: undefined,
|
|
2406
|
-
}, profileKey);
|
|
1196
|
+
let result;
|
|
2407
1197
|
try {
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
1198
|
+
uploadStarted = true;
|
|
1199
|
+
result = await runToolCommand({
|
|
1200
|
+
// App deploys upload both the built artifact and the editable source
|
|
1201
|
+
// snapshot. The ordinary 30s CLI timeout is too short for larger apps,
|
|
1202
|
+
// and timing out a mutation is ambiguous: the backend may commit after
|
|
1203
|
+
// the client disconnects. Give this operation its real completion window.
|
|
1204
|
+
runtime: {
|
|
1205
|
+
...ctx.runtime,
|
|
1206
|
+
timeoutMs: Math.max(ctx.runtime.timeoutMs || 0, APP_DEPLOY_TIMEOUT_MS),
|
|
1207
|
+
},
|
|
1208
|
+
toolName: SAVE_APP_FILES_TOOL,
|
|
1209
|
+
arguments_: {
|
|
1210
|
+
app_id: appId,
|
|
1211
|
+
files,
|
|
1212
|
+
source_files: sourceFiles,
|
|
1213
|
+
manifest,
|
|
1214
|
+
...appRowFieldsFromManifest(manifest),
|
|
1215
|
+
base_version: baseVersion,
|
|
1216
|
+
expected_updated_at: linkedState.expected_updated_at,
|
|
1217
|
+
},
|
|
1218
|
+
mutating: true,
|
|
1219
|
+
idempotencyKey,
|
|
1220
|
+
});
|
|
2412
1221
|
} catch (error) {
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
1222
|
+
if (error.code === 'conflict') {
|
|
1223
|
+
throw toolConflictToError(error.details, 'Deploy conflict');
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// Transport failure may arrive after commit. Never replay an uncertain release.
|
|
1227
|
+
if (error.code === 'network_timeout' || error.code === 'network_error') {
|
|
1228
|
+
error.message = `${error.message}. Deployment outcome is unknown; read back the exact app/version before any retry.`;
|
|
1229
|
+
error.retryable = false;
|
|
1230
|
+
error.details = { ...error.details, app_id: appId, base_version: baseVersion,
|
|
1231
|
+
target_version: baseVersion + 1, idempotency_key: idempotencyKey };
|
|
1232
|
+
error.hints = [{ command: 'notis apps list --json', reason: `Read back app ${appId} and reconcile the deployment outcome` }];
|
|
1233
|
+
}
|
|
1234
|
+
throw error;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
const deployedVersion = Number(result?.payload?.version);
|
|
1238
|
+
if (!Number.isInteger(deployedVersion) || deployedVersion !== baseVersion + 1 || result.payload.app_id !== appId || !result.payload.updated_at) {
|
|
1239
|
+
throw new CliError({
|
|
1240
|
+
code: 'network_error',
|
|
1241
|
+
message: 'The backend returned an incomplete deploy response. The deploy may have committed; inspect the app version and pull before retrying.',
|
|
1242
|
+
exitCode: EXIT_CODES.network,
|
|
1243
|
+
retryable: false,
|
|
1244
|
+
details: { app_id: appId, base_version: baseVersion, target_version: baseVersion + 1, idempotency_key: idempotencyKey },
|
|
1245
|
+
hints: [{ command: 'notis apps list --json', reason: 'Reconcile the incomplete deployment response' }],
|
|
2416
1246
|
});
|
|
2417
1247
|
}
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
1248
|
+
|
|
1249
|
+
const warnings = [];
|
|
1250
|
+
try { updateLinkedDeployState(projectDir, linkedState, appId, deployedVersion, profileKey, result.payload.updated_at); }
|
|
1251
|
+
catch { warnings.push('The app was updated, but the local link could not be saved. Pull the installed version before editing again.'); }
|
|
1252
|
+
try { release.close(); }
|
|
1253
|
+
catch { warnings.push('The app was updated, but the temporary release directory needs local cleanup.'); }
|
|
1254
|
+
|
|
1255
|
+
return ctx.output.emitSuccess({
|
|
1256
|
+
command: ctx.spec.command_path.join(' '),
|
|
1257
|
+
data: {
|
|
1258
|
+
app_id: appId,
|
|
1259
|
+
version: deployedVersion,
|
|
1260
|
+
idempotency_key: idempotencyKey,
|
|
1261
|
+
},
|
|
1262
|
+
warnings,
|
|
1263
|
+
humanSummary: `Deployed to app ${appId} (version ${deployedVersion})`,
|
|
1264
|
+
meta: { mutating: true, idempotency_key: idempotencyKey },
|
|
2421
1265
|
});
|
|
2422
|
-
}
|
|
2423
|
-
|
|
1266
|
+
} finally {
|
|
1267
|
+
removeDeploySignalHandlers();
|
|
1268
|
+
try { release.close(); } catch { /* Do not mask a committed or unknown release. */ }
|
|
2424
1269
|
}
|
|
2425
|
-
return ctx.output.emitSuccess({
|
|
2426
|
-
command: ctx.spec.command_path.join(' '),
|
|
2427
|
-
data: {
|
|
2428
|
-
app_id: appId,
|
|
2429
|
-
version: deployedVersion,
|
|
2430
|
-
idempotency_key: idempotencyKey,
|
|
2431
|
-
},
|
|
2432
|
-
humanSummary: `Deployed to app ${appId} (version ${deployedVersion})`,
|
|
2433
|
-
meta: { mutating: true, idempotency_key: idempotencyKey },
|
|
2434
|
-
});
|
|
2435
1270
|
}
|
|
2436
1271
|
|
|
2437
1272
|
function deployedAppVersion(app) {
|
|
2438
1273
|
const value = app?.current_version ?? app?.manifest?.version;
|
|
1274
|
+
if (value === null || value === undefined) return 0;
|
|
2439
1275
|
const parsed = Number(value);
|
|
2440
|
-
|
|
1276
|
+
if (!Number.isInteger(parsed) || parsed < 0) throw usageError('The app returned an invalid deployment version.');
|
|
1277
|
+
return parsed;
|
|
2441
1278
|
}
|
|
2442
1279
|
|
|
2443
1280
|
async function appsDuplicateHandler(ctx) {
|
|
@@ -2622,9 +1459,6 @@ export function doctorLinkSummary(linkedState) {
|
|
|
2622
1459
|
if (linkedState?.app_id) {
|
|
2623
1460
|
return ` Linked to app ${linkedState.app_id}.`;
|
|
2624
1461
|
}
|
|
2625
|
-
if (linkedState?.dev_app_id) {
|
|
2626
|
-
return ` Local development app ${linkedState.dev_app_id} is active.`;
|
|
2627
|
-
}
|
|
2628
1462
|
return ' Not linked.';
|
|
2629
1463
|
}
|
|
2630
1464
|
|
|
@@ -2699,89 +1533,17 @@ export const appsCommandSpecs = [
|
|
|
2699
1533
|
{ token: '<name>', description: 'Display name for the remote app.' },
|
|
2700
1534
|
{ token: '[dir]', key: 'dir', description: 'Project directory to link after creation (default: do not link).' },
|
|
2701
1535
|
],
|
|
2702
|
-
options: [],
|
|
1536
|
+
options: [{ flags: '--team-id <id>', description: 'Create or reuse the exact team-scoped app (default: personal).' }],
|
|
2703
1537
|
},
|
|
2704
1538
|
examples: [
|
|
2705
1539
|
'notis apps create "My App"',
|
|
2706
1540
|
'notis apps create "My App" .',
|
|
2707
1541
|
],
|
|
2708
1542
|
mutates: true,
|
|
2709
|
-
idempotent:
|
|
1543
|
+
idempotent: true,
|
|
2710
1544
|
backend_call: { type: 'tool', name: CREATE_APP_TOOL },
|
|
2711
1545
|
handler: appsCreateHandler,
|
|
2712
1546
|
},
|
|
2713
|
-
{
|
|
2714
|
-
command_path: ['apps', 'dev'],
|
|
2715
|
-
summary: 'Register a development root and connect its apps to the shared local development host.',
|
|
2716
|
-
when_to_use:
|
|
2717
|
-
'Run this once for any folder that should be watched permanently. The folder itself, direct child apps, and apps/* are discovered automatically by every signed-in Notis Desktop instance. A linked app substitutes its online bundle only when local notisAppVersion is strictly greater than the installed release.',
|
|
2718
|
-
args_schema: {
|
|
2719
|
-
arguments: [
|
|
2720
|
-
{ token: '[dir]', key: 'dir', description: 'Project directory or monorepo root (default: current dir).' },
|
|
2721
|
-
],
|
|
2722
|
-
options: [
|
|
2723
|
-
{ flags: '--port <number>', description: `Local bundle server port (default: ${DEFAULT_DEV_PORT}).` },
|
|
2724
|
-
{
|
|
2725
|
-
flags: '--scratch',
|
|
2726
|
-
description:
|
|
2727
|
-
'Use isolated empty databases, bundled skills, and bundled automations for this session '
|
|
2728
|
-
+ "instead of the installed app's resources. For fixture work and destructive experiments.",
|
|
2729
|
-
},
|
|
2730
|
-
{
|
|
2731
|
-
flags: '--live-data',
|
|
2732
|
-
description:
|
|
2733
|
-
'Deprecated: using the installed app\'s real resources is now the default. '
|
|
2734
|
-
+ 'Accepted as a no-op; use `--scratch` for the old isolated behavior.',
|
|
2735
|
-
},
|
|
2736
|
-
{
|
|
2737
|
-
flags: '--grant-cloud-shell',
|
|
2738
|
-
description:
|
|
2739
|
-
"Approve a cloudComputer: 'shell' declaration without the interactive prompt. "
|
|
2740
|
-
+ 'The grant persists for this dev app; authorship alone never grants it.',
|
|
2741
|
-
},
|
|
2742
|
-
],
|
|
2743
|
-
},
|
|
2744
|
-
examples: [
|
|
2745
|
-
'notis apps dev',
|
|
2746
|
-
'notis apps dev ./my-app',
|
|
2747
|
-
'notis apps dev ./workspace --port 5200',
|
|
2748
|
-
'notis apps dev --scratch # isolated resources for fixture or schema experiments',
|
|
2749
|
-
],
|
|
2750
|
-
mutates: true,
|
|
2751
|
-
idempotent: true,
|
|
2752
|
-
require_auth: true,
|
|
2753
|
-
backend_call: { type: 'tool', name: ENSURE_DEV_APP_INSTALLATION_TOOL },
|
|
2754
|
-
handler: appsDevHandler,
|
|
2755
|
-
},
|
|
2756
|
-
{
|
|
2757
|
-
command_path: ['apps', 'roots', 'list'],
|
|
2758
|
-
summary: 'List persistent machine-local Notis app development roots.',
|
|
2759
|
-
when_to_use: 'See which folders every local Notis Desktop instance watches for development apps.',
|
|
2760
|
-
args_schema: { arguments: [], options: [] },
|
|
2761
|
-
examples: ['notis apps roots list'],
|
|
2762
|
-
mutates: false,
|
|
2763
|
-
idempotent: true,
|
|
2764
|
-
require_auth: false,
|
|
2765
|
-
backend_call: { type: 'local', name: 'list_app_development_roots' },
|
|
2766
|
-
handler: appsRootsListHandler,
|
|
2767
|
-
},
|
|
2768
|
-
{
|
|
2769
|
-
command_path: ['apps', 'roots', 'remove'],
|
|
2770
|
-
summary: 'Stop watching a registered Notis app development root.',
|
|
2771
|
-
when_to_use: 'Remove a persistent development root. The built-in ~/.notis/apps root cannot be removed.',
|
|
2772
|
-
args_schema: {
|
|
2773
|
-
arguments: [
|
|
2774
|
-
{ token: '<folder>', key: 'dir', description: 'Previously registered development root.' },
|
|
2775
|
-
],
|
|
2776
|
-
options: [],
|
|
2777
|
-
},
|
|
2778
|
-
examples: ['notis apps roots remove ./old-apps'],
|
|
2779
|
-
mutates: true,
|
|
2780
|
-
idempotent: true,
|
|
2781
|
-
require_auth: false,
|
|
2782
|
-
backend_call: { type: 'local', name: 'remove_app_development_root' },
|
|
2783
|
-
handler: appsRootsRemoveHandler,
|
|
2784
|
-
},
|
|
2785
1547
|
{
|
|
2786
1548
|
command_path: ['apps', 'build'],
|
|
2787
1549
|
summary: 'Build and package the app into .notis/output/.',
|
|
@@ -2877,9 +1639,11 @@ export const appsCommandSpecs = [
|
|
|
2877
1639
|
{ token: '<app-id>', description: 'Remote app ID to link to.' },
|
|
2878
1640
|
{ token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
|
|
2879
1641
|
],
|
|
2880
|
-
options: [
|
|
1642
|
+
options: [
|
|
1643
|
+
{ flags: '--expected-version <version>', description: 'Link only if the remote deployment version still matches this non-negative integer.' },
|
|
1644
|
+
],
|
|
2881
1645
|
},
|
|
2882
|
-
examples: ['notis apps link abc123', 'notis apps link abc123 ./my-app'],
|
|
1646
|
+
examples: ['notis apps link abc123', 'notis apps link abc123 ./my-app', 'notis apps link abc123 ./recovered-app --expected-version 0'],
|
|
2883
1647
|
mutates: true,
|
|
2884
1648
|
idempotent: true,
|
|
2885
1649
|
require_auth: false,
|
|
@@ -2890,7 +1654,7 @@ export const appsCommandSpecs = [
|
|
|
2890
1654
|
command_path: ['apps', 'pull'],
|
|
2891
1655
|
summary: 'Download a Notis app source snapshot into a local project folder.',
|
|
2892
1656
|
when_to_use:
|
|
2893
|
-
'Edit an installed app
|
|
1657
|
+
'Edit an installed app. Preserve local edits, pull and link its persisted source, then build, verify and deploy.',
|
|
2894
1658
|
args_schema: {
|
|
2895
1659
|
arguments: [
|
|
2896
1660
|
{ token: '<app-id>', description: 'Remote app ID to pull.' },
|
|
@@ -2913,20 +1677,18 @@ export const appsCommandSpecs = [
|
|
|
2913
1677
|
},
|
|
2914
1678
|
{
|
|
2915
1679
|
command_path: ['apps', 'deploy'],
|
|
2916
|
-
summary: 'Build and
|
|
1680
|
+
summary: 'Build, verify and release the linked Workspace app.',
|
|
2917
1681
|
when_to_use:
|
|
2918
|
-
'
|
|
1682
|
+
'Build, verify and release the linked personal or team Workspace app. This command does not publish to the Store.',
|
|
2919
1683
|
args_schema: {
|
|
2920
1684
|
arguments: [
|
|
2921
1685
|
{ token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
|
|
2922
1686
|
],
|
|
2923
1687
|
options: [
|
|
2924
1688
|
{ flags: '--app-id <id>', description: 'Override linked app ID.' },
|
|
2925
|
-
{ flags: '--skip-build', description: '
|
|
2926
|
-
{ flags: '--direct', description: 'Explicitly upload to Supabase storage, bypassing the backend server.' },
|
|
1689
|
+
{ flags: '--skip-build', description: 'Reuse unchanged build output; automated verification still runs.' },
|
|
2927
1690
|
],
|
|
2928
1691
|
},
|
|
2929
|
-
examples: ['notis apps deploy', 'notis apps deploy --skip-build', 'notis apps deploy --app-id abc123', 'notis apps deploy --direct'],
|
|
2930
1692
|
mutates: true,
|
|
2931
1693
|
idempotent: true,
|
|
2932
1694
|
backend_call: { type: 'tool', name: SAVE_APP_FILES_TOOL },
|