@notis_ai/cli 0.2.15 → 0.2.17
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 +2 -2
- package/dist/agent-hooks/notis-agent-hook.mjs +616 -198
- package/dist/base-skills/notis-apps/SKILL.md +101 -37
- package/dist/base-skills/notis-cli/SKILL.md +80 -22
- package/package.json +1 -1
- package/skills/notis-apps/cli.md +2 -2
- package/src/command-specs/apps.js +183 -70
- package/src/runtime/app-dev-process-identity.js +111 -0
- package/src/runtime/app-dev-server.js +236 -8
- package/src/runtime/app-platform.js +23 -1
- package/src/runtime/profiles.js +19 -11
- package/src/runtime/sync-skills.js +20 -4
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +7 -1
- package/template/packages/sdk/src/config.ts +6 -0
- package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +23 -6
- package/template/packages/sdk/src/hooks/useNotis.ts +3 -0
- package/template/packages/sdk/src/hooks/useNotisNavigation.ts +7 -4
- package/template/packages/sdk/src/interactions/shortcuts.tsx +7 -1
- package/template/packages/sdk/src/runtime.ts +3 -0
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { randomUUID } from 'node:crypto';
|
|
11
|
-
import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
|
11
|
+
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
12
12
|
import { tmpdir } from 'node:os';
|
|
13
13
|
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
14
14
|
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
normalizeAppCapabilities,
|
|
37
37
|
normalizeAppToolBindings,
|
|
38
38
|
normalizeAppSkillManifestPath,
|
|
39
|
+
appRowFieldsFromManifest,
|
|
39
40
|
directDeploy,
|
|
40
41
|
pullAppSource,
|
|
41
42
|
} from '../runtime/app-platform.js';
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
scaffoldRegistryLabel,
|
|
46
47
|
} from '../runtime/app-registry-scaffolds.js';
|
|
47
48
|
import { startAppDevServer } from '../runtime/app-dev-server.js';
|
|
49
|
+
import { captureDesktopHostOwnership } from '../runtime/app-dev-process-identity.js';
|
|
48
50
|
import {
|
|
49
51
|
discoverRegisteredAppProjects,
|
|
50
52
|
readAppDevRoots,
|
|
@@ -87,6 +89,8 @@ import {
|
|
|
87
89
|
toolConflictToError,
|
|
88
90
|
} from './helpers.js';
|
|
89
91
|
|
|
92
|
+
export { appRowFieldsFromManifest } from '../runtime/app-platform.js';
|
|
93
|
+
|
|
90
94
|
const DEFAULT_DEV_PORT = 5173;
|
|
91
95
|
const DEV_HEARTBEAT_INTERVAL_MS = 10_000;
|
|
92
96
|
const DEV_CONSUMER_HEARTBEAT_INTERVAL_MS = 3_000;
|
|
@@ -98,6 +102,72 @@ function projectIsWithinRoot(projectDir, rootDir) {
|
|
|
98
102
|
return nested === '' || (nested !== '..' && !nested.startsWith(`..${sep}`) && !isAbsolute(nested));
|
|
99
103
|
}
|
|
100
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
|
+
|
|
101
171
|
/**
|
|
102
172
|
* A CLI launch may add one root, but the shared host always serves the complete
|
|
103
173
|
* machine registry. Desktop sets skipRootRegistration because it is reconciling
|
|
@@ -410,13 +480,6 @@ export function screenshotIndexByRouteSlug(manifest) {
|
|
|
410
480
|
return new Map(routes.map((route, index) => [route.slug, index + 1]));
|
|
411
481
|
}
|
|
412
482
|
|
|
413
|
-
export function appRowFieldsFromManifest(manifest) {
|
|
414
|
-
const app = manifest?.app && typeof manifest.app === 'object' ? manifest.app : {};
|
|
415
|
-
return {
|
|
416
|
-
accent: app.accent ?? null,
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
|
|
420
483
|
export function screenshotExitCode(failedCount) {
|
|
421
484
|
return failedCount === 0 ? EXIT_CODES.ok : EXIT_CODES.unexpected;
|
|
422
485
|
}
|
|
@@ -570,7 +633,7 @@ function renderVerifyReport({ summary, results, noBrowser }) {
|
|
|
570
633
|
return lines.join('\n');
|
|
571
634
|
}
|
|
572
635
|
|
|
573
|
-
function buildManifestForDev(appConfig) {
|
|
636
|
+
function buildManifestForDev(appConfig, projectDir) {
|
|
574
637
|
const routes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
|
|
575
638
|
return {
|
|
576
639
|
version: 1,
|
|
@@ -579,6 +642,7 @@ function buildManifestForDev(appConfig) {
|
|
|
579
642
|
name: appConfig.name,
|
|
580
643
|
description: appConfig.description || null,
|
|
581
644
|
icon: appConfig.icon || null,
|
|
645
|
+
release_version: readLocalNotisAppVersion(projectDir),
|
|
582
646
|
},
|
|
583
647
|
routes: routes.map((route) => ({
|
|
584
648
|
path: route.path,
|
|
@@ -587,6 +651,7 @@ function buildManifestForDev(appConfig) {
|
|
|
587
651
|
icon: route.icon || null,
|
|
588
652
|
parentSlug: route.parentSlug || null,
|
|
589
653
|
default: route.default || false,
|
|
654
|
+
resourceDeepLinks: route.resourceDeepLinks === true,
|
|
590
655
|
export_name: route.exportName || route.export_name,
|
|
591
656
|
collection: route.collection || null,
|
|
592
657
|
})),
|
|
@@ -726,7 +791,7 @@ export async function ensureDevInstall({
|
|
|
726
791
|
throw usageError(`notis.config.ts devSlug or name in ${projectDir} must slugify to a non-empty value.`);
|
|
727
792
|
}
|
|
728
793
|
|
|
729
|
-
const manifest = buildManifestForDev(appConfig);
|
|
794
|
+
const manifest = buildManifestForDev(appConfig, projectDir);
|
|
730
795
|
const skills = resolveConfiguredAppSkills(appConfig, projectDir);
|
|
731
796
|
const profileKey = linkedStateProfileKey(ctx.runtime);
|
|
732
797
|
let linkedState = readLinkedState(projectDir, profileKey);
|
|
@@ -801,6 +866,10 @@ export async function ensureDevInstall({
|
|
|
801
866
|
].includes(key)),
|
|
802
867
|
)
|
|
803
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;
|
|
804
873
|
const ensureArguments = buildEnsureDevInstallArguments({
|
|
805
874
|
appConfig,
|
|
806
875
|
manifest,
|
|
@@ -866,6 +935,9 @@ export async function ensureDevInstall({
|
|
|
866
935
|
linkedAppId: runtimeLinkedState?.app_id || null,
|
|
867
936
|
targetAppId: runtimeLinkedState?.app_id || null,
|
|
868
937
|
targetAppSlug: linkedApp?.slug || null,
|
|
938
|
+
localReleaseVersion,
|
|
939
|
+
installedReleaseVersion: linkedApp ? installedReleaseVersion : null,
|
|
940
|
+
mountEligible,
|
|
869
941
|
databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
|
|
870
942
|
liveData: ensureResult.payload.live_data || null,
|
|
871
943
|
};
|
|
@@ -892,6 +964,16 @@ function liveDataWarnings(apps) {
|
|
|
892
964
|
.map((app) => `${app.name}: ${app.liveData.warning}`);
|
|
893
965
|
}
|
|
894
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
|
+
|
|
895
977
|
async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
|
|
896
978
|
const result = await runTool({
|
|
897
979
|
runtime,
|
|
@@ -1305,6 +1387,7 @@ async function appsDevHandler(ctx) {
|
|
|
1305
1387
|
isBundleReady: () => true,
|
|
1306
1388
|
updateApp: () => {},
|
|
1307
1389
|
waitForBundle: async () => {},
|
|
1390
|
+
getWatcherOwnership: () => null,
|
|
1308
1391
|
}
|
|
1309
1392
|
: await startAppDevServer({
|
|
1310
1393
|
apps: canonicalCandidates.map((app) => ({
|
|
@@ -1320,8 +1403,70 @@ async function appsDevHandler(ctx) {
|
|
|
1320
1403
|
port,
|
|
1321
1404
|
sessionsFilePath,
|
|
1322
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
|
+
};
|
|
1323
1462
|
|
|
1324
|
-
|
|
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(() => {
|
|
1325
1470
|
try {
|
|
1326
1471
|
heartbeatAppDevSession(sessionId, new Date().toISOString(), sessionsFilePath);
|
|
1327
1472
|
} catch (error) {
|
|
@@ -1379,6 +1524,8 @@ async function appsDevHandler(ctx) {
|
|
|
1379
1524
|
sessionId,
|
|
1380
1525
|
hostPid: process.pid,
|
|
1381
1526
|
sourceHost: !sharedBundleBaseUrls,
|
|
1527
|
+
...(desktopHostOwnership || {}),
|
|
1528
|
+
...(devServer.getWatcherOwnership(app.devSlug) || {}),
|
|
1382
1529
|
bundleReady: devServer.isBundleReady(app.devSlug),
|
|
1383
1530
|
...(!sharedBundleBaseUrls ? {
|
|
1384
1531
|
discoveredProjects: discoveredAppDirs,
|
|
@@ -1425,6 +1572,8 @@ async function appsDevHandler(ctx) {
|
|
|
1425
1572
|
}
|
|
1426
1573
|
}
|
|
1427
1574
|
if (apps.length === 0) {
|
|
1575
|
+
process.off('SIGINT', handleSigint);
|
|
1576
|
+
process.off('SIGTERM', handleSigterm);
|
|
1428
1577
|
clearInterval(heartbeatTimer);
|
|
1429
1578
|
heartbeatTimer = null;
|
|
1430
1579
|
try {
|
|
@@ -1456,10 +1605,9 @@ async function appsDevHandler(ctx) {
|
|
|
1456
1605
|
...registrationWarnings,
|
|
1457
1606
|
...databaseMaterializationWarnings(apps),
|
|
1458
1607
|
...liveDataWarnings(apps),
|
|
1608
|
+
...versionPrecedenceWarnings(apps),
|
|
1459
1609
|
];
|
|
1460
1610
|
|
|
1461
|
-
let consumerTimer = null;
|
|
1462
|
-
|
|
1463
1611
|
ctx.output.emitSuccess({
|
|
1464
1612
|
command: ctx.spec.command_path.join(' '),
|
|
1465
1613
|
data: {
|
|
@@ -1481,6 +1629,9 @@ async function appsDevHandler(ctx) {
|
|
|
1481
1629
|
linked_app_id: app.linkedAppId,
|
|
1482
1630
|
database_materialization: app.databaseMaterialization,
|
|
1483
1631
|
live_data: app.liveData,
|
|
1632
|
+
local_release_version: app.localReleaseVersion,
|
|
1633
|
+
installed_release_version: app.installedReleaseVersion,
|
|
1634
|
+
mount_eligible: app.mountEligible,
|
|
1484
1635
|
})),
|
|
1485
1636
|
},
|
|
1486
1637
|
warnings,
|
|
@@ -1492,64 +1643,20 @@ async function appsDevHandler(ctx) {
|
|
|
1492
1643
|
? [`Databases: ${apps.filter((app) => app.liveData?.enabled).length}/${apps.length} app(s) reading the installed app's live rows`]
|
|
1493
1644
|
: []),
|
|
1494
1645
|
'',
|
|
1495
|
-
...apps.map((app) =>
|
|
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
|
+
)),
|
|
1496
1651
|
'',
|
|
1497
1652
|
sharedBundleBaseUrls
|
|
1498
|
-
? `
|
|
1499
|
-
: `Serving one shared loopback host for ${apps.length} app${apps.length === 1 ? '' : 's'}.`,
|
|
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.`,
|
|
1500
1655
|
'',
|
|
1501
1656
|
'Press Ctrl-C to stop.',
|
|
1502
1657
|
].join('\n'),
|
|
1503
1658
|
});
|
|
1504
1659
|
|
|
1505
|
-
let shuttingDown = false;
|
|
1506
|
-
const shutdown = async (signal) => {
|
|
1507
|
-
if (shuttingDown) return;
|
|
1508
|
-
shuttingDown = true;
|
|
1509
|
-
process.stdout.write(`\n[notis apps dev] stopping (${signal})...\n`);
|
|
1510
|
-
if (heartbeatTimer) {
|
|
1511
|
-
clearInterval(heartbeatTimer);
|
|
1512
|
-
heartbeatTimer = null;
|
|
1513
|
-
}
|
|
1514
|
-
if (consumerTimer) {
|
|
1515
|
-
clearInterval(consumerTimer);
|
|
1516
|
-
consumerTimer = null;
|
|
1517
|
-
}
|
|
1518
|
-
if (manualConsumerTimer) {
|
|
1519
|
-
clearInterval(manualConsumerTimer);
|
|
1520
|
-
manualConsumerTimer = null;
|
|
1521
|
-
}
|
|
1522
|
-
if (manualConsumerInstanceId) {
|
|
1523
|
-
try {
|
|
1524
|
-
removeAppDevConsumer(manualConsumerInstanceId);
|
|
1525
|
-
} catch {
|
|
1526
|
-
// A crashed CLI lease expires automatically after the heartbeat window.
|
|
1527
|
-
}
|
|
1528
|
-
}
|
|
1529
|
-
try {
|
|
1530
|
-
removeAppDevSession(sessionId, sessionsFilePath);
|
|
1531
|
-
} catch {
|
|
1532
|
-
// ignore cleanup failures during shutdown
|
|
1533
|
-
}
|
|
1534
|
-
try {
|
|
1535
|
-
await devServer.close();
|
|
1536
|
-
} catch {
|
|
1537
|
-
// ignore cleanup failures during shutdown
|
|
1538
|
-
}
|
|
1539
|
-
if (sourceHostLock) {
|
|
1540
|
-
releaseAppDevHostLock(sourceHostLock);
|
|
1541
|
-
sourceHostLock = null;
|
|
1542
|
-
}
|
|
1543
|
-
process.exit(EXIT_CODES.ok);
|
|
1544
|
-
};
|
|
1545
|
-
|
|
1546
|
-
process.on('SIGINT', () => {
|
|
1547
|
-
void shutdown('SIGINT');
|
|
1548
|
-
});
|
|
1549
|
-
process.on('SIGTERM', () => {
|
|
1550
|
-
void shutdown('SIGTERM');
|
|
1551
|
-
});
|
|
1552
|
-
|
|
1553
1660
|
if (consumerMode === 'machine' || consumerMode === 'environment') {
|
|
1554
1661
|
consumerTimer = setInterval(() => {
|
|
1555
1662
|
if (!hasAppDevConsumer(readAppDevConsumers(), {
|
|
@@ -2114,8 +2221,10 @@ async function appsPullHandler(ctx) {
|
|
|
2114
2221
|
const appId = ctx.args.appId;
|
|
2115
2222
|
const result = await runToolCommand({
|
|
2116
2223
|
runtime: ctx.runtime,
|
|
2117
|
-
|
|
2118
|
-
|
|
2224
|
+
// Pull is source retrieval plus local link state. LIST_APPS is deliberately
|
|
2225
|
+
// non-materializing; GET_APP hydrates missing declared databases and would
|
|
2226
|
+
// turn a read-only pull into a remote mutation before build/verification.
|
|
2227
|
+
toolName: LIST_APPS_TOOL,
|
|
2119
2228
|
});
|
|
2120
2229
|
if (
|
|
2121
2230
|
ctx.runtime.credentialKind === 'oauth'
|
|
@@ -2123,7 +2232,11 @@ async function appsPullHandler(ctx) {
|
|
|
2123
2232
|
) {
|
|
2124
2233
|
throw usageError('Pulling app source requires a current OAuth grant. Run `notis login` and retry.');
|
|
2125
2234
|
}
|
|
2126
|
-
const
|
|
2235
|
+
const apps = Array.isArray(result.payload?.apps) ? result.payload.apps : [];
|
|
2236
|
+
const app = apps.find((candidate) => (candidate?.app_id || candidate?.id) === appId);
|
|
2237
|
+
if (!app) {
|
|
2238
|
+
throw usageError(`App ${appId} is not accessible to the active profile.`);
|
|
2239
|
+
}
|
|
2127
2240
|
const defaultDir = slugify(app.slug) || slugify(app.name) || slugify(appId);
|
|
2128
2241
|
const targetDir = ctx.args.dir
|
|
2129
2242
|
? resolveProjectDir(ctx.args.dir)
|
|
@@ -2148,7 +2261,7 @@ async function appsPullHandler(ctx) {
|
|
|
2148
2261
|
project_dir: pulled.projectDir,
|
|
2149
2262
|
version: pulled.version,
|
|
2150
2263
|
},
|
|
2151
|
-
humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}.
|
|
2264
|
+
humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}. Increment package.json notisAppVersion above the pulled release, then run \`cd ${pulled.projectDir} && npm install && notis apps dev\` to substitute the online bundle.`,
|
|
2152
2265
|
});
|
|
2153
2266
|
}
|
|
2154
2267
|
|
|
@@ -2599,7 +2712,7 @@ export const appsCommandSpecs = [
|
|
|
2599
2712
|
command_path: ['apps', 'dev'],
|
|
2600
2713
|
summary: 'Register a development root and connect its apps to the shared local development host.',
|
|
2601
2714
|
when_to_use:
|
|
2602
|
-
'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.',
|
|
2715
|
+
'Run this once for any folder that should be watched permanently. The folder itself, direct child apps, and apps/* are discovered automatically by every signed-in Notis Desktop instance. A linked app substitutes its online bundle only when local notisAppVersion is strictly greater than the installed release.',
|
|
2603
2716
|
args_schema: {
|
|
2604
2717
|
arguments: [
|
|
2605
2718
|
{ token: '[dir]', key: 'dir', description: 'Project directory or monorepo root (default: current dir).' },
|
|
@@ -2775,7 +2888,7 @@ export const appsCommandSpecs = [
|
|
|
2775
2888
|
command_path: ['apps', 'pull'],
|
|
2776
2889
|
summary: 'Download a Notis app source snapshot into a local project folder.',
|
|
2777
2890
|
when_to_use:
|
|
2778
|
-
'Edit an installed app locally.
|
|
2891
|
+
'Edit an installed app locally. Preserve any local edits, pull and link the latest persisted source, then increment package.json notisAppVersion above that release before notis apps dev; continue with build and deploy.',
|
|
2779
2892
|
args_schema: {
|
|
2780
2893
|
arguments: [
|
|
2781
2894
|
{ token: '<app-id>', description: 'Remote app ID to pull.' },
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readlinkSync, realpathSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
export const NOTIS_APP_BUILD_COMMAND_FINGERPRINT = 'npm:run-build:watch:v1';
|
|
5
|
+
export const NOTIS_APPS_DEV_HOST_COMMAND_FINGERPRINT = 'notis:apps-dev:v1';
|
|
6
|
+
|
|
7
|
+
function normalizePath(value) {
|
|
8
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
9
|
+
try {
|
|
10
|
+
return realpathSync(value.trim());
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isExpectedNotisBuildCommand(command) {
|
|
17
|
+
if (typeof command !== 'string') return false;
|
|
18
|
+
return /(?:^|[/\s])npm(?:\s|$)/.test(command)
|
|
19
|
+
&& /\brun\s+build\b/.test(command)
|
|
20
|
+
&& /(?:^|\s)--watch(?:\s|$)/.test(command);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isExpectedNotisAppsDevHostCommand(command) {
|
|
24
|
+
return typeof command === 'string'
|
|
25
|
+
&& /(?:notis(?:\.js)?|@notis_ai[/\\]cli)/.test(command)
|
|
26
|
+
&& /\bapps\s+dev\b/.test(command);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readDarwinProcessCwd(pid, execute) {
|
|
30
|
+
const output = execute('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], {
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
33
|
+
});
|
|
34
|
+
const line = output.split('\n').find((entry) => entry.startsWith('n'));
|
|
35
|
+
return line ? line.slice(1) : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function inspectAppDevWatcherProcess(pid, {
|
|
39
|
+
platform = process.platform,
|
|
40
|
+
execute = execFileSync,
|
|
41
|
+
readLink = readlinkSync,
|
|
42
|
+
} = {}) {
|
|
43
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || platform === 'win32') return null;
|
|
44
|
+
try {
|
|
45
|
+
const ps = (field) => execute('ps', ['-o', `${field}=`, '-p', String(pid)], {
|
|
46
|
+
encoding: 'utf8',
|
|
47
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
48
|
+
}).trim();
|
|
49
|
+
const processGroupPid = Number.parseInt(ps('pgid'), 10);
|
|
50
|
+
const startIdentity = ps('lstart').replace(/\s+/g, ' ').trim();
|
|
51
|
+
const command = ps('command');
|
|
52
|
+
const cwd = platform === 'linux'
|
|
53
|
+
? readLink(`/proc/${pid}/cwd`)
|
|
54
|
+
: readDarwinProcessCwd(pid, execute);
|
|
55
|
+
const projectDir = normalizePath(cwd);
|
|
56
|
+
if (!Number.isSafeInteger(processGroupPid) || processGroupPid <= 0) return null;
|
|
57
|
+
if (!startIdentity || !command || !projectDir) return null;
|
|
58
|
+
return { pid, processGroupPid, startIdentity, command, projectDir };
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function captureDesktopWatcherOwnership({
|
|
65
|
+
pid,
|
|
66
|
+
projectDir,
|
|
67
|
+
desktopOwnerId,
|
|
68
|
+
desktopOwnerScope,
|
|
69
|
+
inspect = inspectAppDevWatcherProcess,
|
|
70
|
+
} = {}) {
|
|
71
|
+
const owner = typeof desktopOwnerId === 'string' ? desktopOwnerId.trim() : '';
|
|
72
|
+
const ownerScope = typeof desktopOwnerScope === 'string' ? desktopOwnerScope.trim() : '';
|
|
73
|
+
const expectedProjectDir = normalizePath(projectDir);
|
|
74
|
+
if (!owner || !ownerScope || !expectedProjectDir) return null;
|
|
75
|
+
const identity = inspect(pid);
|
|
76
|
+
if (
|
|
77
|
+
!identity
|
|
78
|
+
|| identity.processGroupPid !== pid
|
|
79
|
+
|| identity.projectDir !== expectedProjectDir
|
|
80
|
+
|| !isExpectedNotisBuildCommand(identity.command)
|
|
81
|
+
) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
desktopOwnerId: owner,
|
|
86
|
+
desktopOwnerScope: ownerScope,
|
|
87
|
+
watcherProcessGroupPid: identity.processGroupPid,
|
|
88
|
+
watcherStartIdentity: identity.startIdentity,
|
|
89
|
+
watcherProjectDir: identity.projectDir,
|
|
90
|
+
watcherCommandFingerprint: NOTIS_APP_BUILD_COMMAND_FINGERPRINT,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function captureDesktopHostOwnership({
|
|
95
|
+
pid = process.pid,
|
|
96
|
+
desktopOwnerId,
|
|
97
|
+
desktopOwnerScope,
|
|
98
|
+
inspect = inspectAppDevWatcherProcess,
|
|
99
|
+
} = {}) {
|
|
100
|
+
const owner = typeof desktopOwnerId === 'string' ? desktopOwnerId.trim() : '';
|
|
101
|
+
const ownerScope = typeof desktopOwnerScope === 'string' ? desktopOwnerScope.trim() : '';
|
|
102
|
+
if (!owner || !ownerScope) return null;
|
|
103
|
+
const identity = inspect(pid);
|
|
104
|
+
if (!identity || !isExpectedNotisAppsDevHostCommand(identity.command)) return null;
|
|
105
|
+
return {
|
|
106
|
+
desktopOwnerId: owner,
|
|
107
|
+
desktopOwnerScope: ownerScope,
|
|
108
|
+
desktopHostStartIdentity: identity.startIdentity,
|
|
109
|
+
desktopHostCommandFingerprint: NOTIS_APPS_DEV_HOST_COMMAND_FINGERPRINT,
|
|
110
|
+
};
|
|
111
|
+
}
|