@notis_ai/cli 0.2.7 → 0.2.9

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 (183) hide show
  1. package/README.md +56 -2
  2. package/dist/scaffolds/notis-database/app/page.tsx +38 -41
  3. package/dist/scaffolds/notis-database/lib/types.ts +5 -7
  4. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  5. package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
  6. package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
  7. package/dist/scaffolds/notis-database/metadata/screenshot-4.png +0 -0
  8. package/dist/scaffolds/notis-database/metadata/screenshot-5.png +0 -0
  9. package/dist/scaffolds/notis-database/metadata/screenshot-fixtures.json +1839 -0
  10. package/dist/scaffolds/notis-database/notis.config.ts +48 -1
  11. package/dist/scaffolds/notis-database/package-lock.json +3935 -0
  12. package/dist/scaffolds/notis-database/package.json +2 -1
  13. package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/package.json +4 -0
  14. package/dist/scaffolds/notis-database/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  15. package/dist/scaffolds/notis-database/packages/sdk/src/components/Markdown.tsx +60 -0
  16. package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/components/MultiSelectActionBar.tsx +7 -2
  17. package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +196 -0
  18. package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +229 -0
  19. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  20. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocument.ts +78 -0
  21. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocuments.ts +121 -0
  22. package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useMultiSelect.ts +38 -2
  23. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  24. package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/index.ts +30 -1
  25. package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/runtime.ts +76 -17
  26. package/dist/scaffolds/notis-database/packages/sdk/src/styles.css +186 -0
  27. package/dist/scaffolds/notis-database/src/mock-runtime.ts +4 -1
  28. package/dist/scaffolds/notis-journal/CHANGELOG.md +4 -0
  29. package/dist/scaffolds/notis-journal/app/page.tsx +1 -0
  30. package/dist/scaffolds/notis-journal/package-lock.json +4615 -0
  31. package/dist/scaffolds/notis-journal/package.json +2 -2
  32. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +52 -1
  33. package/dist/scaffolds/notis-notes/app/page.tsx +39 -46
  34. package/dist/scaffolds/notis-notes/app/phosphor-icons.ts +596 -0
  35. package/dist/scaffolds/notis-notes/lib/visible-properties.ts +144 -0
  36. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  37. package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
  38. package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
  39. package/dist/scaffolds/notis-notes/metadata/screenshot-4.png +0 -0
  40. package/dist/scaffolds/notis-notes/metadata/screenshot-5.png +0 -0
  41. package/dist/scaffolds/notis-notes/metadata/screenshot-fixtures.json +752 -0
  42. package/dist/scaffolds/notis-notes/notis.config.ts +48 -3
  43. package/dist/scaffolds/notis-notes/package-lock.json +4636 -0
  44. package/dist/scaffolds/notis-notes/package.json +7 -3
  45. package/dist/scaffolds/notis-notes/packages/sdk/package.json +36 -0
  46. package/dist/scaffolds/notis-notes/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  47. package/dist/scaffolds/notis-notes/packages/sdk/src/components/Markdown.tsx +60 -0
  48. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
  49. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  50. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  51. package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +196 -0
  52. package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +229 -0
  53. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useBackend.ts +41 -0
  54. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  55. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocument.ts +78 -0
  56. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocuments.ts +121 -0
  57. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
  58. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotis.ts +34 -0
  59. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  60. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTool.ts +64 -0
  61. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTools.ts +56 -0
  62. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  63. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  64. package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +83 -0
  65. package/dist/scaffolds/notis-notes/packages/sdk/src/provider.tsx +43 -0
  66. package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +220 -0
  67. package/dist/scaffolds/notis-notes/packages/sdk/src/styles.css +186 -0
  68. package/dist/scaffolds/notis-notes/packages/sdk/src/ui.ts +15 -0
  69. package/dist/scaffolds/notis-notes/packages/sdk/src/vite.ts +56 -0
  70. package/dist/scaffolds/notis-notes/packages/sdk/tsconfig.json +15 -0
  71. package/dist/scaffolds/notis-random/CHANGELOG.md +12 -2
  72. package/dist/scaffolds/notis-random/app/history/page.tsx +5 -4
  73. package/dist/scaffolds/notis-random/app/page.tsx +141 -74
  74. package/dist/scaffolds/notis-random/components/ui/button.tsx +50 -0
  75. package/dist/scaffolds/notis-random/components/ui/card.tsx +16 -0
  76. package/dist/scaffolds/notis-random/components/ui/input.tsx +23 -0
  77. package/dist/scaffolds/notis-random/lib/notis-tools.ts +26 -7
  78. package/dist/scaffolds/notis-random/lib/rng.ts +179 -19
  79. package/dist/scaffolds/notis-random/lib/roll-record.ts +103 -16
  80. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  81. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  82. package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
  83. package/dist/scaffolds/notis-random/metadata/screenshot-4.png +0 -0
  84. package/dist/scaffolds/notis-random/metadata/screenshot-5.png +0 -0
  85. package/dist/scaffolds/notis-random/metadata/screenshot-fixtures.json +753 -0
  86. package/dist/scaffolds/notis-random/notis.config.ts +47 -3
  87. package/dist/scaffolds/notis-random/package-lock.json +4513 -0
  88. package/dist/scaffolds/notis-random/package.json +6 -2
  89. package/dist/scaffolds/notis-random/packages/sdk/package.json +36 -0
  90. package/dist/scaffolds/notis-random/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  91. package/dist/scaffolds/notis-random/packages/sdk/src/components/Markdown.tsx +60 -0
  92. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
  93. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  94. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  95. package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +196 -0
  96. package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +229 -0
  97. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useBackend.ts +41 -0
  98. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  99. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocument.ts +78 -0
  100. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocuments.ts +121 -0
  101. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
  102. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotis.ts +34 -0
  103. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  104. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTool.ts +64 -0
  105. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTools.ts +56 -0
  106. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  107. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  108. package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +83 -0
  109. package/dist/scaffolds/notis-random/packages/sdk/src/provider.tsx +43 -0
  110. package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +220 -0
  111. package/dist/scaffolds/notis-random/packages/sdk/src/styles.css +186 -0
  112. package/dist/scaffolds/notis-random/packages/sdk/src/ui.ts +15 -0
  113. package/dist/scaffolds/notis-random/packages/sdk/src/vite.ts +56 -0
  114. package/dist/scaffolds/notis-random/packages/sdk/tsconfig.json +15 -0
  115. package/dist/scaffolds/notis-random/tailwind.config.ts +8 -1
  116. package/dist/scaffolds.json +8 -17
  117. package/package.json +2 -1
  118. package/skills/notis-apps/SKILL.md +438 -144
  119. package/skills/notis-apps/cli.md +16 -1
  120. package/skills/notis-cli/SKILL.md +11 -4
  121. package/skills/notis-onboarding/BRIEF.md +93 -0
  122. package/skills/notis-query/cli.md +1 -1
  123. package/src/cli.js +50 -12
  124. package/src/command-specs/apps.js +309 -41
  125. package/src/command-specs/auth.js +107 -0
  126. package/src/command-specs/index.js +4 -0
  127. package/src/command-specs/meta.js +68 -6
  128. package/src/command-specs/onboarding.js +304 -0
  129. package/src/command-specs/tools.js +8 -4
  130. package/src/runtime/app-dev-server.js +3 -2
  131. package/src/runtime/app-dev-sessions.js +14 -40
  132. package/src/runtime/app-platform.js +65 -4
  133. package/src/runtime/desktop-auth.js +86 -17
  134. package/src/runtime/errors.js +1 -0
  135. package/src/runtime/oauth.js +1105 -0
  136. package/src/runtime/output.js +16 -2
  137. package/src/runtime/profiles.js +542 -18
  138. package/src/runtime/telemetry.js +92 -0
  139. package/src/runtime/transport.js +66 -11
  140. package/template/.harness/index.html.tmpl +30 -2
  141. package/template/packages/sdk/src/config.ts +38 -1
  142. package/dist/scaffolds/notis-affiliate-prospects/CHANGELOG.md +0 -7
  143. package/dist/scaffolds/notis-affiliate-prospects/README.md +0 -20
  144. package/dist/scaffolds/notis-affiliate-prospects/app/globals.css +0 -42
  145. package/dist/scaffolds/notis-affiliate-prospects/app/layout.tsx +0 -7
  146. package/dist/scaffolds/notis-affiliate-prospects/app/page.tsx +0 -248
  147. package/dist/scaffolds/notis-affiliate-prospects/app/prospects/page.tsx +0 -134
  148. package/dist/scaffolds/notis-affiliate-prospects/app/segments/page.tsx +0 -108
  149. package/dist/scaffolds/notis-affiliate-prospects/components/ui/card.tsx +0 -35
  150. package/dist/scaffolds/notis-affiliate-prospects/index.html +0 -12
  151. package/dist/scaffolds/notis-affiliate-prospects/lib/affiliate-data.ts +0 -218
  152. package/dist/scaffolds/notis-affiliate-prospects/lib/demo-prospects.ts +0 -52
  153. package/dist/scaffolds/notis-affiliate-prospects/lib/notis-tools.ts +0 -100
  154. package/dist/scaffolds/notis-affiliate-prospects/lib/utils.ts +0 -25
  155. package/dist/scaffolds/notis-affiliate-prospects/metadata/screenshot-1.png +0 -0
  156. package/dist/scaffolds/notis-affiliate-prospects/metadata/screenshot-2.png +0 -0
  157. package/dist/scaffolds/notis-affiliate-prospects/metadata/screenshot-3.png +0 -0
  158. package/dist/scaffolds/notis-affiliate-prospects/metadata/screenshot-fixtures.json +0 -187
  159. package/dist/scaffolds/notis-affiliate-prospects/notis.config.ts +0 -65
  160. package/dist/scaffolds/notis-affiliate-prospects/package.json +0 -32
  161. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/config.ts +0 -90
  162. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useDatabase.ts +0 -76
  163. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useUpsertDocument.ts +0 -50
  164. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/styles.css +0 -38
  165. package/dist/scaffolds/notis-affiliate-prospects/postcss.config.mjs +0 -6
  166. package/dist/scaffolds/notis-affiliate-prospects/src/dev-main.tsx +0 -72
  167. package/dist/scaffolds/notis-affiliate-prospects/src/mock-runtime.ts +0 -242
  168. package/dist/scaffolds/notis-affiliate-prospects/tailwind.config.ts +0 -51
  169. package/dist/scaffolds/notis-affiliate-prospects/tsconfig.json +0 -23
  170. package/dist/scaffolds/notis-affiliate-prospects/vite.config.ts +0 -11
  171. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -0
  172. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -0
  173. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useBackend.ts +0 -0
  174. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useNotis.ts +0 -0
  175. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useNotisNavigation.ts +0 -0
  176. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useTool.ts +0 -0
  177. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useTools.ts +0 -0
  178. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useTopBarSearch.ts +0 -0
  179. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/provider.tsx +0 -0
  180. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/ui.ts +0 -0
  181. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/vite.ts +0 -0
  182. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/tsconfig.json +0 -0
  183. /package/dist/scaffolds/{notis-affiliate-prospects → notis-random}/components.json +0 -0
