@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.
Files changed (60) hide show
  1. package/README.md +56 -3
  2. package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +40 -2
  3. package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +21 -0
  4. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  5. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  6. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useHandover.ts +75 -0
  7. package/dist/scaffolds/notis-database/packages/sdk/src/index.ts +17 -0
  8. package/dist/scaffolds/notis-database/packages/sdk/src/runtime.ts +132 -1
  9. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +40 -2
  10. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +21 -0
  11. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  12. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  13. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useHandover.ts +75 -0
  14. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +17 -0
  15. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +132 -1
  16. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +2 -0
  17. package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +40 -2
  18. package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +21 -0
  19. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  20. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  21. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useHandover.ts +75 -0
  22. package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +17 -0
  23. package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +132 -1
  24. package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +40 -2
  25. package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +21 -0
  26. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  27. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  28. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useHandover.ts +75 -0
  29. package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +17 -0
  30. package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +132 -1
  31. package/package.json +1 -1
  32. package/skills/notis-apps/SKILL.md +11 -7
  33. package/skills/notis-apps/cli.md +8 -3
  34. package/skills/notis-cli/SKILL.md +2 -0
  35. package/src/cli.js +158 -0
  36. package/src/command-specs/apps.js +238 -50
  37. package/src/command-specs/handover.js +374 -0
  38. package/src/command-specs/index.js +3 -0
  39. package/src/command-specs/meta.js +53 -0
  40. package/src/command-specs/tools.js +6 -0
  41. package/src/runtime/app-dev-server.js +17 -8
  42. package/src/runtime/app-platform.js +218 -6
  43. package/src/runtime/auth-recovery.js +13 -3
  44. package/src/runtime/channel.js +133 -0
  45. package/src/runtime/delegated-context.js +68 -0
  46. package/src/runtime/git.js +233 -0
  47. package/src/runtime/oauth.js +36 -4
  48. package/src/runtime/profiles.js +17 -1
  49. package/src/runtime/transport.js +19 -2
  50. package/template/.harness/index.html.tmpl +116 -47
  51. package/template/packages/sdk/src/config.ts +52 -0
  52. package/template/packages/sdk/src/documents.ts +21 -0
  53. package/template/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  54. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  55. package/template/packages/sdk/src/hooks/useHandover.ts +75 -0
  56. package/template/packages/sdk/src/index.ts +17 -0
  57. package/template/packages/sdk/src/runtime.ts +132 -1
  58. package/template/metadata/screenshot-1.png +0 -0
  59. package/template/metadata/screenshot-2.png +0 -0
  60. 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: String(skill.path || '').replace(/^\.\/+/, ''),
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) || !statSync(absolutePath).isFile()) {
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: typeof skill.description === 'string' ? skill.description.trim() : null,
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 SOURCE_COPY_EXCLUDES.has(name) || name.startsWith('.env');
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
- const CLI_NPX = 'npx --package @notis_ai/cli@latest -- notis';
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({ profileName } = {}, { mode = 'expired' } = {}) {
23
- const suffix = profileSuffix(profileName);
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
+ }