@notis_ai/cli 0.2.12 → 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 +56 -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/skills/notis-cli/SKILL.md +2 -0
- package/src/cli.js +158 -0
- 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/meta.js +53 -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/auth-recovery.js +13 -3
- package/src/runtime/channel.js +133 -0
- package/src/runtime/delegated-context.js +68 -0
- package/src/runtime/git.js +233 -0
- package/src/runtime/oauth.js +36 -4
- package/src/runtime/profiles.js +17 -1
- 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) {
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { CliError, EXIT_CODES } from './errors.js';
|
|
2
|
+
import { channelFromProfile, cliCommandForChannel } from './channel.js';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
// Recovery is only useful when the printed command is the one that will run:
|
|
5
|
+
// telling a beta profile to reinstall `@latest` sends it back to the build it
|
|
6
|
+
// just failed on.
|
|
7
|
+
function cliNpx(runtime = {}) {
|
|
8
|
+
return cliCommandForChannel(
|
|
9
|
+
runtime.channel || channelFromProfile({ api_base: runtime.apiBase }),
|
|
10
|
+
);
|
|
11
|
+
}
|
|
4
12
|
|
|
5
13
|
export function quoteShellArgument(value) {
|
|
6
14
|
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
|
@@ -19,8 +27,9 @@ function profileSuffix(profileName) {
|
|
|
19
27
|
* "expired" means the profile holds a grant the browser can renew, while
|
|
20
28
|
* "missing" means this profile has never been authorized at all.
|
|
21
29
|
*/
|
|
22
|
-
export function getAuthRecovery(
|
|
23
|
-
const
|
|
30
|
+
export function getAuthRecovery(runtime = {}, { mode = 'expired' } = {}) {
|
|
31
|
+
const CLI_NPX = cliNpx(runtime);
|
|
32
|
+
const suffix = profileSuffix(runtime.profileName);
|
|
24
33
|
const hints = [
|
|
25
34
|
{
|
|
26
35
|
command: `${CLI_NPX} login${suffix}`,
|
|
@@ -41,6 +50,7 @@ export function getAuthRecovery({ profileName } = {}, { mode = 'expired' } = {})
|
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
export function createExpiredAuthError(runtime) {
|
|
53
|
+
const CLI_NPX = cliNpx(runtime || {});
|
|
44
54
|
if (runtime?.credentialKind === 'worktree') {
|
|
45
55
|
return new CliError({
|
|
46
56
|
code: 'auth_expired',
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which published CLI build a profile should run.
|
|
3
|
+
*
|
|
4
|
+
* The npm tag has to be chosen before the CLI starts, and the CLI only learns
|
|
5
|
+
* which Notis it talks to after it reads the profile — so a single documented
|
|
6
|
+
* install command can never be right for both environments on its own. The
|
|
7
|
+
* deployment answers the question at login (`notis_cli_channel` in the CLI
|
|
8
|
+
* protected-resource metadata), the answer is pinned on the profile, and every
|
|
9
|
+
* later run re-executes the matching build. `@notis_ai/cli@latest` therefore
|
|
10
|
+
* stays the one command worth documenting, including for beta accounts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const RELEASE_CHANNELS = ['stable', 'beta'];
|
|
14
|
+
const CHANNEL_TAGS = { stable: 'latest', beta: 'beta' };
|
|
15
|
+
export const CLI_PACKAGE_NAME = '@notis_ai/cli';
|
|
16
|
+
// Set on the child so a build that disagrees about its own channel — a bad
|
|
17
|
+
// version string, a half-published tag — cannot bounce the process forever.
|
|
18
|
+
export const CHANNEL_SWITCH_ENV = 'NOTIS_CLI_CHANNEL_SWITCHED';
|
|
19
|
+
export const CHANNEL_DISABLE_ENV = 'NOTIS_CLI_AUTO_CHANNEL';
|
|
20
|
+
|
|
21
|
+
export function isReleaseChannel(value) {
|
|
22
|
+
return RELEASE_CHANNELS.includes(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function packageTagForChannel(channel) {
|
|
26
|
+
return CHANNEL_TAGS[channel] || CHANNEL_TAGS.stable;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function cliCommandForChannel(channel) {
|
|
30
|
+
return `npx --package ${CLI_PACKAGE_NAME}@${packageTagForChannel(channel)} -- notis`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The channel of the build that is currently running.
|
|
35
|
+
*
|
|
36
|
+
* The publish pipeline stamps beta releases as prereleases
|
|
37
|
+
* (`0.2.0-beta.129.1`) and production releases as plain semver (`0.2.10`), so
|
|
38
|
+
* the manifest version is the only channel marker that cannot drift from what
|
|
39
|
+
* npm actually served.
|
|
40
|
+
*/
|
|
41
|
+
export function channelFromVersion(version) {
|
|
42
|
+
return String(version || '').includes('-') ? 'beta' : 'stable';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The channel a profile is pinned to.
|
|
47
|
+
*
|
|
48
|
+
* `channel` is written at login from the deployment's own metadata. The
|
|
49
|
+
* `beta` flag and the endpoint host are the fallbacks that let a profile
|
|
50
|
+
* authorized by an older CLI resolve without a second login.
|
|
51
|
+
*/
|
|
52
|
+
export function channelFromProfile(profile = {}) {
|
|
53
|
+
if (isReleaseChannel(profile.channel)) {
|
|
54
|
+
return profile.channel;
|
|
55
|
+
}
|
|
56
|
+
if (profile.beta === true) return 'beta';
|
|
57
|
+
if (profile.beta === false) return 'stable';
|
|
58
|
+
for (const candidate of [profile.oauth_api_base, profile.api_base]) {
|
|
59
|
+
if (typeof candidate !== 'string' || !candidate) continue;
|
|
60
|
+
try {
|
|
61
|
+
const { hostname } = new URL(candidate);
|
|
62
|
+
if (hostname === 'api-beta.notis.ai') return 'beta';
|
|
63
|
+
if (hostname === 'api.notis.ai') return 'stable';
|
|
64
|
+
} catch {
|
|
65
|
+
// A malformed endpoint says nothing about the channel.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A profile served by `./dev.sh` runs the CLI from that worktree on purpose.
|
|
73
|
+
* Re-executing it into a published build would swap both the code under test
|
|
74
|
+
* and the credential the worktree minted.
|
|
75
|
+
*/
|
|
76
|
+
export function isDevManagedProfile(profile = {}) {
|
|
77
|
+
if (profile.dev_access_token || profile.dev_workspace_root) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
if (typeof profile.api_base !== 'string' || !profile.api_base) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const { hostname } = new URL(profile.api_base);
|
|
85
|
+
return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(hostname);
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A checkout run through `node bin/notis.js` is someone testing this source
|
|
93
|
+
* tree. Only an installed copy — one that npm placed under node_modules — may
|
|
94
|
+
* hand its invocation to a different published build.
|
|
95
|
+
*/
|
|
96
|
+
export function isInstalledPackage(moduleDirectory) {
|
|
97
|
+
return String(moduleDirectory || '').split(/[\\/]/).includes('node_modules');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Decide whether this process should hand over to another published build.
|
|
102
|
+
*
|
|
103
|
+
* Returns the reason in every case: the caller reports it under `--verbose`
|
|
104
|
+
* and the tests assert on it, so a switch that silently does not happen is
|
|
105
|
+
* still explainable.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveChannelSwitch({
|
|
108
|
+
runningVersion,
|
|
109
|
+
profile = {},
|
|
110
|
+
moduleDirectory = '',
|
|
111
|
+
env = process.env,
|
|
112
|
+
platform = process.platform,
|
|
113
|
+
} = {}) {
|
|
114
|
+
const runningChannel = channelFromVersion(runningVersion);
|
|
115
|
+
const targetChannel = channelFromProfile(profile);
|
|
116
|
+
const stay = (reason) => ({ switch: false, reason, runningChannel, targetChannel });
|
|
117
|
+
|
|
118
|
+
if (env[CHANNEL_SWITCH_ENV] === '1') return stay('already_switched');
|
|
119
|
+
if (env[CHANNEL_DISABLE_ENV] === '0') return stay('disabled');
|
|
120
|
+
if (!targetChannel) return stay('profile_channel_unknown');
|
|
121
|
+
if (targetChannel === runningChannel) return stay('channel_matches');
|
|
122
|
+
if (isDevManagedProfile(profile)) return stay('dev_managed_profile');
|
|
123
|
+
if (!isInstalledPackage(moduleDirectory)) return stay('source_checkout');
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
switch: true,
|
|
127
|
+
reason: 'channel_mismatch',
|
|
128
|
+
runningChannel,
|
|
129
|
+
targetChannel,
|
|
130
|
+
command: platform === 'win32' ? 'npx.cmd' : 'npx',
|
|
131
|
+
args: ['--yes', '--package', `${CLI_PACKAGE_NAME}@${packageTagForChannel(targetChannel)}`, '--', 'notis'],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -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
|
+
}
|