pi-commandcode-provider 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/LICENSE +21 -0
- package/README.md +110 -0
- package/index.ts +197 -0
- package/package.json +54 -0
- package/src/auth-server.ts +214 -0
- package/src/converters.ts +276 -0
- package/src/core.ts +458 -0
- package/src/oauth.ts +167 -0
- package/src/types.ts +156 -0
package/src/core.ts
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Testable Command Code provider core.
|
|
3
|
+
*
|
|
4
|
+
* The runtime imports live in index.ts; this module takes injected stream/cost
|
|
5
|
+
* dependencies so tests can exercise the real serialization and stream parser.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from "node:crypto"
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
getApiKey,
|
|
12
|
+
getEnvironmentInfo,
|
|
13
|
+
isRecord,
|
|
14
|
+
mapFinishReason,
|
|
15
|
+
messagesToCC,
|
|
16
|
+
numberValue,
|
|
17
|
+
parseStreamEventLine,
|
|
18
|
+
recordOrEmpty,
|
|
19
|
+
stringValue,
|
|
20
|
+
toolsToJson,
|
|
21
|
+
} from "./converters.ts"
|
|
22
|
+
import type {
|
|
23
|
+
AssistantMessageEventStreamLike,
|
|
24
|
+
AssistantMessageLike,
|
|
25
|
+
ContextLike,
|
|
26
|
+
CoreDependencies,
|
|
27
|
+
ErrorReason,
|
|
28
|
+
ModelLike,
|
|
29
|
+
StopReason,
|
|
30
|
+
StreamOptions,
|
|
31
|
+
TerminalReason,
|
|
32
|
+
TextContent,
|
|
33
|
+
ToolCallContent,
|
|
34
|
+
Usage,
|
|
35
|
+
} from "./types.ts"
|
|
36
|
+
|
|
37
|
+
export * from "./converters.ts"
|
|
38
|
+
export * from "./types.ts"
|
|
39
|
+
|
|
40
|
+
export const DEFAULT_API_BASE = "https://api.commandcode.ai"
|
|
41
|
+
|
|
42
|
+
function defaultUsage(): Usage {
|
|
43
|
+
return {
|
|
44
|
+
input: 0,
|
|
45
|
+
output: 0,
|
|
46
|
+
cacheRead: 0,
|
|
47
|
+
cacheWrite: 0,
|
|
48
|
+
totalTokens: 0,
|
|
49
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function commandCodeUsage(event: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
54
|
+
return isRecord(event.totalUsage) ? event.totalUsage : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function commandCodeInputTokenDetails(
|
|
58
|
+
usage: Record<string, unknown>,
|
|
59
|
+
): Record<string, unknown> | undefined {
|
|
60
|
+
return isRecord(usage.inputTokenDetails) ? usage.inputTokenDetails : undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function headersToRecord(headers: Headers): Record<string, string> {
|
|
64
|
+
const out: Record<string, string> = {}
|
|
65
|
+
headers.forEach((value, key) => {
|
|
66
|
+
out[key] = value
|
|
67
|
+
})
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function abortError(message = "The operation was aborted"): DOMException {
|
|
72
|
+
return new DOMException(message, "AbortError")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function successStopReason(reason: TerminalReason): StopReason {
|
|
76
|
+
if (reason === "length" || reason === "toolUse") return reason
|
|
77
|
+
return "stop"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createStreamCommandCode(deps: CoreDependencies) {
|
|
81
|
+
const apiBase = deps.apiBase ?? DEFAULT_API_BASE
|
|
82
|
+
const fetchImpl = deps.fetchImpl ?? fetch
|
|
83
|
+
const cwd = deps.cwd ?? (() => process.cwd())
|
|
84
|
+
const now = deps.now ?? (() => Date.now())
|
|
85
|
+
const uuid = deps.uuid ?? (() => randomUUID())
|
|
86
|
+
|
|
87
|
+
function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
88
|
+
if (signal.aborted) return Promise.reject(abortError())
|
|
89
|
+
|
|
90
|
+
return new Promise<T>((resolve, reject) => {
|
|
91
|
+
const onAbort = () => reject(abortError())
|
|
92
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
93
|
+
promise.then(
|
|
94
|
+
(value) => {
|
|
95
|
+
signal.removeEventListener("abort", onAbort)
|
|
96
|
+
resolve(value)
|
|
97
|
+
},
|
|
98
|
+
(error: unknown) => {
|
|
99
|
+
signal.removeEventListener("abort", onAbort)
|
|
100
|
+
reject(error)
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return function streamCommandCode(
|
|
107
|
+
model: ModelLike,
|
|
108
|
+
context: ContextLike,
|
|
109
|
+
options?: StreamOptions,
|
|
110
|
+
): AssistantMessageEventStreamLike {
|
|
111
|
+
const stream = deps.createStream()
|
|
112
|
+
|
|
113
|
+
async function run() {
|
|
114
|
+
const apiKey =
|
|
115
|
+
options?.apiKey ??
|
|
116
|
+
getApiKey({
|
|
117
|
+
env: deps.env,
|
|
118
|
+
authPaths: deps.authPaths,
|
|
119
|
+
homeDir: deps.homeDir,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
if (!apiKey) {
|
|
123
|
+
const msg: AssistantMessageLike = {
|
|
124
|
+
role: "assistant",
|
|
125
|
+
content: [],
|
|
126
|
+
api: model.api,
|
|
127
|
+
provider: model.provider,
|
|
128
|
+
model: model.id,
|
|
129
|
+
usage: defaultUsage(),
|
|
130
|
+
stopReason: "error",
|
|
131
|
+
errorMessage:
|
|
132
|
+
"No Command Code API key. Run /login and select Command Code, set COMMANDCODE_API_KEY env var, or configure ~/.commandcode/auth.json or ~/.pi/agent/auth.json.",
|
|
133
|
+
timestamp: now(),
|
|
134
|
+
}
|
|
135
|
+
stream.push({ type: "error", reason: "error", error: msg })
|
|
136
|
+
stream.end()
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const output: AssistantMessageLike = {
|
|
141
|
+
role: "assistant",
|
|
142
|
+
content: [],
|
|
143
|
+
api: model.api,
|
|
144
|
+
provider: model.provider,
|
|
145
|
+
model: model.id,
|
|
146
|
+
usage: defaultUsage(),
|
|
147
|
+
stopReason: "stop",
|
|
148
|
+
timestamp: now(),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const controller = new AbortController()
|
|
152
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
|
|
153
|
+
let textBlock: TextContent | undefined
|
|
154
|
+
let currentTextIdx = -1
|
|
155
|
+
let thinkingBlock: string[] = []
|
|
156
|
+
let finished = false
|
|
157
|
+
|
|
158
|
+
const abortUpstream = () => {
|
|
159
|
+
if (!controller.signal.aborted) controller.abort()
|
|
160
|
+
try {
|
|
161
|
+
reader?.cancel().catch(() => undefined)
|
|
162
|
+
} catch {
|
|
163
|
+
// Reader cancellation is best-effort.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (options?.signal?.aborted) {
|
|
168
|
+
abortUpstream()
|
|
169
|
+
} else {
|
|
170
|
+
options?.signal?.addEventListener("abort", abortUpstream, {
|
|
171
|
+
once: true,
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const endTextBlock = () => {
|
|
176
|
+
if (!textBlock) return
|
|
177
|
+
stream.push({
|
|
178
|
+
type: "text_end",
|
|
179
|
+
contentIndex: currentTextIdx,
|
|
180
|
+
content: textBlock.text,
|
|
181
|
+
partial: output,
|
|
182
|
+
})
|
|
183
|
+
textBlock = undefined
|
|
184
|
+
currentTextIdx = -1
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const flushThinkingBlock = () => {
|
|
188
|
+
if (thinkingBlock.length === 0) return
|
|
189
|
+
const thinkingText = thinkingBlock.join("")
|
|
190
|
+
thinkingBlock = []
|
|
191
|
+
output.content.push({ type: "thinking", thinking: thinkingText })
|
|
192
|
+
const idx = output.content.length - 1
|
|
193
|
+
stream.push({
|
|
194
|
+
type: "thinking_start",
|
|
195
|
+
contentIndex: idx,
|
|
196
|
+
partial: output,
|
|
197
|
+
})
|
|
198
|
+
stream.push({
|
|
199
|
+
type: "thinking_delta",
|
|
200
|
+
contentIndex: idx,
|
|
201
|
+
delta: thinkingText,
|
|
202
|
+
partial: output,
|
|
203
|
+
})
|
|
204
|
+
stream.push({
|
|
205
|
+
type: "thinking_end",
|
|
206
|
+
contentIndex: idx,
|
|
207
|
+
content: thinkingText,
|
|
208
|
+
partial: output,
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const handleEvent = (event: unknown) => {
|
|
213
|
+
if (!isRecord(event)) return
|
|
214
|
+
|
|
215
|
+
switch (event.type) {
|
|
216
|
+
case "text-delta": {
|
|
217
|
+
if (!textBlock) {
|
|
218
|
+
textBlock = { type: "text", text: "" }
|
|
219
|
+
output.content.push(textBlock)
|
|
220
|
+
currentTextIdx = output.content.length - 1
|
|
221
|
+
stream.push({
|
|
222
|
+
type: "text_start",
|
|
223
|
+
contentIndex: currentTextIdx,
|
|
224
|
+
partial: output,
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
const delta = stringValue(event.text) ?? ""
|
|
228
|
+
textBlock.text += delta
|
|
229
|
+
stream.push({
|
|
230
|
+
type: "text_delta",
|
|
231
|
+
contentIndex: currentTextIdx,
|
|
232
|
+
delta,
|
|
233
|
+
partial: output,
|
|
234
|
+
})
|
|
235
|
+
break
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
case "reasoning-delta": {
|
|
239
|
+
thinkingBlock.push(stringValue(event.text) ?? "")
|
|
240
|
+
break
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
case "reasoning-end": {
|
|
244
|
+
flushThinkingBlock()
|
|
245
|
+
break
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
case "tool-call": {
|
|
249
|
+
endTextBlock()
|
|
250
|
+
const toolCall: ToolCallContent = {
|
|
251
|
+
type: "toolCall",
|
|
252
|
+
id: stringValue(event.toolCallId) ?? "",
|
|
253
|
+
name: stringValue(event.toolName) ?? "",
|
|
254
|
+
arguments: recordOrEmpty(event.input ?? event.args ?? event.arguments),
|
|
255
|
+
}
|
|
256
|
+
output.content.push(toolCall)
|
|
257
|
+
const idx = output.content.length - 1
|
|
258
|
+
stream.push({
|
|
259
|
+
type: "toolcall_start",
|
|
260
|
+
contentIndex: idx,
|
|
261
|
+
partial: output,
|
|
262
|
+
})
|
|
263
|
+
stream.push({
|
|
264
|
+
type: "toolcall_end",
|
|
265
|
+
contentIndex: idx,
|
|
266
|
+
toolCall,
|
|
267
|
+
partial: output,
|
|
268
|
+
})
|
|
269
|
+
break
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
case "finish": {
|
|
273
|
+
const usage = commandCodeUsage(event)
|
|
274
|
+
if (usage) {
|
|
275
|
+
const details = commandCodeInputTokenDetails(usage)
|
|
276
|
+
output.usage.input = numberValue(usage.inputTokens) ?? 0
|
|
277
|
+
output.usage.output = numberValue(usage.outputTokens) ?? 0
|
|
278
|
+
output.usage.cacheRead = numberValue(details?.cacheReadTokens) ?? 0
|
|
279
|
+
output.usage.cacheWrite = numberValue(details?.cacheWriteTokens) ?? 0
|
|
280
|
+
output.usage.totalTokens =
|
|
281
|
+
output.usage.input +
|
|
282
|
+
output.usage.output +
|
|
283
|
+
output.usage.cacheRead +
|
|
284
|
+
output.usage.cacheWrite
|
|
285
|
+
deps.calculateCost(model, output.usage)
|
|
286
|
+
}
|
|
287
|
+
output.stopReason = mapFinishReason(event.finishReason)
|
|
288
|
+
finished = true
|
|
289
|
+
break
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
case "error": {
|
|
293
|
+
const errorRecord = isRecord(event.error) ? event.error : undefined
|
|
294
|
+
const message =
|
|
295
|
+
stringValue(errorRecord?.message) ?? stringValue(event.error) ?? "Stream error"
|
|
296
|
+
output.stopReason = "error"
|
|
297
|
+
output.errorMessage = message
|
|
298
|
+
throw new Error(message)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
stream.push({ type: "start", partial: output })
|
|
305
|
+
|
|
306
|
+
let body: unknown = {
|
|
307
|
+
config: {
|
|
308
|
+
workingDir: cwd(),
|
|
309
|
+
date: new Date(now()).toISOString().split("T")[0],
|
|
310
|
+
environment: getEnvironmentInfo(),
|
|
311
|
+
structure: [],
|
|
312
|
+
isGitRepo: false,
|
|
313
|
+
currentBranch: "",
|
|
314
|
+
mainBranch: "",
|
|
315
|
+
gitStatus: "",
|
|
316
|
+
recentCommits: [],
|
|
317
|
+
},
|
|
318
|
+
memory: "",
|
|
319
|
+
taste: "",
|
|
320
|
+
skills: null,
|
|
321
|
+
permissionMode: "standard",
|
|
322
|
+
params: {
|
|
323
|
+
model: model.id,
|
|
324
|
+
messages: messagesToCC(context.messages),
|
|
325
|
+
tools: toolsToJson(context.tools),
|
|
326
|
+
system: context.systemPrompt ?? "",
|
|
327
|
+
max_tokens: Math.min(options?.maxTokens ?? model.maxTokens, 200_000),
|
|
328
|
+
stream: true,
|
|
329
|
+
},
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const nextBody = await raceAbort(
|
|
333
|
+
Promise.resolve(options?.onPayload?.(body, model)),
|
|
334
|
+
controller.signal,
|
|
335
|
+
)
|
|
336
|
+
if (nextBody !== undefined) body = nextBody
|
|
337
|
+
|
|
338
|
+
const response = await raceAbort(
|
|
339
|
+
fetchImpl(`${apiBase}/alpha/generate`, {
|
|
340
|
+
method: "POST",
|
|
341
|
+
headers: {
|
|
342
|
+
"Content-Type": "application/json",
|
|
343
|
+
Authorization: `Bearer ${apiKey}`,
|
|
344
|
+
"x-command-code-version": "0.24.1",
|
|
345
|
+
"x-cli-environment": "production",
|
|
346
|
+
"x-project-slug": "pi-cc",
|
|
347
|
+
"x-taste-learning": "false",
|
|
348
|
+
"x-co-flag": "false",
|
|
349
|
+
"x-session-id": uuid(),
|
|
350
|
+
...options?.headers,
|
|
351
|
+
},
|
|
352
|
+
body: JSON.stringify(body),
|
|
353
|
+
signal: controller.signal,
|
|
354
|
+
}),
|
|
355
|
+
controller.signal,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
await raceAbort(
|
|
359
|
+
Promise.resolve(
|
|
360
|
+
options?.onResponse?.(
|
|
361
|
+
{
|
|
362
|
+
status: response.status,
|
|
363
|
+
headers: headersToRecord(response.headers),
|
|
364
|
+
},
|
|
365
|
+
model,
|
|
366
|
+
),
|
|
367
|
+
),
|
|
368
|
+
controller.signal,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
if (!response.ok) {
|
|
372
|
+
const errBody = await raceAbort(
|
|
373
|
+
response.text().catch(() => ""),
|
|
374
|
+
controller.signal,
|
|
375
|
+
)
|
|
376
|
+
throw new Error(`Command Code API error ${response.status}: ${errBody.slice(0, 500)}`)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
reader = response.body?.getReader()
|
|
380
|
+
if (!reader) throw new Error("No response body")
|
|
381
|
+
|
|
382
|
+
const decoder = new TextDecoder()
|
|
383
|
+
let buffer = ""
|
|
384
|
+
|
|
385
|
+
readLoop: for (;;) {
|
|
386
|
+
if (controller.signal.aborted) throw abortError("Aborted")
|
|
387
|
+
const { done, value } = await raceAbort(reader.read(), controller.signal)
|
|
388
|
+
if (done) {
|
|
389
|
+
if (buffer.trim()) handleEvent(parseStreamEventLine(buffer))
|
|
390
|
+
break
|
|
391
|
+
}
|
|
392
|
+
if (controller.signal.aborted) throw abortError("Aborted")
|
|
393
|
+
|
|
394
|
+
buffer += decoder.decode(value, { stream: true })
|
|
395
|
+
const lines = buffer.split("\n")
|
|
396
|
+
buffer = lines.pop() ?? ""
|
|
397
|
+
|
|
398
|
+
for (const line of lines) {
|
|
399
|
+
if (controller.signal.aborted) throw abortError("Aborted")
|
|
400
|
+
handleEvent(parseStreamEventLine(line))
|
|
401
|
+
if (finished) break readLoop
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
endTextBlock()
|
|
406
|
+
flushThinkingBlock()
|
|
407
|
+
|
|
408
|
+
stream.push({
|
|
409
|
+
type: "done",
|
|
410
|
+
reason: successStopReason(output.stopReason),
|
|
411
|
+
message: output,
|
|
412
|
+
})
|
|
413
|
+
stream.end()
|
|
414
|
+
} catch (error: unknown) {
|
|
415
|
+
const reason: ErrorReason = controller.signal.aborted ? "aborted" : "error"
|
|
416
|
+
output.stopReason = reason
|
|
417
|
+
output.errorMessage =
|
|
418
|
+
reason === "aborted"
|
|
419
|
+
? "Request aborted"
|
|
420
|
+
: error instanceof Error
|
|
421
|
+
? error.message
|
|
422
|
+
: String(error)
|
|
423
|
+
stream.push({ type: "error", reason, error: output })
|
|
424
|
+
stream.end()
|
|
425
|
+
} finally {
|
|
426
|
+
options?.signal?.removeEventListener("abort", abortUpstream)
|
|
427
|
+
try {
|
|
428
|
+
await reader?.cancel()
|
|
429
|
+
} catch {
|
|
430
|
+
// Reader may already be closed/cancelled.
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
reader?.releaseLock()
|
|
434
|
+
} catch {
|
|
435
|
+
// Reader may already be released/cancelled by the abort path.
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
run().catch((error: unknown) => {
|
|
441
|
+
const msg: AssistantMessageLike = {
|
|
442
|
+
role: "assistant",
|
|
443
|
+
content: [],
|
|
444
|
+
api: model.api,
|
|
445
|
+
provider: model.provider,
|
|
446
|
+
model: model.id,
|
|
447
|
+
usage: defaultUsage(),
|
|
448
|
+
stopReason: "error",
|
|
449
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
450
|
+
timestamp: now(),
|
|
451
|
+
}
|
|
452
|
+
stream.push({ type: "error", reason: "error", error: msg })
|
|
453
|
+
stream.end()
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
return stream
|
|
457
|
+
}
|
|
458
|
+
}
|
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command Code OAuth provider for pi's /login flow.
|
|
3
|
+
*
|
|
4
|
+
* Implements a browser-assisted API key retrieval flow:
|
|
5
|
+
* 1. Starts a local HTTP server on a Command Code CLI-compatible port
|
|
6
|
+
* 2. Opens the Command Code Studio auth page in the browser
|
|
7
|
+
* 3. The user authenticates on the Command Code website
|
|
8
|
+
* 4. The website POSTs the API key back to the local server
|
|
9
|
+
* 5. If browser transfer fails, the user can paste the API key manually
|
|
10
|
+
* 6. The API key is stored in pi's auth.json as OAuth credentials
|
|
11
|
+
*
|
|
12
|
+
* Since Command Code API keys don't expire, we store them as
|
|
13
|
+
* OAuth credentials with a far-future expiry.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { randomBytes } from "node:crypto"
|
|
17
|
+
import { startAuthServer } from "./auth-server.ts"
|
|
18
|
+
|
|
19
|
+
const STUDIO_BASE_URL = "https://commandcode.ai"
|
|
20
|
+
const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000 // API keys don't expire
|
|
21
|
+
const DEFAULT_AUTH_TIMEOUT_MS = 15_000
|
|
22
|
+
|
|
23
|
+
export interface OAuthLoginCallbacks {
|
|
24
|
+
onAuth(params: { url: string }): void
|
|
25
|
+
onPrompt(params: { message: string }): Promise<string>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface OAuthCredentials {
|
|
29
|
+
refresh: string
|
|
30
|
+
access: string
|
|
31
|
+
expires: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class AuthTimeoutError extends Error {
|
|
35
|
+
constructor() {
|
|
36
|
+
super("Browser authentication timed out")
|
|
37
|
+
this.name = "AuthTimeoutError"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function generateStateToken(): string {
|
|
42
|
+
return randomBytes(32).toString("base64url")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getAuthTimeoutMs(): number {
|
|
46
|
+
const raw = process.env.COMMANDCODE_AUTH_TIMEOUT_MS
|
|
47
|
+
if (!raw) return DEFAULT_AUTH_TIMEOUT_MS
|
|
48
|
+
|
|
49
|
+
const parsed = Number(raw)
|
|
50
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_AUTH_TIMEOUT_MS
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const timer = setTimeout(() => reject(new AuthTimeoutError()), timeoutMs)
|
|
56
|
+
|
|
57
|
+
promise.then(
|
|
58
|
+
(value) => {
|
|
59
|
+
clearTimeout(timer)
|
|
60
|
+
resolve(value)
|
|
61
|
+
},
|
|
62
|
+
(error) => {
|
|
63
|
+
clearTimeout(timer)
|
|
64
|
+
reject(error)
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function credentialsFromApiKey(apiKey: string): OAuthCredentials {
|
|
71
|
+
return {
|
|
72
|
+
refresh: apiKey,
|
|
73
|
+
access: apiKey,
|
|
74
|
+
expires: Date.now() + TEN_YEARS_MS,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Remove common terminal paste wrappers/control chars and surrounding whitespace.
|
|
80
|
+
*/
|
|
81
|
+
export function sanitizeApiKey(input: string): string {
|
|
82
|
+
const esc = String.fromCharCode(27)
|
|
83
|
+
return Array.from(
|
|
84
|
+
input
|
|
85
|
+
.replaceAll(`${esc}[200~`, "")
|
|
86
|
+
.replaceAll(`${esc}[201~`, "")
|
|
87
|
+
.replaceAll("[200~", "")
|
|
88
|
+
.replaceAll("[201~", ""),
|
|
89
|
+
)
|
|
90
|
+
.filter((char) => {
|
|
91
|
+
const code = char.charCodeAt(0)
|
|
92
|
+
return code > 31 && code !== 127
|
|
93
|
+
})
|
|
94
|
+
.join("")
|
|
95
|
+
.trim()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function promptForApiKey(callbacks: OAuthLoginCallbacks, message: string) {
|
|
99
|
+
const apiKey = sanitizeApiKey(await callbacks.onPrompt({ message }))
|
|
100
|
+
if (!apiKey) throw new Error("No Command Code API key provided")
|
|
101
|
+
return credentialsFromApiKey(apiKey)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Starts the browser-based login flow for Command Code.
|
|
106
|
+
*
|
|
107
|
+
* Returns OAuth credentials where access == refresh == the user's API key.
|
|
108
|
+
* The keys don't expire, so we set a far-future expiry.
|
|
109
|
+
*/
|
|
110
|
+
export async function login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
|
111
|
+
let authServer
|
|
112
|
+
try {
|
|
113
|
+
authServer = await startAuthServer()
|
|
114
|
+
} catch {
|
|
115
|
+
return promptForApiKey(
|
|
116
|
+
callbacks,
|
|
117
|
+
"Could not start browser auth. Paste your Command Code API key:",
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const stateToken = generateStateToken()
|
|
122
|
+
const callbackUrl = `http://localhost:${authServer.port}/callback`
|
|
123
|
+
const authUrl = `${STUDIO_BASE_URL}/studio/auth/cli?callback=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(stateToken)}`
|
|
124
|
+
|
|
125
|
+
// Tell pi to open the browser.
|
|
126
|
+
callbacks.onAuth({ url: authUrl })
|
|
127
|
+
|
|
128
|
+
// Wait for the Command Code Studio to POST the API key back. If the browser
|
|
129
|
+
// cannot reach localhost (Command Code shows "Copy your API key"), fall back
|
|
130
|
+
// to pi's prompt so the user can paste the key from the browser.
|
|
131
|
+
let callback: { apiKey: string; state: string }
|
|
132
|
+
try {
|
|
133
|
+
callback = await withTimeout(authServer.waitForCallback, getAuthTimeoutMs())
|
|
134
|
+
} catch (error) {
|
|
135
|
+
authServer.server.close()
|
|
136
|
+
if (error instanceof AuthTimeoutError) {
|
|
137
|
+
return promptForApiKey(
|
|
138
|
+
callbacks,
|
|
139
|
+
"Automatic transfer failed or timed out. Paste your Command Code API key:",
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
throw error
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Validate state token to prevent CSRF.
|
|
146
|
+
if (callback.state !== stateToken) {
|
|
147
|
+
authServer.server.close()
|
|
148
|
+
throw new Error("State token mismatch. Authentication may have been tampered with.")
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return credentialsFromApiKey(callback.apiKey)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Command Code API keys don't expire, so "refresh" is a no-op.
|
|
156
|
+
* Returns the same credentials with an updated far-future expiry.
|
|
157
|
+
*/
|
|
158
|
+
export async function refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
|
|
159
|
+
return credentialsFromApiKey(credentials.refresh)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Returns the access token (API key) from OAuth credentials.
|
|
164
|
+
*/
|
|
165
|
+
export function getApiKey(credentials: OAuthCredentials): string {
|
|
166
|
+
return credentials.access
|
|
167
|
+
}
|