@coldtea/pr-lens-cli 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -3
- package/dist/account.d.ts +76 -0
- package/dist/account.d.ts.map +1 -0
- package/dist/account.js +211 -0
- package/dist/account.js.map +1 -0
- package/dist/auth.d.ts +40 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +121 -0
- package/dist/auth.js.map +1 -0
- package/dist/canvas/api.d.ts +35 -1
- package/dist/canvas/api.d.ts.map +1 -1
- package/dist/canvas/api.js +57 -1
- package/dist/canvas/api.js.map +1 -1
- package/dist/canvas/claim.d.ts +3 -0
- package/dist/canvas/claim.d.ts.map +1 -0
- package/dist/canvas/claim.js +84 -0
- package/dist/canvas/claim.js.map +1 -0
- package/dist/canvas/delete.js +3 -3
- package/dist/canvas/delete.js.map +1 -1
- package/dist/canvas/registry.d.ts +8 -2
- package/dist/canvas/registry.d.ts.map +1 -1
- package/dist/canvas/registry.js +15 -2
- package/dist/canvas/registry.js.map +1 -1
- package/dist/canvas/write.d.ts +11 -2
- package/dist/canvas/write.d.ts.map +1 -1
- package/dist/canvas/write.js +27 -8
- package/dist/canvas/write.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +8 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/auth.d.ts +9 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +369 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/canvas.d.ts.map +1 -1
- package/dist/commands/canvas.js +162 -21
- package/dist/commands/canvas.js.map +1 -1
- package/dist/commands/render.d.ts +1 -1
- package/dist/commands/render.d.ts.map +1 -1
- package/dist/commands/render.js +6 -3
- package/dist/commands/render.js.map +1 -1
- package/dist/config-home.d.ts +4 -0
- package/dist/config-home.d.ts.map +1 -0
- package/dist/config-home.js +38 -0
- package/dist/config-home.js.map +1 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/install.d.ts +5 -0
- package/dist/install.d.ts.map +1 -0
- package/dist/install.js +83 -0
- package/dist/install.js.map +1 -0
- package/dist/skill-content.generated.d.ts +2 -2
- package/dist/skill-content.generated.d.ts.map +1 -1
- package/dist/skill-content.generated.js +2 -2
- package/dist/skill-content.generated.js.map +1 -1
- package/dist/slug.d.ts +7 -0
- package/dist/slug.d.ts.map +1 -0
- package/dist/slug.js +23 -0
- package/dist/slug.js.map +1 -0
- package/dist/terminal.d.ts +5 -0
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +7 -0
- package/dist/terminal.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/workspace.d.ts +4 -0
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +26 -4
- package/dist/workspace.js.map +1 -1
- package/package.json +3 -3
- package/src/account.ts +302 -0
- package/src/auth.ts +183 -0
- package/src/canvas/api.ts +114 -2
- package/src/canvas/claim.ts +127 -0
- package/src/canvas/delete.ts +3 -3
- package/src/canvas/registry.ts +24 -2
- package/src/canvas/write.ts +37 -6
- package/src/cli.ts +8 -2
- package/src/commands/auth.ts +560 -0
- package/src/commands/canvas.ts +238 -18
- package/src/commands/render.ts +7 -3
- package/src/config-home.ts +45 -0
- package/src/errors.ts +5 -1
- package/src/install.ts +107 -0
- package/src/skill-content.generated.ts +2 -2
- package/src/slug.ts +26 -0
- package/src/terminal.ts +14 -0
- package/src/version.ts +1 -1
- package/src/workspace.ts +31 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coldtea/pr-lens-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "The PR Lens command line: turn a pull request diff into a schema-valid graph with your own model key, render it, and compose the comment.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Coldtea AI",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"yaml": "^2.8.1",
|
|
42
42
|
"zod": "^4.4.3",
|
|
43
|
-
"@coldtea/pr-lens-
|
|
44
|
-
"@coldtea/pr-lens-
|
|
43
|
+
"@coldtea/pr-lens-schema": "^0.4.0",
|
|
44
|
+
"@coldtea/pr-lens-renderer": "^0.2.6"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/node": "^20.19.0",
|
package/src/account.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign-in is the OAuth 2.0 device grant (RFC 8628), the one flow that works
|
|
3
|
+
* over SSH and in containers. Its wire codes are the RFC's, not this CLI's.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { assertNever } from "@coldtea/pr-lens-schema";
|
|
8
|
+
|
|
9
|
+
import { CLI_VERSION } from "./version.js";
|
|
10
|
+
import { PrLensCliError } from "./errors.js";
|
|
11
|
+
|
|
12
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
const hostOf = (api: string): string => new URL(api).host;
|
|
15
|
+
|
|
16
|
+
const unavailable = (
|
|
17
|
+
api: string,
|
|
18
|
+
details: string,
|
|
19
|
+
status?: number,
|
|
20
|
+
): PrLensCliError =>
|
|
21
|
+
new PrLensCliError(
|
|
22
|
+
"APP_UNAVAILABLE",
|
|
23
|
+
status === undefined
|
|
24
|
+
? `${hostOf(api)} did not answer`
|
|
25
|
+
: `${hostOf(api)} answered ${status}`,
|
|
26
|
+
details,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
type Answer = { status: number; body: unknown };
|
|
30
|
+
|
|
31
|
+
const parseJson = (text: string): unknown => {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(text);
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const send = async (
|
|
40
|
+
api: string,
|
|
41
|
+
path: string,
|
|
42
|
+
init: { method: "GET" | "POST" | "DELETE"; token?: string; body?: unknown },
|
|
43
|
+
): Promise<Answer> => {
|
|
44
|
+
const headers: Record<string, string> = {
|
|
45
|
+
accept: "application/json",
|
|
46
|
+
"user-agent": `pr-lens-cli/${CLI_VERSION}`,
|
|
47
|
+
};
|
|
48
|
+
if (init.token !== undefined) headers.authorization = `Bearer ${init.token}`;
|
|
49
|
+
if (init.body !== undefined) headers["content-type"] = "application/json";
|
|
50
|
+
|
|
51
|
+
const response = await fetch(`${api}${path}`, {
|
|
52
|
+
method: init.method,
|
|
53
|
+
headers,
|
|
54
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
55
|
+
// A redirect would carry the token to whatever host answered.
|
|
56
|
+
redirect: "manual",
|
|
57
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
58
|
+
}).catch(() => {
|
|
59
|
+
// The runtime's message leaks addresses and internals.
|
|
60
|
+
throw unavailable(api, "check the address and the connection, then try again");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (response.status >= 300 && response.status < 400)
|
|
64
|
+
throw unavailable(api, "this address redirects, and the sign-in routes must answer directly", response.status);
|
|
65
|
+
|
|
66
|
+
const text = await response.text().catch(() => {
|
|
67
|
+
throw unavailable(api, "the answer was cut off; check the connection, then try again", response.status);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return { status: response.status, body: parseJson(text) };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Refusals outside the device flow use the app's own envelope. */
|
|
74
|
+
const Refusal = z.object({
|
|
75
|
+
error: z.looseObject({ code: z.string(), message: z.string() }),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const Started = z.object({
|
|
79
|
+
device_code: z.string().min(1),
|
|
80
|
+
user_code: z.string().min(1),
|
|
81
|
+
verification_uri: z.string().min(1),
|
|
82
|
+
verification_uri_complete: z.string().min(1).optional(),
|
|
83
|
+
expires_in: z.number().int().positive(),
|
|
84
|
+
interval: z.number().int().nonnegative().optional(),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export type StartedSignIn = {
|
|
88
|
+
deviceCode: string;
|
|
89
|
+
userCode: string;
|
|
90
|
+
/** Carries the code, so nobody types it. */
|
|
91
|
+
approveUrl: string;
|
|
92
|
+
expiresInSeconds: number;
|
|
93
|
+
intervalSeconds: number;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** RFC 8628 §3.2: the floor, and the default when none is served. */
|
|
97
|
+
export const DEFAULT_INTERVAL_SECONDS = 5;
|
|
98
|
+
|
|
99
|
+
/** The install id goes with the request: the approving browser may be on a phone and cannot know which machine asked. */
|
|
100
|
+
export const startSignIn = async (
|
|
101
|
+
api: string,
|
|
102
|
+
installId: string,
|
|
103
|
+
client: string,
|
|
104
|
+
name: string,
|
|
105
|
+
): Promise<StartedSignIn> => {
|
|
106
|
+
const answer = await send(api, "/api/device/code", {
|
|
107
|
+
method: "POST",
|
|
108
|
+
body: { install_id: installId, client, name },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (answer.status !== 200) {
|
|
112
|
+
const refused = Refusal.safeParse(answer.body);
|
|
113
|
+
const code = refused.success ? refused.data.error.code : undefined;
|
|
114
|
+
// Twenty codes an hour per address. Usually a retry after a typo, so say what to do next.
|
|
115
|
+
throw code === "RATE_LIMITED"
|
|
116
|
+
? new PrLensCliError(
|
|
117
|
+
"APP_UNAVAILABLE",
|
|
118
|
+
`${hostOf(api)} has handed this machine enough sign-in codes for now`,
|
|
119
|
+
"it goes by the hour, so a short wait clears it; the sign-in you already have is unaffected",
|
|
120
|
+
)
|
|
121
|
+
: unavailable(
|
|
122
|
+
api,
|
|
123
|
+
refused.success ? refused.data.error.message : "the answer was not in the shape the sign-in expects",
|
|
124
|
+
answer.status,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const started = Started.safeParse(answer.body);
|
|
129
|
+
if (!started.success)
|
|
130
|
+
throw unavailable(api, "the answer was not in the shape the sign-in expects", answer.status);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
deviceCode: started.data.device_code,
|
|
134
|
+
userCode: started.data.user_code,
|
|
135
|
+
approveUrl: started.data.verification_uri_complete ?? started.data.verification_uri,
|
|
136
|
+
expiresInSeconds: started.data.expires_in,
|
|
137
|
+
intervalSeconds: started.data.interval ?? DEFAULT_INTERVAL_SECONDS,
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** `claimed` is an app extension (RFC 6749 §5.1 allows it); optional so a sign-in never fails over it. */
|
|
142
|
+
const Granted = z.object({
|
|
143
|
+
access_token: z.string().min(1),
|
|
144
|
+
claimed: z.coerce.number().int().min(0).optional(),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const Pending = z.object({
|
|
148
|
+
error: z.enum(["authorization_pending", "slow_down", "access_denied", "expired_token"]),
|
|
149
|
+
interval: z.number().int().nonnegative().optional(),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
export type PollOutcome =
|
|
153
|
+
| { type: "token"; token: string; claimed: number | undefined }
|
|
154
|
+
| { type: "pending" }
|
|
155
|
+
| { type: "slow_down"; intervalSeconds: number | undefined }
|
|
156
|
+
| { type: "denied" }
|
|
157
|
+
| { type: "expired" };
|
|
158
|
+
|
|
159
|
+
export const pollSignIn = async (api: string, deviceCode: string): Promise<PollOutcome> => {
|
|
160
|
+
const answer = await send(api, "/api/device/token", {
|
|
161
|
+
method: "POST",
|
|
162
|
+
body: { device_code: deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code" },
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
if (answer.status === 200) {
|
|
166
|
+
const granted = Granted.safeParse(answer.body);
|
|
167
|
+
if (!granted.success)
|
|
168
|
+
throw unavailable(api, "the sign-in was approved and the answer carried no token", answer.status);
|
|
169
|
+
return { type: "token", token: granted.data.access_token, claimed: granted.data.claimed };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const pending = Pending.safeParse(answer.body);
|
|
173
|
+
if (!pending.success)
|
|
174
|
+
// Includes `invalid_request`, which waiting will not fix.
|
|
175
|
+
throw unavailable(api, "the answer was not one the device flow defines", answer.status);
|
|
176
|
+
|
|
177
|
+
switch (pending.data.error) {
|
|
178
|
+
case "authorization_pending":
|
|
179
|
+
return { type: "pending" };
|
|
180
|
+
case "slow_down":
|
|
181
|
+
return { type: "slow_down", intervalSeconds: pending.data.interval };
|
|
182
|
+
case "access_denied":
|
|
183
|
+
return { type: "denied" };
|
|
184
|
+
case "expired_token":
|
|
185
|
+
return { type: "expired" };
|
|
186
|
+
default:
|
|
187
|
+
return assertNever(pending.data.error, "Unhandled device flow answer");
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Must match the app's hash. Duplicated on purpose: asking the app for ours
|
|
193
|
+
* would put the install id in a URL.
|
|
194
|
+
*/
|
|
195
|
+
export const installHash = (installId: string): string =>
|
|
196
|
+
createHash("sha256").update(installId, "utf8").digest("hex");
|
|
197
|
+
|
|
198
|
+
const Machines = z.object({
|
|
199
|
+
machines: z.array(z.object({ id: z.string(), revoked: z.boolean() })),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/** `unknown`: this machine has no install id to look up. */
|
|
203
|
+
export type MachineState = "linked" | "revoked" | "unlinked" | "unknown";
|
|
204
|
+
|
|
205
|
+
export type SignInCheck =
|
|
206
|
+
| { type: "live"; machine: MachineState }
|
|
207
|
+
| { type: "rejected" }
|
|
208
|
+
/** The app could not be asked; says nothing about the credential. */
|
|
209
|
+
| { type: "unknown"; why: string };
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* `/api/machines` also answers whether this machine is linked. A store without
|
|
213
|
+
* accounts reads as `unknown`, never as a revoked credential.
|
|
214
|
+
*/
|
|
215
|
+
export const checkSignIn = async (
|
|
216
|
+
api: string,
|
|
217
|
+
token: string,
|
|
218
|
+
ourInstallId: string | undefined,
|
|
219
|
+
): Promise<SignInCheck> => {
|
|
220
|
+
let answer: Answer;
|
|
221
|
+
try {
|
|
222
|
+
answer = await send(api, "/api/machines", { method: "GET", token });
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (!(error instanceof PrLensCliError)) throw error;
|
|
225
|
+
return { type: "unknown", why: error.message };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (answer.status === 401 || answer.status === 403) return { type: "rejected" };
|
|
229
|
+
|
|
230
|
+
const machines = Machines.safeParse(answer.body);
|
|
231
|
+
if (!machines.success)
|
|
232
|
+
return { type: "unknown", why: `${hostOf(api)} answered ${answer.status}, which this CLI cannot read` };
|
|
233
|
+
|
|
234
|
+
if (ourInstallId === undefined) return { type: "live", machine: "unknown" };
|
|
235
|
+
|
|
236
|
+
const ours = installHash(ourInstallId);
|
|
237
|
+
const machine = machines.data.machines.find((entry) => entry.id === ours);
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
type: "live",
|
|
241
|
+
machine: machine === undefined ? "unlinked" : machine.revoked ? "revoked" : "linked",
|
|
242
|
+
};
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const Account = z.object({ email: z.string().min(3) });
|
|
246
|
+
|
|
247
|
+
/** Undefined on any failure: the sign-in already succeeded and the email is only for the greeting. */
|
|
248
|
+
export const whoAmI = async (api: string, token: string): Promise<string | undefined> => {
|
|
249
|
+
try {
|
|
250
|
+
const answer = await send(api, "/api/account", { method: "GET", token });
|
|
251
|
+
const account = Account.safeParse(answer.body);
|
|
252
|
+
return answer.status === 200 && account.success ? account.data.email : undefined;
|
|
253
|
+
} catch {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Unlike `whoAmI`, keeps a dead token (`ended`) apart from an unreachable store
|
|
260
|
+
* (`unknown`), which the sign-in flow will report itself.
|
|
261
|
+
*/
|
|
262
|
+
export type Session =
|
|
263
|
+
| { type: "active"; email: string }
|
|
264
|
+
| { type: "ended" }
|
|
265
|
+
| { type: "unknown" };
|
|
266
|
+
|
|
267
|
+
export const checkSession = async (api: string, token: string): Promise<Session> => {
|
|
268
|
+
let answer: Awaited<ReturnType<typeof send>>;
|
|
269
|
+
try {
|
|
270
|
+
answer = await send(api, "/api/account", { method: "GET", token });
|
|
271
|
+
} catch {
|
|
272
|
+
return { type: "unknown" };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Any other status is an outage, not evidence about the credential.
|
|
276
|
+
if (answer.status === 401 || answer.status === 403) return { type: "ended" };
|
|
277
|
+
|
|
278
|
+
const account = Account.safeParse(answer.body);
|
|
279
|
+
return answer.status === 200 && account.success
|
|
280
|
+
? { type: "active", email: account.data.email }
|
|
281
|
+
: { type: "unknown" };
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
export type SignOut = "ended" | "already" | { type: "unreachable"; why: string };
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Ends the session and leaves the machine linked (`DELETE /api/machines/{id}`
|
|
288
|
+
* would unlink it). Unreachable is not thrown: the caller forgets the token
|
|
289
|
+
* locally either way.
|
|
290
|
+
*/
|
|
291
|
+
export const endSession = async (api: string, token: string): Promise<SignOut> => {
|
|
292
|
+
try {
|
|
293
|
+
const answer = await send(api, "/api/session", { method: "DELETE", token });
|
|
294
|
+
if (answer.status === 200) return "ended";
|
|
295
|
+
// No such route, or the token was already dead.
|
|
296
|
+
if (answer.status === 404 || answer.status === 401) return "already";
|
|
297
|
+
return { type: "unreachable", why: `${hostOf(api)} answered ${answer.status}` };
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (!(error instanceof PrLensCliError)) throw error;
|
|
300
|
+
return { type: "unreachable", why: error.message };
|
|
301
|
+
}
|
|
302
|
+
};
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sign-in credential on disk, one file per store so a token never reaches
|
|
3
|
+
* the wrong one. A file per origin also means two shells signing in at once
|
|
4
|
+
* can only race on the same origin, where the last write wins correctly.
|
|
5
|
+
* `0600` on the file, `0700` on the directory: the file names reveal which
|
|
6
|
+
* stores this person signs in to.
|
|
7
|
+
*/
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
10
|
+
import { assertNever } from "@coldtea/pr-lens-schema";
|
|
11
|
+
|
|
12
|
+
import { PrLensCliError } from "./errors.js";
|
|
13
|
+
import { writeSecretJsonFile } from "./io.js";
|
|
14
|
+
import { originPath } from "./config-home.js";
|
|
15
|
+
|
|
16
|
+
const CREDENTIALS = "auth";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Read with `||`, never `??`: the Action sets it to "" when no token was
|
|
20
|
+
* given, since Actions cannot omit an `env` key conditionally.
|
|
21
|
+
*/
|
|
22
|
+
export const TOKEN_ENV = "PR_LENS_TOKEN";
|
|
23
|
+
|
|
24
|
+
const ACCOUNT_TOKEN = /^prl_u_[A-Za-z0-9_-]{22}$/;
|
|
25
|
+
|
|
26
|
+
export const authPath = (
|
|
27
|
+
env: Record<string, string | undefined>,
|
|
28
|
+
api: string,
|
|
29
|
+
): string | undefined => originPath(env, CREDENTIALS, api);
|
|
30
|
+
|
|
31
|
+
export type Credential = { token: string; signedInAt: string | undefined };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* An unreadable file is kept apart from a missing one and never deleted here:
|
|
35
|
+
* it may hold a working token. Only `auth logout` removes it.
|
|
36
|
+
*/
|
|
37
|
+
export type CredentialRead =
|
|
38
|
+
| { type: "credential"; path: string; credential: Credential }
|
|
39
|
+
| { type: "none" }
|
|
40
|
+
| { type: "unreadable"; path: string; why: string };
|
|
41
|
+
|
|
42
|
+
const describe = (error: unknown): string =>
|
|
43
|
+
error instanceof Error ? error.message : String(error);
|
|
44
|
+
|
|
45
|
+
const missing = (error: unknown): boolean =>
|
|
46
|
+
typeof error === "object" &&
|
|
47
|
+
error !== null &&
|
|
48
|
+
"code" in error &&
|
|
49
|
+
error.code === "ENOENT";
|
|
50
|
+
|
|
51
|
+
const credentialIn = (text: string): Credential | undefined => {
|
|
52
|
+
const contents = ((): unknown => {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(text) as unknown;
|
|
55
|
+
} catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
})();
|
|
59
|
+
if (typeof contents !== "object" || contents === null) return undefined;
|
|
60
|
+
|
|
61
|
+
const { token, signedInAt } = contents as {
|
|
62
|
+
token?: unknown;
|
|
63
|
+
signedInAt?: unknown;
|
|
64
|
+
};
|
|
65
|
+
if (typeof token !== "string" || !ACCOUNT_TOKEN.test(token)) return undefined;
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
token,
|
|
69
|
+
signedInAt: typeof signedInAt === "string" ? signedInAt : undefined,
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const readCredential = async (
|
|
74
|
+
env: Record<string, string | undefined>,
|
|
75
|
+
api: string,
|
|
76
|
+
): Promise<CredentialRead> => {
|
|
77
|
+
const path = authPath(env, api);
|
|
78
|
+
if (path === undefined) return { type: "none" };
|
|
79
|
+
|
|
80
|
+
let text: string;
|
|
81
|
+
try {
|
|
82
|
+
text = await readFile(path, "utf8");
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return missing(error)
|
|
85
|
+
? { type: "none" }
|
|
86
|
+
: { type: "unreadable", path, why: describe(error) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const credential = credentialIn(text);
|
|
90
|
+
return credential === undefined
|
|
91
|
+
? { type: "unreadable", path, why: "it holds no token this CLI can read" }
|
|
92
|
+
: { type: "credential", path, credential };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Every failure answers undefined, so a push never starts failing over an account. */
|
|
96
|
+
export const readToken = async (
|
|
97
|
+
env: Record<string, string | undefined>,
|
|
98
|
+
api: string,
|
|
99
|
+
): Promise<string | undefined> => {
|
|
100
|
+
const given = env[TOKEN_ENV];
|
|
101
|
+
if (given) return given;
|
|
102
|
+
|
|
103
|
+
const stored = await readCredential(env, api);
|
|
104
|
+
return stored.type === "credential" ? stored.credential.token : undefined;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* For account commands, which must tell an unreadable file apart from being
|
|
109
|
+
* signed out rather than advise signing in again.
|
|
110
|
+
*/
|
|
111
|
+
export const requireToken = async (
|
|
112
|
+
env: Record<string, string | undefined>,
|
|
113
|
+
api: string,
|
|
114
|
+
): Promise<string> => {
|
|
115
|
+
const given = env[TOKEN_ENV];
|
|
116
|
+
if (given) return given;
|
|
117
|
+
|
|
118
|
+
const host = new URL(api).host;
|
|
119
|
+
const stored = await readCredential(env, api);
|
|
120
|
+
switch (stored.type) {
|
|
121
|
+
case "credential":
|
|
122
|
+
return stored.credential.token;
|
|
123
|
+
case "unreadable":
|
|
124
|
+
throw new PrLensCliError(
|
|
125
|
+
"UNREADABLE_FILE",
|
|
126
|
+
`the sign-in for ${host} cannot be read`,
|
|
127
|
+
[
|
|
128
|
+
`${stored.path}: ${stored.why}`,
|
|
129
|
+
"pr-lens auth logout clears it, and then auth login writes a fresh one",
|
|
130
|
+
].join("\n"),
|
|
131
|
+
);
|
|
132
|
+
case "none":
|
|
133
|
+
throw new PrLensCliError(
|
|
134
|
+
"AUTH_REQUIRED",
|
|
135
|
+
`not signed in to ${host}`,
|
|
136
|
+
"pr-lens auth login",
|
|
137
|
+
);
|
|
138
|
+
default:
|
|
139
|
+
return assertNever(stored, "Unhandled credential read");
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const writeCredential = async (
|
|
144
|
+
env: Record<string, string | undefined>,
|
|
145
|
+
api: string,
|
|
146
|
+
token: string,
|
|
147
|
+
now: string,
|
|
148
|
+
): Promise<string> => {
|
|
149
|
+
const path = authPath(env, api);
|
|
150
|
+
if (path === undefined)
|
|
151
|
+
throw new PrLensCliError(
|
|
152
|
+
"UNREADABLE_FILE",
|
|
153
|
+
"there is no home directory to keep this sign-in in",
|
|
154
|
+
"set HOME, or XDG_CONFIG_HOME, to somewhere this machine can write",
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
158
|
+
return writeSecretJsonFile(path, { api, token, signedInAt: now });
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The only place that removes the file, readable or not. No read first: a
|
|
163
|
+
* sign-in from another shell in between would be thrown away unseen.
|
|
164
|
+
*/
|
|
165
|
+
export const forgetCredential = async (
|
|
166
|
+
env: Record<string, string | undefined>,
|
|
167
|
+
api: string,
|
|
168
|
+
): Promise<boolean> => {
|
|
169
|
+
const path = authPath(env, api);
|
|
170
|
+
if (path === undefined) return false;
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
await rm(path);
|
|
174
|
+
return true;
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (missing(error)) return false;
|
|
177
|
+
throw new PrLensCliError(
|
|
178
|
+
"UNREADABLE_FILE",
|
|
179
|
+
`cannot remove ${path}`,
|
|
180
|
+
describe(error),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
};
|
package/src/canvas/api.ts
CHANGED
|
@@ -85,6 +85,8 @@ const Refusal = z.discriminatedUnion("code", [
|
|
|
85
85
|
retryAt: z.string(),
|
|
86
86
|
}),
|
|
87
87
|
z.object({ code: z.literal("TOO_LARGE"), message: z.string() }),
|
|
88
|
+
z.object({ code: z.literal("UNAUTHENTICATED"), message: z.string() }),
|
|
89
|
+
z.object({ code: z.literal("ALREADY_OWNED"), message: z.string() }),
|
|
88
90
|
]);
|
|
89
91
|
|
|
90
92
|
type Request = {
|
|
@@ -93,6 +95,8 @@ type Request = {
|
|
|
93
95
|
/** Undefined when minting, so a 404 there is not blamed on a canvas. */
|
|
94
96
|
canvas: string | undefined;
|
|
95
97
|
token?: string;
|
|
98
|
+
/** Lets a later sign-in claim what this machine pushed. */
|
|
99
|
+
install?: string;
|
|
96
100
|
ifMatch?: number;
|
|
97
101
|
body?: unknown;
|
|
98
102
|
};
|
|
@@ -112,6 +116,25 @@ const unavailable = (
|
|
|
112
116
|
details,
|
|
113
117
|
);
|
|
114
118
|
|
|
119
|
+
/** Untrusted server content, so printable and short. */
|
|
120
|
+
const LOCATION_SHOWN = 200;
|
|
121
|
+
|
|
122
|
+
/** Printing the target turns an `http://` for `https://` typo into a fix. */
|
|
123
|
+
const redirected = (api: string, response: Response): PrLensCliError => {
|
|
124
|
+
const location = response.headers
|
|
125
|
+
.get("location")
|
|
126
|
+
?.replace(/[^\x20-\x7e]/g, "")
|
|
127
|
+
.slice(0, LOCATION_SHOWN);
|
|
128
|
+
|
|
129
|
+
return unavailable(
|
|
130
|
+
api,
|
|
131
|
+
response.status,
|
|
132
|
+
location === undefined || location === ""
|
|
133
|
+
? "the canvas API answers at the address it is given, and this one redirects"
|
|
134
|
+
: `the canvas API answers at the address it is given; this one points at ${location}, so pass that as --api`,
|
|
135
|
+
);
|
|
136
|
+
};
|
|
137
|
+
|
|
115
138
|
const parseJson = (text: string): unknown => {
|
|
116
139
|
try {
|
|
117
140
|
return JSON.parse(text);
|
|
@@ -170,6 +193,21 @@ const refusal = (
|
|
|
170
193
|
`${hostOf(api)} is rate limiting this client until ${error.retryAt}`,
|
|
171
194
|
error.message,
|
|
172
195
|
);
|
|
196
|
+
case "UNAUTHENTICATED":
|
|
197
|
+
return new PrLensCliError(
|
|
198
|
+
"AUTH_REQUIRED",
|
|
199
|
+
`${hostOf(api)} did not accept this sign-in`,
|
|
200
|
+
[error.message, "pr-lens auth login signs this machine in again"].join(
|
|
201
|
+
"\n",
|
|
202
|
+
),
|
|
203
|
+
);
|
|
204
|
+
case "ALREADY_OWNED":
|
|
205
|
+
// The caller holds the write token, so naming the case leaks nothing.
|
|
206
|
+
return new PrLensCliError(
|
|
207
|
+
"CANVAS_OWNED",
|
|
208
|
+
`${request.canvas ?? "that canvas"} belongs to another account on ${hostOf(api)}`,
|
|
209
|
+
"the first claim wins, and somebody else's landed first",
|
|
210
|
+
);
|
|
173
211
|
case "INVALID_REQUEST":
|
|
174
212
|
case "TOO_LARGE":
|
|
175
213
|
return unavailable(api, status, error.message);
|
|
@@ -191,6 +229,9 @@ const call = async <T>(
|
|
|
191
229
|
if (request.token !== undefined)
|
|
192
230
|
headers.authorization = `Bearer ${request.token}`;
|
|
193
231
|
|
|
232
|
+
if (request.install !== undefined)
|
|
233
|
+
headers["x-pr-lens-install"] = request.install;
|
|
234
|
+
|
|
194
235
|
if (request.ifMatch !== undefined)
|
|
195
236
|
headers["if-match"] = String(request.ifMatch);
|
|
196
237
|
|
|
@@ -200,6 +241,9 @@ const call = async <T>(
|
|
|
200
241
|
method: request.method,
|
|
201
242
|
headers,
|
|
202
243
|
body: request.body === undefined ? undefined : JSON.stringify(request.body),
|
|
244
|
+
// A cross-origin redirect keeps every header but `authorization`, which
|
|
245
|
+
// would leak the install id to another host.
|
|
246
|
+
redirect: "manual",
|
|
203
247
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
204
248
|
}).catch(() => {
|
|
205
249
|
// The runtime's message names addresses and internals; keep it out.
|
|
@@ -210,6 +254,10 @@ const call = async <T>(
|
|
|
210
254
|
);
|
|
211
255
|
});
|
|
212
256
|
|
|
257
|
+
// A 3xx body is not an error envelope.
|
|
258
|
+
if (response.status >= 300 && response.status < 400)
|
|
259
|
+
throw redirected(api, response);
|
|
260
|
+
|
|
213
261
|
// A body can fail after the headers arrived.
|
|
214
262
|
const text = await response.text().catch(() => {
|
|
215
263
|
throw unavailable(
|
|
@@ -234,8 +282,17 @@ const call = async <T>(
|
|
|
234
282
|
|
|
235
283
|
const canvasPath = (id: string): string => `/api/canvas/${id}`;
|
|
236
284
|
|
|
237
|
-
|
|
238
|
-
|
|
285
|
+
/** The bearer is how CI attributes a mint: a runner's install id is never linked. */
|
|
286
|
+
export const mintCanvas = (
|
|
287
|
+
api: string,
|
|
288
|
+
install: string | undefined,
|
|
289
|
+
token: string | undefined,
|
|
290
|
+
): Promise<Minted> =>
|
|
291
|
+
call(
|
|
292
|
+
api,
|
|
293
|
+
{ method: "POST", path: "/api/canvas", canvas: undefined, install, token },
|
|
294
|
+
Minted,
|
|
295
|
+
);
|
|
239
296
|
|
|
240
297
|
export const fetchCanvas = async (
|
|
241
298
|
api: string,
|
|
@@ -322,3 +379,58 @@ export const deleteCanvas = (
|
|
|
322
379
|
{ method: "DELETE", path: canvasPath(id), canvas: id, token },
|
|
323
380
|
z.object({ id: z.literal(id), deleted: z.literal(true) }),
|
|
324
381
|
);
|
|
382
|
+
|
|
383
|
+
/** An unreadable revision must not read as an empty canvas. */
|
|
384
|
+
const Preview = z.discriminatedUnion("type", [
|
|
385
|
+
z.object({ type: z.literal("drawn"), title: z.string().min(1) }),
|
|
386
|
+
z.object({ type: z.literal("not_drawn") }),
|
|
387
|
+
z.object({ type: z.literal("unreadable") }),
|
|
388
|
+
]);
|
|
389
|
+
|
|
390
|
+
const Owned = z.object({
|
|
391
|
+
id: z.string(),
|
|
392
|
+
rev: z.number().int(),
|
|
393
|
+
preview: Preview,
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const Owning = z.object({ canvases: z.array(Owned) });
|
|
397
|
+
|
|
398
|
+
export type CanvasPreview = z.infer<typeof Preview>;
|
|
399
|
+
export type OwnedCanvas = z.infer<typeof Owned>;
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Carries no write tokens: the app keeps only their hashes. A store without
|
|
403
|
+
* accounts 404s here, which reads as unavailable, not as a missing canvas.
|
|
404
|
+
*/
|
|
405
|
+
export const listOwnedCanvases = (
|
|
406
|
+
api: string,
|
|
407
|
+
token: string,
|
|
408
|
+
): Promise<OwnedCanvas[]> =>
|
|
409
|
+
call(
|
|
410
|
+
api,
|
|
411
|
+
{ method: "GET", path: "/api/canvases", canvas: undefined, token },
|
|
412
|
+
Owning,
|
|
413
|
+
).then(({ canvases }) => canvases);
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The account token takes the header, so the write tokens go in the body.
|
|
417
|
+
* The caller mints the next token so a lost answer can be replayed.
|
|
418
|
+
*/
|
|
419
|
+
export const claimCanvas = (
|
|
420
|
+
api: string,
|
|
421
|
+
id: string,
|
|
422
|
+
accountToken: string,
|
|
423
|
+
writeToken: string,
|
|
424
|
+
nextWriteToken: string,
|
|
425
|
+
): Promise<Rotated> =>
|
|
426
|
+
call(
|
|
427
|
+
api,
|
|
428
|
+
{
|
|
429
|
+
method: "POST",
|
|
430
|
+
path: `${canvasPath(id)}/claim`,
|
|
431
|
+
canvas: id,
|
|
432
|
+
token: accountToken,
|
|
433
|
+
body: { writeToken, nextWriteToken },
|
|
434
|
+
},
|
|
435
|
+
Rotated,
|
|
436
|
+
);
|