@notis_ai/cli 0.2.13 → 0.2.14
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 +45 -3
- package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +40 -2
- package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +21 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useHandover.ts +75 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/index.ts +17 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/runtime.ts +132 -1
- package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +40 -2
- package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +21 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useHandover.ts +75 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +17 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +132 -1
- package/dist/scaffolds/notis-journal/src/mock-runtime.ts +2 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +40 -2
- package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +21 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useHandover.ts +75 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +17 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +132 -1
- package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +40 -2
- package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +21 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useHandover.ts +75 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +17 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +132 -1
- package/package.json +1 -1
- package/skills/notis-apps/SKILL.md +11 -7
- package/skills/notis-apps/cli.md +8 -3
- package/src/command-specs/apps.js +238 -50
- package/src/command-specs/handover.js +374 -0
- package/src/command-specs/index.js +3 -0
- package/src/command-specs/tools.js +6 -0
- package/src/runtime/app-dev-server.js +17 -8
- package/src/runtime/app-platform.js +218 -6
- package/src/runtime/delegated-context.js +68 -0
- package/src/runtime/git.js +233 -0
- package/src/runtime/transport.js +19 -2
- package/template/.harness/index.html.tmpl +116 -47
- package/template/packages/sdk/src/config.ts +52 -0
- package/template/packages/sdk/src/documents.ts +21 -0
- package/template/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
- package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/template/packages/sdk/src/hooks/useHandover.ts +75 -0
- package/template/packages/sdk/src/index.ts +17 -0
- package/template/packages/sdk/src/runtime.ts +132 -1
- package/template/metadata/screenshot-1.png +0 -0
- package/template/metadata/screenshot-2.png +0 -0
- package/template/metadata/screenshot-3.png +0 -0
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
10
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
11
11
|
import { createRequire } from 'node:module';
|
|
12
12
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
@@ -15,7 +15,7 @@ import { gunzipSync } from 'node:zlib';
|
|
|
15
15
|
|
|
16
16
|
import { usageError } from './errors.js';
|
|
17
17
|
import { validateArtifactBoundary, validateProjectBoundary } from './app-boundary-validator.js';
|
|
18
|
-
import { readAppChangelog } from './app-changelog.js';
|
|
18
|
+
import { CHANGELOG_MERGE_DATE, readAppChangelog } from './app-changelog.js';
|
|
19
19
|
|
|
20
20
|
const NOTIS_DIR = '.notis';
|
|
21
21
|
const STATE_FILE = join(NOTIS_DIR, 'state.json');
|
|
@@ -44,6 +44,9 @@ const SOURCE_COPY_EXCLUDES = new Set([
|
|
|
44
44
|
'dist',
|
|
45
45
|
'tsconfig.tsbuildinfo',
|
|
46
46
|
'.DS_Store',
|
|
47
|
+
// Interpreter droppings: a stray `python -m py_compile` in a skill's
|
|
48
|
+
// scripts/ directory must not ship version-specific bytecode in the bundle.
|
|
49
|
+
'__pycache__',
|
|
47
50
|
]);
|
|
48
51
|
const SCAFFOLD_COPY_EXCLUDES = new Set([
|
|
49
52
|
...SOURCE_COPY_EXCLUDES,
|
|
@@ -51,6 +54,17 @@ const SCAFFOLD_COPY_EXCLUDES = new Set([
|
|
|
51
54
|
'.next',
|
|
52
55
|
'.turbo',
|
|
53
56
|
]);
|
|
57
|
+
// Listing media describes the scaffold's own Store entry, so a new project must
|
|
58
|
+
// never inherit it. Scaffold packaging still ships these files -- only the
|
|
59
|
+
// `apps init` copy drops them. Screenshots only: `screenshot-fixtures.json` is
|
|
60
|
+
// not listing media, it is the stub data the dev harness serves, and dropping
|
|
61
|
+
// it would make every route of a fresh project render its empty state.
|
|
62
|
+
const SCAFFOLD_LISTING_MEDIA = /^metadata\/screenshot-\d+\.png$/i;
|
|
63
|
+
// A directory-declared skill ships every supporting file to the sandbox, so it
|
|
64
|
+
// needs a ceiling of its own. Kept well above the 512 KB SKILL.md limit so a
|
|
65
|
+
// handful of scripts always fits, and far below the bundle machinery's own
|
|
66
|
+
// limits so an accidental asset dump fails on the client with a clear message.
|
|
67
|
+
export const MAX_APP_SKILL_BUNDLE_BYTES = 5 * 1024 * 1024;
|
|
54
68
|
let appConfigImportNonce = 0;
|
|
55
69
|
|
|
56
70
|
// ---------------------------------------------------------------------------
|
|
@@ -427,6 +441,39 @@ export function resolveListingScreenshots(projectDir, appConfig = null) {
|
|
|
427
441
|
});
|
|
428
442
|
}
|
|
429
443
|
|
|
444
|
+
/**
|
|
445
|
+
* Screenshot scenarios named in notis.config.ts that metadata/screenshot-fixtures.json
|
|
446
|
+
* does not define. A missing scenario is silent at capture time -- the harness
|
|
447
|
+
* simply falls back to the default fixtures -- so it is reported as a warning.
|
|
448
|
+
*/
|
|
449
|
+
export function findUnknownScreenshotScenarios(projectDir, screenshots = []) {
|
|
450
|
+
const named = screenshots.filter((entry) => entry?.scenario);
|
|
451
|
+
if (named.length === 0) {
|
|
452
|
+
return [];
|
|
453
|
+
}
|
|
454
|
+
const fixturesPath = join(projectDir, METADATA_DIR, 'screenshot-fixtures.json');
|
|
455
|
+
if (!existsSync(fixturesPath)) {
|
|
456
|
+
return ['metadata/screenshot-fixtures.json is missing, so screenshot scenarios cannot be applied.'];
|
|
457
|
+
}
|
|
458
|
+
let defined;
|
|
459
|
+
try {
|
|
460
|
+
const parsed = JSON.parse(readFileSync(fixturesPath, 'utf-8'));
|
|
461
|
+
const scenarios = parsed?.scenarios && typeof parsed.scenarios === 'object' ? parsed.scenarios : {};
|
|
462
|
+
defined = new Set(Object.keys(scenarios));
|
|
463
|
+
} catch {
|
|
464
|
+
return ['metadata/screenshot-fixtures.json is not valid JSON, so screenshot scenarios cannot be applied.'];
|
|
465
|
+
}
|
|
466
|
+
const warnings = [];
|
|
467
|
+
for (const entry of named) {
|
|
468
|
+
if (!defined.has(entry.scenario)) {
|
|
469
|
+
warnings.push(
|
|
470
|
+
`${entry.path} names scenario "${entry.scenario}", which metadata/screenshot-fixtures.json does not define.`,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return warnings;
|
|
475
|
+
}
|
|
476
|
+
|
|
430
477
|
export function inspectListingReadiness(projectDir, appConfig = null) {
|
|
431
478
|
const config = appConfig || {};
|
|
432
479
|
const warnings = [];
|
|
@@ -738,7 +785,7 @@ export function generateManifest(appConfig, projectDir) {
|
|
|
738
785
|
const displayTitle = appConfig.title || appConfig.displayName || appConfig.name;
|
|
739
786
|
const skills = (Array.isArray(appConfig.skills) ? appConfig.skills : []).map((skill) => ({
|
|
740
787
|
key: skill.key,
|
|
741
|
-
path:
|
|
788
|
+
path: normalizeAppSkillManifestPath(skill.path),
|
|
742
789
|
name: skill.name,
|
|
743
790
|
description: skill.description || null,
|
|
744
791
|
}));
|
|
@@ -830,12 +877,54 @@ export function normalizeAppCapabilities(capabilities) {
|
|
|
830
877
|
if (capabilities.workspaceDatabases === 'read') {
|
|
831
878
|
normalized.workspaceDatabases = 'read';
|
|
832
879
|
}
|
|
880
|
+
if (capabilities.cloudComputer === 'read' || capabilities.cloudComputer === 'shell') {
|
|
881
|
+
normalized.cloudComputer = capabilities.cloudComputer;
|
|
882
|
+
}
|
|
833
883
|
return normalized;
|
|
834
884
|
}
|
|
835
885
|
|
|
886
|
+
/**
|
|
887
|
+
* Manifest form of a declared skill path: no leading `./`, no trailing slash.
|
|
888
|
+
* A directory declaration is the same string as the source-tree prefix the
|
|
889
|
+
* server matches the uploaded source files against.
|
|
890
|
+
*/
|
|
891
|
+
export function normalizeAppSkillManifestPath(sourcePath) {
|
|
892
|
+
return String(sourcePath || '').replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/\/+$/, '');
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Every packageable file under a declared skill directory, relative to that
|
|
897
|
+
* directory, in stable order. Excludes match `readSourceFiles` so the files a
|
|
898
|
+
* dev session sends inline are exactly the ones a deploy uploads as source.
|
|
899
|
+
*/
|
|
900
|
+
function readAppSkillDirectoryFiles(skillDir) {
|
|
901
|
+
const entries = [];
|
|
902
|
+
|
|
903
|
+
function walk(dir, prefix) {
|
|
904
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
905
|
+
if (shouldExcludeSourceEntry(entry.name) || entry.isSymbolicLink()) {
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
const fullPath = join(dir, entry.name);
|
|
909
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
910
|
+
if (entry.isDirectory()) {
|
|
911
|
+
walk(fullPath, relPath);
|
|
912
|
+
} else if (entry.isFile()) {
|
|
913
|
+
entries.push({ path: relPath, absolutePath: fullPath });
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
walk(skillDir, '');
|
|
919
|
+
// Byte order, not locale order: the server sorts the same file set the same
|
|
920
|
+
// way before hashing it, so a dev session and a deploy agree on the hash.
|
|
921
|
+
return entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
922
|
+
}
|
|
923
|
+
|
|
836
924
|
export function resolveConfiguredAppSkills(appConfig, projectDir) {
|
|
837
925
|
const configured = Array.isArray(appConfig.skills) ? appConfig.skills : [];
|
|
838
926
|
const projectRoot = resolve(projectDir);
|
|
927
|
+
const realProjectRoot = realpathSync(projectRoot);
|
|
839
928
|
const seenKeys = new Set();
|
|
840
929
|
|
|
841
930
|
return configured.map((skill, index) => {
|
|
@@ -855,7 +944,49 @@ export function resolveConfiguredAppSkills(appConfig, projectDir) {
|
|
|
855
944
|
if (!relativePath || relativePath.startsWith('../') || relativePath === '..') {
|
|
856
945
|
throw usageError(`App skill path must stay inside the project: ${sourcePath}`);
|
|
857
946
|
}
|
|
858
|
-
if (!existsSync(absolutePath)
|
|
947
|
+
if (!existsSync(absolutePath)) {
|
|
948
|
+
throw usageError(`App skill entrypoint not found: ${sourcePath}`);
|
|
949
|
+
}
|
|
950
|
+
if (lstatSync(absolutePath).isSymbolicLink()) {
|
|
951
|
+
throw usageError(`App skill entrypoint cannot be a symbolic link: ${sourcePath}`);
|
|
952
|
+
}
|
|
953
|
+
const realAbsolutePath = realpathSync(absolutePath);
|
|
954
|
+
const realRelativePath = relative(realProjectRoot, realAbsolutePath).replace(/\\/g, '/');
|
|
955
|
+
if (!realRelativePath || realRelativePath.startsWith('../') || realRelativePath === '..') {
|
|
956
|
+
throw usageError(`App skill path must stay inside the project after resolving links: ${sourcePath}`);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const description = typeof skill.description === 'string' ? skill.description.trim() : null;
|
|
960
|
+
const stats = statSync(absolutePath);
|
|
961
|
+
if (stats.isDirectory()) {
|
|
962
|
+
const entries = readAppSkillDirectoryFiles(absolutePath);
|
|
963
|
+
if (!entries.some((entry) => entry.path === 'SKILL.md')) {
|
|
964
|
+
throw usageError(`App skill directory must contain SKILL.md: ${sourcePath}`);
|
|
965
|
+
}
|
|
966
|
+
const bundleFiles = entries.map((entry) => ({
|
|
967
|
+
path: entry.path,
|
|
968
|
+
content: readFileSync(entry.absolutePath),
|
|
969
|
+
}));
|
|
970
|
+
const totalBytes = bundleFiles.reduce((total, entry) => total + entry.content.length, 0);
|
|
971
|
+
if (totalBytes > MAX_APP_SKILL_BUNDLE_BYTES) {
|
|
972
|
+
throw usageError(
|
|
973
|
+
`App skill "${key}" bundles ${totalBytes} bytes, above the ${MAX_APP_SKILL_BUNDLE_BYTES} byte limit.`,
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
const skillMd = bundleFiles.find((entry) => entry.path === 'SKILL.md');
|
|
977
|
+
return {
|
|
978
|
+
key,
|
|
979
|
+
path: relativePath,
|
|
980
|
+
name,
|
|
981
|
+
description,
|
|
982
|
+
skill_md: skillMd.content.toString('utf8'),
|
|
983
|
+
bundle_files: bundleFiles.map((entry) => ({
|
|
984
|
+
path: entry.path,
|
|
985
|
+
content_b64: entry.content.toString('base64'),
|
|
986
|
+
})),
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
if (!stats.isFile()) {
|
|
859
990
|
throw usageError(`App skill entrypoint not found: ${sourcePath}`);
|
|
860
991
|
}
|
|
861
992
|
|
|
@@ -863,7 +994,7 @@ export function resolveConfiguredAppSkills(appConfig, projectDir) {
|
|
|
863
994
|
key,
|
|
864
995
|
path: relativePath,
|
|
865
996
|
name,
|
|
866
|
-
description
|
|
997
|
+
description,
|
|
867
998
|
skill_md: readFileSync(absolutePath, 'utf8'),
|
|
868
999
|
};
|
|
869
1000
|
});
|
|
@@ -1024,12 +1155,85 @@ export function scaffoldProject({ projectDir, appName, fromSlug = null }) {
|
|
|
1024
1155
|
config = config.replace(/'My Notis App'/, displayName);
|
|
1025
1156
|
}
|
|
1026
1157
|
}
|
|
1158
|
+
config = removeConfigArrayProperty(config, 'screenshots');
|
|
1027
1159
|
writeFileSync(configPath, config);
|
|
1028
1160
|
}
|
|
1029
1161
|
|
|
1162
|
+
resetScaffoldChangelog(projectDir, appName);
|
|
1163
|
+
|
|
1030
1164
|
return { projectDir };
|
|
1031
1165
|
}
|
|
1032
1166
|
|
|
1167
|
+
/**
|
|
1168
|
+
* Drop `property: [ ... ]` from a notis.config.ts source.
|
|
1169
|
+
*
|
|
1170
|
+
* The scan tracks bracket depth while skipping string literals, so entries
|
|
1171
|
+
* whose text contains a bracket (alt text, selectors) cannot end the array
|
|
1172
|
+
* early. When the array cannot be resolved the source is returned untouched --
|
|
1173
|
+
* a stale screenshots list is a warning at verify time, a broken config is not.
|
|
1174
|
+
*/
|
|
1175
|
+
function removeConfigArrayProperty(source, property) {
|
|
1176
|
+
const start = source.search(new RegExp(`^[ \\t]*${property}[ \\t]*:[ \\t]*\\[`, 'm'));
|
|
1177
|
+
if (start === -1) {
|
|
1178
|
+
return source;
|
|
1179
|
+
}
|
|
1180
|
+
let index = source.indexOf('[', start);
|
|
1181
|
+
let depth = 0;
|
|
1182
|
+
let quote = null;
|
|
1183
|
+
for (; index < source.length; index += 1) {
|
|
1184
|
+
const char = source[index];
|
|
1185
|
+
if (quote) {
|
|
1186
|
+
if (char === '\\') {
|
|
1187
|
+
index += 1;
|
|
1188
|
+
} else if (char === quote) {
|
|
1189
|
+
quote = null;
|
|
1190
|
+
}
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
1193
|
+
if (char === '\'' || char === '"' || char === '`') {
|
|
1194
|
+
quote = char;
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
if (char === '[') {
|
|
1198
|
+
depth += 1;
|
|
1199
|
+
} else if (char === ']') {
|
|
1200
|
+
depth -= 1;
|
|
1201
|
+
if (depth === 0) {
|
|
1202
|
+
break;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
if (depth !== 0) {
|
|
1207
|
+
return source;
|
|
1208
|
+
}
|
|
1209
|
+
let end = index + 1;
|
|
1210
|
+
if (source[end] === ',') {
|
|
1211
|
+
end += 1;
|
|
1212
|
+
}
|
|
1213
|
+
while (end < source.length && (source[end] === ' ' || source[end] === '\t')) {
|
|
1214
|
+
end += 1;
|
|
1215
|
+
}
|
|
1216
|
+
if (source[end] === '\n') {
|
|
1217
|
+
end += 1;
|
|
1218
|
+
}
|
|
1219
|
+
return source.slice(0, start) + source.slice(end);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* A new project starts its own release history: the scaffold's entries describe
|
|
1224
|
+
* releases of a different app.
|
|
1225
|
+
*/
|
|
1226
|
+
function resetScaffoldChangelog(projectDir, appName) {
|
|
1227
|
+
const changelogPath = join(projectDir, 'CHANGELOG.md');
|
|
1228
|
+
if (!existsSync(changelogPath)) {
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
writeFileSync(
|
|
1232
|
+
changelogPath,
|
|
1233
|
+
`# ${appName} Changelog\n\n## [Initial Release] - ${CHANGELOG_MERGE_DATE}\n\n- First Store release.\n`,
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1033
1237
|
function normalizeScaffoldLockfile(projectDir, pkg) {
|
|
1034
1238
|
const lockPath = join(projectDir, 'package-lock.json');
|
|
1035
1239
|
if (!existsSync(lockPath)) {
|
|
@@ -1126,6 +1330,9 @@ function copyScaffoldSource(sourceDir, targetDir) {
|
|
|
1126
1330
|
function shouldCopy(path) {
|
|
1127
1331
|
const name = path.split(/[\\/]/).pop();
|
|
1128
1332
|
if (!name) return true;
|
|
1333
|
+
if (SCAFFOLD_LISTING_MEDIA.test(relative(sourceDir, path).replace(/\\/g, '/'))) {
|
|
1334
|
+
return false;
|
|
1335
|
+
}
|
|
1129
1336
|
return !SCAFFOLD_COPY_EXCLUDES.has(name)
|
|
1130
1337
|
&& !name.startsWith('.env')
|
|
1131
1338
|
&& !/\.(test|spec)\.[cm]?[jt]sx?$/i.test(name);
|
|
@@ -1187,7 +1394,12 @@ export function collectArtifactFiles(projectDir) {
|
|
|
1187
1394
|
}
|
|
1188
1395
|
|
|
1189
1396
|
function shouldExcludeSourceEntry(name) {
|
|
1190
|
-
return
|
|
1397
|
+
return (
|
|
1398
|
+
SOURCE_COPY_EXCLUDES.has(name)
|
|
1399
|
+
|| name.startsWith('.env')
|
|
1400
|
+
|| name.endsWith('.pyc')
|
|
1401
|
+
|| name.endsWith('.pyo')
|
|
1402
|
+
);
|
|
1191
1403
|
}
|
|
1192
1404
|
|
|
1193
1405
|
function readSourceFiles(projectDir) {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect that this CLI is running *as* a Notis agent rather than for a person.
|
|
3
|
+
*
|
|
4
|
+
* Hand-over is the one command where that distinction matters: an agent Notis
|
|
5
|
+
* is already running has no business handing the same work back to Notis, and
|
|
6
|
+
* nothing about the loop it would create is self-limiting.
|
|
7
|
+
*
|
|
8
|
+
* This is the polite half of the guard. The server refuses the same call unless
|
|
9
|
+
* it carries a purpose-scoped CLI/MCP OAuth token, which a delegated runtime
|
|
10
|
+
* never has -- that is the half that actually holds. What this adds is a clear
|
|
11
|
+
* message at the point of the mistake, instead of a permission error the agent
|
|
12
|
+
* will try to work around.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { CliError, EXIT_CODES } from './errors.js';
|
|
17
|
+
|
|
18
|
+
// The cloud computer's root. Present only inside the user's Vercel sandbox.
|
|
19
|
+
const SANDBOX_ROOT = '/vercel/sandbox';
|
|
20
|
+
|
|
21
|
+
export function delegatedContextReason(env = process.env, { fileExists = existsSync } = {}) {
|
|
22
|
+
if (env.NOTIS_DELEGATED_CONTEXT === '1') {
|
|
23
|
+
// Set by Notis Desktop on every coding agent it spawns. Those runs use the
|
|
24
|
+
// user's own OAuth profile, so nothing else distinguishes them.
|
|
25
|
+
return 'this process was started by Notis as a delegated coding agent';
|
|
26
|
+
}
|
|
27
|
+
if (fileExists(SANDBOX_ROOT)) {
|
|
28
|
+
return 'this process is running on the Notis cloud computer';
|
|
29
|
+
}
|
|
30
|
+
if (env.NOTIS_AGENT === '1' && env.NOTIS_JWT) {
|
|
31
|
+
return 'this process is authenticated as a Notis agent, not as you';
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function assertNotDelegated(commandLabel, env = process.env) {
|
|
37
|
+
const reason = delegatedContextReason(env);
|
|
38
|
+
if (!reason) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
throw new CliError({
|
|
42
|
+
code: 'handover_from_delegated_context',
|
|
43
|
+
message:
|
|
44
|
+
`\`notis ${commandLabel}\` hands work to a Notis agent, and ${reason}. ` +
|
|
45
|
+
'An agent cannot hand its own task back to Notis.',
|
|
46
|
+
exitCode: EXIT_CODES.usage,
|
|
47
|
+
hints: [
|
|
48
|
+
{ message: 'Run the hand-over from the terminal on your own machine.' },
|
|
49
|
+
{ message: 'If you are the agent: just do the work here, in this workspace.' },
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Tools that hand work to a Notis agent, by canonical name.
|
|
56
|
+
*
|
|
57
|
+
* `notis handover` is not the only way to reach these: `notis tools exec` takes
|
|
58
|
+
* any tool name, and `tools exec-parallel` takes a list of them. Guarding only
|
|
59
|
+
* the friendly command would leave the escape hatch it exists to wrap.
|
|
60
|
+
*/
|
|
61
|
+
export const HANDOVER_TOOL_NAMES = new Set(['LOCAL_NOTIS_HAND_OVER']);
|
|
62
|
+
|
|
63
|
+
export function assertToolNotDelegated(toolName, env = process.env) {
|
|
64
|
+
if (!HANDOVER_TOOL_NAMES.has(String(toolName || '').toUpperCase())) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
assertNotDelegated(`tools exec ${toolName}`, env);
|
|
68
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local git inspection for `notis handover`.
|
|
3
|
+
*
|
|
4
|
+
* The CLI has no other reason to know about git, so this stays deliberately
|
|
5
|
+
* small: read where we are, push the branch, and report what the remote is.
|
|
6
|
+
* Everything else about the repository is the cloud workspace's problem.
|
|
7
|
+
*
|
|
8
|
+
* Every call is `spawnSync` on the real `git` binary rather than a library.
|
|
9
|
+
* The user's credentials, hooks, SSH agent and includeIf config are what make a
|
|
10
|
+
* push succeed on their machine, and only their own git honors all of them.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { lstatSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { basename, relative, resolve, sep } from 'node:path';
|
|
16
|
+
import { CliError, EXIT_CODES } from './errors.js';
|
|
17
|
+
|
|
18
|
+
const GIT_TIMEOUT_MS = 120_000;
|
|
19
|
+
const MAX_SECRET_SCAN_BYTES = 1_000_000;
|
|
20
|
+
const SAFE_ENV_TEMPLATES = /^\.env\.(?:example|sample|template)$/i;
|
|
21
|
+
const SENSITIVE_PATH = /^(?:\.env(?:\..+)?|\.envrc|\.git-credentials|\.npmrc|\.pypirc|\.netrc|credentials(?:\..+)?|secrets?(?:\..+)?|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|.*\.(?:pem|key|p12|pfx|jks))$/i;
|
|
22
|
+
const SENSITIVE_CONTENT = [
|
|
23
|
+
/-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/,
|
|
24
|
+
/\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/i,
|
|
25
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
26
|
+
/\bgh[pousr]_[A-Za-z0-9]{36,}\b/,
|
|
27
|
+
/\bsk-[A-Za-z0-9_-]{20,}\b/,
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export function runGit(
|
|
31
|
+
args,
|
|
32
|
+
{ cwd = process.cwd(), timeoutMs = GIT_TIMEOUT_MS, trimOutput = true } = {},
|
|
33
|
+
) {
|
|
34
|
+
const result = spawnSync('git', args, {
|
|
35
|
+
cwd,
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
timeout: timeoutMs,
|
|
38
|
+
env: {
|
|
39
|
+
...process.env,
|
|
40
|
+
// A push that stops to ask for a password would hang a non-interactive
|
|
41
|
+
// agent run forever. Fail fast instead and let the hint explain.
|
|
42
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
if (result.error && result.error.code === 'ENOENT') {
|
|
46
|
+
throw new CliError({
|
|
47
|
+
code: 'git_not_found',
|
|
48
|
+
message: 'git is not installed or not on PATH.',
|
|
49
|
+
exitCode: EXIT_CODES.usage,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
exitCode: result.status ?? 1,
|
|
54
|
+
stdout: trimOutput ? (result.stdout || '').trim() : (result.stdout || ''),
|
|
55
|
+
stderr: trimOutput ? (result.stderr || '').trim() : (result.stderr || ''),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function gitOrNull(args, options) {
|
|
60
|
+
const result = runGit(args, options);
|
|
61
|
+
return result.exitCode === 0 ? result.stdout : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function nulSeparatedGitPaths(args, repository) {
|
|
65
|
+
const result = runGit([...args, '-z'], { cwd: repository.toplevel, trimOutput: false });
|
|
66
|
+
if (result.exitCode !== 0 || !result.stdout) return [];
|
|
67
|
+
return result.stdout.split('\0').filter(Boolean);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Exact files `git add -A` would include, expanding untracked directories. */
|
|
71
|
+
function filesToAutoCommit(repository) {
|
|
72
|
+
return [...new Set([
|
|
73
|
+
...nulSeparatedGitPaths(['diff', '--name-only'], repository),
|
|
74
|
+
...nulSeparatedGitPaths(['diff', '--cached', '--name-only'], repository),
|
|
75
|
+
...nulSeparatedGitPaths(['ls-files', '--others', '--exclude-standard'], repository),
|
|
76
|
+
])];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Refuse files that are unsafe to publish automatically without user review. */
|
|
80
|
+
export function sensitiveAutoCommitFiles(repository) {
|
|
81
|
+
const root = resolve(repository.toplevel);
|
|
82
|
+
const sensitive = [];
|
|
83
|
+
for (const path of filesToAutoCommit(repository)) {
|
|
84
|
+
const absolute = resolve(root, path);
|
|
85
|
+
const insideRoot = relative(root, absolute);
|
|
86
|
+
if (insideRoot === '..' || insideRoot.startsWith(`..${sep}`) || insideRoot === '') continue;
|
|
87
|
+
const name = basename(path);
|
|
88
|
+
if (SENSITIVE_PATH.test(name) && !SAFE_ENV_TEMPLATES.test(name)) {
|
|
89
|
+
sensitive.push(path);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const stat = lstatSync(absolute);
|
|
94
|
+
if (!stat.isFile() || stat.size > MAX_SECRET_SCAN_BYTES) continue;
|
|
95
|
+
const content = readFileSync(absolute, 'utf8');
|
|
96
|
+
if (SENSITIVE_CONTENT.some((pattern) => pattern.test(content))) sensitive.push(path);
|
|
97
|
+
} catch {
|
|
98
|
+
// Deleted files and paths racing with an editor have no content to leak.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return sensitive;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parse an origin URL into {owner, repo}. Handles the three forms git remotes
|
|
106
|
+
* actually take: scp-style ssh, ssh:// and https://.
|
|
107
|
+
*/
|
|
108
|
+
export function parseRemoteUrl(url) {
|
|
109
|
+
if (typeof url !== 'string' || !url) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const trimmed = url.trim().replace(/\.git$/, '');
|
|
113
|
+
const scp = trimmed.match(/^[^@]+@([^:]+):(.+)$/);
|
|
114
|
+
const path = scp ? scp[2] : trimmed.replace(/^[a-z+]+:\/\/(?:[^@/]+@)?[^/]+\//i, '');
|
|
115
|
+
const host = scp ? scp[1] : (trimmed.match(/^[a-z+]+:\/\/(?:[^@/]+@)?([^/]+)/i) || [])[1];
|
|
116
|
+
const segments = path.split('/').filter(Boolean);
|
|
117
|
+
if (segments.length < 2) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
host: host || null,
|
|
122
|
+
owner: segments[segments.length - 2],
|
|
123
|
+
repo: segments[segments.length - 1],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Everything `handover start` needs to know about where it is running. */
|
|
128
|
+
export function inspectRepository(cwd = process.cwd()) {
|
|
129
|
+
const toplevel = gitOrNull(['rev-parse', '--show-toplevel'], { cwd });
|
|
130
|
+
if (!toplevel) {
|
|
131
|
+
throw new CliError({
|
|
132
|
+
code: 'not_a_git_repository',
|
|
133
|
+
message: 'Hand-over runs from inside a git repository.',
|
|
134
|
+
exitCode: EXIT_CODES.usage,
|
|
135
|
+
hints: [{ message: 'cd into the repository you want Notis to work on, then run the command again.' }],
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const branch = gitOrNull(['branch', '--show-current'], { cwd: toplevel });
|
|
140
|
+
if (!branch) {
|
|
141
|
+
throw new CliError({
|
|
142
|
+
code: 'detached_head',
|
|
143
|
+
message: 'HEAD is detached, so there is no branch to hand over.',
|
|
144
|
+
exitCode: EXIT_CODES.usage,
|
|
145
|
+
hints: [{ message: 'Check out a branch first: git switch -c my-feature' }],
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const remoteUrl = gitOrNull(['remote', 'get-url', 'origin'], { cwd: toplevel });
|
|
150
|
+
if (!remoteUrl) {
|
|
151
|
+
throw new CliError({
|
|
152
|
+
code: 'no_origin_remote',
|
|
153
|
+
message: 'This repository has no "origin" remote, so the cloud agent cannot fetch the branch.',
|
|
154
|
+
exitCode: EXIT_CODES.usage,
|
|
155
|
+
hints: [{ message: 'Add one: git remote add origin <url>' }],
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const status = runGit(['status', '--porcelain'], { cwd: toplevel });
|
|
160
|
+
const dirtyFiles = status.stdout
|
|
161
|
+
? status.stdout.split('\n').map((line) => line.slice(3).trim()).filter(Boolean)
|
|
162
|
+
: [];
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
toplevel,
|
|
166
|
+
branch,
|
|
167
|
+
remoteUrl,
|
|
168
|
+
remote: parseRemoteUrl(remoteUrl),
|
|
169
|
+
dirtyFiles,
|
|
170
|
+
head: gitOrNull(['rev-parse', 'HEAD'], { cwd: toplevel }),
|
|
171
|
+
upstream: gitOrNull(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], {
|
|
172
|
+
cwd: toplevel,
|
|
173
|
+
}),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Commit everything in the working tree, including untracked files. */
|
|
178
|
+
export function commitWorkingTree(repository, message) {
|
|
179
|
+
const sensitiveFiles = sensitiveAutoCommitFiles(repository);
|
|
180
|
+
if (sensitiveFiles.length) {
|
|
181
|
+
throw new CliError({
|
|
182
|
+
code: 'sensitive_working_tree',
|
|
183
|
+
message: 'Refusing to publish files that may contain credentials or private keys.',
|
|
184
|
+
exitCode: EXIT_CODES.conflict,
|
|
185
|
+
details: { sensitive_files: sensitiveFiles.slice(0, 20) },
|
|
186
|
+
hints: [
|
|
187
|
+
{ message: 'Review, remove, or ignore these files before handing over.' },
|
|
188
|
+
{ message: 'To publish them deliberately, commit and push them yourself first.' },
|
|
189
|
+
],
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const add = runGit(['add', '-A'], { cwd: repository.toplevel });
|
|
193
|
+
if (add.exitCode !== 0) {
|
|
194
|
+
throw new CliError({
|
|
195
|
+
code: 'git_add_failed',
|
|
196
|
+
message: `Could not stage the working tree: ${add.stderr || add.stdout}`,
|
|
197
|
+
exitCode: EXIT_CODES.unexpected,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
const commit = runGit(['commit', '-m', message], { cwd: repository.toplevel });
|
|
201
|
+
if (commit.exitCode !== 0) {
|
|
202
|
+
throw new CliError({
|
|
203
|
+
code: 'git_commit_failed',
|
|
204
|
+
message: `Could not commit the working tree: ${commit.stderr || commit.stdout}`,
|
|
205
|
+
exitCode: EXIT_CODES.unexpected,
|
|
206
|
+
hints: [{ message: 'Commit the changes yourself, then run the hand-over again.' }],
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return gitOrNull(['rev-parse', 'HEAD'], { cwd: repository.toplevel });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Push the branch to origin. The cloud workspace fetches from origin, so an
|
|
214
|
+
* unpushed commit simply does not exist as far as the hand-over is concerned.
|
|
215
|
+
*/
|
|
216
|
+
export function pushBranch(repository, branch) {
|
|
217
|
+
const args = repository.upstream
|
|
218
|
+
? ['push', 'origin', branch]
|
|
219
|
+
: ['push', '--set-upstream', 'origin', branch];
|
|
220
|
+
const result = runGit(args, { cwd: repository.toplevel });
|
|
221
|
+
if (result.exitCode !== 0) {
|
|
222
|
+
throw new CliError({
|
|
223
|
+
code: 'git_push_failed',
|
|
224
|
+
message: `Could not push ${branch} to origin: ${result.stderr || result.stdout}`,
|
|
225
|
+
exitCode: EXIT_CODES.conflict,
|
|
226
|
+
hints: [
|
|
227
|
+
{ message: 'The cloud agent works from origin, so the branch has to be pushed first.' },
|
|
228
|
+
{ message: 'If the remote moved ahead, reconcile locally (git pull --rebase) and retry.' },
|
|
229
|
+
],
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return true;
|
|
233
|
+
}
|
package/src/runtime/transport.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { createReadStream } from 'node:fs';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
4
|
import { CliError, EXIT_CODES } from './errors.js';
|
|
5
|
+
import { delegatedContextReason } from './delegated-context.js';
|
|
5
6
|
import {
|
|
6
7
|
credentialIsExpired,
|
|
7
8
|
getJwtExpiration,
|
|
@@ -327,9 +328,11 @@ export async function httpRequest({
|
|
|
327
328
|
});
|
|
328
329
|
|
|
329
330
|
let payload = null;
|
|
331
|
+
let payloadReadError = null;
|
|
330
332
|
try {
|
|
331
333
|
payload = await response.json();
|
|
332
|
-
} catch {
|
|
334
|
+
} catch (error) {
|
|
335
|
+
payloadReadError = error;
|
|
333
336
|
payload = null;
|
|
334
337
|
}
|
|
335
338
|
|
|
@@ -350,9 +353,11 @@ export async function httpRequest({
|
|
|
350
353
|
signal: controller.signal,
|
|
351
354
|
...(requestBody.duplex ? { duplex: requestBody.duplex } : {}),
|
|
352
355
|
});
|
|
356
|
+
payloadReadError = null;
|
|
353
357
|
try {
|
|
354
358
|
payload = await response.json();
|
|
355
|
-
} catch {
|
|
359
|
+
} catch (error) {
|
|
360
|
+
payloadReadError = error;
|
|
356
361
|
payload = null;
|
|
357
362
|
}
|
|
358
363
|
}
|
|
@@ -376,6 +381,14 @@ export async function httpRequest({
|
|
|
376
381
|
throw normalizeBackendError(response.status, payload, runtime);
|
|
377
382
|
}
|
|
378
383
|
|
|
384
|
+
// A successful status is not a successful tool call until its response
|
|
385
|
+
// body has been read. Mutations may already have committed before a socket
|
|
386
|
+
// reset truncates the JSON; surface that as an ambiguous network failure
|
|
387
|
+
// so callers never persist an undefined result or attempt a second write.
|
|
388
|
+
if (response.status !== 204 && payloadReadError) {
|
|
389
|
+
throw payloadReadError;
|
|
390
|
+
}
|
|
391
|
+
|
|
379
392
|
return {
|
|
380
393
|
requestId,
|
|
381
394
|
payload: payload || {},
|
|
@@ -428,6 +441,10 @@ export async function callTool({
|
|
|
428
441
|
cwd: process.cwd(),
|
|
429
442
|
agent_mode: runtime.agentMode,
|
|
430
443
|
cli_version: runtime.cliVersion,
|
|
444
|
+
// Reported so the server can refuse work-handing tools from a run Notis
|
|
445
|
+
// is itself driving. Advisory -- a determined agent can unset the markers
|
|
446
|
+
// -- so the server treats it as one layer, not the whole guard.
|
|
447
|
+
...(delegatedContextReason() ? { delegated_context: true } : {}),
|
|
431
448
|
...(runtime.debugEntitlementOverride
|
|
432
449
|
? { debug_entitlement_override: runtime.debugEntitlementOverride }
|
|
433
450
|
: {}),
|