@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.
- package/README.md +41 -2
- package/package.json +1 -1
- package/skills/notis-apps/SKILL.md +10 -21
- package/skills/notis-apps/cli.md +1 -1
- package/skills/notis-cli/SKILL.md +11 -5
- package/skills/notis-query/cli.md +1 -1
- package/src/command-specs/apps.js +188 -23
- package/src/command-specs/auth.js +107 -0
- package/src/command-specs/diagnostics.js +6 -1
- package/src/command-specs/helpers.js +7 -1
- package/src/command-specs/index.js +2 -0
- package/src/command-specs/meta.js +68 -6
- package/src/command-specs/onboarding.js +94 -6
- package/src/runtime/app-dev-server.js +3 -2
- package/src/runtime/app-dev-sessions.js +14 -40
- package/src/runtime/cli-mode.generated.js +4 -3
- package/src/runtime/cli-mode.js +13 -8
- package/src/runtime/desktop-auth.js +22 -2
- package/src/runtime/oauth.js +1121 -0
- package/src/runtime/output.js +7 -0
- package/src/runtime/profiles.js +423 -23
- package/src/runtime/transport.js +41 -11
|
@@ -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
|
|
144
|
-
|
|
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 =
|
|
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'
|
|
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 = (
|
|
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
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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,
|