@notis_ai/cli 0.2.8 → 0.2.10

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.
@@ -44,15 +44,18 @@ import {
44
44
  runHarnessRoute,
45
45
  } from '../runtime/agent-browser.js';
46
46
  import {
47
+ getAppDevSessionsFile,
47
48
  heartbeatAppDevSession,
48
49
  removeAppDevSession,
49
50
  upsertAppDevSessions,
50
51
  waitForAppDevSessionMountAcknowledgements,
52
+ waitForAppDevSessionRenderAcknowledgements,
51
53
  } from '../runtime/app-dev-sessions.js';
52
54
  import { getAvailablePort } from '../runtime/ports.js';
53
55
  import { getCliMode } from '../runtime/cli-mode.js';
54
56
  import { composeStoreScreenshot } from '../runtime/store-screenshot.js';
55
57
  import { httpRequest } from '../runtime/transport.js';
58
+ import { ensureFreshOAuthCredential } from '../runtime/oauth.js';
56
59
  import {
57
60
  localNotisToolSlug,
58
61
  nextIdempotencyKey,
@@ -140,13 +143,24 @@ function decodeJwtSub(jwt) {
140
143
  }
141
144
  }
142
145
 
143
- function openInBrowser(url) {
144
- const platform = process.platform;
146
+ export function developmentDesktopOpenCommand(
147
+ url,
148
+ {
149
+ platform = process.platform,
150
+ appName = null,
151
+ bundleId = null,
152
+ scheme = 'notis',
153
+ } = {},
154
+ ) {
145
155
  let command;
146
156
  let args;
147
157
  if (platform === 'darwin') {
148
158
  command = 'open';
149
- args = [url];
159
+ args = bundleId && scheme === 'notis'
160
+ ? ['-b', bundleId, url]
161
+ : appName && scheme === 'notis'
162
+ ? ['-a', appName, url]
163
+ : [url];
150
164
  } else if (platform === 'win32') {
151
165
  command = 'cmd';
152
166
  args = ['/c', 'start', '', url];
@@ -154,10 +168,14 @@ function openInBrowser(url) {
154
168
  command = 'xdg-open';
155
169
  args = [url];
156
170
  }
171
+ return { command, args };
172
+ }
173
+
174
+ function openInBrowser(url, options = {}) {
175
+ const { command, args } = developmentDesktopOpenCommand(url, options);
157
176
  try {
158
- const child = spawn(command, args, { stdio: 'ignore', detached: true });
177
+ const child = spawn(command, args, { stdio: 'ignore' });
159
178
  child.on('error', () => {});
160
- child.unref();
161
179
  } catch {
162
180
  // Non-fatal. The URL is printed in the CLI output.
163
181
  }
@@ -207,21 +225,55 @@ function pickDefaultRouteSlug(manifest) {
207
225
 
208
226
  const DESKTOP_DEEP_LINK_SCHEME_PATTERN = /^[a-z][a-z0-9-]*$/;
209
227
 
210
- export function resolveDevelopmentDesktopScheme(env = process.env) {
228
+ export function resolveDevelopmentDesktopScheme(env = process.env, worktreeRuntime = null) {
211
229
  // Mirror the desktop app's own scheme resolution (electron main + forge config):
212
230
  // local dev launches register `notis-dev`, while installed prod/beta builds claim
213
231
  // `notis`. Hardcoding `notis` here is what made `apps dev` open the installed
214
232
  // prod/beta app instead of the local dev app.
215
- const scheme = (env.NOTIS_DESKTOP_DEEP_LINK_SCHEME || '').trim();
233
+ const scheme = (
234
+ worktreeRuntime?.desktop_deep_link_scheme
235
+ || env.NOTIS_DESKTOP_DEEP_LINK_SCHEME
236
+ || ''
237
+ ).trim();
216
238
  return DESKTOP_DEEP_LINK_SCHEME_PATTERN.test(scheme) ? scheme : 'notis';
217
239
  }
218
240
 
241
+ export function resolveDevelopmentDesktopAppName(runtime = {}) {
242
+ const explicit = String(
243
+ runtime.desktopAppName
244
+ || runtime.worktreeRuntime?.desktop_app_name
245
+ || '',
246
+ ).trim();
247
+ if (explicit) {
248
+ return explicit;
249
+ }
250
+ try {
251
+ return new URL(runtime.apiBase).hostname === 'api-beta.notis.ai'
252
+ ? 'Notis Beta'
253
+ : 'Notis';
254
+ } catch {
255
+ return 'Notis';
256
+ }
257
+ }
258
+
259
+ export function resolveDevelopmentDesktopBundleId(runtime = {}) {
260
+ return resolveDevelopmentDesktopAppName(runtime) === 'Notis Beta'
261
+ ? 'ai.notis.desktop.beta'
262
+ : 'ai.notis.desktop';
263
+ }
264
+
219
265
  export function buildDevelopmentDesktopUrl(appHref = null, scheme = 'notis') {
220
266
  const route = String(appHref || '/store').replace(/^\/+/, '');
221
267
  const normalizedScheme = DESKTOP_DEEP_LINK_SCHEME_PATTERN.test(scheme) ? scheme : 'notis';
222
268
  return `${normalizedScheme}://${route || 'store'}`;
223
269
  }
224
270
 
271
+ export function buildMountedDevelopmentDesktopUrl(appHref, scheme, sessionId) {
272
+ const url = new URL(buildDevelopmentDesktopUrl(appHref, scheme));
273
+ url.searchParams.set('notis_dev_session', sessionId);
274
+ return url.toString();
275
+ }
276
+
225
277
  export function shouldOpenDevelopmentTab(options = {}) {
226
278
  // Commander stores the negatable `--no-open` flag as `options.open === false`;
227
279
  // it never sets `options.noOpen`. Reading the non-existent `noOpen` key meant
@@ -230,8 +282,17 @@ export function shouldOpenDevelopmentTab(options = {}) {
230
282
  return options.open !== false;
231
283
  }
232
284
 
233
- function buildAppHref({ appSlug, appId, manifest }) {
234
- const originlessBase = `/apps/${appSlug}-${appId}`;
285
+ export function buildDevelopmentAppHref({
286
+ appSlug,
287
+ appId,
288
+ devSlug,
289
+ targetAppId = null,
290
+ targetAppSlug = null,
291
+ manifest,
292
+ }) {
293
+ const routeAppId = `${targetAppId || appId}__local_dev__${devSlug}`;
294
+ const routeAppSlug = targetAppSlug || devSlug || appSlug;
295
+ const originlessBase = `/apps/${routeAppSlug}-${routeAppId}`;
235
296
  const routeSlug = pickDefaultRouteSlug(manifest);
236
297
  return routeSlug ? `${originlessBase}/${routeSlug}` : originlessBase;
237
298
  }
@@ -499,8 +560,23 @@ export async function ensureDevInstall({
499
560
  const manifest = buildManifestForDev(appConfig);
500
561
  const skills = resolveConfiguredAppSkills(appConfig, projectDir);
501
562
  let linkedState = readLinkedState(projectDir);
563
+ let linkedApp = null;
564
+ if (linkedState?.dev_app_id) {
565
+ // Stale cross-runtime / deleted dev apps often surface as PostgREST PGRST116
566
+ // zero-row errors from LOCAL_NOTIS_GET_APP (.single()), not exact "App not found".
567
+ // Treat those as absence so ensure can clear and reprovision. Installed app_id
568
+ // verification below stays fail-closed on unstructured DB errors.
569
+ const devApp = await getAccessibleApp(ctx.runtime, linkedState.dev_app_id, runTool, {
570
+ treatPostgrestAbsenceAsMissing: true,
571
+ });
572
+ if (!devApp || devApp.manifest?.is_dev !== true) {
573
+ const { dev_app_id: _devAppId, dev_linked_at: _devLinkedAt, ...rest } = linkedState;
574
+ linkedState = rest;
575
+ writeLinkedState(projectDir, linkedState);
576
+ }
577
+ }
502
578
  if (linkedState?.app_id) {
503
- const linkedApp = await getAccessibleApp(ctx.runtime, linkedState.app_id, runTool);
579
+ linkedApp = await getAccessibleApp(ctx.runtime, linkedState.app_id, runTool);
504
580
  if (linkedApp?.manifest?.is_dev === true) {
505
581
  const { app_id: legacyDevAppId, linked_at: _linkedAt, deployed_at: _deployedAt, version: _version, ...rest } = linkedState;
506
582
  const devAppId = linkedState.dev_app_id || legacyDevAppId;
@@ -512,6 +588,7 @@ export async function ensureDevInstall({
512
588
  } : {}),
513
589
  };
514
590
  writeLinkedState(projectDir, linkedState);
591
+ linkedApp = null;
515
592
  }
516
593
  }
517
594
  const ensureArguments = buildEnsureDevInstallArguments({ appConfig, manifest, linkedState, skills });
@@ -544,6 +621,7 @@ export async function ensureDevInstall({
544
621
  created: ensureResult.payload.created || false,
545
622
  linkedAppId: linkedState?.app_id || null,
546
623
  targetAppId: linkedState?.app_id || null,
624
+ targetAppSlug: linkedApp?.slug || null,
547
625
  databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
548
626
  };
549
627
  }
@@ -563,11 +641,19 @@ function databaseMaterializationWarnings(apps) {
563
641
  return warnings;
564
642
  }
565
643
 
566
- async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
644
+ function isPostgrestNoRowsMessage(message) {
645
+ const text = String(message || '');
646
+ return /\bPGRST116\b/.test(text)
647
+ || /\b0 rows\b/i.test(text)
648
+ || /no rows returned/i.test(text);
649
+ }
650
+
651
+ async function getAccessibleApp(runtime, appId, runTool = runToolCommand, options = {}) {
652
+ const { treatPostgrestAbsenceAsMissing = false } = options;
567
653
  const result = await runTool({
568
654
  runtime,
569
655
  toolName: GET_APP_TOOL,
570
- arguments_: { app_id: appId },
656
+ arguments_: { app_id: appId, include_documents: false },
571
657
  });
572
658
  if (result.payload?.app) {
573
659
  return {
@@ -576,7 +662,15 @@ async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
576
662
  };
577
663
  }
578
664
  const message = typeof result.payload?.message === 'string' ? result.payload.message : '';
579
- if (result.payload?.status === 'error' && /PGRST116|0 rows|no rows|not found/i.test(message)) {
665
+ const errorCode = result.payload?.code || result.payload?.error?.code;
666
+ if (
667
+ result.payload?.status === 'error'
668
+ && (
669
+ errorCode === 'app_not_found'
670
+ || /^App not found\.?$/i.test(message.trim())
671
+ || (treatPostgrestAbsenceAsMissing && isPostgrestNoRowsMessage(message))
672
+ )
673
+ ) {
580
674
  return null;
581
675
  }
582
676
  throw usageError(`Could not verify access to app ${appId}${message ? `: ${message}` : '.'}`);
@@ -725,6 +819,15 @@ async function appsDevHandler(ctx) {
725
819
  throw usageError('Could not determine the current user from the CLI auth token. Open the Notis desktop app, sign in, and retry.');
726
820
  }
727
821
  const apiBase = String(ctx.runtime.apiBase || '').replace(/\/$/, '');
822
+ const sessionsFilePath = getAppDevSessionsFile(
823
+ ctx.runtime.worktreeRuntime?.app_dev_sessions_file,
824
+ );
825
+ const desktopScheme = resolveDevelopmentDesktopScheme(
826
+ process.env,
827
+ ctx.runtime.worktreeRuntime,
828
+ );
829
+ const desktopAppName = resolveDevelopmentDesktopAppName(ctx.runtime);
830
+ const desktopBundleId = resolveDevelopmentDesktopBundleId(ctx.runtime);
728
831
  const sessionId = randomUUID();
729
832
 
730
833
  const candidates = [];
@@ -780,16 +883,25 @@ async function appsDevHandler(ctx) {
780
883
  ...app,
781
884
  bundleBaseUrl,
782
885
  mountNonce: randomUUID(),
783
- appHref: buildAppHref({
886
+ appHref: buildDevelopmentAppHref({
784
887
  appSlug: app.slug,
785
888
  appId: app.appId,
889
+ devSlug: app.devSlug,
890
+ targetAppId: app.targetAppId,
891
+ targetAppSlug: app.targetAppSlug,
786
892
  manifest: app.manifest,
787
893
  }),
788
894
  };
789
895
  });
790
896
  const developmentTabUrl = buildDevelopmentDesktopUrl(
791
897
  apps[0]?.appHref,
792
- resolveDevelopmentDesktopScheme(),
898
+ desktopScheme,
899
+ );
900
+ const desktopWakeUrl = buildDevelopmentDesktopUrl('/manage', desktopScheme);
901
+ const mountedDevelopmentTabUrl = buildMountedDevelopmentDesktopUrl(
902
+ apps[0]?.appHref,
903
+ desktopScheme,
904
+ sessionId,
793
905
  );
794
906
  const warnings = databaseMaterializationWarnings(apps);
795
907
 
@@ -802,6 +914,7 @@ async function appsDevHandler(ctx) {
802
914
  userId: identity,
803
915
  })),
804
916
  port,
917
+ sessionsFilePath,
805
918
  });
806
919
 
807
920
  try {
@@ -818,7 +931,8 @@ async function appsDevHandler(ctx) {
818
931
  projectDir: app.projectDir,
819
932
  startedAt: now,
820
933
  lastHeartbeatAt: now,
821
- })));
934
+ desktopAppName,
935
+ })), sessionsFilePath);
822
936
  } catch (error) {
823
937
  try {
824
938
  await devServer.close();
@@ -830,7 +944,7 @@ async function appsDevHandler(ctx) {
830
944
 
831
945
  let heartbeatTimer = setInterval(() => {
832
946
  try {
833
- heartbeatAppDevSession(sessionId, new Date().toISOString());
947
+ heartbeatAppDevSession(sessionId, new Date().toISOString(), sessionsFilePath);
834
948
  } catch (error) {
835
949
  const message = error instanceof Error ? error.message : String(error);
836
950
  process.stderr.write(`[notis apps dev] heartbeat failed: ${message}\n`);
@@ -852,6 +966,8 @@ async function appsDevHandler(ctx) {
852
966
  development_url: developmentTabUrl,
853
967
  session_id: sessionId,
854
968
  mount_status: 'serving',
969
+ render_status: 'waiting_for_route',
970
+ desktop_target: desktopAppName,
855
971
  identity,
856
972
  apps: apps.map((app) => ({
857
973
  slug: app.devSlug,
@@ -869,36 +985,67 @@ async function appsDevHandler(ctx) {
869
985
  warnings,
870
986
  humanSummary: [
871
987
  `Running apps dev against ${apiBase} as ${identity} (mode: ${mode})`,
988
+ `Target desktop: ${desktopAppName}`,
872
989
  '',
873
990
  `Open in desktop: ${developmentTabUrl}`,
874
991
  '',
875
992
  ...apps.map((app) => ` ${app.name.padEnd(24)} ${app.bundleBaseUrl} -> ${app.appHref}`),
876
993
  '',
877
- `Serving locally; waiting for Notis to mount ${apps.length === 1 ? 'the app' : `${apps.length} apps`}.`,
994
+ `Serving locally; waiting for ${desktopAppName} to mount ${apps.length === 1 ? 'the app' : `${apps.length} apps`}.`,
878
995
  '',
879
996
  'Press Ctrl-C to stop.',
880
997
  ].join('\n'),
881
998
  });
882
999
 
883
1000
  if (shouldOpenDevelopmentTab(ctx.options)) {
884
- openInBrowser(developmentTabUrl);
1001
+ openInBrowser(desktopWakeUrl, {
1002
+ appName: desktopAppName,
1003
+ bundleId: desktopBundleId,
1004
+ scheme: desktopScheme,
1005
+ });
885
1006
  }
886
1007
 
887
1008
  void (async () => {
888
- let result = await waitForAppDevSessionMountAcknowledgements(expectedMountAcknowledgements);
1009
+ let result = await waitForAppDevSessionMountAcknowledgements(expectedMountAcknowledgements, {
1010
+ sessionsFilePath,
1011
+ });
889
1012
  if (!result.mounted) {
890
1013
  process.stderr.write(
891
- `[notis apps dev] Serving locally, but Notis has not acknowledged ${result.missing.length === 1 ? 'the app' : `${result.missing.length} apps`} in its Local development sidebar yet. Keep this command running and open Notis.\n`,
1014
+ `[notis apps dev] Serving locally, but ${desktopAppName} has not acknowledged ${result.missing.length === 1 ? 'the app' : `${result.missing.length} apps`} in its Local development sidebar yet. Keep this command running and open ${desktopAppName}.\n`,
892
1015
  );
893
1016
  }
894
1017
  while (!result.mounted) {
895
1018
  result = await waitForAppDevSessionMountAcknowledgements(expectedMountAcknowledgements, {
1019
+ sessionsFilePath,
896
1020
  timeoutMs: 60_000,
897
1021
  pollIntervalMs: 250,
898
1022
  });
899
1023
  }
900
1024
  process.stderr.write(
901
- `[notis apps dev] Mounted in Notis: ${apps.map((app) => app.name).join(', ')}.\n`,
1025
+ `[notis apps dev] Mounted in ${desktopAppName}: ${apps.map((app) => app.name).join(', ')}.\n`,
1026
+ );
1027
+ if (shouldOpenDevelopmentTab(ctx.options)) {
1028
+ openInBrowser(mountedDevelopmentTabUrl, {
1029
+ appName: desktopAppName,
1030
+ bundleId: desktopBundleId,
1031
+ scheme: desktopScheme,
1032
+ });
1033
+ }
1034
+
1035
+ const firstAppRender = expectedMountAcknowledgements.slice(0, 1);
1036
+ let renderResult = await waitForAppDevSessionRenderAcknowledgements(firstAppRender, {
1037
+ sessionsFilePath,
1038
+ timeoutMs: 0,
1039
+ });
1040
+ while (!renderResult.mounted) {
1041
+ renderResult = await waitForAppDevSessionRenderAcknowledgements(firstAppRender, {
1042
+ sessionsFilePath,
1043
+ timeoutMs: 60_000,
1044
+ pollIntervalMs: 250,
1045
+ });
1046
+ }
1047
+ process.stderr.write(
1048
+ `[notis apps dev] Rendered in ${desktopAppName}: ${apps[0].name}.\n`,
902
1049
  );
903
1050
  })().catch((error) => {
904
1051
  const message = error instanceof Error ? error.message : String(error);
@@ -915,7 +1062,7 @@ async function appsDevHandler(ctx) {
915
1062
  heartbeatTimer = null;
916
1063
  }
917
1064
  try {
918
- removeAppDevSession(sessionId);
1065
+ removeAppDevSession(sessionId, sessionsFilePath);
919
1066
  } catch {
920
1067
  // ignore cleanup failures during shutdown
921
1068
  }
@@ -971,6 +1118,12 @@ async function appsVerifyHandler(ctx) {
971
1118
 
972
1119
  let linkedState = null;
973
1120
  if (mode === 'live') {
1121
+ if (
1122
+ ctx.runtime.credentialKind === 'oauth'
1123
+ && !await ensureFreshOAuthCredential(ctx.runtime)
1124
+ ) {
1125
+ throw usageError('Live verify mode requires a current OAuth grant. Run `notis login` and retry.');
1126
+ }
974
1127
  if (!ctx.runtime.jwt) {
975
1128
  throw usageError('Live verify mode requires CLI auth. Open the Notis desktop app, sign in, and retry.');
976
1129
  }
@@ -1185,6 +1338,12 @@ async function appsScreenshotHandler(ctx) {
1185
1338
 
1186
1339
  let linkedState = null;
1187
1340
  if (mode === 'live') {
1341
+ if (
1342
+ ctx.runtime.credentialKind === 'oauth'
1343
+ && !await ensureFreshOAuthCredential(ctx.runtime)
1344
+ ) {
1345
+ throw usageError('Live mode requires a current OAuth grant. Run `notis login` and retry.');
1346
+ }
1188
1347
  if (!ctx.runtime.jwt) {
1189
1348
  throw usageError('Live mode requires CLI auth. Open the Notis desktop app, sign in, and retry.');
1190
1349
  }
@@ -1423,6 +1582,12 @@ async function appsPullHandler(ctx) {
1423
1582
  toolName: GET_APP_TOOL,
1424
1583
  arguments_: { app_id: appId },
1425
1584
  });
1585
+ if (
1586
+ ctx.runtime.credentialKind === 'oauth'
1587
+ && !await ensureFreshOAuthCredential(ctx.runtime)
1588
+ ) {
1589
+ throw usageError('Pulling app source requires a current OAuth grant. Run `notis login` and retry.');
1590
+ }
1426
1591
  const app = result.payload?.app || {};
1427
1592
  const defaultDir = app.slug || slugify(app.name) || appId;
1428
1593
  const targetDir = resolveProjectDir(ctx.args.dir || defaultDir);
@@ -0,0 +1,107 @@
1
+ import { loginWithOAuth, logoutOAuth } from '../runtime/oauth.js';
2
+
3
+ async function loginHandler(ctx) {
4
+ const result = await loginWithOAuth(ctx.runtime, ctx.options, ctx.output);
5
+ if (result.desktopFastPath) {
6
+ return ctx.output.emitSuccess({
7
+ command: 'login',
8
+ data: {
9
+ authenticated: true,
10
+ credential_source: result.credentialSource || 'desktop',
11
+ },
12
+ humanSummary: 'Notis Desktop already provides a valid CLI credential.',
13
+ });
14
+ }
15
+ if (result.agentAuthorization) {
16
+ return ctx.output.emitSuccess({
17
+ command: 'login',
18
+ data: result.agentAuthorization,
19
+ humanSummary: 'Open the authorization URL in a browser to continue.',
20
+ });
21
+ }
22
+ return ctx.output.emitSuccess({
23
+ command: 'login',
24
+ data: {
25
+ authenticated: true,
26
+ credential_source: 'oauth',
27
+ profile: ctx.runtime.profileName,
28
+ user_id: result.profile.oauth_user_id,
29
+ scopes: result.profile.oauth_scopes,
30
+ access_expires_at: result.profile.oauth_access_expires_at,
31
+ refresh_expires_at: result.profile.oauth_refresh_expires_at,
32
+ },
33
+ humanSummary: `Notis CLI is authorized for profile "${ctx.runtime.profileName}".`,
34
+ });
35
+ }
36
+
37
+ async function logoutHandler(ctx) {
38
+ const result = await logoutOAuth(ctx.runtime, {
39
+ allProfiles: Boolean(ctx.options.allProfiles),
40
+ });
41
+ return ctx.output.emitSuccess({
42
+ command: 'logout',
43
+ data: {
44
+ oauth_connected: false,
45
+ cleared_profiles: result.profiles,
46
+ },
47
+ humanSummary: ctx.options.allProfiles
48
+ ? 'OAuth credentials were removed from all CLI profiles.'
49
+ : `OAuth credentials were removed from profile "${ctx.runtime.profileName}".`,
50
+ });
51
+ }
52
+
53
+ export const authCommandSpecs = [
54
+ {
55
+ command_path: ['login'],
56
+ summary: 'Authorize the Notis CLI in a browser with scoped OAuth access.',
57
+ when_to_use:
58
+ 'Use this on a machine where Notis Desktop is unavailable, signed out, or should not own CLI authentication.',
59
+ args_schema: {
60
+ arguments: [],
61
+ options: [
62
+ { flags: '--no-browser', description: 'Print the authorization URL without opening a browser.' },
63
+ { flags: '--print-url', description: 'Print the authorization URL even when opening a browser.' },
64
+ { flags: '--paste-code', description: 'Use the copy-paste callback for SSH and headless machines.' },
65
+ { flags: '--force', description: 'Create an independent OAuth grant even when Desktop is signed in.' },
66
+ { flags: '--timeout-seconds <n>', description: 'How long to wait for authorization (default 300).' },
67
+ { flags: '--scope <scope>', description: 'OAuth permission to request (repeatable).', collect: true },
68
+ { flags: '--code <code>', description: 'Redeem the code shown in the browser after a non-interactive login.' },
69
+ ],
70
+ },
71
+ examples: [
72
+ 'notis login',
73
+ 'notis login --no-browser --print-url',
74
+ 'notis login --paste-code',
75
+ 'notis login --code 4f3c2b1a',
76
+ 'notis login --force',
77
+ ],
78
+ output_schema:
79
+ 'Returns credential_source, profile, user_id, scopes, and credential expiries; agent mode returns authorize_url and expires_in.',
80
+ mutates: true,
81
+ idempotent: true,
82
+ require_auth: false,
83
+ related_commands: ['notis logout', 'notis doctor', 'notis whoami'],
84
+ backend_call: { type: 'oauth', name: 'authorization_code+pkce' },
85
+ handler: loginHandler,
86
+ },
87
+ {
88
+ command_path: ['logout'],
89
+ summary: 'Revoke and remove the scoped OAuth credential for the active CLI profile.',
90
+ when_to_use:
91
+ 'Use this to disconnect the command line without signing Notis Desktop out.',
92
+ args_schema: {
93
+ arguments: [],
94
+ options: [
95
+ { flags: '--all-profiles', description: 'Remove OAuth credentials from every CLI profile.' },
96
+ ],
97
+ },
98
+ examples: ['notis logout', 'notis logout --all-profiles'],
99
+ output_schema: 'Returns oauth_connected=false and the profiles whose OAuth credentials were removed.',
100
+ mutates: true,
101
+ idempotent: true,
102
+ require_auth: false,
103
+ related_commands: ['notis login', 'notis doctor'],
104
+ backend_call: { type: 'oauth', name: 'revocation' },
105
+ handler: logoutHandler,
106
+ },
107
+ ];
@@ -1,4 +1,4 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { usageError } from '../runtime/errors.js';
4
4
  import {
@@ -178,6 +178,11 @@ async function executeReadOnlySql(ctx, query, phase) {
178
178
  tools: [{ tool_slug: toolName, arguments: { query } }],
179
179
  },
180
180
  mutating: false,
181
+ // Always a fresh key, never the operator's --idempotency-key: a diagnostic
182
+ // must observe current state rather than replay a cached response, and two
183
+ // different queries under one reused key would collide on the request hash.
184
+ idempotencyKey: randomUUID(),
185
+ sendIdempotencyKeyWhenReading: true,
181
186
  });
182
187
  if (result.payload?.successful === false || result.payload?.error) {
183
188
  throw usageError(
@@ -56,12 +56,18 @@ export async function runToolCommand({
56
56
  mutating = false,
57
57
  idempotencyKey,
58
58
  fileBindings = [],
59
+ sendIdempotencyKeyWhenReading = false,
59
60
  }) {
61
+ // The server owns effect classification and requires a key whenever *its*
62
+ // metadata says write or unknown — a client-side `mutating: false` hint does
63
+ // not exempt the call. Callers that knowingly dispatch through an
64
+ // unknown-classified wrapper (e.g. COMPOSIO_MULTI_EXECUTE_TOOL) opt in so the
65
+ // request carries a key instead of being rejected as idempotency_key_required.
60
66
  const result = await callTool({
61
67
  runtime: { ...runtime, mutating },
62
68
  toolName,
63
69
  arguments_,
64
- idempotencyKey: mutating ? idempotencyKey : null,
70
+ idempotencyKey: mutating || sendIdempotencyKeyWhenReading ? idempotencyKey : null,
65
71
  fileBindings,
66
72
  });
67
73
  return result;
@@ -4,6 +4,7 @@ import { metaCommandSpecs } from './meta.js';
4
4
  import { onboardingCommandSpecs } from './onboarding.js';
5
5
  import { diagnosticCommandSpecs } from './diagnostics.js';
6
6
  import { smokeCommandSpecs } from './smoke.js';
7
+ import { authCommandSpecs } from './auth.js';
7
8
 
8
9
  export const GROUP_SUMMARIES = {
9
10
  apps: 'Develop, deploy, and submit Notis Apps.',
@@ -13,6 +14,7 @@ export const GROUP_SUMMARIES = {
13
14
  };
14
15
 
15
16
  export const COMMAND_SPECS = [
17
+ ...authCommandSpecs,
16
18
  ...onboardingCommandSpecs,
17
19
  ...appsCommandSpecs,
18
20
  ...toolsCommandSpecs,