@neta-art/cohub-cli 1.2.0 → 1.4.0
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 +183 -13
- package/dist/auth.d.ts +41 -12
- package/dist/auth.js +249 -33
- package/dist/client.d.ts +1 -1
- package/dist/client.js +4 -2
- package/dist/commands/auth.js +93 -32
- package/dist/commands/channels.js +4 -14
- package/dist/commands/cron-jobs.js +6 -19
- package/dist/commands/generations.js +45 -50
- package/dist/commands/models.js +26 -6
- package/dist/commands/prompts.js +2 -6
- package/dist/commands/search.d.ts +2 -0
- package/dist/commands/search.js +104 -0
- package/dist/commands/session-access.js +4 -14
- package/dist/commands/spaces.d.ts +1 -0
- package/dist/commands/spaces.js +360 -150
- package/dist/commands/tasks.js +4 -11
- package/dist/index.js +40 -10
- package/dist/output.js +3 -0
- package/dist/self-update.d.ts +1 -0
- package/dist/self-update.js +136 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,38 +1,208 @@
|
|
|
1
1
|
# @neta-art/cohub-cli
|
|
2
2
|
|
|
3
|
-
CLI for [Cohub](https://cohub.run) —
|
|
3
|
+
CLI for [Cohub](https://cohub.run) — work with Spaces, Chats, files, Saves, Tasks, scheduled prompts, search, and multimodal generation from your terminal.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
npm install -g @neta-art/cohub-cli
|
|
9
|
+
cohub --help
|
|
9
10
|
```
|
|
10
11
|
|
|
11
|
-
##
|
|
12
|
+
## Sign in
|
|
13
|
+
|
|
14
|
+
For normal use outside Cohub Sandbox, sign in once:
|
|
12
15
|
|
|
13
16
|
```bash
|
|
14
|
-
cohub
|
|
17
|
+
cohub auth login
|
|
18
|
+
cohub auth whoami
|
|
15
19
|
```
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
The CLI will keep you signed in and refresh your session automatically.
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
Inside Cohub Sandbox or CI, `COHUB_EXECUTION_TOKEN` can be used as an ephemeral auth override.
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
## Environment
|
|
26
|
+
|
|
27
|
+
The CLI uses production by default.
|
|
23
28
|
|
|
24
29
|
Use the development environment with `ENV=dev`:
|
|
25
30
|
|
|
26
31
|
```bash
|
|
32
|
+
ENV=dev cohub auth login
|
|
27
33
|
ENV=dev cohub spaces ls
|
|
28
34
|
```
|
|
29
35
|
|
|
30
|
-
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
Use `--json` when reading output for decisions, extracting IDs, or chaining commands.
|
|
39
|
+
|
|
40
|
+
For command details:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
cohub -h
|
|
44
|
+
cohub spaces -h
|
|
45
|
+
cohub spaces prompt -h
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Terminology
|
|
49
|
+
|
|
50
|
+
| Product UI | CLI / API |
|
|
51
|
+
|---|---|
|
|
52
|
+
| Chat | Session |
|
|
53
|
+
| Save | Checkpoint |
|
|
54
|
+
| Tasks | Task runs |
|
|
55
|
+
| Scheduled prompt | `spaces prompt` schedule |
|
|
56
|
+
| Recurring scheduled prompt | Cron job |
|
|
57
|
+
|
|
58
|
+
## Spaces
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
cohub spaces ls --json
|
|
62
|
+
cohub spaces get <spaceId> --json
|
|
63
|
+
cohub spaces create --name "<name>" --description "<description>" --json
|
|
64
|
+
cohub spaces rename <spaceId> "<new name>"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Many space-scoped commands need a target Space:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
cohub -s <spaceId> spaces prompt "message" --json
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Chats and prompts
|
|
74
|
+
|
|
75
|
+
Use `spaces prompt` for immediate sends, delayed sends, one-time schedules, recurring schedules, new Chats, and existing Chats.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Send now
|
|
79
|
+
cohub -s <spaceId> spaces prompt "message" --json
|
|
80
|
+
|
|
81
|
+
# Send long content from stdin
|
|
82
|
+
cat prompt.md | cohub -s <spaceId> spaces prompt --json
|
|
83
|
+
|
|
84
|
+
# Send to an existing Chat
|
|
85
|
+
cohub -s <spaceId> spaces prompt --session <sessionId> "message" --json
|
|
86
|
+
|
|
87
|
+
# Create a new Chat and send
|
|
88
|
+
cohub -s <spaceId> spaces prompt --title "<chat title>" "message" --json
|
|
89
|
+
|
|
90
|
+
# Schedule once
|
|
91
|
+
cohub -s <spaceId> spaces prompt --at "2026-05-12T09:00:00+08:00" "message" --json
|
|
92
|
+
|
|
93
|
+
# Schedule recurring
|
|
94
|
+
cohub -s <spaceId> spaces prompt \
|
|
95
|
+
--cron "0 9 * * 1-5" \
|
|
96
|
+
--timezone "Asia/Shanghai" \
|
|
97
|
+
--title "Daily reminder" \
|
|
98
|
+
"message" \
|
|
99
|
+
--json
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Scheduling rules:
|
|
103
|
+
|
|
104
|
+
- Use only one of `--delay-ms`, `--at`, or `--cron`.
|
|
105
|
+
- `--cron` requires `--timezone`.
|
|
106
|
+
|
|
107
|
+
## Chats / Sessions
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
cohub -s <spaceId> spaces sessions ls --json
|
|
111
|
+
cohub -s <spaceId> spaces sessions create "<title>" --json
|
|
112
|
+
cohub -s <spaceId> spaces sessions get <sessionId> --json
|
|
113
|
+
cohub -s <spaceId> spaces sessions rename <sessionId> "<new title>"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Use `spaces prompt --session <sessionId>` to send to a Chat.
|
|
117
|
+
|
|
118
|
+
## Search
|
|
119
|
+
|
|
120
|
+
Search Spaces, Chats, and prior turns:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
cohub search "query"
|
|
124
|
+
cohub search "query" --limit 20 --json
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Models and multimodal generation
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
cohub models ls --json
|
|
131
|
+
cohub models ls --model-type multimodal --json
|
|
132
|
+
|
|
133
|
+
cohub generate "a calm lake at sunrise" \
|
|
134
|
+
--model <model> \
|
|
135
|
+
--output output.png \
|
|
136
|
+
--json
|
|
137
|
+
|
|
138
|
+
cohub generate "restyle this image" \
|
|
139
|
+
--model <model> \
|
|
140
|
+
--image ./input.png \
|
|
141
|
+
--param size=1024x1024 \
|
|
142
|
+
--json
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Supported inputs:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
--image <path-or-url>
|
|
149
|
+
--video <path-or-url>
|
|
150
|
+
--audio <path-or-url>
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Pass generation parameters with `--param key=value` or `--parameters '<json>'`.
|
|
154
|
+
|
|
155
|
+
## Files
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
cohub -s <spaceId> spaces files ls [path] --json
|
|
159
|
+
cohub -s <spaceId> spaces files cat <path>
|
|
160
|
+
cohub -s <spaceId> spaces files write <path> -c "<content>"
|
|
161
|
+
cohub -s <spaceId> spaces files upload <files...> --dir <dir>
|
|
162
|
+
cohub -s <spaceId> spaces files mv <from> <to>
|
|
163
|
+
cohub -s <spaceId> spaces files rm <path>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Confirm before deleting files or directories.
|
|
167
|
+
|
|
168
|
+
## Saves
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
cohub -s <spaceId> spaces checkpoints ls --json
|
|
172
|
+
cohub -s <spaceId> spaces checkpoints get <checkpointId> --json
|
|
173
|
+
cohub -s <spaceId> spaces checkpoints create "<description>" --json
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Tasks
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
cohub tasks ls --space <spaceId> --json
|
|
180
|
+
cohub tasks get <taskRunId> --json
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Create scheduled sends with `spaces prompt` scheduling flags, not task commands.
|
|
184
|
+
|
|
185
|
+
## Recurring scheduled prompts
|
|
186
|
+
|
|
187
|
+
Create recurring scheduled prompts with `spaces prompt --cron ... --timezone ...`.
|
|
188
|
+
|
|
189
|
+
Manage them with `cron-jobs`:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
cohub cron-jobs ls <spaceId> --json
|
|
193
|
+
cohub cron-jobs runs <cronJobId> --json
|
|
194
|
+
cohub cron-jobs toggle <cronJobId> on
|
|
195
|
+
cohub cron-jobs toggle <cronJobId> off
|
|
196
|
+
cohub cron-jobs delete <cronJobId>
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Confirm before enabling, disabling, or deleting recurring scheduled prompts.
|
|
31
200
|
|
|
32
|
-
|
|
33
|
-
- WebSocket: `wss://gateway-dev.cohub.run/ws`
|
|
201
|
+
## Safety
|
|
34
202
|
|
|
35
|
-
|
|
203
|
+
Confirm before:
|
|
36
204
|
|
|
37
|
-
-
|
|
38
|
-
-
|
|
205
|
+
- deleting files or directories
|
|
206
|
+
- creating scheduled or recurring prompts with side effects
|
|
207
|
+
- enabling, disabling, or deleting recurring scheduled prompts
|
|
208
|
+
- changing access policies, member roles, or membership
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,12 +1,41 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
import { type CohubEnvironment } from "@neta-art/cohub";
|
|
2
|
+
export type AuthSource = "execution-token" | "logto" | null;
|
|
3
|
+
type DeviceCode = {
|
|
4
|
+
deviceCode: string;
|
|
5
|
+
userCode: string;
|
|
6
|
+
verificationUri: string;
|
|
7
|
+
verificationUriComplete: string;
|
|
8
|
+
expiresAt: number;
|
|
9
|
+
interval: number;
|
|
10
|
+
createdAt: number;
|
|
11
|
+
};
|
|
12
|
+
type AuthSession = {
|
|
13
|
+
schemaVersion: 1;
|
|
14
|
+
env: CohubEnvironment;
|
|
15
|
+
issuer: string;
|
|
16
|
+
clientId: string;
|
|
17
|
+
resource: string;
|
|
18
|
+
scope: string;
|
|
19
|
+
tokenType: "Bearer";
|
|
20
|
+
accessToken: string;
|
|
21
|
+
refreshToken: string;
|
|
22
|
+
idToken?: string;
|
|
23
|
+
accessTokenExpiresAt: number;
|
|
24
|
+
createdAt: number;
|
|
25
|
+
updatedAt: number;
|
|
26
|
+
};
|
|
27
|
+
export declare class AuthRequiredError extends Error {
|
|
28
|
+
constructor(message?: string);
|
|
29
|
+
}
|
|
30
|
+
export declare const readAuthSession: () => AuthSession | null;
|
|
31
|
+
export declare const clearDeviceCode: () => void;
|
|
32
|
+
export declare const clearAuthSession: () => void;
|
|
33
|
+
export declare const authSource: () => AuthSource;
|
|
34
|
+
export declare function resolveAccessToken(): Promise<string | null>;
|
|
35
|
+
export declare function requireAccessToken(): Promise<string>;
|
|
36
|
+
export declare function refreshAccessToken(session?: AuthSession | null): Promise<string | null>;
|
|
37
|
+
export declare function requestDeviceCode(): Promise<DeviceCode>;
|
|
38
|
+
export declare function verifyDeviceCode(): Promise<AuthSession>;
|
|
39
|
+
export declare function loginWithDeviceFlow(onCode: (code: DeviceCode) => void, onPoll?: () => void): Promise<AuthSession>;
|
|
40
|
+
export declare function revokeAndClearAuthSession(): Promise<void>;
|
|
41
|
+
export {};
|
package/dist/auth.js
CHANGED
|
@@ -1,43 +1,259 @@
|
|
|
1
1
|
import { resolveCohubEnvironment } from "@neta-art/cohub";
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
export function resolveToken() {
|
|
15
|
-
if (process.env.COHUB_EXECUTION_TOKEN) {
|
|
16
|
-
return process.env.COHUB_EXECUTION_TOKEN.trim();
|
|
17
|
-
}
|
|
18
|
-
const path = getTokenPath();
|
|
19
|
-
if (existsSync(path)) {
|
|
20
|
-
return readFileSync(path, "utf-8").trim();
|
|
5
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
6
|
+
const CONFIG_DIR = join(homedir(), ".config", "cohub");
|
|
7
|
+
const EXPIRY_SKEW_MS = 5 * 60 * 1000;
|
|
8
|
+
const DEFAULT_SCOPE = "openid profile email offline_access";
|
|
9
|
+
const DEFAULT_RESOURCE = "https://api.talesofai";
|
|
10
|
+
export class AuthRequiredError extends Error {
|
|
11
|
+
constructor(message = "Not authenticated") {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "AuthRequiredError";
|
|
21
14
|
}
|
|
15
|
+
}
|
|
16
|
+
const currentEnv = () => resolveCohubEnvironment();
|
|
17
|
+
const normalizeUrl = (url) => url.trim().replace(/\/+$/, "");
|
|
18
|
+
const authConfig = (env = currentEnv()) => {
|
|
19
|
+
const prefix = env === "dev" ? "COHUB_DEV" : "COHUB";
|
|
20
|
+
const defaultIssuer = env === "dev" ? "https://dev-auth.neta.art" : "https://auth.neta.art";
|
|
21
|
+
const defaultClientId = env === "dev" ? "u2fnfgvf8f16dnt8si2bv" : "f8d26cdlwx85b0e5l3om2";
|
|
22
|
+
return {
|
|
23
|
+
issuer: normalizeUrl(process.env[`${prefix}_AUTH_ISSUER`] ?? process.env.COHUB_AUTH_ISSUER ?? defaultIssuer),
|
|
24
|
+
clientId: process.env[`${prefix}_AUTH_CLIENT_ID`] ?? process.env.COHUB_AUTH_CLIENT_ID ?? defaultClientId,
|
|
25
|
+
resource: process.env[`${prefix}_AUTH_RESOURCE`] ?? process.env.COHUB_AUTH_RESOURCE ?? DEFAULT_RESOURCE,
|
|
26
|
+
scope: process.env[`${prefix}_AUTH_SCOPE`] ?? process.env.COHUB_AUTH_SCOPE ?? DEFAULT_SCOPE,
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
const sessionPath = (env = currentEnv()) => join(CONFIG_DIR, env === "dev" ? "auth.dev.json" : "auth.json");
|
|
30
|
+
const deviceCodePath = (env = currentEnv()) => join(CONFIG_DIR, env === "dev" ? "device-code.dev.json" : "device-code.json");
|
|
31
|
+
const legacyTokenPath = (env = currentEnv()) => join(CONFIG_DIR, env === "dev" ? "token.dev" : "token");
|
|
32
|
+
const writePrivateJson = (path, value) => {
|
|
33
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
34
|
+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
35
|
+
};
|
|
36
|
+
const readJson = (path) => {
|
|
37
|
+
if (!existsSync(path))
|
|
38
|
+
return null;
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const removeIfExists = (path) => {
|
|
47
|
+
if (existsSync(path))
|
|
48
|
+
rmSync(path);
|
|
49
|
+
};
|
|
50
|
+
const tokenEndpoint = (issuer) => `${issuer}/oidc/token`;
|
|
51
|
+
const deviceEndpoint = (issuer) => `${issuer}/oidc/device/auth`;
|
|
52
|
+
const revocationEndpoint = (issuer) => `${issuer}/oidc/token/revocation`;
|
|
53
|
+
const formPost = async (url, body) => {
|
|
54
|
+
const response = await fetch(url, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
57
|
+
body,
|
|
58
|
+
});
|
|
59
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
60
|
+
const data = contentType.includes("application/json")
|
|
61
|
+
? await response.json().catch(() => null)
|
|
62
|
+
: await response.text().catch(() => null);
|
|
63
|
+
return { response, data };
|
|
64
|
+
};
|
|
65
|
+
const toNonEmptyString = (value, field) => {
|
|
66
|
+
if (typeof value !== "string" || !value.trim())
|
|
67
|
+
throw new Error(`Invalid auth response: missing ${field}`);
|
|
68
|
+
return value;
|
|
69
|
+
};
|
|
70
|
+
const toPositiveNumber = (value, field) => {
|
|
71
|
+
const number = typeof value === "number" ? value : Number(value);
|
|
72
|
+
if (!Number.isFinite(number) || number <= 0)
|
|
73
|
+
throw new Error(`Invalid auth response: invalid ${field}`);
|
|
74
|
+
return number;
|
|
75
|
+
};
|
|
76
|
+
const authErrorCode = (data) => {
|
|
77
|
+
if (typeof data !== "object" || data === null)
|
|
78
|
+
return null;
|
|
79
|
+
const error = data.error;
|
|
80
|
+
return typeof error === "string" ? error : null;
|
|
81
|
+
};
|
|
82
|
+
const isUnrecoverableRefreshError = (data) => {
|
|
83
|
+
const code = authErrorCode(data);
|
|
84
|
+
return code === "invalid_grant" || code === "invalid_token";
|
|
85
|
+
};
|
|
86
|
+
const toSession = (token, config, env, previous) => {
|
|
87
|
+
const accessToken = toNonEmptyString(token.access_token, "access_token");
|
|
88
|
+
const expiresIn = toPositiveNumber(token.expires_in, "expires_in");
|
|
89
|
+
if (token.token_type !== "Bearer")
|
|
90
|
+
throw new Error(`Unsupported token type: ${token.token_type}`);
|
|
91
|
+
if (token.refresh_token !== undefined && !token.refresh_token.trim())
|
|
92
|
+
throw new Error("Invalid auth response: invalid refresh_token");
|
|
93
|
+
if (!token.refresh_token && !previous?.refreshToken)
|
|
94
|
+
throw new Error("Logto did not return a refresh token");
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
return {
|
|
97
|
+
schemaVersion: 1,
|
|
98
|
+
env,
|
|
99
|
+
issuer: config.issuer,
|
|
100
|
+
clientId: config.clientId,
|
|
101
|
+
resource: config.resource,
|
|
102
|
+
scope: token.scope ?? config.scope,
|
|
103
|
+
tokenType: "Bearer",
|
|
104
|
+
accessToken,
|
|
105
|
+
refreshToken: token.refresh_token ?? previous?.refreshToken ?? "",
|
|
106
|
+
idToken: token.id_token ?? previous?.idToken,
|
|
107
|
+
accessTokenExpiresAt: now + expiresIn * 1000,
|
|
108
|
+
createdAt: previous?.createdAt ?? now,
|
|
109
|
+
updatedAt: now,
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
export const readAuthSession = () => readJson(sessionPath());
|
|
113
|
+
export const clearDeviceCode = () => {
|
|
114
|
+
removeIfExists(deviceCodePath());
|
|
115
|
+
};
|
|
116
|
+
export const clearAuthSession = () => {
|
|
117
|
+
removeIfExists(sessionPath());
|
|
118
|
+
clearDeviceCode();
|
|
119
|
+
removeIfExists(legacyTokenPath());
|
|
120
|
+
};
|
|
121
|
+
export const authSource = () => {
|
|
122
|
+
if (process.env.COHUB_EXECUTION_TOKEN?.trim())
|
|
123
|
+
return "execution-token";
|
|
124
|
+
if (readAuthSession())
|
|
125
|
+
return "logto";
|
|
22
126
|
return null;
|
|
127
|
+
};
|
|
128
|
+
export async function resolveAccessToken() {
|
|
129
|
+
const executionToken = process.env.COHUB_EXECUTION_TOKEN?.trim();
|
|
130
|
+
if (executionToken)
|
|
131
|
+
return executionToken;
|
|
132
|
+
const session = readAuthSession();
|
|
133
|
+
if (!session)
|
|
134
|
+
throw new AuthRequiredError();
|
|
135
|
+
if (session.accessTokenExpiresAt - Date.now() > EXPIRY_SKEW_MS)
|
|
136
|
+
return session.accessToken;
|
|
137
|
+
return refreshAccessToken(session);
|
|
23
138
|
}
|
|
24
|
-
export function
|
|
25
|
-
const
|
|
26
|
-
if (!
|
|
27
|
-
throw new
|
|
28
|
-
|
|
29
|
-
writeFileSync(getTokenPath(), trimmed);
|
|
139
|
+
export async function requireAccessToken() {
|
|
140
|
+
const token = await resolveAccessToken();
|
|
141
|
+
if (!token)
|
|
142
|
+
throw new AuthRequiredError();
|
|
143
|
+
return token;
|
|
30
144
|
}
|
|
31
|
-
export function
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
145
|
+
export async function refreshAccessToken(session = readAuthSession()) {
|
|
146
|
+
if (!session?.refreshToken)
|
|
147
|
+
throw new AuthRequiredError();
|
|
148
|
+
const config = authConfig(session.env);
|
|
149
|
+
const { response, data } = await formPost(tokenEndpoint(session.issuer), new URLSearchParams({
|
|
150
|
+
client_id: session.clientId,
|
|
151
|
+
grant_type: "refresh_token",
|
|
152
|
+
refresh_token: session.refreshToken,
|
|
153
|
+
scope: session.scope,
|
|
154
|
+
resource: session.resource,
|
|
155
|
+
}));
|
|
156
|
+
if (!response.ok) {
|
|
157
|
+
if (isUnrecoverableRefreshError(data)) {
|
|
158
|
+
clearAuthSession();
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
throw new Error(formatAuthError(data, response.status));
|
|
35
162
|
}
|
|
163
|
+
const next = toSession(data, config, session.env, session);
|
|
164
|
+
writePrivateJson(sessionPath(session.env), next);
|
|
165
|
+
return next.accessToken;
|
|
36
166
|
}
|
|
37
|
-
export function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
167
|
+
export async function requestDeviceCode() {
|
|
168
|
+
const env = currentEnv();
|
|
169
|
+
const config = authConfig(env);
|
|
170
|
+
const { response, data } = await formPost(deviceEndpoint(config.issuer), new URLSearchParams({
|
|
171
|
+
client_id: config.clientId,
|
|
172
|
+
scope: config.scope,
|
|
173
|
+
resource: config.resource,
|
|
174
|
+
}));
|
|
175
|
+
if (!response.ok || typeof data !== "object" || data === null) {
|
|
176
|
+
throw new Error(typeof data === "string" ? data : `Failed to request device code: HTTP ${response.status}`);
|
|
177
|
+
}
|
|
178
|
+
const body = data;
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
const expiresIn = toPositiveNumber(body.expires_in, "expires_in");
|
|
181
|
+
const interval = body.interval === undefined ? 5 : toPositiveNumber(body.interval, "interval");
|
|
182
|
+
const verificationUri = toNonEmptyString(body.verification_uri, "verification_uri");
|
|
183
|
+
const deviceCode = {
|
|
184
|
+
deviceCode: toNonEmptyString(body.device_code, "device_code"),
|
|
185
|
+
userCode: toNonEmptyString(body.user_code, "user_code"),
|
|
186
|
+
verificationUri,
|
|
187
|
+
verificationUriComplete: body.verification_uri_complete === undefined
|
|
188
|
+
? verificationUri
|
|
189
|
+
: toNonEmptyString(body.verification_uri_complete, "verification_uri_complete"),
|
|
190
|
+
expiresAt: now + expiresIn * 1000,
|
|
191
|
+
interval,
|
|
192
|
+
createdAt: now,
|
|
193
|
+
};
|
|
194
|
+
writePrivateJson(deviceCodePath(env), deviceCode);
|
|
195
|
+
return deviceCode;
|
|
196
|
+
}
|
|
197
|
+
export async function verifyDeviceCode() {
|
|
198
|
+
const env = currentEnv();
|
|
199
|
+
const config = authConfig(env);
|
|
200
|
+
const deviceCode = readJson(deviceCodePath(env));
|
|
201
|
+
if (!deviceCode)
|
|
202
|
+
throw new Error("Device code not found. Run `cohub auth login --request-code` first.");
|
|
203
|
+
if (deviceCode.expiresAt <= Date.now())
|
|
204
|
+
throw new Error("Device code expired. Run `cohub auth login` again.");
|
|
205
|
+
const { response, data } = await formPost(tokenEndpoint(config.issuer), new URLSearchParams({
|
|
206
|
+
client_id: config.clientId,
|
|
207
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
208
|
+
device_code: deviceCode.deviceCode,
|
|
209
|
+
resource: config.resource,
|
|
210
|
+
}));
|
|
211
|
+
if (!response.ok)
|
|
212
|
+
throw new Error(formatAuthError(data, response.status));
|
|
213
|
+
const session = toSession(data, config, env);
|
|
214
|
+
writePrivateJson(sessionPath(env), session);
|
|
215
|
+
removeIfExists(deviceCodePath(env));
|
|
216
|
+
return session;
|
|
217
|
+
}
|
|
218
|
+
export async function loginWithDeviceFlow(onCode, onPoll) {
|
|
219
|
+
const code = await requestDeviceCode();
|
|
220
|
+
onCode(code);
|
|
221
|
+
while (Date.now() < code.expiresAt) {
|
|
222
|
+
await delay(code.interval * 1000);
|
|
223
|
+
onPoll?.();
|
|
224
|
+
try {
|
|
225
|
+
return await verifyDeviceCode();
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
229
|
+
if (message.includes("authorization_pending"))
|
|
230
|
+
continue;
|
|
231
|
+
if (message.includes("slow_down")) {
|
|
232
|
+
await delay(code.interval * 1000);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
throw e;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
throw new Error("Login timed out. Run `cohub auth login` again.");
|
|
239
|
+
}
|
|
240
|
+
export async function revokeAndClearAuthSession() {
|
|
241
|
+
const session = readAuthSession();
|
|
242
|
+
if (session?.refreshToken) {
|
|
243
|
+
await formPost(revocationEndpoint(session.issuer), new URLSearchParams({
|
|
244
|
+
client_id: session.clientId,
|
|
245
|
+
token: session.refreshToken,
|
|
246
|
+
token_type_hint: "refresh_token",
|
|
247
|
+
})).catch(() => null);
|
|
248
|
+
}
|
|
249
|
+
clearAuthSession();
|
|
250
|
+
}
|
|
251
|
+
function formatAuthError(data, status) {
|
|
252
|
+
if (typeof data === "object" && data !== null) {
|
|
253
|
+
const record = data;
|
|
254
|
+
const code = String(record.error ?? "");
|
|
255
|
+
const description = String(record.error_description ?? "");
|
|
256
|
+
return [code, description].filter(Boolean).join(": ") || `Authentication failed: HTTP ${status}`;
|
|
257
|
+
}
|
|
258
|
+
return typeof data === "string" && data ? data : `Authentication failed: HTTP ${status}`;
|
|
43
259
|
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import { CohubHttpClient } from "@neta-art/cohub";
|
|
2
|
-
export declare function createClient(
|
|
2
|
+
export declare function createClient(): CohubHttpClient;
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { CohubHttpClient } from "@neta-art/cohub";
|
|
2
|
-
|
|
2
|
+
import { clearDeviceCode, resolveAccessToken } from "./auth.js";
|
|
3
|
+
export function createClient() {
|
|
3
4
|
return new CohubHttpClient({
|
|
4
|
-
getAccessToken:
|
|
5
|
+
getAccessToken: resolveAccessToken,
|
|
6
|
+
onUnauthorized: clearDeviceCode,
|
|
5
7
|
});
|
|
6
8
|
}
|