@notis_ai/cli 0.2.0-beta.116.1 → 0.2.0-beta.118.1
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/cli.md +1 -1
- package/skills/notis-cli/SKILL.md +3 -0
- package/skills/notis-query/cli.md +1 -1
- package/src/command-specs/apps.js +19 -0
- package/src/command-specs/auth.js +107 -0
- 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/desktop-auth.js +22 -2
- package/src/runtime/oauth.js +1105 -0
- package/src/runtime/output.js +7 -0
- package/src/runtime/profiles.js +360 -8
- package/src/runtime/transport.js +41 -11
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Agent-first Notis CLI for apps and generic tool execution.
|
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
|
-
Use the Notis CLI through NPX
|
|
7
|
+
Use the Notis CLI through NPX; do not rely on an installed `notis` command. When Notis Desktop is signed in, its local credential remains the fastest path. On a server, container, or machine without Desktop, run `notis login` to authorize a scoped, revocable OAuth credential in the browser.
|
|
8
8
|
|
|
9
9
|
For CI, hosted agents, or internal scripts, pass a non-persisted token with `NOTIS_JWT=<token>`.
|
|
10
10
|
|
|
@@ -12,12 +12,13 @@ For CI, hosted agents, or internal scripts, pass a non-persisted token with `NOT
|
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
14
|
npx --package @notis_ai/cli@latest -- notis --help
|
|
15
|
+
npx --package @notis_ai/cli@latest -- notis login
|
|
15
16
|
npx --package @notis_ai/cli@latest -- notis doctor
|
|
16
17
|
npx --package @notis_ai/cli@latest -- notis apps list
|
|
17
18
|
npx --package @notis_ai/cli@latest -- notis tools search "list Notis databases"
|
|
18
19
|
```
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
Credential precedence is worktree runtime, `NOTIS_JWT`, a valid Desktop credential, then OAuth. `notis logout` revokes and removes OAuth without signing Desktop out. Use `notis login --paste-code` for the HTTPS copy-paste fallback on a remote machine.
|
|
21
22
|
|
|
22
23
|
The CLI defaults to `json` output in agent or non-TTY contexts and `table` output in interactive terminals.
|
|
23
24
|
|
|
@@ -31,6 +32,44 @@ The CLI defaults to `json` output in agent or non-TTY contexts and `table` outpu
|
|
|
31
32
|
- `--timeout-ms <n>` — HTTP timeout in milliseconds
|
|
32
33
|
- `--idempotency-key <key>` — Override the generated idempotency key for mutating commands
|
|
33
34
|
|
|
35
|
+
## Authentication
|
|
36
|
+
|
|
37
|
+
### `npx --package @notis_ai/cli@latest -- notis login`
|
|
38
|
+
|
|
39
|
+
Authorize the Notis CLI in a browser with scoped OAuth access.
|
|
40
|
+
|
|
41
|
+
When to use: Use this on a machine where Notis Desktop is unavailable, signed out, or should not own CLI authentication.
|
|
42
|
+
|
|
43
|
+
Options:
|
|
44
|
+
- `--no-browser` — Print the authorization URL without opening a browser.
|
|
45
|
+
- `--print-url` — Print the authorization URL even when opening a browser.
|
|
46
|
+
- `--paste-code` — Use the copy-paste callback for SSH and headless machines.
|
|
47
|
+
- `--force` — Create an independent OAuth grant even when Desktop is signed in.
|
|
48
|
+
- `--timeout-seconds <n>` — How long to wait for authorization (default 300).
|
|
49
|
+
- `--scope <scope>` — OAuth permission to request (repeatable).
|
|
50
|
+
- `--code <code>` — Redeem the code shown in the browser after a non-interactive login.
|
|
51
|
+
|
|
52
|
+
Examples:
|
|
53
|
+
- `npx --package @notis_ai/cli@latest -- notis login`
|
|
54
|
+
- `npx --package @notis_ai/cli@latest -- notis login --no-browser --print-url`
|
|
55
|
+
- `npx --package @notis_ai/cli@latest -- notis login --paste-code`
|
|
56
|
+
- `npx --package @notis_ai/cli@latest -- notis login --code 4f3c2b1a`
|
|
57
|
+
- `npx --package @notis_ai/cli@latest -- notis login --force`
|
|
58
|
+
|
|
59
|
+
### `npx --package @notis_ai/cli@latest -- notis logout`
|
|
60
|
+
|
|
61
|
+
Revoke and remove the scoped OAuth credential for the active CLI profile.
|
|
62
|
+
|
|
63
|
+
When to use: Use this to disconnect the command line without signing Notis Desktop out.
|
|
64
|
+
|
|
65
|
+
Options:
|
|
66
|
+
- `--all-profiles` — Remove OAuth credentials from every CLI profile.
|
|
67
|
+
|
|
68
|
+
Examples:
|
|
69
|
+
- `npx --package @notis_ai/cli@latest -- notis logout`
|
|
70
|
+
- `npx --package @notis_ai/cli@latest -- notis logout --all-profiles`
|
|
71
|
+
|
|
72
|
+
|
|
34
73
|
## Apps
|
|
35
74
|
|
|
36
75
|
### `npx --package @notis_ai/cli@latest -- notis apps list`
|
package/package.json
CHANGED
package/skills/notis-apps/cli.md
CHANGED
|
@@ -8,7 +8,7 @@ Important: `notis apps deploy` updates the linked installed app. It is not an ap
|
|
|
8
8
|
|
|
9
9
|
## Setup
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Sign into Notis Desktop or run `npx --package @notis_ai/cli@latest -- notis login` to authorize the CLI. Run commands through NPX, for example `npx --package @notis_ai/cli@latest -- notis apps list`.
|
|
12
12
|
|
|
13
13
|
For CI, hosted agents, or internal scripts, pass a non-persisted token with `NOTIS_JWT=<token>` and use `--api-base <server-url>` when targeting a non-default server.
|
|
14
14
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: notis-cli
|
|
3
3
|
description: Use when agents should work through the Notis CLI, especially to develop Notis apps locally or to access Notis, Composio, or MCP tools they do not currently have loaded directly.
|
|
4
|
+
feature_flag: cli_access
|
|
5
|
+
mcp_resource: true
|
|
6
|
+
mcp_tool_patterns: []
|
|
4
7
|
---
|
|
5
8
|
|
|
6
9
|
# Notis CLI Skill
|
|
@@ -4,7 +4,7 @@ Use the generic `notis tools` workflow through NPX for native Notis Database ope
|
|
|
4
4
|
|
|
5
5
|
## Setup
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Sign into Notis Desktop or run `npx --package @notis_ai/cli@latest -- notis login` to authorize the CLI. Run commands through NPX, for example `npx --package @notis_ai/cli@latest -- notis tools search "list Notis databases"`.
|
|
8
8
|
|
|
9
9
|
For CI, hosted agents, or internal scripts, pass a non-persisted token with `NOTIS_JWT=<token>` and use `--api-base <server-url>` when targeting a non-default server.
|
|
10
10
|
|
|
@@ -55,6 +55,7 @@ import { getAvailablePort } from '../runtime/ports.js';
|
|
|
55
55
|
import { getCliMode } from '../runtime/cli-mode.js';
|
|
56
56
|
import { composeStoreScreenshot } from '../runtime/store-screenshot.js';
|
|
57
57
|
import { httpRequest } from '../runtime/transport.js';
|
|
58
|
+
import { ensureFreshOAuthCredential } from '../runtime/oauth.js';
|
|
58
59
|
import {
|
|
59
60
|
localNotisToolSlug,
|
|
60
61
|
nextIdempotencyKey,
|
|
@@ -1087,6 +1088,12 @@ async function appsVerifyHandler(ctx) {
|
|
|
1087
1088
|
|
|
1088
1089
|
let linkedState = null;
|
|
1089
1090
|
if (mode === 'live') {
|
|
1091
|
+
if (
|
|
1092
|
+
ctx.runtime.credentialKind === 'oauth'
|
|
1093
|
+
&& !await ensureFreshOAuthCredential(ctx.runtime)
|
|
1094
|
+
) {
|
|
1095
|
+
throw usageError('Live verify mode requires a current OAuth grant. Run `notis login` and retry.');
|
|
1096
|
+
}
|
|
1090
1097
|
if (!ctx.runtime.jwt) {
|
|
1091
1098
|
throw usageError('Live verify mode requires CLI auth. Open the Notis desktop app, sign in, and retry.');
|
|
1092
1099
|
}
|
|
@@ -1301,6 +1308,12 @@ async function appsScreenshotHandler(ctx) {
|
|
|
1301
1308
|
|
|
1302
1309
|
let linkedState = null;
|
|
1303
1310
|
if (mode === 'live') {
|
|
1311
|
+
if (
|
|
1312
|
+
ctx.runtime.credentialKind === 'oauth'
|
|
1313
|
+
&& !await ensureFreshOAuthCredential(ctx.runtime)
|
|
1314
|
+
) {
|
|
1315
|
+
throw usageError('Live mode requires a current OAuth grant. Run `notis login` and retry.');
|
|
1316
|
+
}
|
|
1304
1317
|
if (!ctx.runtime.jwt) {
|
|
1305
1318
|
throw usageError('Live mode requires CLI auth. Open the Notis desktop app, sign in, and retry.');
|
|
1306
1319
|
}
|
|
@@ -1539,6 +1552,12 @@ async function appsPullHandler(ctx) {
|
|
|
1539
1552
|
toolName: GET_APP_TOOL,
|
|
1540
1553
|
arguments_: { app_id: appId },
|
|
1541
1554
|
});
|
|
1555
|
+
if (
|
|
1556
|
+
ctx.runtime.credentialKind === 'oauth'
|
|
1557
|
+
&& !await ensureFreshOAuthCredential(ctx.runtime)
|
|
1558
|
+
) {
|
|
1559
|
+
throw usageError('Pulling app source requires a current OAuth grant. Run `notis login` and retry.');
|
|
1560
|
+
}
|
|
1542
1561
|
const app = result.payload?.app || {};
|
|
1543
1562
|
const defaultDir = app.slug || slugify(app.name) || appId;
|
|
1544
1563
|
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
|
+
];
|
|
@@ -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,
|
|
@@ -1,19 +1,52 @@
|
|
|
1
1
|
import { COMPOSIO_SEARCH_TOOLS, healthCheck, probeAuth } from './helpers.js';
|
|
2
2
|
import { findCommandSpec, formatDescribe } from '../runtime/help.js';
|
|
3
|
-
import { createExpiredAuthError } from '../runtime/desktop-auth.js';
|
|
4
|
-
import {
|
|
3
|
+
import { createExpiredAuthError, getDesktopAuthRecovery } from '../runtime/desktop-auth.js';
|
|
4
|
+
import {
|
|
5
|
+
credentialIsExpired,
|
|
6
|
+
getJwtCanonicalUserId,
|
|
7
|
+
getProfile,
|
|
8
|
+
loadConfig,
|
|
9
|
+
} from '../runtime/profiles.js';
|
|
10
|
+
import { ensureFreshOAuthCredential } from '../runtime/oauth.js';
|
|
11
|
+
|
|
12
|
+
export const DOCTOR_TOOL_ROUNDTRIP_TIMEOUT_MS = 90_000;
|
|
13
|
+
|
|
14
|
+
export function doctorToolRoundtripRuntime(runtime) {
|
|
15
|
+
return {
|
|
16
|
+
...runtime,
|
|
17
|
+
timeoutMs: Math.max(runtime.timeoutMs || 0, DOCTOR_TOOL_ROUNDTRIP_TIMEOUT_MS),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
5
20
|
|
|
6
21
|
async function doctorHandler(ctx) {
|
|
7
22
|
const checks = {
|
|
8
23
|
config: 'ok',
|
|
9
24
|
auth: 'missing',
|
|
25
|
+
identity: 'ok',
|
|
10
26
|
health: 'unknown',
|
|
11
27
|
tool_roundtrip: 'unknown',
|
|
12
28
|
};
|
|
13
29
|
|
|
30
|
+
if (ctx.runtime.credentialKind === 'oauth') {
|
|
31
|
+
try {
|
|
32
|
+
await ensureFreshOAuthCredential(ctx.runtime);
|
|
33
|
+
} catch {
|
|
34
|
+
// Doctor still reports the remaining health and recovery checks when a
|
|
35
|
+
// refresh endpoint is unavailable or rejects the stored credential.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
let profile = getProfile(loadConfig(ctx.runtime.worktreeRuntime), ctx.runtime.profileName);
|
|
14
39
|
checks.auth = ctx.runtime.jwt
|
|
15
|
-
? (
|
|
40
|
+
? (credentialIsExpired(ctx.runtime, profile) ? 'expired' : 'configured')
|
|
16
41
|
: 'missing';
|
|
42
|
+
const desktopUserId = getJwtCanonicalUserId(profile.jwt);
|
|
43
|
+
if (
|
|
44
|
+
desktopUserId
|
|
45
|
+
&& profile.oauth_user_id
|
|
46
|
+
&& desktopUserId !== profile.oauth_user_id
|
|
47
|
+
) {
|
|
48
|
+
checks.identity = 'error';
|
|
49
|
+
}
|
|
17
50
|
|
|
18
51
|
try {
|
|
19
52
|
await healthCheck(ctx.runtime);
|
|
@@ -24,8 +57,15 @@ async function doctorHandler(ctx) {
|
|
|
24
57
|
|
|
25
58
|
if (ctx.runtime.jwt) {
|
|
26
59
|
try {
|
|
27
|
-
|
|
60
|
+
// Tool discovery may have to query several connected MCP servers on a
|
|
61
|
+
// cold local backend. A diagnostic must not report a false failure just
|
|
62
|
+
// because that legitimate roundtrip exceeds the general 30s default.
|
|
63
|
+
const payload = await probeAuth(doctorToolRoundtripRuntime(ctx.runtime));
|
|
28
64
|
checks.tool_roundtrip = Array.isArray(payload.toolkit_connection_statuses) ? 'ok' : 'error';
|
|
65
|
+
profile = getProfile(loadConfig(ctx.runtime.worktreeRuntime), ctx.runtime.profileName);
|
|
66
|
+
checks.auth = ctx.runtime.jwt
|
|
67
|
+
? (credentialIsExpired(ctx.runtime, profile) ? 'expired' : 'configured')
|
|
68
|
+
: 'missing';
|
|
29
69
|
} catch {
|
|
30
70
|
checks.tool_roundtrip = 'error';
|
|
31
71
|
}
|
|
@@ -33,7 +73,7 @@ async function doctorHandler(ctx) {
|
|
|
33
73
|
|
|
34
74
|
const hints = [];
|
|
35
75
|
if (checks.auth === 'missing') {
|
|
36
|
-
hints.push({
|
|
76
|
+
hints.push(...getDesktopAuthRecovery(ctx.runtime, { mode: 'missing' }).hints);
|
|
37
77
|
} else if (checks.auth === 'expired') {
|
|
38
78
|
hints.push(...createExpiredAuthError(ctx.runtime).hints);
|
|
39
79
|
}
|
|
@@ -43,12 +83,27 @@ async function doctorHandler(ctx) {
|
|
|
43
83
|
if (checks.tool_roundtrip === 'error') {
|
|
44
84
|
hints.push({ command: 'notis whoami', reason: 'Verify your account and permissions' });
|
|
45
85
|
}
|
|
86
|
+
if (checks.identity === 'error') {
|
|
87
|
+
hints.push({
|
|
88
|
+
command: 'notis logout',
|
|
89
|
+
reason: 'Desktop and OAuth credentials identify different accounts; remove the independent OAuth grant or sign Desktop back into the same account',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
46
92
|
|
|
47
93
|
return ctx.output.emitSuccess({
|
|
48
94
|
command: ctx.spec.command_path.join(' '),
|
|
49
95
|
data: {
|
|
50
96
|
profile: ctx.runtime.profileName,
|
|
51
97
|
api_base: ctx.runtime.apiBase,
|
|
98
|
+
credential_source: ctx.runtime.credentialKind || null,
|
|
99
|
+
...(ctx.runtime.credentialKind === 'oauth'
|
|
100
|
+
? {
|
|
101
|
+
oauth_client_id: profile.oauth_client_id || null,
|
|
102
|
+
oauth_scopes: profile.oauth_scopes || [],
|
|
103
|
+
oauth_access_expires_at: profile.oauth_access_expires_at || null,
|
|
104
|
+
oauth_refresh_expires_at: profile.oauth_refresh_expires_at || null,
|
|
105
|
+
}
|
|
106
|
+
: {}),
|
|
52
107
|
checks,
|
|
53
108
|
},
|
|
54
109
|
humanSummary: `Doctor checks completed for profile ${ctx.runtime.profileName}`,
|
|
@@ -72,16 +127,23 @@ function decodeJwtUserId(jwt) {
|
|
|
72
127
|
}
|
|
73
128
|
}
|
|
74
129
|
|
|
130
|
+
export function activeRuntimeUserId(runtime) {
|
|
131
|
+
return runtime.credentialKind === 'oauth'
|
|
132
|
+
? runtime.oauthUserId
|
|
133
|
+
: decodeJwtUserId(runtime.jwt);
|
|
134
|
+
}
|
|
135
|
+
|
|
75
136
|
async function whoamiHandler(ctx) {
|
|
76
137
|
const payload = await probeAuth(ctx.runtime);
|
|
77
138
|
const toolkits = payload.toolkit_connection_statuses || [];
|
|
78
|
-
const userId =
|
|
139
|
+
const userId = activeRuntimeUserId(ctx.runtime);
|
|
79
140
|
|
|
80
141
|
return ctx.output.emitSuccess({
|
|
81
142
|
command: ctx.spec.command_path.join(' '),
|
|
82
143
|
data: {
|
|
83
144
|
profile: ctx.runtime.profileName,
|
|
84
145
|
api_base: ctx.runtime.apiBase,
|
|
146
|
+
credential_source: ctx.runtime.credentialKind || null,
|
|
85
147
|
user_id: userId,
|
|
86
148
|
toolkit_count: toolkits.length,
|
|
87
149
|
toolkits: toolkits.map((t) => t.toolkit),
|
|
@@ -4,7 +4,12 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
|
|
5
5
|
import { CliError, EXIT_CODES } from '../runtime/errors.js';
|
|
6
6
|
import { getDesktopAuthRecovery, waitForDesktopAuth } from '../runtime/desktop-auth.js';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
credentialIsExpired,
|
|
9
|
+
getProfile,
|
|
10
|
+
loadConfig,
|
|
11
|
+
} from '../runtime/profiles.js';
|
|
12
|
+
import { ensureFreshOAuthCredential, loginWithOAuth } from '../runtime/oauth.js';
|
|
8
13
|
|
|
9
14
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
10
15
|
const BUNDLED_BRIEF_PATH = join(HERE, '..', '..', 'skills', 'notis-onboarding', 'BRIEF.md');
|
|
@@ -85,7 +90,20 @@ async function requestSignupLink(apiBase, { email, useCases }) {
|
|
|
85
90
|
async function startHandler(ctx) {
|
|
86
91
|
const { runtime, options, output } = ctx;
|
|
87
92
|
const apiBase = runtime.apiBase;
|
|
88
|
-
|
|
93
|
+
if (runtime.credentialKind === 'oauth') {
|
|
94
|
+
try {
|
|
95
|
+
await ensureFreshOAuthCredential(runtime);
|
|
96
|
+
} catch {
|
|
97
|
+
// A failed refresh is equivalent to no usable session here. Interactive
|
|
98
|
+
// starts may still authorize again; brief-only and agent runs surface
|
|
99
|
+
// the normal authentication recovery below.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
let authenticated = Boolean(runtime.jwt)
|
|
103
|
+
&& !credentialIsExpired(
|
|
104
|
+
runtime,
|
|
105
|
+
getProfile(loadConfig(runtime.worktreeRuntime), runtime.profileName),
|
|
106
|
+
);
|
|
89
107
|
|
|
90
108
|
// Safe to re-run: an already-authenticated machine skips straight to the brief.
|
|
91
109
|
// Agents retry commands, and a second signup would invalidate the first email.
|
|
@@ -108,6 +126,54 @@ async function startHandler(ctx) {
|
|
|
108
126
|
});
|
|
109
127
|
}
|
|
110
128
|
|
|
129
|
+
if (
|
|
130
|
+
!options.email
|
|
131
|
+
&& !runtime.agentMode
|
|
132
|
+
&& !runtime.nonInteractive
|
|
133
|
+
&& options.wait !== false
|
|
134
|
+
) {
|
|
135
|
+
let authorization;
|
|
136
|
+
let authorizationError;
|
|
137
|
+
try {
|
|
138
|
+
authorization = await loginWithOAuth(runtime, { browser: true }, output);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
// Keep the original OAuth failure while checking whether another process
|
|
141
|
+
// completed authentication before this attempt failed.
|
|
142
|
+
authorizationError = error;
|
|
143
|
+
authorization = null;
|
|
144
|
+
}
|
|
145
|
+
if (authorization?.agentAuthorization) {
|
|
146
|
+
return output.emitSuccess({
|
|
147
|
+
command: 'start',
|
|
148
|
+
data: { authenticated: false, ...authorization.agentAuthorization },
|
|
149
|
+
humanSummary: 'Open the authorization URL to finish signing in.',
|
|
150
|
+
renderHuman: () => `Authorize Notis CLI: ${authorization.agentAuthorization.authorize_url}`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
// A failed authorization leaves the stale credential that got us here in
|
|
154
|
+
// place, so re-apply the same expiry test used above rather than reporting
|
|
155
|
+
// a machine as signed in on the strength of a dead token.
|
|
156
|
+
authenticated = Boolean(runtime.jwt)
|
|
157
|
+
&& !credentialIsExpired(runtime, getProfile(loadConfig(runtime.worktreeRuntime), runtime.profileName));
|
|
158
|
+
if (authenticated) {
|
|
159
|
+
const brief = await fetchBrief(apiBase, runtime.timeoutMs);
|
|
160
|
+
return output.emitSuccess({
|
|
161
|
+
command: 'start',
|
|
162
|
+
data: {
|
|
163
|
+
authenticated: true,
|
|
164
|
+
credential_source: runtime.credentialKind,
|
|
165
|
+
brief: brief.markdown,
|
|
166
|
+
brief_source: brief.source,
|
|
167
|
+
},
|
|
168
|
+
humanSummary: 'Notis CLI is authenticated. Follow the onboarding brief below.',
|
|
169
|
+
renderHuman: () => brief.markdown || 'Notis CLI is authenticated.',
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (authorizationError) {
|
|
173
|
+
throw authorizationError;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
111
177
|
if (!options.email) {
|
|
112
178
|
// The one point in the flow that has to stop and talk to the human.
|
|
113
179
|
throw new CliError({
|
|
@@ -143,8 +209,29 @@ async function startHandler(ctx) {
|
|
|
143
209
|
const timeoutMs = Number(options.waitTimeoutMs) > 0 ? Number(options.waitTimeoutMs) : 300_000;
|
|
144
210
|
const jwt = await waitForDesktopAuth({
|
|
145
211
|
loadConfig,
|
|
146
|
-
getJwt,
|
|
147
|
-
|
|
212
|
+
getJwt: (config, profileName) => {
|
|
213
|
+
const profile = getProfile(config, profileName);
|
|
214
|
+
if (
|
|
215
|
+
profile.jwt
|
|
216
|
+
&& !credentialIsExpired(
|
|
217
|
+
{ credentialKind: 'desktop', jwt: profile.jwt },
|
|
218
|
+
profile,
|
|
219
|
+
)
|
|
220
|
+
) {
|
|
221
|
+
return profile.jwt;
|
|
222
|
+
}
|
|
223
|
+
if (
|
|
224
|
+
profile.oauth_access_token
|
|
225
|
+
&& !credentialIsExpired(
|
|
226
|
+
{ credentialKind: 'oauth', jwt: profile.oauth_access_token },
|
|
227
|
+
profile,
|
|
228
|
+
)
|
|
229
|
+
) {
|
|
230
|
+
return profile.oauth_access_token;
|
|
231
|
+
}
|
|
232
|
+
return undefined;
|
|
233
|
+
},
|
|
234
|
+
isJwtExpired: () => false,
|
|
148
235
|
profileName: runtime.profileName,
|
|
149
236
|
timeoutMs,
|
|
150
237
|
onTick: (remaining) => {
|
|
@@ -165,7 +252,8 @@ async function startHandler(ctx) {
|
|
|
165
252
|
command: `Install Notis Desktop: ${signup.desktop_download_url || 'https://notis.ai/channels/desktop-app'}`,
|
|
166
253
|
reason: 'The desktop app is what writes the CLI credential',
|
|
167
254
|
},
|
|
168
|
-
{ command: 'notis
|
|
255
|
+
{ command: 'notis login', reason: 'Authorize this machine directly in a browser' },
|
|
256
|
+
{ command: 'notis start --brief-only', reason: 'Resume once authentication is complete' },
|
|
169
257
|
{ command: 'notis doctor', reason: 'Re-check config, auth, and API reachability' },
|
|
170
258
|
],
|
|
171
259
|
});
|
|
@@ -183,7 +271,7 @@ async function startHandler(ctx) {
|
|
|
183
271
|
export const onboardingCommandSpecs = [
|
|
184
272
|
{
|
|
185
273
|
command_path: ['start'],
|
|
186
|
-
summary: 'Create or access a Notis account and
|
|
274
|
+
summary: 'Create or access a Notis account and authorize the CLI.',
|
|
187
275
|
when_to_use:
|
|
188
276
|
'Run this first on a new machine, before anything that needs auth. Safe to re-run: an already-signed-in machine just reprints the onboarding brief.',
|
|
189
277
|
args_schema: {
|
|
@@ -56,8 +56,8 @@ export function getDesktopAuthRecovery(runtime, { mode = 'expired' } = {}) {
|
|
|
56
56
|
// Leads the list: a machine that was never signed in may not even have an
|
|
57
57
|
// account yet, and `notis start` covers both cases.
|
|
58
58
|
hints.unshift({
|
|
59
|
-
command: 'notis
|
|
60
|
-
reason: '
|
|
59
|
+
command: 'notis login',
|
|
60
|
+
reason: 'Sign in or create an account in the browser and authorize this machine',
|
|
61
61
|
});
|
|
62
62
|
}
|
|
63
63
|
hints.push({
|
|
@@ -100,6 +100,18 @@ export async function waitForDesktopAuth({
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
export function createExpiredAuthError(runtime) {
|
|
103
|
+
if (runtime.credentialKind === 'oauth') {
|
|
104
|
+
return new CliError({
|
|
105
|
+
code: 'auth_expired',
|
|
106
|
+
message: 'Notis CLI OAuth authentication has expired',
|
|
107
|
+
exitCode: EXIT_CODES.auth,
|
|
108
|
+
details: { credential_source: 'oauth' },
|
|
109
|
+
hints: [
|
|
110
|
+
{ command: 'notis login', reason: 'Authorize a new scoped CLI credential' },
|
|
111
|
+
{ command: 'notis doctor', reason: 'Inspect the active credential state' },
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
}
|
|
103
115
|
if (runtime.credentialSource === 'env') {
|
|
104
116
|
return new CliError({
|
|
105
117
|
code: 'auth_expired',
|
|
@@ -130,6 +142,14 @@ export function createExpiredAuthError(runtime) {
|
|
|
130
142
|
}
|
|
131
143
|
|
|
132
144
|
export function createInvalidAuthHints(runtime) {
|
|
145
|
+
if (runtime?.credentialKind === 'oauth') {
|
|
146
|
+
return [
|
|
147
|
+
{
|
|
148
|
+
command: 'notis login',
|
|
149
|
+
reason: 'Authorize a new scoped CLI credential',
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
}
|
|
133
153
|
if (runtime?.credentialSource === 'env') {
|
|
134
154
|
return [
|
|
135
155
|
{
|