@@ -22,6 +22,13 @@ function formatTable(rows, columns) {
22
22
  return [header, divider, ...body].join('\n');
23
23
  }
24
24
 
25
+ function formatHint(hint) {
26
+ if (typeof hint?.message === 'string' && hint.message) {
27
+ return ` ${hint.message}`;
28
+ }
29
+ return ` ${hint?.command || ''} ${hint?.reason || ''}`.trimEnd();
30
+ }
31
+
25
32
  function yamlScalar(value) {
26
33
  if (value === null || value === undefined) {
27
34
  return 'null';
@@ -94,6 +101,13 @@ export class OutputManager {
94
101
  process.stderr.write(`${message}\n`);
95
102
  }
96
103
 
104
+ note(message) {
105
+ if (this.isMachineMode()) {
106
+ return;
107
+ }
108
+ process.stderr.write(`${message}\n`);
109
+ }
110
+
97
111
  emitProgress({ phase, message, requestId = null }) {
98
112
  if (this.runtime.quiet) {
99
113
  return;
@@ -153,7 +167,7 @@ export class OutputManager {
153
167
  }
154
168
 
155
169
  if (hints.length) {
156
- const lines = hints.map((hint) => ` ${hint.command} ${hint.reason}`);
170
+ const lines = hints.map(formatHint);
157
171
  process.stdout.write(`\nNext:\n${lines.join('\n')}\n`);
158
172
  }
159
173
 
@@ -182,7 +196,7 @@ export class OutputManager {
182
196
 
183
197
  process.stderr.write(`Error: ${error.message}\n`);
184
198
  for (const hint of error.hints || []) {
185
- process.stderr.write(` ${hint.command} ${hint.reason}\n`);
199
+ process.stderr.write(`${formatHint(hint)}\n`);
186
200
  }
187
201
  return error.exitCode || EXIT_CODES.unexpected;
188
202
  }
@@ -1,6 +1,15 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ rmSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
2
11
  import { homedir } from 'node:os';
3
- import { join } from 'node:path';
12
+ import { dirname, join, parse, resolve } from 'node:path';
4
13
  import { CliError, EXIT_CODES } from './errors.js';
5
14
  import { getDesktopAuthRecovery } from './desktop-auth.js';
6
15
 
@@ -9,11 +18,22 @@ export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
9
18
  export const WORKSPACE_DIR = join(CONFIG_DIR, 'workspace');
10
19
  export const DEFAULT_API_BASE = 'https://api.notis.ai';
11
20
  export const DEFAULT_PROFILE = 'default';
21
+ const WORKTREE_RUNTIME_FILENAME = join('.context', 'notis-runtime.json');
22
+ const WORKTREE_ROUTING_FILENAME = join('.context', 'notis-routing.json');
12
23
  const LOCAL_DEFAULT_API_BASES = new Set([
13
24
  'http://localhost:3001',
14
25
  'http://127.0.0.1:3001',
15
26
  ]);
16
27
  const LOCAL_API_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
28
+ // Cross-process write lock over ~/.notis/config.json. Notis Desktop implements
29
+ // the same protocol independently in electron/src/cli-auth.ts (updateConfig);
30
+ // the `${configFile}.write-lock` directory name and these three timings must
31
+ // stay identical on both sides or `notis login` and desktop syncAuth stop
32
+ // excluding each other and clobber stored OAuth tokens. Guarded by the drift
33
+ // test in packages/cli/test/runtime-auth.test.js.
34
+ const CONFIG_WRITE_LOCK_TIMEOUT_MS = 5_000;
35
+ const CONFIG_WRITE_LOCK_STALE_MS = 2_000;
36
+ const CONFIG_WRITE_LOCK_POLL_MS = 10;
17
37
 
18
38
  function clone(value) {
19
39
  return JSON.parse(JSON.stringify(value));
@@ -41,6 +61,28 @@ export function normalizeConfig(rawConfig = {}) {
41
61
  desktop_app_name:
42
62
  typeof profile.desktop_app_name === 'string' ? profile.desktop_app_name : undefined,
43
63
  desktop_pid: typeof profile.desktop_pid === 'number' ? profile.desktop_pid : undefined,
64
+ oauth_access_token:
65
+ typeof profile.oauth_access_token === 'string' ? profile.oauth_access_token : undefined,
66
+ oauth_refresh_token:
67
+ typeof profile.oauth_refresh_token === 'string' ? profile.oauth_refresh_token : undefined,
68
+ oauth_access_expires_at:
69
+ typeof profile.oauth_access_expires_at === 'number' ? profile.oauth_access_expires_at : undefined,
70
+ oauth_refresh_expires_at:
71
+ typeof profile.oauth_refresh_expires_at === 'number' ? profile.oauth_refresh_expires_at : undefined,
72
+ oauth_client_id:
73
+ typeof profile.oauth_client_id === 'string' ? profile.oauth_client_id : undefined,
74
+ oauth_issuer:
75
+ typeof profile.oauth_issuer === 'string' ? profile.oauth_issuer : undefined,
76
+ oauth_api_base:
77
+ typeof profile.oauth_api_base === 'string' ? profile.oauth_api_base : undefined,
78
+ oauth_resource:
79
+ typeof profile.oauth_resource === 'string' ? profile.oauth_resource : undefined,
80
+ oauth_scopes:
81
+ Array.isArray(profile.oauth_scopes)
82
+ ? profile.oauth_scopes.filter((scope) => typeof scope === 'string')
83
+ : undefined,
84
+ oauth_user_id:
85
+ typeof profile.oauth_user_id === 'string' ? profile.oauth_user_id : undefined,
44
86
  };
45
87
  }
46
88
 
@@ -72,22 +114,282 @@ export function normalizeConfig(rawConfig = {}) {
72
114
  desktop_app_name:
73
115
  typeof raw.desktop_app_name === 'string' ? raw.desktop_app_name : undefined,
74
116
  desktop_pid: typeof raw.desktop_pid === 'number' ? raw.desktop_pid : undefined,
117
+ oauth_access_token:
118
+ typeof raw.oauth_access_token === 'string' ? raw.oauth_access_token : undefined,
119
+ oauth_refresh_token:
120
+ typeof raw.oauth_refresh_token === 'string' ? raw.oauth_refresh_token : undefined,
121
+ oauth_access_expires_at:
122
+ typeof raw.oauth_access_expires_at === 'number' ? raw.oauth_access_expires_at : undefined,
123
+ oauth_refresh_expires_at:
124
+ typeof raw.oauth_refresh_expires_at === 'number' ? raw.oauth_refresh_expires_at : undefined,
125
+ oauth_client_id:
126
+ typeof raw.oauth_client_id === 'string' ? raw.oauth_client_id : undefined,
127
+ oauth_issuer:
128
+ typeof raw.oauth_issuer === 'string' ? raw.oauth_issuer : undefined,
129
+ oauth_api_base:
130
+ typeof raw.oauth_api_base === 'string' ? raw.oauth_api_base : undefined,
131
+ oauth_resource:
132
+ typeof raw.oauth_resource === 'string' ? raw.oauth_resource : undefined,
133
+ oauth_scopes:
134
+ Array.isArray(raw.oauth_scopes)
135
+ ? raw.oauth_scopes.filter((scope) => typeof scope === 'string')
136
+ : undefined,
137
+ oauth_user_id:
138
+ typeof raw.oauth_user_id === 'string' ? raw.oauth_user_id : undefined,
75
139
  },
76
140
  },
77
141
  };
78
142
  }
79
143
 
80
- export function loadConfig() {
81
- if (!existsSync(CONFIG_FILE)) {
144
+ function readJsonFile(path) {
145
+ try {
146
+ return JSON.parse(readFileSync(path, 'utf-8'));
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ function findUp(filename, startDir = process.cwd()) {
153
+ let current = resolve(startDir);
154
+ const root = parse(current).root;
155
+ while (true) {
156
+ const candidate = join(current, filename);
157
+ if (existsSync(candidate)) {
158
+ return candidate;
159
+ }
160
+ if (current === root) {
161
+ return null;
162
+ }
163
+ current = dirname(current);
164
+ }
165
+ }
166
+
167
+ function processIsAlive(pid) {
168
+ if (!Number.isInteger(pid) || pid <= 0) {
169
+ return false;
170
+ }
171
+ try {
172
+ process.kill(pid, 0);
173
+ return true;
174
+ } catch {
175
+ return false;
176
+ }
177
+ }
178
+
179
+ export function resolveWorktreeRuntime(startDir = process.cwd()) {
180
+ if (
181
+ process.env.NODE_ENV === 'test' &&
182
+ process.env.NOTIS_TEST_DISABLE_WORKTREE_ROUTING === '1'
183
+ ) {
184
+ return null;
185
+ }
186
+ const runtimePath = findUp(WORKTREE_RUNTIME_FILENAME, startDir);
187
+ const routingPath = findUp(WORKTREE_ROUTING_FILENAME, startDir);
188
+ const routing = routingPath ? readJsonFile(routingPath) : null;
189
+
190
+ if (!runtimePath) {
191
+ if (routing?.mode === 'local-only') {
192
+ throw new CliError({
193
+ code: 'dev_runtime_unavailable',
194
+ message: `This worktree is local-only, but its dev.sh runtime is not active`,
195
+ exitCode: EXIT_CODES.network,
196
+ hints: [
197
+ { message: 'Start ./dev.sh in this worktree, then retry the command.' },
198
+ { message: `Routing policy: ${routingPath}` },
199
+ ],
200
+ });
201
+ }
202
+ return null;
203
+ }
204
+
205
+ const runtime = readJsonFile(runtimePath);
206
+ const apiBase = typeof runtime?.api_base === 'string' ? runtime.api_base.replace(/\/+$/, '') : '';
207
+ const configFile = typeof runtime?.config_file === 'string' ? runtime.config_file : '';
208
+ const appDevSessionsFile =
209
+ typeof runtime?.app_dev_sessions_file === 'string' && runtime.app_dev_sessions_file.trim()
210
+ ? runtime.app_dev_sessions_file.trim()
211
+ : join(dirname(runtimePath), 'app-dev-sessions.json');
212
+ const desktopDeepLinkScheme =
213
+ typeof runtime?.desktop_deep_link_scheme === 'string'
214
+ ? runtime.desktop_deep_link_scheme.trim()
215
+ : '';
216
+ const pid = Number(runtime?.dev_pid);
217
+ if (
218
+ runtime?.mode !== 'local-only' ||
219
+ !isLocalApiBase(apiBase) ||
220
+ !configFile ||
221
+ !processIsAlive(pid)
222
+ ) {
223
+ throw new CliError({
224
+ code: 'dev_runtime_unavailable',
225
+ message: 'The local-only worktree runtime is stale or invalid',
226
+ exitCode: EXIT_CODES.network,
227
+ hints: [
228
+ { message: 'Restart ./dev.sh in this worktree, then retry the command.' },
229
+ { message: `Runtime lease: ${runtimePath}` },
230
+ ],
231
+ });
232
+ }
233
+
234
+ return {
235
+ ...runtime,
236
+ api_base: apiBase,
237
+ config_file: resolve(dirname(runtimePath), configFile),
238
+ app_dev_sessions_file: resolve(dirname(runtimePath), appDevSessionsFile),
239
+ desktop_deep_link_scheme: desktopDeepLinkScheme || undefined,
240
+ runtime_path: runtimePath,
241
+ routing_path: routingPath,
242
+ };
243
+ }
244
+
245
+ export function resolveConfigFile(runtime = null) {
246
+ if (runtime?.config_file) {
247
+ return runtime.config_file;
248
+ }
249
+ const envConfigFile = process.env.NOTIS_CLI_CONFIG_FILE;
250
+ if (envConfigFile) {
251
+ return resolve(envConfigFile);
252
+ }
253
+ return CONFIG_FILE;
254
+ }
255
+
256
+ export function loadConfig(runtime = null) {
257
+ const configFile = resolveConfigFile(runtime);
258
+ if (!existsSync(configFile)) {
259
+ return normalizeConfig({});
260
+ }
261
+
262
+ // The desktop app rewrites this file while the CLI may be reading it, so a
263
+ // torn or corrupt read is expected rather than exceptional. Degrade to
264
+ // "unauthenticated", which surfaces the actionable auth_missing error instead
265
+ // of a raw SyntaxError from deep inside the runtime.
266
+ try {
267
+ return normalizeConfig(JSON.parse(readFileSync(configFile, 'utf-8')));
268
+ } catch {
82
269
  return normalizeConfig({});
83
270
  }
271
+ }
272
+
273
+ function writeConfig(configFile, config) {
274
+ mkdirSync(dirname(configFile), { recursive: true });
275
+ const temporaryFile = `${configFile}.${process.pid}.${Date.now()}.tmp`;
276
+ writeFileSync(temporaryFile, JSON.stringify(normalizeConfig(config), null, 2), { mode: 0o600 });
277
+ renameSync(temporaryFile, configFile);
278
+ }
279
+
280
+ // The lock records who holds it and when they took it. Reclaiming a stale lock
281
+ // by mtime alone is not safe: the reclaimer cannot tell an abandoned lock from
282
+ // one it is about to steal from a live writer, and the victim's own release
283
+ // would then delete the thief's lock and admit a third writer. Both sides are
284
+ // on the same host, so the stored timestamp and Date.now() share a clock.
285
+ function readLockOwner(lockDirectory) {
286
+ try {
287
+ return JSON.parse(readFileSync(join(lockDirectory, 'owner'), 'utf-8'));
288
+ } catch {
289
+ return null;
290
+ }
291
+ }
292
+
293
+ function lockDirectoryMtime(lockDirectory) {
294
+ try {
295
+ return statSync(lockDirectory).mtimeMs;
296
+ } catch {
297
+ return null;
298
+ }
299
+ }
300
+
301
+ function publishConfigWriteLock(lockDirectory, ownerId) {
302
+ const candidateDirectory = `${lockDirectory}.candidate-${ownerId}`;
303
+ try {
304
+ mkdirSync(candidateDirectory);
305
+ writeFileSync(
306
+ join(candidateDirectory, 'owner'),
307
+ JSON.stringify({ id: ownerId, at: Date.now() }),
308
+ { mode: 0o600 },
309
+ );
310
+ try {
311
+ renameSync(candidateDirectory, lockDirectory);
312
+ return true;
313
+ } catch (error) {
314
+ if (!existsSync(lockDirectory)) throw error;
315
+ return false;
316
+ }
317
+ } finally {
318
+ rmSync(candidateDirectory, { recursive: true, force: true });
319
+ }
320
+ }
321
+
322
+ function withConfigWriteLock(runtime, callback) {
323
+ const configFile = resolveConfigFile(runtime);
324
+ const lockDirectory = `${configFile}.write-lock`;
325
+ const ownerId = `${process.pid}.${randomUUID()}`;
326
+ const deadline = Date.now() + CONFIG_WRITE_LOCK_TIMEOUT_MS;
327
+ mkdirSync(dirname(configFile), { recursive: true });
328
+ for (;;) {
329
+ if (!existsSync(lockDirectory) && publishConfigWriteLock(lockDirectory, ownerId)) {
330
+ break;
331
+ }
332
+
333
+ const owner = readLockOwner(lockDirectory);
334
+ const observedMtime = lockDirectoryMtime(lockDirectory);
335
+ const ownerIsStale =
336
+ owner?.id && Date.now() - Number(owner.at) > CONFIG_WRITE_LOCK_STALE_MS;
337
+ const ownerlessIsStale =
338
+ !owner
339
+ && observedMtime !== null
340
+ && Date.now() - observedMtime > CONFIG_WRITE_LOCK_STALE_MS;
341
+ if (ownerIsStale || ownerlessIsStale) {
342
+ try {
343
+ const currentOwner = readLockOwner(lockDirectory);
344
+ const ownerUnchanged = owner?.id
345
+ ? currentOwner?.id === owner.id
346
+ : !currentOwner && lockDirectoryMtime(lockDirectory) === observedMtime;
347
+ if (ownerUnchanged) {
348
+ rmSync(lockDirectory, { recursive: true, force: true });
349
+ continue;
350
+ }
351
+ } catch {
352
+ // Losing the reclaim race is normal; fall through and keep waiting.
353
+ }
354
+ }
355
+ if (Date.now() >= deadline) {
356
+ throw new Error(`Timed out waiting to update ${configFile}`);
357
+ }
358
+ Atomics.wait(
359
+ new Int32Array(new SharedArrayBuffer(4)),
360
+ 0,
361
+ 0,
362
+ CONFIG_WRITE_LOCK_POLL_MS,
363
+ );
364
+ }
365
+ try {
366
+ return callback(configFile);
367
+ } finally {
368
+ try {
369
+ // Never release a lock that was reclaimed from us while we worked.
370
+ if (readLockOwner(lockDirectory)?.id === ownerId) {
371
+ rmSync(lockDirectory, { recursive: true, force: true });
372
+ }
373
+ } catch {
374
+ // The lock may already have been removed after an interrupted write.
375
+ }
376
+ }
377
+ }
84
378
 
85
- return normalizeConfig(JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')));
379
+ export function saveConfig(config, runtime = null) {
380
+ return withConfigWriteLock(runtime, (configFile) => {
381
+ writeConfig(configFile, config);
382
+ });
86
383
  }
87
384
 
88
- export function saveConfig(config) {
89
- mkdirSync(CONFIG_DIR, { recursive: true });
90
- writeFileSync(CONFIG_FILE, JSON.stringify(normalizeConfig(config), null, 2));
385
+ export function updateConfig(updater, runtime = null) {
386
+ return withConfigWriteLock(runtime, (configFile) => {
387
+ const current = loadConfig(runtime);
388
+ const updated = updater(normalizeConfig(current));
389
+ const next = normalizeConfig(updated ?? current);
390
+ writeConfig(configFile, next);
391
+ return next;
392
+ });
91
393
  }
92
394
 
93
395
  export function getProfile(config, profileName) {
@@ -221,15 +523,132 @@ export function parseDebugEntitlementOverride(value = process.env.NOTIS_DEBUG_EN
221
523
  });
222
524
  }
223
525
 
526
+ export function getOAuthResource(profile = {}) {
527
+ if (typeof profile.oauth_resource === 'string' && profile.oauth_resource) {
528
+ return profile.oauth_resource.replace(/\/+$/, '');
529
+ }
530
+ if (typeof profile.oauth_access_token === 'string' && profile.oauth_access_token) {
531
+ try {
532
+ const parts = profile.oauth_access_token.split('.');
533
+ if (parts.length === 3) {
534
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
535
+ if (typeof payload.aud === 'string' && payload.aud) {
536
+ return payload.aud.replace(/\/+$/, '');
537
+ }
538
+ }
539
+ } catch {
540
+ // Legacy tokens without a readable audience fall back below.
541
+ }
542
+ }
543
+ return null;
544
+ }
545
+
546
+ export function getOAuthApiBase(profile = {}) {
547
+ if (typeof profile.oauth_api_base === 'string' && profile.oauth_api_base) {
548
+ return profile.oauth_api_base.replace(/\/+$/, '');
549
+ }
550
+ const resource = getOAuthResource(profile);
551
+ if (resource?.endsWith('/cli')) {
552
+ return resource.slice(0, -'/cli'.length);
553
+ }
554
+ return null;
555
+ }
556
+
224
557
  export function resolveRuntimeProfile(
225
558
  globalOptions = {},
226
559
  { requireAuth = true, includeDebugEntitlementOverride = true } = {},
227
560
  ) {
228
- const config = loadConfig();
561
+ const worktreeRuntime = resolveWorktreeRuntime();
562
+ const config = loadConfig(worktreeRuntime);
229
563
  const profileName = getCurrentProfileName(config, globalOptions.profile);
230
- const apiBase = getApiBase(config, profileName, globalOptions.apiBase);
231
- const jwt = getJwt(config, profileName);
564
+ const requestedApiBase = globalOptions.apiBase;
565
+ if (
566
+ worktreeRuntime &&
567
+ requestedApiBase &&
568
+ requestedApiBase.replace(/\/+$/, '') !== worktreeRuntime.api_base
569
+ ) {
570
+ throw new CliError({
571
+ code: 'dev_runtime_route_mismatch',
572
+ message: `This local-only worktree cannot route to ${requestedApiBase}`,
573
+ exitCode: EXIT_CODES.usage,
574
+ hints: [{ message: `Expected local API: ${worktreeRuntime.api_base}` }],
575
+ });
576
+ }
577
+ let apiBase = worktreeRuntime
578
+ ? worktreeRuntime.api_base
579
+ : getApiBase(config, profileName, globalOptions.apiBase);
232
580
  const profile = getProfile(config, profileName);
581
+ const envJwt = !worktreeRuntime ? process.env.NOTIS_JWT : undefined;
582
+ const desktopJwt = profile.jwt;
583
+ const oauthJwt = profile.oauth_access_token;
584
+ let jwt;
585
+ let credentialKind;
586
+
587
+ if (worktreeRuntime && desktopJwt) {
588
+ jwt = desktopJwt;
589
+ credentialKind = 'worktree';
590
+ } else if (envJwt) {
591
+ jwt = envJwt;
592
+ credentialKind = 'env';
593
+ } else if (
594
+ desktopJwt
595
+ && !credentialIsExpired({ credentialKind: 'desktop', jwt: desktopJwt }, profile)
596
+ ) {
597
+ jwt = desktopJwt;
598
+ credentialKind = 'desktop';
599
+ } else if (
600
+ oauthJwt
601
+ && !credentialIsExpired({ credentialKind: 'oauth', jwt: oauthJwt }, profile)
602
+ ) {
603
+ jwt = oauthJwt;
604
+ credentialKind = 'oauth';
605
+ } else if (oauthJwt && profile.oauth_refresh_token) {
606
+ // A lapsed OAuth access token remains usable through its rotating refresh
607
+ // token and must outrank an abandoned, expired Desktop credential.
608
+ jwt = oauthJwt;
609
+ credentialKind = 'oauth';
610
+ } else if (desktopJwt) {
611
+ jwt = desktopJwt;
612
+ credentialKind = 'desktop';
613
+ } else if (oauthJwt) {
614
+ // Preserve the OAuth credential so transport can refresh it before use.
615
+ jwt = oauthJwt;
616
+ credentialKind = 'oauth';
617
+ }
618
+ const oauthApiBase = getOAuthApiBase(profile);
619
+ const oauthResource = getOAuthResource(profile);
620
+ const normalizedRequestedApiBase = requestedApiBase
621
+ ? requestedApiBase.replace(/\/+$/, '')
622
+ : null;
623
+ if (
624
+ requireAuth
625
+ && credentialKind === 'oauth'
626
+ && normalizedRequestedApiBase
627
+ && oauthApiBase
628
+ && normalizedRequestedApiBase !== oauthApiBase
629
+ ) {
630
+ const quoteShellArgument = (value) => `'${String(value).replace(/'/g, `'"'"'`)}'`;
631
+ throw new CliError({
632
+ code: 'oauth_api_target_mismatch',
633
+ message: (
634
+ `The OAuth credential for profile ${profileName} belongs to ${oauthApiBase}, `
635
+ + `not ${normalizedRequestedApiBase}`
636
+ ),
637
+ exitCode: EXIT_CODES.auth,
638
+ hints: [{
639
+ command: [
640
+ 'npx --package @notis_ai/cli@latest -- notis',
641
+ `--profile ${quoteShellArgument(profileName)}`,
642
+ `--api-base ${quoteShellArgument(normalizedRequestedApiBase)}`,
643
+ 'login --force',
644
+ ].join(' '),
645
+ reason: 'Authorize a separate OAuth grant for the requested Notis environment',
646
+ }],
647
+ });
648
+ }
649
+ if (credentialKind === 'oauth' && oauthApiBase && !requestedApiBase) {
650
+ apiBase = oauthApiBase;
651
+ }
233
652
  const agentMode = isAgentMode(globalOptions);
234
653
  const nonInteractive = isNonInteractive(globalOptions);
235
654
  const outputMode = resolveOutputMode(globalOptions);
@@ -239,11 +658,14 @@ export function resolveRuntimeProfile(
239
658
  : null;
240
659
 
241
660
  if (requireAuth && !jwt) {
242
- const recovery = getDesktopAuthRecovery({
243
- apiBase,
244
- desktopAppName: profile.desktop_app_name,
245
- desktopPid: profile.desktop_pid,
246
- });
661
+ const recovery = getDesktopAuthRecovery(
662
+ {
663
+ apiBase,
664
+ desktopAppName: profile.desktop_app_name,
665
+ desktopPid: profile.desktop_pid,
666
+ },
667
+ { mode: 'missing' },
668
+ );
247
669
  throw new CliError({
248
670
  code: 'auth_missing',
249
671
  message: `No JWT configured for profile ${profileName}`,
@@ -251,16 +673,46 @@ export function resolveRuntimeProfile(
251
673
  hints: recovery.hints,
252
674
  });
253
675
  }
676
+ if (
677
+ worktreeRuntime?.expected_user_id &&
678
+ (
679
+ credentialKind === 'oauth'
680
+ ? profile.oauth_user_id
681
+ : getJwtSubject(jwt)
682
+ ) !== worktreeRuntime.expected_user_id
683
+ ) {
684
+ throw new CliError({
685
+ code: 'dev_runtime_identity_mismatch',
686
+ message: 'The scoped dev credential does not belong to this worktree test user',
687
+ exitCode: EXIT_CODES.auth,
688
+ hints: [
689
+ { message: 'Restart ./dev.sh to restore the approved worktree identity.' },
690
+ { message: `Expected user: ${worktreeRuntime.expected_user_id}` },
691
+ ],
692
+ });
693
+ }
254
694
 
255
695
  // An explicit NOTIS_JWT is a complete credential override. Use it verbatim
256
696
  // and never replace it with a token later synced by the desktop profile.
257
- const usingEnvJwt = Boolean(process.env.NOTIS_JWT);
697
+ const usingEnvJwt = credentialKind === 'env';
258
698
  return {
259
699
  config,
260
700
  profileName,
261
701
  apiBase,
702
+ requestedApiBase: normalizedRequestedApiBase,
262
703
  jwt,
263
- credentialSource: usingEnvJwt ? 'env' : 'profile',
704
+ credentialKind,
705
+ credentialSource: credentialKind === 'desktop' ? 'profile' : credentialKind,
706
+ oauthAccessToken: profile.oauth_access_token,
707
+ oauthRefreshToken: profile.oauth_refresh_token,
708
+ oauthAccessExpiresAt: profile.oauth_access_expires_at,
709
+ oauthRefreshExpiresAt: profile.oauth_refresh_expires_at,
710
+ oauthClientId: profile.oauth_client_id,
711
+ oauthIssuer: profile.oauth_issuer,
712
+ oauthApiBase,
713
+ oauthResource,
714
+ oauthScopes: profile.oauth_scopes || [],
715
+ oauthUserId: profile.oauth_user_id,
264
716
  desktopAppName: usingEnvJwt ? undefined : profile.desktop_app_name,
265
717
  desktopPid: usingEnvJwt ? undefined : profile.desktop_pid,
266
718
  agentMode,
@@ -268,6 +720,7 @@ export function resolveRuntimeProfile(
268
720
  outputMode,
269
721
  timeoutMs,
270
722
  debugEntitlementOverride,
723
+ worktreeRuntime,
271
724
  };
272
725
  }
273
726
 
@@ -285,11 +738,82 @@ export function getJwtExpiration(jwt) {
285
738
  }
286
739
  }
287
740
 
741
+ export function getJwtSubject(jwt) {
742
+ if (typeof jwt !== 'string' || !jwt) {
743
+ return null;
744
+ }
745
+ try {
746
+ const parts = jwt.split('.');
747
+ if (parts.length !== 3) return null;
748
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
749
+ return typeof payload.sub === 'string' && payload.sub ? payload.sub : null;
750
+ } catch {
751
+ return null;
752
+ }
753
+ }
754
+
755
+ export function getJwtCanonicalUserId(jwt) {
756
+ if (typeof jwt !== 'string' || !jwt) {
757
+ return null;
758
+ }
759
+ try {
760
+ const parts = jwt.split('.');
761
+ if (parts.length !== 3) return null;
762
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
763
+ const candidate = payload?.app_metadata?.app_user_id
764
+ || payload?.user_metadata?.notis_user_id
765
+ || payload?.notis_user_id;
766
+ return typeof candidate === 'string' && candidate ? candidate : null;
767
+ } catch {
768
+ return null;
769
+ }
770
+ }
771
+
288
772
  export function isJwtExpired(jwt, nowSeconds = Math.floor(Date.now() / 1000)) {
289
773
  const expiration = getJwtExpiration(jwt);
290
774
  return expiration !== null && expiration <= nowSeconds;
291
775
  }
292
776
 
777
+ export function credentialIsExpired(
778
+ runtime,
779
+ profile = {},
780
+ nowSeconds = Math.floor(Date.now() / 1000),
781
+ ) {
782
+ // Older callers and focused transport tests predate `credentialKind`.
783
+ // Infer only the legacy desktop/env shapes; OAuth must always opt in
784
+ // explicitly so a scoped token can never be mistaken for a Supabase JWT.
785
+ const credentialKind = runtime?.credentialKind
786
+ || (runtime?.credentialSource === 'env' ? 'env' : 'desktop');
787
+ switch (credentialKind) {
788
+ case 'oauth': {
789
+ const expiration = Number(profile.oauth_access_expires_at);
790
+ return !Number.isFinite(expiration) || expiration <= nowSeconds;
791
+ }
792
+ case 'env':
793
+ // NOTIS_JWT is a complete override. Never combine it with expiry
794
+ // metadata left behind by a desktop credential in the same profile.
795
+ // A token with no readable `exp` is a personal API key, which never
796
+ // expires, so let the server rather than the CLI reject it.
797
+ {
798
+ const expiration = getJwtExpiration(runtime?.jwt);
799
+ return expiration !== null && expiration <= nowSeconds;
800
+ }
801
+ case 'worktree':
802
+ case 'desktop': {
803
+ const rawExpiration = profile.access_expires_at ?? getJwtExpiration(runtime?.jwt);
804
+ // Every selected credential must carry an independently verifiable
805
+ // expiry. Missing or malformed expiry metadata fails closed.
806
+ if (rawExpiration === null || rawExpiration === undefined || rawExpiration === '') {
807
+ return true;
808
+ }
809
+ const expiration = Number(rawExpiration);
810
+ return !Number.isFinite(expiration) || expiration <= nowSeconds;
811
+ }
812
+ default:
813
+ return true;
814
+ }
815
+ }
816
+
293
817
  export function workspacePath(appId) {
294
818
  return join(WORKSPACE_DIR, appId);
295
819
  }