@pugi/sdk 0.1.0-alpha.3
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/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/agent-contracts.d.ts +311 -0
- package/dist/agent-contracts.js +67 -0
- package/dist/audit-trace.d.ts +387 -0
- package/dist/audit-trace.js +44 -0
- package/dist/device-flow.d.ts +98 -0
- package/dist/device-flow.js +55 -0
- package/dist/engine-adapter.d.ts +376 -0
- package/dist/engine-adapter.js +47 -0
- package/dist/engine-loop.d.ts +457 -0
- package/dist/engine-loop.js +342 -0
- package/dist/handoff.d.ts +605 -0
- package/dist/handoff.js +76 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/mcp-schemas.d.ts +27 -0
- package/dist/mcp-schemas.js +11 -0
- package/dist/permission-rules.d.ts +65 -0
- package/dist/permission-rules.js +35 -0
- package/dist/transport.d.ts +559 -0
- package/dist/transport.js +482 -0
- package/package.json +47 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { pugiSyncRequestSchema, pugiSyncResponseSchema, } from './handoff.js';
|
|
3
|
+
import { pugiDevicePollResponseSchema, pugiDeviceStartResponseSchema, } from './device-flow.js';
|
|
4
|
+
export const anvilCapabilitySchema = z.object({
|
|
5
|
+
name: z.string().min(1),
|
|
6
|
+
version: z.string().min(1),
|
|
7
|
+
enabled: z.boolean(),
|
|
8
|
+
});
|
|
9
|
+
export const anvilCapabilitiesResponseSchema = z.object({
|
|
10
|
+
endpoint: z.string().min(1),
|
|
11
|
+
capabilities: z.array(anvilCapabilitySchema),
|
|
12
|
+
});
|
|
13
|
+
/**
|
|
14
|
+
* Pugi runtime client config.
|
|
15
|
+
*
|
|
16
|
+
* Defaults: PUGI_API_URL=https://api.pugi.io, PUGI_API_KEY required for remote.
|
|
17
|
+
* Self-hosted override: PUGI_API_URL=https://anvil.acme.corp.
|
|
18
|
+
*/
|
|
19
|
+
export const pugiRuntimeConfigSchema = z.object({
|
|
20
|
+
apiUrl: z.string().url(),
|
|
21
|
+
apiKey: z.string().min(1),
|
|
22
|
+
timeoutMs: z.number().int().positive().default(120_000),
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* Build a `PugiRuntimeConfig` from a known apiKey + apiUrl (e.g. resolved
|
|
26
|
+
* from a credentials store) plus the env for timeout override. Pure —
|
|
27
|
+
* does not touch the filesystem. The CLI's credential store layer is
|
|
28
|
+
* what reads disk / env first; this function exists so callers can
|
|
29
|
+
* provide credentials from any source (env, keychain, OAuth refresh)
|
|
30
|
+
* and still receive a validated config.
|
|
31
|
+
*/
|
|
32
|
+
export function buildRuntimeConfig(input) {
|
|
33
|
+
const env = input.env ?? process.env;
|
|
34
|
+
return pugiRuntimeConfigSchema.parse({
|
|
35
|
+
apiUrl: input.apiUrl,
|
|
36
|
+
apiKey: input.apiKey,
|
|
37
|
+
timeoutMs: env.PUGI_API_TIMEOUT_MS ? Number.parseInt(env.PUGI_API_TIMEOUT_MS, 10) : 120_000,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Convenience: env-only resolution. Returns `null` when `PUGI_API_KEY`
|
|
42
|
+
* is unset. Used by CI flows that authenticate purely via environment
|
|
43
|
+
* variables.
|
|
44
|
+
*/
|
|
45
|
+
export function loadRuntimeConfig(env = process.env) {
|
|
46
|
+
const apiUrl = env.PUGI_API_URL ?? 'https://api.pugi.io';
|
|
47
|
+
const apiKey = env.PUGI_API_KEY;
|
|
48
|
+
if (!apiKey)
|
|
49
|
+
return null;
|
|
50
|
+
return buildRuntimeConfig({ apiUrl, apiKey, env });
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Triple-review rubric (verbatim from /triple-review skill + OES MCP triple_review tool):
|
|
54
|
+
* any P0 -> BLOCK
|
|
55
|
+
* P1 from >= 2 reviewers -> BLOCK
|
|
56
|
+
* P1 from 1 reviewer -> WARN
|
|
57
|
+
* no P0/P1 -> PASS
|
|
58
|
+
* all reviewers errored -> BLOCK
|
|
59
|
+
*/
|
|
60
|
+
export const tripleReviewSeveritySchema = z.enum(['P0', 'P1', 'P2', 'P3']);
|
|
61
|
+
export const tripleReviewVerdictSchema = z.enum(['PASS', 'WARN', 'BLOCK']);
|
|
62
|
+
export const pugiTripleReviewFindingSchema = z.object({
|
|
63
|
+
reviewer: z.string().min(1),
|
|
64
|
+
severity: tripleReviewSeveritySchema,
|
|
65
|
+
line: z.number().int().positive().nullable(),
|
|
66
|
+
path: z.string().min(1).optional(),
|
|
67
|
+
issue: z.string().min(1),
|
|
68
|
+
fix: z.string(),
|
|
69
|
+
});
|
|
70
|
+
export const pugiTripleReviewReviewerSchema = z.object({
|
|
71
|
+
model: z.string().min(1),
|
|
72
|
+
latencyMs: z.number().int().nonnegative(),
|
|
73
|
+
tokensUsed: z.number().int().nonnegative().nullable(),
|
|
74
|
+
rawContent: z.string(),
|
|
75
|
+
findings: z.array(z.object({
|
|
76
|
+
severity: tripleReviewSeveritySchema,
|
|
77
|
+
line: z.number().int().positive().nullable(),
|
|
78
|
+
issue: z.string().min(1),
|
|
79
|
+
fix: z.string(),
|
|
80
|
+
})),
|
|
81
|
+
declaredVerdict: tripleReviewVerdictSchema.nullable(),
|
|
82
|
+
error: z.string().nullable(),
|
|
83
|
+
});
|
|
84
|
+
export const pugiTripleReviewRequestSchema = z.object({
|
|
85
|
+
schema: z.literal(1),
|
|
86
|
+
workspace: z.object({
|
|
87
|
+
rootName: z.string().min(1),
|
|
88
|
+
gitBranch: z.string().min(1).nullable(),
|
|
89
|
+
gitHead: z.string().min(1).nullable(),
|
|
90
|
+
baseRef: z.string().min(1).nullable(),
|
|
91
|
+
dirty: z.boolean(),
|
|
92
|
+
}),
|
|
93
|
+
/**
|
|
94
|
+
* Branch diff vs baseRef (e.g. `origin/main`). Truncated to a server-side
|
|
95
|
+
* cap on the receiver. We do NOT send raw file contents outside of
|
|
96
|
+
* `--privacy selected-files` or `--privacy full-sync` modes; the diff
|
|
97
|
+
* itself is the evidence the reviewers inspect.
|
|
98
|
+
*/
|
|
99
|
+
diffPatch: z.string(),
|
|
100
|
+
diffStats: z.object({
|
|
101
|
+
filesChanged: z.number().int().nonnegative(),
|
|
102
|
+
insertions: z.number().int().nonnegative(),
|
|
103
|
+
deletions: z.number().int().nonnegative(),
|
|
104
|
+
}),
|
|
105
|
+
/**
|
|
106
|
+
* Optional prompt (`pugi review --triple "<prompt>"`). When absent the
|
|
107
|
+
* reviewers infer scope from the diff alone.
|
|
108
|
+
*/
|
|
109
|
+
prompt: z.string().optional(),
|
|
110
|
+
locale: z.string().default('en-US'),
|
|
111
|
+
/**
|
|
112
|
+
* Reviewer persona slug on the server side. Default 'oes-dev' (Sigma)
|
|
113
|
+
* is the tier-2 reviewer today and bumps to tier-3 transparently when
|
|
114
|
+
* the operator configures ANVIL_TIER1_MODELS with 3+ models.
|
|
115
|
+
*/
|
|
116
|
+
reviewerPersona: z.string().default('oes-dev'),
|
|
117
|
+
});
|
|
118
|
+
export const pugiTripleReviewResponseSchema = z.object({
|
|
119
|
+
schema: z.literal(1),
|
|
120
|
+
verdict: tripleReviewVerdictSchema,
|
|
121
|
+
reason: z.string().min(1),
|
|
122
|
+
reviewerCount: z.number().int().nonnegative(),
|
|
123
|
+
effectiveTier: z.union([z.literal(1), z.literal(2), z.literal(3)]),
|
|
124
|
+
draft: z.boolean(),
|
|
125
|
+
reviewers: z.array(pugiTripleReviewReviewerSchema),
|
|
126
|
+
findings: z.array(pugiTripleReviewFindingSchema),
|
|
127
|
+
counts: z.object({
|
|
128
|
+
P0: z.number().int().nonnegative(),
|
|
129
|
+
P1: z.number().int().nonnegative(),
|
|
130
|
+
P2: z.number().int().nonnegative(),
|
|
131
|
+
P3: z.number().int().nonnegative(),
|
|
132
|
+
}),
|
|
133
|
+
/**
|
|
134
|
+
* ISO-8601 timestamp when the server completed the review. Pugi
|
|
135
|
+
* persists this in the local artifact so audit replay knows when
|
|
136
|
+
* the runtime gate fired.
|
|
137
|
+
*/
|
|
138
|
+
completedAt: z.string().datetime(),
|
|
139
|
+
});
|
|
140
|
+
/**
|
|
141
|
+
* Submit a triple-review request to the Pugi runtime endpoint.
|
|
142
|
+
*
|
|
143
|
+
* Endpoint contract (admin-api side, ships in a separate PR):
|
|
144
|
+
* POST {apiUrl}/api/pugi/triple-review
|
|
145
|
+
* Authorization: Bearer {apiKey}
|
|
146
|
+
* Content-Type: application/json
|
|
147
|
+
* Body: PugiTripleReviewRequest
|
|
148
|
+
* 200: PugiTripleReviewResponse
|
|
149
|
+
* 401/403: unauthenticated
|
|
150
|
+
* 404: endpoint not yet deployed (graceful local-only fallback)
|
|
151
|
+
* 429: rate limited (per-tenant)
|
|
152
|
+
* 5xx: failed
|
|
153
|
+
*
|
|
154
|
+
* Local-first contract: this function never reads the local file system,
|
|
155
|
+
* never logs the diff payload, and never retries on transient errors —
|
|
156
|
+
* the caller decides whether a retry makes sense.
|
|
157
|
+
*/
|
|
158
|
+
export async function submitTripleReview(config, request) {
|
|
159
|
+
const body = pugiTripleReviewRequestSchema.parse(request);
|
|
160
|
+
// Admin-api mounts every route under the global `/api` prefix. Pugi
|
|
161
|
+
// CLI talks to `/api/pugi/triple-review`; the server-side endpoint
|
|
162
|
+
// ships in apps/admin-api/src/pugi/.
|
|
163
|
+
const url = `${config.apiUrl.replace(/\/+$/, '')}/api/pugi/triple-review`;
|
|
164
|
+
const controller = new AbortController();
|
|
165
|
+
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
166
|
+
try {
|
|
167
|
+
const res = await fetch(url, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: {
|
|
170
|
+
'content-type': 'application/json',
|
|
171
|
+
authorization: `Bearer ${config.apiKey}`,
|
|
172
|
+
'user-agent': 'pugi-cli/0.0.1',
|
|
173
|
+
},
|
|
174
|
+
body: JSON.stringify(body),
|
|
175
|
+
signal: controller.signal,
|
|
176
|
+
});
|
|
177
|
+
const code = res.status;
|
|
178
|
+
const text = await res.text();
|
|
179
|
+
if (code === 200) {
|
|
180
|
+
let raw;
|
|
181
|
+
try {
|
|
182
|
+
raw = JSON.parse(text);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
return {
|
|
186
|
+
status: 'failed',
|
|
187
|
+
code,
|
|
188
|
+
message: `runtime returned 200 with non-JSON body: ${error.message}`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const parsed = pugiTripleReviewResponseSchema.safeParse(raw);
|
|
192
|
+
if (!parsed.success) {
|
|
193
|
+
return {
|
|
194
|
+
status: 'failed',
|
|
195
|
+
code,
|
|
196
|
+
message: `runtime response failed schema: ${parsed.error.issues
|
|
197
|
+
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
|
|
198
|
+
.join('; ')}`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return { status: 'ok', response: parsed.data };
|
|
202
|
+
}
|
|
203
|
+
if (code === 404) {
|
|
204
|
+
return {
|
|
205
|
+
status: 'endpoint_missing',
|
|
206
|
+
code,
|
|
207
|
+
message: 'POST /api/pugi/triple-review not deployed on this runtime',
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (code === 401 || code === 403) {
|
|
211
|
+
return {
|
|
212
|
+
status: 'unauthenticated',
|
|
213
|
+
code,
|
|
214
|
+
message: `runtime rejected credentials (HTTP ${code})`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
if (code === 429) {
|
|
218
|
+
const retryAfterHeader = res.headers.get('retry-after');
|
|
219
|
+
const retryAfterMs = retryAfterHeader
|
|
220
|
+
? Number.parseInt(retryAfterHeader, 10) * 1000
|
|
221
|
+
: 60_000;
|
|
222
|
+
return {
|
|
223
|
+
status: 'rate_limited',
|
|
224
|
+
code,
|
|
225
|
+
retryAfterMs: Number.isFinite(retryAfterMs) ? retryAfterMs : 60_000,
|
|
226
|
+
message: 'runtime rate limit reached for this tenant',
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
status: 'failed',
|
|
231
|
+
code,
|
|
232
|
+
message: `runtime returned HTTP ${code}${text ? `: ${text.slice(0, 200)}` : ''}`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
const message = error instanceof Error
|
|
237
|
+
? error.name === 'AbortError'
|
|
238
|
+
? `runtime call timed out after ${config.timeoutMs}ms`
|
|
239
|
+
: error.message
|
|
240
|
+
: 'unknown error';
|
|
241
|
+
return { status: 'failed', code: 0, message };
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
clearTimeout(timeout);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Submit an explicit-continuation sync to the Pugi runtime endpoint.
|
|
249
|
+
*
|
|
250
|
+
* Endpoint contract (admin-api side, ships in this PR):
|
|
251
|
+
* POST {apiUrl}/api/pugi/sync
|
|
252
|
+
* Authorization: Bearer {apiKey}
|
|
253
|
+
* Content-Type: application/json
|
|
254
|
+
* Body: PugiSyncRequest (handoff bundle + upload-enabled plan)
|
|
255
|
+
* 200: PugiSyncResponse
|
|
256
|
+
* 401/403: unauthenticated
|
|
257
|
+
* 404: endpoint not yet deployed (graceful local-only fallback)
|
|
258
|
+
* 429: rate limited (per-tenant)
|
|
259
|
+
* 5xx: failed
|
|
260
|
+
*
|
|
261
|
+
* Local-first contract (ADR-0037): this function never reads files,
|
|
262
|
+
* never logs the bundle payload, and never retries on transient
|
|
263
|
+
* errors. The caller has already surfaced the dry-run plan to the
|
|
264
|
+
* operator; this is the explicit upload step.
|
|
265
|
+
*/
|
|
266
|
+
export async function submitSync(config, request) {
|
|
267
|
+
const body = pugiSyncRequestSchema.parse(request);
|
|
268
|
+
const url = `${config.apiUrl.replace(/\/+$/, '')}/api/pugi/sync`;
|
|
269
|
+
const controller = new AbortController();
|
|
270
|
+
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
271
|
+
try {
|
|
272
|
+
const res = await fetch(url, {
|
|
273
|
+
method: 'POST',
|
|
274
|
+
headers: {
|
|
275
|
+
'content-type': 'application/json',
|
|
276
|
+
authorization: `Bearer ${config.apiKey}`,
|
|
277
|
+
'user-agent': 'pugi-cli/0.0.1',
|
|
278
|
+
},
|
|
279
|
+
body: JSON.stringify(body),
|
|
280
|
+
signal: controller.signal,
|
|
281
|
+
});
|
|
282
|
+
const code = res.status;
|
|
283
|
+
const text = await res.text();
|
|
284
|
+
if (code === 200) {
|
|
285
|
+
let raw;
|
|
286
|
+
try {
|
|
287
|
+
raw = JSON.parse(text);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
return {
|
|
291
|
+
status: 'failed',
|
|
292
|
+
code,
|
|
293
|
+
message: `runtime returned 200 with non-JSON body: ${error.message}`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
const parsed = pugiSyncResponseSchema.safeParse(raw);
|
|
297
|
+
if (!parsed.success) {
|
|
298
|
+
return {
|
|
299
|
+
status: 'failed',
|
|
300
|
+
code,
|
|
301
|
+
message: `runtime response failed schema: ${parsed.error.issues
|
|
302
|
+
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
|
|
303
|
+
.join('; ')}`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
return { status: 'ok', response: parsed.data };
|
|
307
|
+
}
|
|
308
|
+
if (code === 404) {
|
|
309
|
+
return {
|
|
310
|
+
status: 'endpoint_missing',
|
|
311
|
+
code,
|
|
312
|
+
message: 'POST /api/pugi/sync not deployed on this runtime',
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
if (code === 401 || code === 403) {
|
|
316
|
+
return {
|
|
317
|
+
status: 'unauthenticated',
|
|
318
|
+
code,
|
|
319
|
+
message: `runtime rejected credentials (HTTP ${code})`,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
if (code === 429) {
|
|
323
|
+
const retryAfterHeader = res.headers.get('retry-after');
|
|
324
|
+
const retryAfterMs = retryAfterHeader
|
|
325
|
+
? Number.parseInt(retryAfterHeader, 10) * 1000
|
|
326
|
+
: 60_000;
|
|
327
|
+
return {
|
|
328
|
+
status: 'rate_limited',
|
|
329
|
+
code,
|
|
330
|
+
retryAfterMs: Number.isFinite(retryAfterMs) ? retryAfterMs : 60_000,
|
|
331
|
+
message: 'runtime rate limit reached for this tenant',
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
status: 'failed',
|
|
336
|
+
code,
|
|
337
|
+
message: `runtime returned HTTP ${code}${text ? `: ${text.slice(0, 200)}` : ''}`,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
const message = error instanceof Error
|
|
342
|
+
? error.name === 'AbortError'
|
|
343
|
+
? `runtime call timed out after ${config.timeoutMs}ms`
|
|
344
|
+
: error.message
|
|
345
|
+
: 'unknown error';
|
|
346
|
+
return { status: 'failed', code: 0, message };
|
|
347
|
+
}
|
|
348
|
+
finally {
|
|
349
|
+
clearTimeout(timeout);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* RFC 8628 §3.1 — CLI initiates the device flow. Anonymous request
|
|
354
|
+
* (no Authorization header). The runtime returns a `device_code` the
|
|
355
|
+
* CLI must keep secret and a `user_code` the user types into the
|
|
356
|
+
* cabinet Approve page.
|
|
357
|
+
*/
|
|
358
|
+
export async function startDeviceFlow(apiUrl, timeoutMs = 15_000) {
|
|
359
|
+
const url = `${apiUrl.replace(/\/+$/, '')}/api/auth/device/start`;
|
|
360
|
+
const controller = new AbortController();
|
|
361
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
362
|
+
try {
|
|
363
|
+
const res = await fetch(url, {
|
|
364
|
+
method: 'POST',
|
|
365
|
+
headers: { 'content-type': 'application/json', 'user-agent': 'pugi-cli/0.0.1' },
|
|
366
|
+
body: JSON.stringify({ clientId: 'pugi-cli' }),
|
|
367
|
+
signal: controller.signal,
|
|
368
|
+
});
|
|
369
|
+
const text = await res.text();
|
|
370
|
+
if (res.status === 200) {
|
|
371
|
+
try {
|
|
372
|
+
const parsed = pugiDeviceStartResponseSchema.safeParse(JSON.parse(text));
|
|
373
|
+
if (!parsed.success) {
|
|
374
|
+
return {
|
|
375
|
+
status: 'failed',
|
|
376
|
+
code: 200,
|
|
377
|
+
message: `runtime response failed schema: ${parsed.error.issues
|
|
378
|
+
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
|
|
379
|
+
.join('; ')}`,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
return { status: 'ok', response: parsed.data };
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
return {
|
|
386
|
+
status: 'failed',
|
|
387
|
+
code: 200,
|
|
388
|
+
message: `runtime returned 200 with non-JSON body: ${error.message}`,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (res.status === 404) {
|
|
393
|
+
return {
|
|
394
|
+
status: 'endpoint_missing',
|
|
395
|
+
code: 404,
|
|
396
|
+
message: 'POST /api/auth/device/start not deployed on this runtime',
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
status: 'failed',
|
|
401
|
+
code: res.status,
|
|
402
|
+
message: `runtime returned HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
const message = error instanceof Error
|
|
407
|
+
? error.name === 'AbortError'
|
|
408
|
+
? `runtime call timed out after ${timeoutMs}ms`
|
|
409
|
+
: error.message
|
|
410
|
+
: 'unknown error';
|
|
411
|
+
return { status: 'failed', code: 0, message };
|
|
412
|
+
}
|
|
413
|
+
finally {
|
|
414
|
+
clearTimeout(timeout);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* RFC 8628 §3.4 — CLI polls until the user authorizes. The runtime
|
|
419
|
+
* returns the outcome class in the response body (always HTTP 200)
|
|
420
|
+
* so older HTTP clients with weak 4xx handling can still poll
|
|
421
|
+
* reliably. See AuthDeviceController for the rationale.
|
|
422
|
+
*/
|
|
423
|
+
export async function pollDeviceFlow(apiUrl, deviceCode, timeoutMs = 15_000) {
|
|
424
|
+
const url = `${apiUrl.replace(/\/+$/, '')}/api/auth/device/poll`;
|
|
425
|
+
const controller = new AbortController();
|
|
426
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
427
|
+
try {
|
|
428
|
+
const res = await fetch(url, {
|
|
429
|
+
method: 'POST',
|
|
430
|
+
headers: { 'content-type': 'application/json', 'user-agent': 'pugi-cli/0.0.1' },
|
|
431
|
+
body: JSON.stringify({ deviceCode }),
|
|
432
|
+
signal: controller.signal,
|
|
433
|
+
});
|
|
434
|
+
const text = await res.text();
|
|
435
|
+
if (res.status === 200) {
|
|
436
|
+
try {
|
|
437
|
+
const parsed = pugiDevicePollResponseSchema.safeParse(JSON.parse(text));
|
|
438
|
+
if (!parsed.success) {
|
|
439
|
+
return {
|
|
440
|
+
status: 'failed',
|
|
441
|
+
code: 200,
|
|
442
|
+
message: `runtime response failed schema: ${parsed.error.issues
|
|
443
|
+
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
|
|
444
|
+
.join('; ')}`,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
return { status: 'ok', response: parsed.data };
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
return {
|
|
451
|
+
status: 'failed',
|
|
452
|
+
code: 200,
|
|
453
|
+
message: `runtime returned 200 with non-JSON body: ${error.message}`,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (res.status === 404) {
|
|
458
|
+
return {
|
|
459
|
+
status: 'endpoint_missing',
|
|
460
|
+
code: 404,
|
|
461
|
+
message: 'POST /api/auth/device/poll not deployed on this runtime',
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
return {
|
|
465
|
+
status: 'failed',
|
|
466
|
+
code: res.status,
|
|
467
|
+
message: `runtime returned HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
const message = error instanceof Error
|
|
472
|
+
? error.name === 'AbortError'
|
|
473
|
+
? `runtime call timed out after ${timeoutMs}ms`
|
|
474
|
+
: error.message
|
|
475
|
+
: 'unknown error';
|
|
476
|
+
return { status: 'failed', code: 0, message };
|
|
477
|
+
}
|
|
478
|
+
finally {
|
|
479
|
+
clearTimeout(timeout);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
//# sourceMappingURL=transport.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pugi/sdk",
|
|
3
|
+
"version": "0.1.0-alpha.3",
|
|
4
|
+
"description": "Pugi CLI contracts shared by the CLI, docs, and Anvil runtime",
|
|
5
|
+
"homepage": "https://pugi.io",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/pugi-io/pugi.git",
|
|
9
|
+
"directory": "packages/pugi-sdk"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/pugi-io/pugi/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"pugi",
|
|
16
|
+
"sdk",
|
|
17
|
+
"contracts",
|
|
18
|
+
"schemas"
|
|
19
|
+
],
|
|
20
|
+
"author": "Pugi <hello@pugi.io>",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "dist/index.js",
|
|
24
|
+
"types": "dist/index.d.ts",
|
|
25
|
+
"files": [
|
|
26
|
+
"dist/**/*.js",
|
|
27
|
+
"dist/**/*.d.ts",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=20"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"zod": "^3.23.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"typescript": "~5.6.0"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc -p tsconfig.json",
|
|
45
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
46
|
+
}
|
|
47
|
+
}
|