@wacht/bench 0.1.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/dist/auth-store.js +19 -0
- package/dist/browser.js +15 -0
- package/dist/commands.js +389 -0
- package/dist/completion.js +132 -0
- package/dist/config-workflow.js +474 -0
- package/dist/config.js +18 -0
- package/dist/context-store.js +28 -0
- package/dist/deployment-context.js +205 -0
- package/dist/guards.js +23 -0
- package/dist/init.js +535 -0
- package/dist/machine-api.js +272 -0
- package/dist/mcp.js +21 -0
- package/dist/oauth-callback.js +104 -0
- package/dist/oauth.js +236 -0
- package/dist/openapi.js +259 -0
- package/dist/pkce.js +14 -0
- package/dist/project-detect.js +64 -0
- package/dist/prompts.js +74 -0
- package/dist/resources.js +204 -0
- package/dist/skills.js +29 -0
- package/dist/types.js +1 -0
- package/dist/ui.js +104 -0
- package/dist/util.js +6 -0
- package/dist/wacht.js +18 -0
- package/package.json +33 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { MACHINE_API_URL } from './config.js';
|
|
4
|
+
import { getValidAuth } from './oauth.js';
|
|
5
|
+
import { promptChoice, promptOptionalList, promptText } from './prompts.js';
|
|
6
|
+
import { field, log, printBannerFor, printJson, section } from './ui.js';
|
|
7
|
+
export async function machineRequest(pathname, options = {}) {
|
|
8
|
+
const auth = await getValidAuth();
|
|
9
|
+
const url = new URL(pathname, auth.machine_api_url || MACHINE_API_URL);
|
|
10
|
+
const headers = new Headers(options.headers);
|
|
11
|
+
headers.set('authorization', `Bearer ${auth.access_token}`);
|
|
12
|
+
headers.set('accept', 'application/json');
|
|
13
|
+
const response = await fetch(url, {
|
|
14
|
+
...options,
|
|
15
|
+
headers,
|
|
16
|
+
});
|
|
17
|
+
const text = await response.text();
|
|
18
|
+
if (!response.ok) {
|
|
19
|
+
throw new Error(`Machine API request failed: HTTP ${response.status}${text ? ` ${text}` : ''}`);
|
|
20
|
+
}
|
|
21
|
+
if (!text)
|
|
22
|
+
return null;
|
|
23
|
+
return JSON.parse(text);
|
|
24
|
+
}
|
|
25
|
+
function isRecord(value) {
|
|
26
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
export function projectList(response) {
|
|
29
|
+
const rawProjects = Array.isArray(response)
|
|
30
|
+
? response
|
|
31
|
+
: isRecord(response) && Array.isArray(response.data)
|
|
32
|
+
? response.data
|
|
33
|
+
: [];
|
|
34
|
+
return rawProjects.filter(isRecord).map((project) => {
|
|
35
|
+
const deploymentItems = Array.isArray(project.deployments)
|
|
36
|
+
? project.deployments
|
|
37
|
+
.filter(isRecord)
|
|
38
|
+
.map((deployment) => ({
|
|
39
|
+
id: String(deployment.id ?? ''),
|
|
40
|
+
mode: typeof deployment.mode === 'string' ? deployment.mode : 'unknown',
|
|
41
|
+
backend_host: typeof deployment.backend_host === 'string' ? deployment.backend_host : undefined,
|
|
42
|
+
frontend_host: typeof deployment.frontend_host === 'string' ? deployment.frontend_host : undefined,
|
|
43
|
+
updated_at: typeof deployment.updated_at === 'string' ? deployment.updated_at : undefined,
|
|
44
|
+
}))
|
|
45
|
+
: [];
|
|
46
|
+
return {
|
|
47
|
+
id: String(project.id ?? ''),
|
|
48
|
+
name: String(project.name ?? 'Untitled project'),
|
|
49
|
+
created_at: typeof project.created_at === 'string' ? project.created_at : undefined,
|
|
50
|
+
updated_at: typeof project.updated_at === 'string' ? project.updated_at : undefined,
|
|
51
|
+
deployments: deploymentItems.length,
|
|
52
|
+
deployment_modes: deploymentItems.map((deployment) => deployment.mode),
|
|
53
|
+
deployment_items: deploymentItems,
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function shortDate(value) {
|
|
58
|
+
if (!value)
|
|
59
|
+
return '-';
|
|
60
|
+
const date = new Date(value);
|
|
61
|
+
if (Number.isNaN(date.getTime()))
|
|
62
|
+
return value;
|
|
63
|
+
return date.toISOString().slice(0, 10);
|
|
64
|
+
}
|
|
65
|
+
function truncate(value, max) {
|
|
66
|
+
if (value.length <= max)
|
|
67
|
+
return value;
|
|
68
|
+
return `${value.slice(0, Math.max(0, max - 1))}~`;
|
|
69
|
+
}
|
|
70
|
+
function pad(value, width) {
|
|
71
|
+
return value.padEnd(width, ' ');
|
|
72
|
+
}
|
|
73
|
+
function printProjectTable(ctx, projects) {
|
|
74
|
+
printBannerFor(ctx);
|
|
75
|
+
log(ctx, section('Projects'));
|
|
76
|
+
log(ctx, field('Count', String(projects.length)));
|
|
77
|
+
log(ctx, '');
|
|
78
|
+
if (!projects.length) {
|
|
79
|
+
log(ctx, 'No projects found for this OAuth grant.');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const rows = projects.map((project) => ({
|
|
83
|
+
name: truncate(project.name, 32),
|
|
84
|
+
id: truncate(project.id, 22),
|
|
85
|
+
deployments: project.deployment_modes.length
|
|
86
|
+
? project.deployment_modes.join(', ')
|
|
87
|
+
: String(project.deployments),
|
|
88
|
+
updated: shortDate(project.updated_at),
|
|
89
|
+
}));
|
|
90
|
+
const nameWidth = Math.max('Name'.length, ...rows.map((row) => row.name.length));
|
|
91
|
+
const idWidth = Math.max('ID'.length, ...rows.map((row) => row.id.length));
|
|
92
|
+
const deploymentWidth = Math.max('Deployments'.length, ...rows.map((row) => row.deployments.length));
|
|
93
|
+
log(ctx, `${pad('Name', nameWidth)} ${pad('ID', idWidth)} ${pad('Deployments', deploymentWidth)} Updated`);
|
|
94
|
+
log(ctx, `${'-'.repeat(nameWidth)} ${'-'.repeat(idWidth)} ${'-'.repeat(deploymentWidth)} ----------`);
|
|
95
|
+
for (const row of rows) {
|
|
96
|
+
log(ctx, `${pad(row.name, nameWidth)} ${pad(row.id, idWidth)} ${pad(row.deployments, deploymentWidth)} ${row.updated}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export async function listProjects(ctx) {
|
|
100
|
+
const response = await machineRequest('/projects');
|
|
101
|
+
const projects = projectList(response);
|
|
102
|
+
if (ctx.json) {
|
|
103
|
+
printJson({
|
|
104
|
+
ok: true,
|
|
105
|
+
data: projects,
|
|
106
|
+
has_more: isRecord(response) && typeof response.has_more === 'boolean' ? response.has_more : undefined,
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
printProjectTable(ctx, projects);
|
|
111
|
+
}
|
|
112
|
+
export async function getProjects() {
|
|
113
|
+
return projectList(await machineRequest('/projects'));
|
|
114
|
+
}
|
|
115
|
+
export async function createProject(name, methods) {
|
|
116
|
+
const form = new FormData();
|
|
117
|
+
form.append('name', name);
|
|
118
|
+
for (const method of methods) {
|
|
119
|
+
form.append('methods', method);
|
|
120
|
+
}
|
|
121
|
+
const response = await machineRequest('/project', {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
body: form,
|
|
124
|
+
});
|
|
125
|
+
const projects = projectList([response]);
|
|
126
|
+
if (!projects[0]) {
|
|
127
|
+
throw new Error('Machine API returned an unexpected project response.');
|
|
128
|
+
}
|
|
129
|
+
return projects[0];
|
|
130
|
+
}
|
|
131
|
+
export async function createDeployment(projectId, mode, methods, customDomain) {
|
|
132
|
+
if (mode !== 'staging' && mode !== 'production') {
|
|
133
|
+
throw new Error('Deployment mode must be staging or production.');
|
|
134
|
+
}
|
|
135
|
+
const body = mode === 'production'
|
|
136
|
+
? { custom_domain: customDomain, auth_methods: methods }
|
|
137
|
+
: { auth_methods: methods };
|
|
138
|
+
const response = await machineRequest(`/project/${projectId}/${mode}-deployment`, {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
headers: {
|
|
141
|
+
'content-type': 'application/json',
|
|
142
|
+
},
|
|
143
|
+
body: JSON.stringify(body),
|
|
144
|
+
});
|
|
145
|
+
if (!isRecord(response)) {
|
|
146
|
+
throw new Error('Machine API returned an unexpected deployment response.');
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
id: String(response.id ?? ''),
|
|
150
|
+
mode: typeof response.mode === 'string' ? response.mode : mode,
|
|
151
|
+
backend_host: typeof response.backend_host === 'string' ? response.backend_host : undefined,
|
|
152
|
+
frontend_host: typeof response.frontend_host === 'string' ? response.frontend_host : undefined,
|
|
153
|
+
updated_at: typeof response.updated_at === 'string' ? response.updated_at : undefined,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
export function entries(values, flag) {
|
|
157
|
+
return (values ?? []).map((value) => {
|
|
158
|
+
const index = value.indexOf('=');
|
|
159
|
+
if (index <= 0) {
|
|
160
|
+
throw new Error(`${flag} expects key=value`);
|
|
161
|
+
}
|
|
162
|
+
return [value.slice(0, index), value.slice(index + 1)];
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
function stripFileMarker(value) {
|
|
166
|
+
return value.startsWith('@') ? value.slice(1) : value;
|
|
167
|
+
}
|
|
168
|
+
function hasAny(values) {
|
|
169
|
+
return !!values && values.length > 0;
|
|
170
|
+
}
|
|
171
|
+
function explicitBodyKind(options) {
|
|
172
|
+
if (options.body)
|
|
173
|
+
return 'json';
|
|
174
|
+
if (hasAny(options.file) || hasAny(options.form))
|
|
175
|
+
return 'multipart';
|
|
176
|
+
if (hasAny(options.field))
|
|
177
|
+
return 'urlencoded';
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
async function resolveApiOptions(method, pathname, options, ctx) {
|
|
181
|
+
const wizard = !method || !pathname;
|
|
182
|
+
const resolvedMethod = method
|
|
183
|
+
? method.toUpperCase()
|
|
184
|
+
: (await promptChoice(ctx, undefined, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], 'HTTP method: ', 'Pass an HTTP method, for example `wacht api GET /projects`.')).toUpperCase();
|
|
185
|
+
const resolvedPath = await promptText(ctx, pathname, 'API path: ', 'Pass an API path, for example `/projects`.');
|
|
186
|
+
const headers = options.header && options.header.length
|
|
187
|
+
? options.header
|
|
188
|
+
: wizard
|
|
189
|
+
? await promptOptionalList(ctx, undefined, 'Headers, comma separated key=value: ')
|
|
190
|
+
: [];
|
|
191
|
+
const nextOptions = { ...options, header: headers };
|
|
192
|
+
if (explicitBodyKind(nextOptions) || resolvedMethod === 'GET' || resolvedMethod === 'HEAD') {
|
|
193
|
+
return { method: resolvedMethod, pathname: resolvedPath, options: nextOptions };
|
|
194
|
+
}
|
|
195
|
+
const bodyKind = await promptChoice(ctx, undefined, ['none', 'json', 'urlencoded', 'multipart'], 'Request body type: ', 'Pass --body, --field, --form, or --file for request bodies.');
|
|
196
|
+
if (bodyKind === 'json') {
|
|
197
|
+
nextOptions.body = await promptText(ctx, undefined, 'JSON body: ', 'Pass --body <json>.');
|
|
198
|
+
}
|
|
199
|
+
else if (bodyKind === 'urlencoded') {
|
|
200
|
+
nextOptions.field = await promptOptionalList(ctx, undefined, 'URL-encoded fields, comma separated key=value: ');
|
|
201
|
+
}
|
|
202
|
+
else if (bodyKind === 'multipart') {
|
|
203
|
+
nextOptions.form = await promptOptionalList(ctx, undefined, 'Multipart text fields, comma separated key=value: ');
|
|
204
|
+
nextOptions.file = await promptOptionalList(ctx, undefined, 'Multipart file fields, comma separated key=path or key=@path: ');
|
|
205
|
+
}
|
|
206
|
+
return { method: resolvedMethod, pathname: resolvedPath, options: nextOptions };
|
|
207
|
+
}
|
|
208
|
+
export async function requestBody(options) {
|
|
209
|
+
const headers = new Headers();
|
|
210
|
+
for (const [key, value] of entries(options.header, '--header')) {
|
|
211
|
+
headers.set(key, value);
|
|
212
|
+
}
|
|
213
|
+
if (options.body) {
|
|
214
|
+
headers.set('content-type', 'application/json');
|
|
215
|
+
return {
|
|
216
|
+
body: JSON.stringify(JSON.parse(options.body)),
|
|
217
|
+
headers,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const files = entries(options.file, '--file');
|
|
221
|
+
const rawMultipartFields = entries(options.form, '--form');
|
|
222
|
+
const multipartFields = rawMultipartFields.filter(([, value]) => !value.startsWith('@'));
|
|
223
|
+
const multipartFiles = [
|
|
224
|
+
...files,
|
|
225
|
+
...rawMultipartFields.filter(([, value]) => value.startsWith('@')),
|
|
226
|
+
];
|
|
227
|
+
if (multipartFiles.length || multipartFields.length) {
|
|
228
|
+
const form = new FormData();
|
|
229
|
+
for (const [key, value] of multipartFields) {
|
|
230
|
+
form.append(key, value);
|
|
231
|
+
}
|
|
232
|
+
for (const [key, filePath] of multipartFiles) {
|
|
233
|
+
const absolutePath = path.resolve(stripFileMarker(filePath));
|
|
234
|
+
const bytes = await readFile(absolutePath);
|
|
235
|
+
form.append(key, new Blob([bytes]), path.basename(absolutePath));
|
|
236
|
+
}
|
|
237
|
+
return { body: form, headers };
|
|
238
|
+
}
|
|
239
|
+
const fields = entries(options.field, '--field');
|
|
240
|
+
if (fields.length) {
|
|
241
|
+
headers.set('content-type', 'application/x-www-form-urlencoded');
|
|
242
|
+
return {
|
|
243
|
+
body: new URLSearchParams(fields),
|
|
244
|
+
headers,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return { headers };
|
|
248
|
+
}
|
|
249
|
+
export async function apiCommand(method, pathname, options, ctx) {
|
|
250
|
+
const resolved = await resolveApiOptions(method, pathname, options, ctx);
|
|
251
|
+
const { body, headers } = await requestBody(resolved.options);
|
|
252
|
+
const data = await machineRequest(resolved.pathname, {
|
|
253
|
+
method: resolved.method,
|
|
254
|
+
body,
|
|
255
|
+
headers,
|
|
256
|
+
});
|
|
257
|
+
if (ctx.json) {
|
|
258
|
+
printJson({
|
|
259
|
+
ok: true,
|
|
260
|
+
method: resolved.method,
|
|
261
|
+
path: resolved.pathname,
|
|
262
|
+
data,
|
|
263
|
+
});
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (typeof data === 'string') {
|
|
267
|
+
console.log(data);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
printJson(data);
|
|
271
|
+
}
|
|
272
|
+
}
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { MCP_URL } from './config.js';
|
|
2
|
+
export function printMcpConfig(client) {
|
|
3
|
+
if (client === 'claude') {
|
|
4
|
+
console.log(JSON.stringify({
|
|
5
|
+
mcpServers: {
|
|
6
|
+
'wacht-docs': {
|
|
7
|
+
command: 'npx',
|
|
8
|
+
args: ['-y', 'mcp-remote', MCP_URL],
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
}, null, 2));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
console.log(JSON.stringify({
|
|
15
|
+
mcpServers: {
|
|
16
|
+
'wacht-docs': {
|
|
17
|
+
url: MCP_URL,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
}, null, 2));
|
|
21
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { REDIRECT_PORT, REDIRECT_URI } from './config.js';
|
|
3
|
+
const SECURITY_HEADERS = {
|
|
4
|
+
'cache-control': 'no-store',
|
|
5
|
+
'content-security-policy': "default-src 'none'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
6
|
+
'referrer-policy': 'no-referrer',
|
|
7
|
+
'x-content-type-options': 'nosniff',
|
|
8
|
+
};
|
|
9
|
+
function closePage(title) {
|
|
10
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title><script>window.close();setTimeout(function(){document.body.textContent='You can close this window.'},250);</script></head><body></body></html>`;
|
|
11
|
+
}
|
|
12
|
+
export function startOAuthCallbackServer(expectedState) {
|
|
13
|
+
let resolveCode;
|
|
14
|
+
let rejectCode;
|
|
15
|
+
let settled = false;
|
|
16
|
+
const code = new Promise((resolve, reject) => {
|
|
17
|
+
resolveCode = resolve;
|
|
18
|
+
rejectCode = reject;
|
|
19
|
+
});
|
|
20
|
+
const server = createServer((req, res) => {
|
|
21
|
+
const finish = (error, value) => {
|
|
22
|
+
if (settled)
|
|
23
|
+
return;
|
|
24
|
+
settled = true;
|
|
25
|
+
try {
|
|
26
|
+
server.close();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// The server may not have started if binding the callback port failed.
|
|
30
|
+
}
|
|
31
|
+
if (error) {
|
|
32
|
+
rejectCode(error);
|
|
33
|
+
}
|
|
34
|
+
else if (value) {
|
|
35
|
+
resolveCode(value);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
rejectCode(new Error('OAuth callback did not include a code'));
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const url = new URL(req.url ?? '/', REDIRECT_URI);
|
|
42
|
+
if (req.method !== 'GET') {
|
|
43
|
+
res.writeHead(405, {
|
|
44
|
+
...SECURITY_HEADERS,
|
|
45
|
+
allow: 'GET',
|
|
46
|
+
});
|
|
47
|
+
res.end();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (url.pathname !== '/callback') {
|
|
51
|
+
res.writeHead(404, SECURITY_HEADERS);
|
|
52
|
+
res.end();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const error = url.searchParams.get('error');
|
|
56
|
+
if (error) {
|
|
57
|
+
res.writeHead(400, {
|
|
58
|
+
...SECURITY_HEADERS,
|
|
59
|
+
'content-type': 'text/html; charset=utf-8',
|
|
60
|
+
});
|
|
61
|
+
res.end(closePage('Wacht Bench login failed'));
|
|
62
|
+
finish(new Error(`OAuth authorization failed: ${error}`));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const state = url.searchParams.get('state');
|
|
66
|
+
if (state !== expectedState) {
|
|
67
|
+
res.writeHead(400, {
|
|
68
|
+
...SECURITY_HEADERS,
|
|
69
|
+
'content-type': 'text/html; charset=utf-8',
|
|
70
|
+
});
|
|
71
|
+
res.end(closePage('Wacht Bench login failed'));
|
|
72
|
+
finish(new Error('OAuth callback state mismatch'));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const authCode = url.searchParams.get('code');
|
|
76
|
+
if (!authCode) {
|
|
77
|
+
res.writeHead(400, {
|
|
78
|
+
...SECURITY_HEADERS,
|
|
79
|
+
'content-type': 'text/html; charset=utf-8',
|
|
80
|
+
});
|
|
81
|
+
res.end(closePage('Wacht Bench login failed'));
|
|
82
|
+
finish(new Error('OAuth callback did not include a code'));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
res.writeHead(200, {
|
|
86
|
+
...SECURITY_HEADERS,
|
|
87
|
+
'content-type': 'text/html; charset=utf-8',
|
|
88
|
+
});
|
|
89
|
+
res.end(closePage('Wacht Bench login complete'));
|
|
90
|
+
finish(null, authCode);
|
|
91
|
+
});
|
|
92
|
+
const ready = new Promise((resolve, reject) => {
|
|
93
|
+
server.once('listening', () => resolve());
|
|
94
|
+
server.once('error', reject);
|
|
95
|
+
server.once('error', (error) => {
|
|
96
|
+
if (!settled) {
|
|
97
|
+
settled = true;
|
|
98
|
+
rejectCode(error);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
server.listen(REDIRECT_PORT, '127.0.0.1');
|
|
102
|
+
});
|
|
103
|
+
return { ready, code };
|
|
104
|
+
}
|
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { openBrowser } from './browser.js';
|
|
2
|
+
import { MACHINE_API_URL, OAUTH_AUTHORIZE_URL, OAUTH_CLIENT_ID, OAUTH_ISSUER, OAUTH_REVOCATION_URL, OAUTH_SCOPES, OAUTH_TOKEN_URL, REDIRECT_URI, } from './config.js';
|
|
3
|
+
import { isOAuthTokenResponse } from './guards.js';
|
|
4
|
+
import { startOAuthCallbackServer } from './oauth-callback.js';
|
|
5
|
+
import { codeChallengeFor, randomToken } from './pkce.js';
|
|
6
|
+
import { clearAuth, readAuth, writeAuth } from './auth-store.js';
|
|
7
|
+
import { clearBenchContext, readBenchContext } from './context-store.js';
|
|
8
|
+
import { command, field, log, printBannerFor, printJson, section, Spinner, success } from './ui.js';
|
|
9
|
+
const TOKEN_EXCHANGE_TIMEOUT_MS = 15_000;
|
|
10
|
+
function tokenExpiresAt(expiresIn) {
|
|
11
|
+
return Date.now() + Math.max(0, Number(expiresIn ?? 0) - 60) * 1000;
|
|
12
|
+
}
|
|
13
|
+
function scopesFrom(raw) {
|
|
14
|
+
const scopes = (raw || OAUTH_SCOPES).split(/\s+/).filter(Boolean);
|
|
15
|
+
return scopes.length ? scopes : OAUTH_SCOPES.split(' ');
|
|
16
|
+
}
|
|
17
|
+
function fetchFailureMessage(error) {
|
|
18
|
+
if (!(error instanceof Error))
|
|
19
|
+
return String(error);
|
|
20
|
+
const cause = error.cause;
|
|
21
|
+
if (cause instanceof Error && cause.message) {
|
|
22
|
+
return `${error.message}: ${cause.message}`;
|
|
23
|
+
}
|
|
24
|
+
if (cause && typeof cause === 'object' && 'message' in cause) {
|
|
25
|
+
return `${error.message}: ${String(cause.message)}`;
|
|
26
|
+
}
|
|
27
|
+
return error.message;
|
|
28
|
+
}
|
|
29
|
+
function delay(ms) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
setTimeout(resolve, ms);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async function exchangeToken(body) {
|
|
35
|
+
let response;
|
|
36
|
+
try {
|
|
37
|
+
response = await postToken(body);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
await delay(500);
|
|
41
|
+
try {
|
|
42
|
+
response = await postToken(body);
|
|
43
|
+
}
|
|
44
|
+
catch (secondError) {
|
|
45
|
+
throw new Error(`OAuth token endpoint was not reachable at ${OAUTH_TOKEN_URL}. ${fetchFailureMessage(secondError)}. Run \`wacht login\` again to request a fresh authorization code.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
const text = await response.text();
|
|
50
|
+
throw new Error(`OAuth token exchange failed: HTTP ${response.status}${text ? ` ${text}` : ''}`);
|
|
51
|
+
}
|
|
52
|
+
const token = await response.json();
|
|
53
|
+
if (!isOAuthTokenResponse(token)) {
|
|
54
|
+
throw new Error('OAuth token exchange returned an unexpected response.');
|
|
55
|
+
}
|
|
56
|
+
return token;
|
|
57
|
+
}
|
|
58
|
+
async function postToken(body) {
|
|
59
|
+
const controller = new AbortController();
|
|
60
|
+
const timeout = setTimeout(() => controller.abort(), TOKEN_EXCHANGE_TIMEOUT_MS);
|
|
61
|
+
try {
|
|
62
|
+
return await fetch(OAUTH_TOKEN_URL, {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: {
|
|
65
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
66
|
+
},
|
|
67
|
+
body,
|
|
68
|
+
signal: controller.signal,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
clearTimeout(timeout);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export async function refreshAuth(auth) {
|
|
76
|
+
const body = new URLSearchParams({
|
|
77
|
+
grant_type: 'refresh_token',
|
|
78
|
+
refresh_token: auth.refresh_token,
|
|
79
|
+
client_id: OAUTH_CLIENT_ID,
|
|
80
|
+
});
|
|
81
|
+
const token = await exchangeToken(body);
|
|
82
|
+
const nextAuth = {
|
|
83
|
+
...auth,
|
|
84
|
+
access_token: token.access_token,
|
|
85
|
+
refresh_token: token.refresh_token,
|
|
86
|
+
token_type: token.token_type,
|
|
87
|
+
scope: token.scope,
|
|
88
|
+
expires_at: tokenExpiresAt(token.expires_in),
|
|
89
|
+
machine_api_url: MACHINE_API_URL,
|
|
90
|
+
oauth_issuer: OAUTH_ISSUER,
|
|
91
|
+
};
|
|
92
|
+
await writeAuth(nextAuth);
|
|
93
|
+
return nextAuth;
|
|
94
|
+
}
|
|
95
|
+
export async function getValidAuth() {
|
|
96
|
+
const auth = await readAuth();
|
|
97
|
+
if (!auth) {
|
|
98
|
+
throw new Error('Not logged in. Run `wacht login`.');
|
|
99
|
+
}
|
|
100
|
+
if (auth.expires_at > Date.now()) {
|
|
101
|
+
return auth;
|
|
102
|
+
}
|
|
103
|
+
return refreshAuth(auth);
|
|
104
|
+
}
|
|
105
|
+
export async function login(ctx) {
|
|
106
|
+
printBannerFor(ctx);
|
|
107
|
+
const state = randomToken(24);
|
|
108
|
+
const codeVerifier = randomToken(64);
|
|
109
|
+
const codeChallenge = codeChallengeFor(codeVerifier);
|
|
110
|
+
const authorizeUrl = new URL(OAUTH_AUTHORIZE_URL);
|
|
111
|
+
authorizeUrl.search = new URLSearchParams({
|
|
112
|
+
response_type: 'code',
|
|
113
|
+
client_id: OAUTH_CLIENT_ID,
|
|
114
|
+
redirect_uri: REDIRECT_URI,
|
|
115
|
+
scope: OAUTH_SCOPES,
|
|
116
|
+
resource: MACHINE_API_URL,
|
|
117
|
+
state,
|
|
118
|
+
code_challenge: codeChallenge,
|
|
119
|
+
code_challenge_method: 'S256',
|
|
120
|
+
}).toString();
|
|
121
|
+
const callback = startOAuthCallbackServer(state);
|
|
122
|
+
await callback.ready;
|
|
123
|
+
log(ctx, section('Browser Login'));
|
|
124
|
+
log(ctx, 'Opening Wacht OAuth login in your browser.');
|
|
125
|
+
log(ctx, field('Callback', REDIRECT_URI));
|
|
126
|
+
log(ctx, '');
|
|
127
|
+
log(ctx, 'If the browser does not open, visit:');
|
|
128
|
+
log(ctx, command(authorizeUrl.toString()));
|
|
129
|
+
log(ctx, '');
|
|
130
|
+
openBrowser(authorizeUrl.toString());
|
|
131
|
+
const authSpinner = new Spinner(ctx, 'Waiting for browser authorization').start();
|
|
132
|
+
const code = await callback.code;
|
|
133
|
+
authSpinner.succeed('Browser authorization received');
|
|
134
|
+
const tokenSpinner = new Spinner(ctx, 'Exchanging authorization code').start();
|
|
135
|
+
let token;
|
|
136
|
+
try {
|
|
137
|
+
token = await exchangeToken(new URLSearchParams({
|
|
138
|
+
grant_type: 'authorization_code',
|
|
139
|
+
code,
|
|
140
|
+
redirect_uri: REDIRECT_URI,
|
|
141
|
+
client_id: OAUTH_CLIENT_ID,
|
|
142
|
+
code_verifier: codeVerifier,
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
tokenSpinner.fail('Token exchange failed');
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
tokenSpinner.stop();
|
|
150
|
+
await writeAuth({
|
|
151
|
+
client_id: OAUTH_CLIENT_ID,
|
|
152
|
+
access_token: token.access_token,
|
|
153
|
+
refresh_token: token.refresh_token,
|
|
154
|
+
token_type: token.token_type,
|
|
155
|
+
scope: token.scope,
|
|
156
|
+
expires_at: tokenExpiresAt(token.expires_in),
|
|
157
|
+
redirect_uri: REDIRECT_URI,
|
|
158
|
+
machine_api_url: MACHINE_API_URL,
|
|
159
|
+
oauth_issuer: OAUTH_ISSUER,
|
|
160
|
+
});
|
|
161
|
+
if (ctx.json) {
|
|
162
|
+
printJson({
|
|
163
|
+
ok: true,
|
|
164
|
+
state: 'logged_in',
|
|
165
|
+
scopes: scopesFrom(token.scope),
|
|
166
|
+
machineApiUrl: MACHINE_API_URL,
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
log(ctx, success('Logged in to Wacht Bench.'));
|
|
171
|
+
}
|
|
172
|
+
export async function logout(ctx) {
|
|
173
|
+
printBannerFor(ctx);
|
|
174
|
+
const auth = await readAuth();
|
|
175
|
+
if (auth?.refresh_token) {
|
|
176
|
+
try {
|
|
177
|
+
await fetch(OAUTH_REVOCATION_URL, {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers: {
|
|
180
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
181
|
+
},
|
|
182
|
+
body: new URLSearchParams({
|
|
183
|
+
token: auth.refresh_token,
|
|
184
|
+
token_type_hint: 'refresh_token',
|
|
185
|
+
client_id: OAUTH_CLIENT_ID,
|
|
186
|
+
}),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Local logout should still succeed if the network is unavailable.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
await clearAuth();
|
|
194
|
+
await clearBenchContext();
|
|
195
|
+
if (ctx.json) {
|
|
196
|
+
printJson({ ok: true, state: 'logged_out' });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
log(ctx, success('Logged out of Wacht Bench.'));
|
|
200
|
+
}
|
|
201
|
+
export async function authStatus(ctx) {
|
|
202
|
+
printBannerFor(ctx);
|
|
203
|
+
const auth = await readAuth();
|
|
204
|
+
const active = await readBenchContext();
|
|
205
|
+
if (!auth) {
|
|
206
|
+
if (ctx.json) {
|
|
207
|
+
printJson({ loggedIn: false, active });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
log(ctx, 'Not logged in.');
|
|
211
|
+
log(ctx, `Run ${command('wacht login')} to connect Bench.`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const expiresAt = new Date(auth.expires_at).toISOString();
|
|
215
|
+
if (ctx.json) {
|
|
216
|
+
printJson({
|
|
217
|
+
loggedIn: true,
|
|
218
|
+
clientId: auth.client_id,
|
|
219
|
+
scopes: scopesFrom(auth.scope),
|
|
220
|
+
machineApiUrl: auth.machine_api_url,
|
|
221
|
+
accessTokenExpiresAt: expiresAt,
|
|
222
|
+
active,
|
|
223
|
+
});
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
log(ctx, section('Auth Status'));
|
|
227
|
+
log(ctx, field('State', success('logged in')));
|
|
228
|
+
log(ctx, field('Client ID', auth.client_id));
|
|
229
|
+
log(ctx, field('Scopes', auth.scope ?? OAUTH_SCOPES));
|
|
230
|
+
log(ctx, field('Machine API', auth.machine_api_url));
|
|
231
|
+
log(ctx, field('Access token expires', expiresAt));
|
|
232
|
+
if (active) {
|
|
233
|
+
log(ctx, field('Active project', `${active.project_name} (${active.project_id})`));
|
|
234
|
+
log(ctx, field('Active deployment', `${active.deployment_mode} (${active.deployment_id})`));
|
|
235
|
+
}
|
|
236
|
+
}
|