@kweaver-ai/kweaver-sdk 0.4.12 → 0.4.13
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 +27 -2
- package/README.zh.md +27 -2
- package/dist/api/dataflow.d.ts +2 -0
- package/dist/api/dataflow.js +5 -3
- package/dist/api/dataviews.d.ts +41 -1
- package/dist/api/dataviews.js +58 -5
- package/dist/api/vega.js +1 -1
- package/dist/auth/oauth.d.ts +21 -0
- package/dist/auth/oauth.js +226 -54
- package/dist/cli.js +5 -2
- package/dist/commands/agent.js +15 -15
- package/dist/commands/auth.js +80 -6
- package/dist/commands/bkn.d.ts +11 -0
- package/dist/commands/bkn.js +90 -55
- package/dist/commands/dataview.js +61 -2
- package/dist/commands/vega.js +7 -7
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/resources/dataviews.d.ts +10 -1
- package/dist/resources/dataviews.js +14 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -102,6 +102,11 @@ const exact = await client.dataviews.find("orders", {
|
|
|
102
102
|
wait: true,
|
|
103
103
|
});
|
|
104
104
|
const dv = await client.dataviews.get(viewId);
|
|
105
|
+
const queryRows = await client.dataviews.query(viewId, {
|
|
106
|
+
sql: "SELECT id, name FROM orders LIMIT 10",
|
|
107
|
+
limit: 10,
|
|
108
|
+
needTotal: true,
|
|
109
|
+
});
|
|
105
110
|
|
|
106
111
|
// Dataflow automation (CSV import pipeline, etc.)
|
|
107
112
|
const result = await client.dataflows.execute({
|
|
@@ -121,12 +126,15 @@ const results = await cl.search({ query: "hypertension treatment" });
|
|
|
121
126
|
## CLI Reference
|
|
122
127
|
|
|
123
128
|
```
|
|
124
|
-
kweaver auth login <url> [--alias name] [-u user] [-p pass] [--playwright] [--insecure|-k]
|
|
129
|
+
kweaver auth login <url> [--alias name] [-u user] [-p pass] [--playwright] [--insecure|-k]
|
|
130
|
+
kweaver auth login <url> --client-id ID --client-secret S --refresh-token T (headless login)
|
|
131
|
+
kweaver auth export [url|alias] [--json] (export command to run on a headless host)
|
|
132
|
+
kweaver auth status/list/use/delete/logout
|
|
125
133
|
kweaver token
|
|
126
134
|
kweaver config show / set-bd <value>
|
|
127
135
|
kweaver ds list/get/delete/tables/connect
|
|
128
136
|
kweaver ds import-csv <ds_id> --files <glob> [--table-prefix <p>] [--batch-size 500]
|
|
129
|
-
kweaver dataview list/find/get/delete
|
|
137
|
+
kweaver dataview list/find/get/query/delete
|
|
130
138
|
kweaver bkn list/get/stats/export/create/update/delete
|
|
131
139
|
kweaver bkn create-from-ds <ds_id> --name <name> [--tables t1,t2] [--build]
|
|
132
140
|
kweaver bkn create-from-csv <ds_id> --files <glob> --name <name> [--build]
|
|
@@ -179,6 +187,23 @@ If you encounter errors like `fetch failed`, `self-signed certificate`, or `UNAB
|
|
|
179
187
|
|
|
180
188
|
> **Security note:** All of the above disable HTTPS certificate verification and should only be used in development or internal network environments. Use trusted CA-signed certificates in production.
|
|
181
189
|
|
|
190
|
+
### Headless / Server Authentication
|
|
191
|
+
|
|
192
|
+
For servers or CI environments without a browser, log in on any machine that has one, then transfer credentials:
|
|
193
|
+
|
|
194
|
+
**Step 1 — Browser machine:** Run `kweaver auth login` as usual. The callback page displays a ready-to-copy command with `--client-id`, `--client-secret`, and `--refresh-token`. Alternatively, run `kweaver auth export` to print the same command.
|
|
195
|
+
|
|
196
|
+
**Step 2 — On the machine without a browser:** Run the pasted command there (SSH server, CI, etc.):
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
kweaver auth login https://your-platform \
|
|
200
|
+
--client-id abc123 \
|
|
201
|
+
--client-secret def456 \
|
|
202
|
+
--refresh-token ghi789
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The SDK exchanges the refresh token for a new access token and saves it locally. Auto-refresh works normally from that point on.
|
|
206
|
+
|
|
182
207
|
## Using with AI Agents
|
|
183
208
|
|
|
184
209
|
Install the KWeaver skill for Claude Code, Cursor, or other AI coding agents:
|
package/README.zh.md
CHANGED
|
@@ -101,6 +101,11 @@ const exact = await client.dataviews.find("orders", {
|
|
|
101
101
|
wait: true,
|
|
102
102
|
});
|
|
103
103
|
const dv = await client.dataviews.get(viewId);
|
|
104
|
+
const queryRows = await client.dataviews.query(viewId, {
|
|
105
|
+
sql: "SELECT id, name FROM orders LIMIT 10",
|
|
106
|
+
limit: 10,
|
|
107
|
+
needTotal: true,
|
|
108
|
+
});
|
|
104
109
|
|
|
105
110
|
// Context Loader(通过 MCP 对 BKN 做语义搜索)
|
|
106
111
|
const cl = client.contextLoader(mcpUrl, "bkn-id");
|
|
@@ -110,10 +115,13 @@ const results = await cl.search({ query: "高血压 治疗" });
|
|
|
110
115
|
## 命令速查
|
|
111
116
|
|
|
112
117
|
```
|
|
113
|
-
kweaver auth login <url> [--alias name] [-u user] [-p pass] [--playwright] [--insecure|-k]
|
|
118
|
+
kweaver auth login <url> [--alias name] [-u user] [-p pass] [--playwright] [--insecure|-k]
|
|
119
|
+
kweaver auth login <url> --client-id ID --client-secret S --refresh-token T (无浏览器登录)
|
|
120
|
+
kweaver auth export [url|alias] [--json] (导出在无浏览器机器上运行的命令)
|
|
121
|
+
kweaver auth status/list/use/delete/logout
|
|
114
122
|
kweaver token
|
|
115
123
|
kweaver ds list/get/delete/tables/connect
|
|
116
|
-
kweaver dataview list/find/get/delete
|
|
124
|
+
kweaver dataview list/find/get/query/delete
|
|
117
125
|
kweaver bkn list/get/stats/export/create/update/delete
|
|
118
126
|
kweaver bkn object-type list/get/create/update/delete/query/properties
|
|
119
127
|
kweaver bkn relation-type list/get/create/update/delete
|
|
@@ -162,6 +170,23 @@ kweaver call <path> [-X METHOD] [-d BODY] [-H header]
|
|
|
162
170
|
|
|
163
171
|
> **安全提示:** 以上方式均会跳过 HTTPS 证书校验,仅适用于开发/内网环境。生产环境请使用受信任的 CA 签发证书。
|
|
164
172
|
|
|
173
|
+
### 无浏览器 / 服务器端认证
|
|
174
|
+
|
|
175
|
+
适用于 SSH 远程服务器、CI 环境等无浏览器场景:
|
|
176
|
+
|
|
177
|
+
**第一步 — 有浏览器的机器:** 正常运行 `kweaver auth login`。登录成功后,回调页面会显示一条可复制的命令(含 `--client-id`、`--client-secret`、`--refresh-token`)。也可以用 `kweaver auth export` 查看。
|
|
178
|
+
|
|
179
|
+
**第二步 — 在没有浏览器的那台机器上:** 在 SSH 服务器、CI 等环境中执行下面这条命令:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
kweaver auth login https://your-platform \
|
|
183
|
+
--client-id abc123 \
|
|
184
|
+
--client-secret def456 \
|
|
185
|
+
--refresh-token ghi789
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
SDK 会用 refresh token 换取新的 access token 并保存到本地,之后自动续期正常工作。
|
|
189
|
+
|
|
165
190
|
## 在 AI 智能体中使用
|
|
166
191
|
|
|
167
192
|
为 Claude Code、Cursor 等 AI 编程助手安装 KWeaver 技能:
|
package/dist/api/dataflow.d.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface PollDataflowOptions {
|
|
|
45
45
|
interval?: number;
|
|
46
46
|
/** Maximum time to wait in seconds. Default: 900 */
|
|
47
47
|
timeout?: number;
|
|
48
|
+
/** Test injection: override sleep function. */
|
|
49
|
+
_sleep?: (ms: number) => Promise<void>;
|
|
48
50
|
}
|
|
49
51
|
/**
|
|
50
52
|
* Poll GET /api/automation/v1/dag/{dagId}/results until the run is done.
|
package/dist/api/dataflow.js
CHANGED
|
@@ -69,10 +69,11 @@ export async function runDataflow(options) {
|
|
|
69
69
|
* Throws on "failed"/"error" status or timeout.
|
|
70
70
|
*/
|
|
71
71
|
export async function pollDataflowResults(options) {
|
|
72
|
-
const { baseUrl, accessToken, businessDomain = "bd_public", dagId, interval = 3, timeout = 900, } = options;
|
|
72
|
+
const { baseUrl, accessToken, businessDomain = "bd_public", dagId, interval = 3, timeout = 900, _sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), } = options;
|
|
73
73
|
const base = baseUrl.replace(/\/+$/, "");
|
|
74
74
|
const url = `${base}/api/automation/v1/dag/${encodeURIComponent(dagId)}/results`;
|
|
75
75
|
const deadlineMs = Date.now() + timeout * 1000;
|
|
76
|
+
let currentInterval = interval;
|
|
76
77
|
while (Date.now() < deadlineMs) {
|
|
77
78
|
const response = await fetch(url, {
|
|
78
79
|
method: "GET",
|
|
@@ -95,9 +96,10 @@ export async function pollDataflowResults(options) {
|
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
98
|
// Still running — wait before next poll
|
|
98
|
-
if (
|
|
99
|
-
await
|
|
99
|
+
if (currentInterval > 0) {
|
|
100
|
+
await _sleep(currentInterval * 1000);
|
|
100
101
|
}
|
|
102
|
+
currentInterval = Math.min(currentInterval * 2, 30);
|
|
101
103
|
}
|
|
102
104
|
throw new Error(`Dataflow polling timed out after ${timeout}s for DAG ${dagId}`);
|
|
103
105
|
}
|
package/dist/api/dataviews.d.ts
CHANGED
|
@@ -11,7 +11,18 @@ export interface DataView {
|
|
|
11
11
|
name: string;
|
|
12
12
|
query_type: string;
|
|
13
13
|
datasource_id: string;
|
|
14
|
-
|
|
14
|
+
/** View type, e.g. "atomic" or "custom". */
|
|
15
|
+
type?: string;
|
|
16
|
+
/** Underlying data source engine, e.g. "mysql", "postgresql". */
|
|
17
|
+
data_source_type?: string;
|
|
18
|
+
/** Human-readable data source name. */
|
|
19
|
+
data_source_name?: string;
|
|
20
|
+
/** Full SQL expression stored in the view definition (Trino catalog.schema.table). */
|
|
21
|
+
sql_str?: string;
|
|
22
|
+
/** Fully-qualified table reference (catalog."schema"."table"). */
|
|
23
|
+
meta_table_name?: string;
|
|
24
|
+
/** Field metadata. Populated by `get`; absent (`undefined`) in `list` results. */
|
|
25
|
+
fields?: ViewField[];
|
|
15
26
|
}
|
|
16
27
|
export declare function parseDataView(raw: Record<string, unknown>): DataView;
|
|
17
28
|
export interface CreateDataViewOptions {
|
|
@@ -75,3 +86,32 @@ export interface FindDataViewOptions {
|
|
|
75
86
|
* applies client-side `name ===` filter. Optional polling with exponential backoff.
|
|
76
87
|
*/
|
|
77
88
|
export declare function findDataView(options: FindDataViewOptions): Promise<DataView[]>;
|
|
89
|
+
/** Options for querying data view rows via mdl-uniquery (SQL / view definition). */
|
|
90
|
+
export interface QueryDataViewOptions {
|
|
91
|
+
baseUrl: string;
|
|
92
|
+
accessToken: string;
|
|
93
|
+
id: string;
|
|
94
|
+
sql?: string;
|
|
95
|
+
offset?: number;
|
|
96
|
+
limit?: number;
|
|
97
|
+
needTotal?: boolean;
|
|
98
|
+
outputFields?: string[];
|
|
99
|
+
filters?: Record<string, unknown>;
|
|
100
|
+
sort?: Array<Record<string, unknown>>;
|
|
101
|
+
businessDomain?: string;
|
|
102
|
+
}
|
|
103
|
+
/** Query result from mdl-uniquery data-views POST (shape varies by backend). */
|
|
104
|
+
export interface DataViewQueryResult {
|
|
105
|
+
columns?: Array<{
|
|
106
|
+
name: string;
|
|
107
|
+
type?: string;
|
|
108
|
+
vega_type?: string;
|
|
109
|
+
}>;
|
|
110
|
+
entries?: unknown;
|
|
111
|
+
total_count?: number;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Execute a query against a data view (POST /api/mdl-uniquery/v1/data-views/:id).
|
|
115
|
+
* When `sql` is omitted, the server uses the view's stored SQL definition.
|
|
116
|
+
*/
|
|
117
|
+
export declare function queryDataView(options: QueryDataViewOptions): Promise<DataViewQueryResult>;
|
package/dist/api/dataviews.js
CHANGED
|
@@ -26,8 +26,9 @@ function extractViewId(data) {
|
|
|
26
26
|
}
|
|
27
27
|
export function parseDataView(raw) {
|
|
28
28
|
const fieldsRaw = raw.fields;
|
|
29
|
-
|
|
30
|
-
if (Array.isArray(fieldsRaw)) {
|
|
29
|
+
let fields;
|
|
30
|
+
if (Array.isArray(fieldsRaw) && fieldsRaw.length > 0) {
|
|
31
|
+
fields = [];
|
|
31
32
|
for (const f of fieldsRaw) {
|
|
32
33
|
if (f && typeof f === "object") {
|
|
33
34
|
const fr = f;
|
|
@@ -40,13 +41,25 @@ export function parseDataView(raw) {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
|
-
|
|
44
|
+
const dv = {
|
|
44
45
|
id: String(raw.id ?? ""),
|
|
45
46
|
name: String(raw.name ?? ""),
|
|
46
47
|
query_type: String(raw.query_type ?? "SQL"),
|
|
47
48
|
datasource_id: String(raw.data_source_id ?? raw.group_id ?? ""),
|
|
48
|
-
fields,
|
|
49
49
|
};
|
|
50
|
+
if (raw.type != null)
|
|
51
|
+
dv.type = String(raw.type);
|
|
52
|
+
if (raw.data_source_type != null)
|
|
53
|
+
dv.data_source_type = String(raw.data_source_type);
|
|
54
|
+
if (raw.data_source_name != null)
|
|
55
|
+
dv.data_source_name = String(raw.data_source_name);
|
|
56
|
+
if (raw.sql_str != null)
|
|
57
|
+
dv.sql_str = String(raw.sql_str);
|
|
58
|
+
if (raw.meta_table_name != null)
|
|
59
|
+
dv.meta_table_name = String(raw.meta_table_name);
|
|
60
|
+
if (fields)
|
|
61
|
+
dv.fields = fields;
|
|
62
|
+
return dv;
|
|
50
63
|
}
|
|
51
64
|
function extractListPayload(data) {
|
|
52
65
|
if (Array.isArray(data))
|
|
@@ -129,7 +142,7 @@ async function findDataViewByName(options) {
|
|
|
129
142
|
return match?.id ?? null;
|
|
130
143
|
}
|
|
131
144
|
export async function listDataViews(options) {
|
|
132
|
-
const { baseUrl, accessToken, businessDomain = "bd_public", datasourceId, name, type, limit =
|
|
145
|
+
const { baseUrl, accessToken, businessDomain = "bd_public", datasourceId, name, type, limit = 30, } = options;
|
|
133
146
|
const base = baseUrl.replace(/\/+$/, "");
|
|
134
147
|
const url = new URL(`${base}/api/mdl-data-model/v1/data-views`);
|
|
135
148
|
url.searchParams.set("limit", String(limit));
|
|
@@ -219,3 +232,43 @@ export async function findDataView(options) {
|
|
|
219
232
|
await sleepMs(delayMs);
|
|
220
233
|
}
|
|
221
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* Execute a query against a data view (POST /api/mdl-uniquery/v1/data-views/:id).
|
|
237
|
+
* When `sql` is omitted, the server uses the view's stored SQL definition.
|
|
238
|
+
*/
|
|
239
|
+
export async function queryDataView(options) {
|
|
240
|
+
const { baseUrl, accessToken, id, sql, offset = 0, limit = 50, needTotal = false, outputFields, filters, sort, businessDomain = "bd_public", } = options;
|
|
241
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
242
|
+
const url = `${base}/api/mdl-uniquery/v1/data-views/${encodeURIComponent(id)}`;
|
|
243
|
+
const body = {
|
|
244
|
+
offset,
|
|
245
|
+
limit,
|
|
246
|
+
need_total: needTotal,
|
|
247
|
+
};
|
|
248
|
+
if (sql !== undefined && sql !== "")
|
|
249
|
+
body.sql = sql;
|
|
250
|
+
if (outputFields !== undefined)
|
|
251
|
+
body.output_fields = outputFields;
|
|
252
|
+
if (filters !== undefined)
|
|
253
|
+
body.filters = filters;
|
|
254
|
+
if (sort !== undefined)
|
|
255
|
+
body.sort = sort;
|
|
256
|
+
const response = await fetch(url, {
|
|
257
|
+
method: "POST",
|
|
258
|
+
headers: {
|
|
259
|
+
...buildHeaders(accessToken, businessDomain),
|
|
260
|
+
"content-type": "application/json",
|
|
261
|
+
"x-http-method-override": "GET",
|
|
262
|
+
},
|
|
263
|
+
body: JSON.stringify(body),
|
|
264
|
+
});
|
|
265
|
+
const bodyText = await response.text();
|
|
266
|
+
if (!response.ok) {
|
|
267
|
+
throw new HttpError(response.status, response.statusText, bodyText);
|
|
268
|
+
}
|
|
269
|
+
const parsed = JSON.parse(bodyText);
|
|
270
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
271
|
+
return parsed;
|
|
272
|
+
}
|
|
273
|
+
return {};
|
|
274
|
+
}
|
package/dist/api/vega.js
CHANGED
|
@@ -190,7 +190,7 @@ export async function queryVegaResourceData(options) {
|
|
|
190
190
|
return body;
|
|
191
191
|
}
|
|
192
192
|
export async function previewVegaResource(options) {
|
|
193
|
-
const { baseUrl, accessToken, id, limit =
|
|
193
|
+
const { baseUrl, accessToken, id, limit = 50, businessDomain = "bd_public", } = options;
|
|
194
194
|
const base = baseUrl.replace(/\/+$/, "");
|
|
195
195
|
const url = new URL(`${base}${VEGA_BASE}/resources/${encodeURIComponent(id)}/preview`);
|
|
196
196
|
url.searchParams.set("limit", String(limit));
|
package/dist/auth/oauth.d.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import { type TokenConfig } from "../config/store.js";
|
|
2
|
+
/** POSIX shell single-quote escaping for copy-paste commands. */
|
|
3
|
+
export declare function shellQuoteForShell(value: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* Build a one-line `kweaver auth login ...` command for headless / other machines.
|
|
6
|
+
* Omits `--client-secret` when empty (PKCE-only client); headless refresh may still require a confidential client.
|
|
7
|
+
*/
|
|
8
|
+
export declare function buildCopyCommand(baseUrl: string, clientId: string, clientSecret: string, refreshToken: string | undefined, tlsInsecure?: boolean): string;
|
|
9
|
+
/**
|
|
10
|
+
* HTML shown after successful OAuth callback with a copyable headless login command.
|
|
11
|
+
*/
|
|
12
|
+
export declare function buildCallbackHtml(copyCommand: string): string;
|
|
2
13
|
export declare function normalizeBaseUrl(value: string): string;
|
|
3
14
|
/**
|
|
4
15
|
* OAuth2 Authorization Code login flow.
|
|
@@ -34,6 +45,16 @@ export declare function playwrightLogin(baseUrl: string, options?: {
|
|
|
34
45
|
scope?: string;
|
|
35
46
|
tlsInsecure?: boolean;
|
|
36
47
|
}): Promise<TokenConfig>;
|
|
48
|
+
/**
|
|
49
|
+
* Log in on a headless machine using OAuth2 client credentials and a refresh token (no browser).
|
|
50
|
+
* Exchanges the refresh token for a new access token and persists ~/.kweaver/ state.
|
|
51
|
+
*/
|
|
52
|
+
export declare function refreshTokenLogin(baseUrl: string, options: {
|
|
53
|
+
clientId: string;
|
|
54
|
+
clientSecret: string;
|
|
55
|
+
refreshToken: string;
|
|
56
|
+
tlsInsecure?: boolean;
|
|
57
|
+
}): Promise<TokenConfig>;
|
|
37
58
|
/**
|
|
38
59
|
* Exchange refresh_token for a new access token (OAuth2 password grant style, same as Python ConfigAuth).
|
|
39
60
|
* Persists the new token to ~/.kweaver/ and returns it.
|
package/dist/auth/oauth.js
CHANGED
|
@@ -5,6 +5,87 @@ const TOKEN_TTL_SECONDS = 3600;
|
|
|
5
5
|
const REFRESH_THRESHOLD_SEC = 60;
|
|
6
6
|
const DEFAULT_REDIRECT_PORT = 9010;
|
|
7
7
|
const DEFAULT_SCOPE = "openid offline all";
|
|
8
|
+
/** POSIX shell single-quote escaping for copy-paste commands. */
|
|
9
|
+
export function shellQuoteForShell(value) {
|
|
10
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Build a one-line `kweaver auth login ...` command for headless / other machines.
|
|
14
|
+
* Omits `--client-secret` when empty (PKCE-only client); headless refresh may still require a confidential client.
|
|
15
|
+
*/
|
|
16
|
+
export function buildCopyCommand(baseUrl, clientId, clientSecret, refreshToken, tlsInsecure) {
|
|
17
|
+
const parts = ["kweaver", "auth", "login", shellQuoteForShell(normalizeBaseUrl(baseUrl)), "--client-id", shellQuoteForShell(clientId)];
|
|
18
|
+
if (clientSecret) {
|
|
19
|
+
parts.push("--client-secret", shellQuoteForShell(clientSecret));
|
|
20
|
+
}
|
|
21
|
+
if (refreshToken) {
|
|
22
|
+
parts.push("--refresh-token", shellQuoteForShell(refreshToken));
|
|
23
|
+
}
|
|
24
|
+
if (tlsInsecure) {
|
|
25
|
+
parts.push("--insecure");
|
|
26
|
+
}
|
|
27
|
+
return parts.join(" ");
|
|
28
|
+
}
|
|
29
|
+
function escapeHtml(value) {
|
|
30
|
+
return value
|
|
31
|
+
.replace(/&/g, "&")
|
|
32
|
+
.replace(/</g, "<")
|
|
33
|
+
.replace(/>/g, ">")
|
|
34
|
+
.replace(/"/g, """);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* HTML shown after successful OAuth callback with a copyable headless login command.
|
|
38
|
+
*/
|
|
39
|
+
export function buildCallbackHtml(copyCommand) {
|
|
40
|
+
const safeCmd = escapeHtml(copyCommand);
|
|
41
|
+
return `<!DOCTYPE html>
|
|
42
|
+
<html lang="en">
|
|
43
|
+
<head>
|
|
44
|
+
<meta charset="utf-8"/>
|
|
45
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
46
|
+
<title>Login successful</title>
|
|
47
|
+
<style>
|
|
48
|
+
body { font-family: system-ui, sans-serif; max-width: 52rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
|
|
49
|
+
pre { background: #f4f4f5; padding: 1rem; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; }
|
|
50
|
+
button { margin-top: 0.75rem; padding: 0.5rem 1rem; cursor: pointer; }
|
|
51
|
+
.warn { color: #b45309; margin-top: 1.5rem; font-size: 0.9rem; }
|
|
52
|
+
</style>
|
|
53
|
+
</head>
|
|
54
|
+
<body>
|
|
55
|
+
<h2>Login successful</h2>
|
|
56
|
+
<p>You can close this tab.</p>
|
|
57
|
+
<h3>Headless machine</h3>
|
|
58
|
+
<p>On the computer that has no browser (SSH server, CI runner, container), run:</p>
|
|
59
|
+
<pre id="kw-cmd">${safeCmd}</pre>
|
|
60
|
+
<button type="button" id="kw-copy">Copy command</button>
|
|
61
|
+
<p class="warn">Keep these credentials secure. Anyone with the refresh token and client secret can obtain new access tokens.</p>
|
|
62
|
+
<script>
|
|
63
|
+
(function () {
|
|
64
|
+
var btn = document.getElementById("kw-copy");
|
|
65
|
+
var pre = document.getElementById("kw-cmd");
|
|
66
|
+
if (btn && pre) {
|
|
67
|
+
btn.addEventListener("click", function () {
|
|
68
|
+
var text = pre.textContent || "";
|
|
69
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
70
|
+
navigator.clipboard.writeText(text.trim()).then(function () {
|
|
71
|
+
btn.textContent = "Copied";
|
|
72
|
+
setTimeout(function () { btn.textContent = "Copy command"; }, 2000);
|
|
73
|
+
});
|
|
74
|
+
} else {
|
|
75
|
+
window.prompt("Copy this command:", text.trim());
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
})();
|
|
80
|
+
</script>
|
|
81
|
+
</body>
|
|
82
|
+
</html>`;
|
|
83
|
+
}
|
|
84
|
+
function buildCallbackExchangeErrorHtml(message) {
|
|
85
|
+
return `<!DOCTYPE html>
|
|
86
|
+
<html lang="en"><head><meta charset="utf-8"/><title>Login error</title></head>
|
|
87
|
+
<body><h2>Login error</h2><pre>${escapeHtml(message)}</pre></body></html>`;
|
|
88
|
+
}
|
|
8
89
|
export function normalizeBaseUrl(value) {
|
|
9
90
|
return value.replace(/\/+$/, "");
|
|
10
91
|
}
|
|
@@ -96,35 +177,62 @@ export async function oauth2Login(baseUrl, options) {
|
|
|
96
177
|
authParams.set("code_challenge_method", "S256");
|
|
97
178
|
}
|
|
98
179
|
const authUrl = `${base}/oauth2/auth?${authParams.toString()}`;
|
|
99
|
-
// Step 4: Start local callback server,
|
|
100
|
-
const
|
|
180
|
+
// Step 4: Start local callback server; exchange code inside handler, then show credentials HTML
|
|
181
|
+
const token = await new Promise((resolve, reject) => {
|
|
182
|
+
let server;
|
|
101
183
|
const timeoutId = setTimeout(() => {
|
|
102
|
-
server
|
|
184
|
+
server?.close();
|
|
103
185
|
reject(new Error("OAuth2 login timed out (120s). No authorization code received."));
|
|
104
186
|
}, 120_000);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
187
|
+
server = createServer((req, res) => {
|
|
188
|
+
void (async () => {
|
|
189
|
+
try {
|
|
190
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
191
|
+
if (url.pathname !== "/callback") {
|
|
192
|
+
res.writeHead(404);
|
|
193
|
+
res.end();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const receivedState = url.searchParams.get("state");
|
|
197
|
+
const receivedCode = url.searchParams.get("code");
|
|
198
|
+
if (receivedState !== state) {
|
|
199
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
200
|
+
res.end(buildCallbackExchangeErrorHtml("OAuth2 state mismatch — possible CSRF attack."));
|
|
201
|
+
clearTimeout(timeoutId);
|
|
202
|
+
server.close();
|
|
203
|
+
reject(new Error("OAuth2 state mismatch — possible CSRF attack."));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (!receivedCode) {
|
|
207
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
208
|
+
res.end(buildCallbackExchangeErrorHtml("No authorization code received in callback."));
|
|
209
|
+
clearTimeout(timeoutId);
|
|
210
|
+
server.close();
|
|
211
|
+
reject(new Error("No authorization code received in callback."));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const exchanged = await exchangeCodeForToken(base, receivedCode, client.clientId, client.clientSecret, redirectUri, pkce?.verifier, options?.tlsInsecure);
|
|
215
|
+
const copyCommand = buildCopyCommand(base, client.clientId, client.clientSecret, exchanged.refreshToken, options?.tlsInsecure);
|
|
216
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
217
|
+
res.end(buildCallbackHtml(copyCommand));
|
|
218
|
+
clearTimeout(timeoutId);
|
|
219
|
+
server.close();
|
|
220
|
+
resolve(exchanged);
|
|
116
221
|
}
|
|
117
|
-
|
|
118
|
-
|
|
222
|
+
catch (err) {
|
|
223
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
224
|
+
try {
|
|
225
|
+
res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
|
|
226
|
+
res.end(buildCallbackExchangeErrorHtml(message));
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
/* response may already be sent */
|
|
230
|
+
}
|
|
231
|
+
clearTimeout(timeoutId);
|
|
232
|
+
server.close();
|
|
233
|
+
reject(err instanceof Error ? err : new Error(message));
|
|
119
234
|
}
|
|
120
|
-
|
|
121
|
-
resolve(receivedCode);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
else {
|
|
125
|
-
res.writeHead(404);
|
|
126
|
-
res.end();
|
|
127
|
-
}
|
|
235
|
+
})();
|
|
128
236
|
});
|
|
129
237
|
server.listen(port, "127.0.0.1", () => {
|
|
130
238
|
// Step 5: Open browser (uses spawn with proper Windows quoting)
|
|
@@ -134,8 +242,6 @@ export async function oauth2Login(baseUrl, options) {
|
|
|
134
242
|
process.stderr.write(`If the wrong browser opens, copy this URL to your correct browser:\n ${authUrl}\n`);
|
|
135
243
|
});
|
|
136
244
|
});
|
|
137
|
-
// Step 6: Exchange code for tokens
|
|
138
|
-
const token = await exchangeCodeForToken(base, code, client.clientId, client.clientSecret, redirectUri, pkce?.verifier, options?.tlsInsecure);
|
|
139
245
|
setCurrentPlatform(base);
|
|
140
246
|
return token;
|
|
141
247
|
});
|
|
@@ -275,40 +381,70 @@ export async function playwrightLogin(baseUrl, options) {
|
|
|
275
381
|
product: "adp",
|
|
276
382
|
});
|
|
277
383
|
const authUrl = `${base}/oauth2/auth?${authParams.toString()}`;
|
|
278
|
-
// Step 4: Start local callback server
|
|
279
|
-
|
|
384
|
+
// Step 4: Start local callback server; exchange code inside handler, then show credentials HTML
|
|
385
|
+
let browser;
|
|
386
|
+
const token = await new Promise((resolve, reject) => {
|
|
280
387
|
const TIMEOUT_MS = hasCredentials ? 30_000 : 120_000;
|
|
388
|
+
let server;
|
|
281
389
|
const timeoutId = setTimeout(() => {
|
|
282
|
-
server
|
|
390
|
+
server?.close();
|
|
283
391
|
browser?.close();
|
|
284
392
|
reject(new Error(`OAuth2 login timed out (${TIMEOUT_MS / 1000}s). No authorization code received.`));
|
|
285
393
|
}, TIMEOUT_MS);
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
394
|
+
server = createServer((req, res) => {
|
|
395
|
+
void (async () => {
|
|
396
|
+
try {
|
|
397
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
398
|
+
if (url.pathname !== "/callback") {
|
|
399
|
+
res.writeHead(404);
|
|
400
|
+
res.end();
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const receivedState = url.searchParams.get("state");
|
|
404
|
+
const receivedCode = url.searchParams.get("code");
|
|
405
|
+
if (receivedState !== state) {
|
|
406
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
407
|
+
res.end(buildCallbackExchangeErrorHtml("OAuth2 state mismatch — possible CSRF attack."));
|
|
408
|
+
clearTimeout(timeoutId);
|
|
409
|
+
server.close();
|
|
410
|
+
browser?.close();
|
|
411
|
+
reject(new Error("OAuth2 state mismatch — possible CSRF attack."));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (!receivedCode) {
|
|
415
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
416
|
+
res.end(buildCallbackExchangeErrorHtml("No authorization code received in callback."));
|
|
417
|
+
clearTimeout(timeoutId);
|
|
418
|
+
server.close();
|
|
419
|
+
browser?.close();
|
|
420
|
+
reject(new Error("No authorization code received in callback."));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const exchanged = await exchangeCodeForToken(base, receivedCode, client.clientId, client.clientSecret, redirectUri, undefined, options?.tlsInsecure);
|
|
424
|
+
const copyCommand = buildCopyCommand(base, client.clientId, client.clientSecret, exchanged.refreshToken, options?.tlsInsecure);
|
|
425
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
426
|
+
res.end(buildCallbackHtml(copyCommand));
|
|
427
|
+
clearTimeout(timeoutId);
|
|
428
|
+
server.close();
|
|
429
|
+
browser?.close();
|
|
430
|
+
resolve(exchanged);
|
|
301
431
|
}
|
|
302
|
-
|
|
303
|
-
|
|
432
|
+
catch (err) {
|
|
433
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
434
|
+
try {
|
|
435
|
+
res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
|
|
436
|
+
res.end(buildCallbackExchangeErrorHtml(message));
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
/* response may already be sent */
|
|
440
|
+
}
|
|
441
|
+
clearTimeout(timeoutId);
|
|
442
|
+
server.close();
|
|
443
|
+
browser?.close();
|
|
444
|
+
reject(err instanceof Error ? err : new Error(message));
|
|
304
445
|
}
|
|
305
|
-
}
|
|
306
|
-
else {
|
|
307
|
-
res.writeHead(404);
|
|
308
|
-
res.end();
|
|
309
|
-
}
|
|
446
|
+
})();
|
|
310
447
|
});
|
|
311
|
-
let browser;
|
|
312
448
|
server.listen(port, "127.0.0.1", async () => {
|
|
313
449
|
try {
|
|
314
450
|
browser = await chromium.launch({ headless: hasCredentials });
|
|
@@ -334,12 +470,48 @@ export async function playwrightLogin(baseUrl, options) {
|
|
|
334
470
|
}
|
|
335
471
|
});
|
|
336
472
|
});
|
|
337
|
-
|
|
338
|
-
|
|
473
|
+
if (hasCredentials) {
|
|
474
|
+
const copyCommand = buildCopyCommand(base, client.clientId, client.clientSecret, token.refreshToken, options?.tlsInsecure);
|
|
475
|
+
process.stderr.write("\nHeadless login: copy this command and run it on a machine without a browser, or use `kweaver auth export`:\n\n" +
|
|
476
|
+
copyCommand +
|
|
477
|
+
"\n\n");
|
|
478
|
+
}
|
|
339
479
|
setCurrentPlatform(base);
|
|
340
480
|
return token;
|
|
341
481
|
});
|
|
342
482
|
}
|
|
483
|
+
/**
|
|
484
|
+
* Log in on a headless machine using OAuth2 client credentials and a refresh token (no browser).
|
|
485
|
+
* Exchanges the refresh token for a new access token and persists ~/.kweaver/ state.
|
|
486
|
+
*/
|
|
487
|
+
export async function refreshTokenLogin(baseUrl, options) {
|
|
488
|
+
const base = normalizeBaseUrl(baseUrl);
|
|
489
|
+
const redirectUri = `http://127.0.0.1:${DEFAULT_REDIRECT_PORT}/callback`;
|
|
490
|
+
const client = {
|
|
491
|
+
baseUrl: base,
|
|
492
|
+
clientId: options.clientId,
|
|
493
|
+
clientSecret: options.clientSecret,
|
|
494
|
+
redirectUri,
|
|
495
|
+
logoutRedirectUri: redirectUri.replace("/callback", "/successful-logout"),
|
|
496
|
+
scope: DEFAULT_SCOPE,
|
|
497
|
+
lang: "zh-cn",
|
|
498
|
+
product: "adp",
|
|
499
|
+
xForwardedPrefix: "",
|
|
500
|
+
};
|
|
501
|
+
saveClientConfig(base, client);
|
|
502
|
+
const synthetic = {
|
|
503
|
+
baseUrl: base,
|
|
504
|
+
accessToken: "",
|
|
505
|
+
tokenType: "Bearer",
|
|
506
|
+
scope: "",
|
|
507
|
+
refreshToken: options.refreshToken,
|
|
508
|
+
obtainedAt: new Date().toISOString(),
|
|
509
|
+
...(options.tlsInsecure ? { tlsInsecure: true } : {}),
|
|
510
|
+
};
|
|
511
|
+
const token = await runWithTlsInsecure(options.tlsInsecure, () => refreshAccessToken(synthetic));
|
|
512
|
+
setCurrentPlatform(base);
|
|
513
|
+
return token;
|
|
514
|
+
}
|
|
343
515
|
function tokenNeedsRefresh(token) {
|
|
344
516
|
if (!token.expiresAt) {
|
|
345
517
|
return false;
|