@coldtea/pr-lens-cli 0.7.0 → 0.8.1
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 +1 -1
- package/dist/skill-content.generated.d.ts.map +1 -1
- package/dist/skill-content.generated.js +1 -1
- 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 +1 -1
- package/src/slug.ts +26 -0
- package/src/terminal.ts +14 -0
- package/src/version.ts +1 -1
- package/src/workspace.ts +30 -4
package/dist/workspace.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { assertNever } from "@coldtea/pr-lens-schema";
|
|
2
|
-
import { dirname, join, resolve } from "node:path";
|
|
2
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
3
|
+
import { readdir, stat } from "node:fs/promises";
|
|
3
4
|
import { git } from "./git.js";
|
|
4
5
|
import { readTextFile, writeTextFile } from "./io.js";
|
|
5
6
|
/**
|
|
@@ -150,14 +151,35 @@ other people's hands.
|
|
|
150
151
|
* user's, and so is the question of what to do with it.
|
|
151
152
|
*/
|
|
152
153
|
export const prepareWorkspace = async (out, terminal) => {
|
|
154
|
+
// A first render into `.pr-lens/<title>` must still gitignore the workspace.
|
|
153
155
|
const directory = resolve(out);
|
|
154
|
-
|
|
156
|
+
const workspace = resolve(WORKSPACE_DIR);
|
|
157
|
+
const inside = directory === workspace || directory.startsWith(`${workspace}${sep}`);
|
|
158
|
+
if (!inside)
|
|
155
159
|
return;
|
|
156
160
|
// The README first, which is what puts the directory on disk: git is asked
|
|
157
161
|
// whether a directory is ignored, and one that is not there cannot be.
|
|
158
|
-
await writeTextFile(join(
|
|
159
|
-
const ignored = await ignoreWorkspace(
|
|
162
|
+
await writeTextFile(join(workspace, README_NAME), README);
|
|
163
|
+
const ignored = await ignoreWorkspace(workspace);
|
|
160
164
|
if (ignored !== undefined)
|
|
161
165
|
terminal.err(`✓ ${ignored} — ${WORKSPACE_DIR}/ is ignored`);
|
|
162
166
|
};
|
|
167
|
+
/** What `render` writes beside the manifest, in every drawing's directory. */
|
|
168
|
+
export const DRAWN_NAME = "drawn.graph.json";
|
|
169
|
+
/** The top-level `drawn.graph.json` from 0.7.0 counts only until a newer render exists. */
|
|
170
|
+
export async function drawings() {
|
|
171
|
+
const found = [];
|
|
172
|
+
// No `isDirectory()` check: it is false for a symlinked drawing.
|
|
173
|
+
const entries = await readdir(WORKSPACE_DIR).catch(() => []);
|
|
174
|
+
for (const entry of entries) {
|
|
175
|
+
const drawn = join(WORKSPACE_DIR, entry, DRAWN_NAME);
|
|
176
|
+
if (await readable(drawn))
|
|
177
|
+
found.push(drawn);
|
|
178
|
+
}
|
|
179
|
+
if (found.length > 0)
|
|
180
|
+
return found;
|
|
181
|
+
const legacy = join(WORKSPACE_DIR, DRAWN_NAME);
|
|
182
|
+
return (await readable(legacy)) ? [legacy] : [];
|
|
183
|
+
}
|
|
184
|
+
const readable = (path) => stat(path).then((found) => found.isFile()).catch(() => false);
|
|
163
185
|
//# sourceMappingURL=workspace.js.map
|
package/dist/workspace.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workspace.js","sourceRoot":"","sources":["../src/workspace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AACtD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"workspace.js","sourceRoot":"","sources":["../src/workspace.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coldtea/pr-lens-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
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-renderer": "^0.2.6",
|
|
44
|
+
"@coldtea/pr-lens-schema": "^0.4.0"
|
|
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
|
+
};
|