@runinfra/cli 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 +48 -0
- package/README.md +458 -0
- package/bin/runinfra.mjs +46 -0
- package/dist/args.js +142 -0
- package/dist/artifact-name.js +62 -0
- package/dist/auth-api.js +177 -0
- package/dist/browser.js +70 -0
- package/dist/catalog-api.js +89 -0
- package/dist/checksum.js +28 -0
- package/dist/context.js +52 -0
- package/dist/credentials.js +136 -0
- package/dist/disk.js +33 -0
- package/dist/downloader.js +265 -0
- package/dist/endpoints.js +58 -0
- package/dist/env.js +1 -0
- package/dist/errors.js +54 -0
- package/dist/help.js +47 -0
- package/dist/http.js +204 -0
- package/dist/lease.js +91 -0
- package/dist/login.js +207 -0
- package/dist/logout.js +17 -0
- package/dist/loopback.js +142 -0
- package/dist/main.js +70 -0
- package/dist/output.js +58 -0
- package/dist/paths.js +20 -0
- package/dist/pkce.js +24 -0
- package/dist/progress.js +63 -0
- package/dist/prompt.js +46 -0
- package/dist/pull.js +236 -0
- package/dist/range-plan.js +112 -0
- package/dist/redact.js +8 -0
- package/dist/scopes.js +2 -0
- package/dist/sidecar.js +108 -0
- package/dist/timers.js +13 -0
- package/dist/version.js +14 -0
- package/dist/whoami.js +28 -0
- package/package.json +43 -0
package/dist/lease.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
export const LEASE_REFRESH_MARGIN_MS = 120_000;
|
|
3
|
+
function isRecord(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
export function parseLeaseResponse(body) {
|
|
7
|
+
if (!isRecord(body))
|
|
8
|
+
return null;
|
|
9
|
+
const { url, expiresAt, version, checksumSha256 } = body;
|
|
10
|
+
if (typeof url !== "string" || url.length === 0)
|
|
11
|
+
return null;
|
|
12
|
+
if (typeof expiresAt !== "string")
|
|
13
|
+
return null;
|
|
14
|
+
if (typeof version !== "number" || !Number.isSafeInteger(version))
|
|
15
|
+
return null;
|
|
16
|
+
if (checksumSha256 !== null && typeof checksumSha256 !== "string")
|
|
17
|
+
return null;
|
|
18
|
+
const expiresAtMs = Date.parse(expiresAt);
|
|
19
|
+
if (!Number.isFinite(expiresAtMs))
|
|
20
|
+
return null;
|
|
21
|
+
let parsedUrl;
|
|
22
|
+
try {
|
|
23
|
+
parsedUrl = new URL(url);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (parsedUrl.protocol !== "https:" && !isLoopback(parsedUrl.hostname)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return { url, expiresAtMs, packageVersion: version, checksumSha256 };
|
|
32
|
+
}
|
|
33
|
+
function isLoopback(hostname) {
|
|
34
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
35
|
+
}
|
|
36
|
+
export function leaseNeedsRefresh(lease, nowMs) {
|
|
37
|
+
return lease.expiresAtMs - nowMs <= LEASE_REFRESH_MARGIN_MS;
|
|
38
|
+
}
|
|
39
|
+
export function leaseExpiredStatus(status) {
|
|
40
|
+
return status === 401 || status === 403;
|
|
41
|
+
}
|
|
42
|
+
export function leaseChangeReason(sidecar, lease) {
|
|
43
|
+
if (sidecar.packageVersion !== lease.packageVersion)
|
|
44
|
+
return "version";
|
|
45
|
+
if (sidecar.checksumSha256 !== null &&
|
|
46
|
+
lease.checksumSha256 !== null &&
|
|
47
|
+
sidecar.checksumSha256.toLowerCase() !== lease.checksumSha256.toLowerCase()) {
|
|
48
|
+
return "checksum";
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
export function downloadFailure(status, body, slug) {
|
|
53
|
+
const code = isRecord(body) && typeof body.code === "string" ? body.code : null;
|
|
54
|
+
const retryAfter = isRecord(body) && typeof body.retryAfterSeconds === "number"
|
|
55
|
+
? Math.max(1, Math.ceil(body.retryAfterSeconds))
|
|
56
|
+
: null;
|
|
57
|
+
if (code === "key_revoked") {
|
|
58
|
+
return new CliError("not_authenticated", "This terminal's access was revoked.", "Run `runinfra login` to connect it again.");
|
|
59
|
+
}
|
|
60
|
+
if (code === "key_expired") {
|
|
61
|
+
return new CliError("auth_expired", "This terminal's access expired.", "Run `runinfra login` to connect it again.");
|
|
62
|
+
}
|
|
63
|
+
if (code === "workspace_access_revoked") {
|
|
64
|
+
return new CliError("not_entitled", "Your account no longer has access to this workspace.", "Ask a workspace admin to re-invite you, then run `runinfra login` again.");
|
|
65
|
+
}
|
|
66
|
+
if (code === "invalid_key" || code === "missing_credentials") {
|
|
67
|
+
return new CliError("not_authenticated", "This machine's credentials were not accepted.", "Run `runinfra login` to connect this terminal again.");
|
|
68
|
+
}
|
|
69
|
+
if (code === "auth_unavailable") {
|
|
70
|
+
return new CliError("server_error", "Credentials could not be checked right now. Nothing was downloaded.", "Try again in a few minutes.");
|
|
71
|
+
}
|
|
72
|
+
if (status === 401) {
|
|
73
|
+
return new CliError("not_authenticated", "This machine is not signed in, or its access was revoked.", "Run `runinfra login` to connect this terminal again.");
|
|
74
|
+
}
|
|
75
|
+
if (status === 403 && code === "plan_gate") {
|
|
76
|
+
return new CliError("not_entitled", `Your plan does not include downloads for ${slug}.`, "Open the package page on runinfra.ai to see what your workspace owns.");
|
|
77
|
+
}
|
|
78
|
+
if (code === "not_found" || status === 404) {
|
|
79
|
+
return new CliError("not_found", `Your workspace does not own ${slug}, or that slug does not exist.`, "Check the slug on runinfra.ai, or buy the package first.");
|
|
80
|
+
}
|
|
81
|
+
if (code === "artifact_not_ready") {
|
|
82
|
+
return new CliError("artifact_not_ready", `${slug} is owned by this workspace but its files are not published yet.`, "This resolves on its own. Contact support if it persists.");
|
|
83
|
+
}
|
|
84
|
+
if (code === "rate_limited" || status === 429) {
|
|
85
|
+
return new CliError("rate_limited", `Too many download requests. Retry in ${retryAfter ?? 60} seconds.`, null);
|
|
86
|
+
}
|
|
87
|
+
if (status === 403) {
|
|
88
|
+
return new CliError("not_entitled", `This key is not allowed to download ${slug}.`, "Run `runinfra login` again, or ask a workspace admin for access.");
|
|
89
|
+
}
|
|
90
|
+
return new CliError("server_error", `The download request for ${slug} failed with HTTP ${status}.`, "Try again shortly. If it keeps failing, contact support with this status.");
|
|
91
|
+
}
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { exchangeAuthorizationCode, pollDeviceCode, startDeviceAuthorization, startLoopbackAuthorization, tokenRefusalError, } from "./auth-api.js";
|
|
2
|
+
import { openBrowser, headlessReason } from "./browser.js";
|
|
3
|
+
import { CREDENTIALS_FORMAT, writeCredentials } from "./credentials.js";
|
|
4
|
+
import { sanitizeDeviceLabel } from "./endpoints.js";
|
|
5
|
+
import { CliError } from "./errors.js";
|
|
6
|
+
import { startLoopbackListener, parseCallback } from "./loopback.js";
|
|
7
|
+
import { createPkcePair, isWellFormedAuthorizationCode } from "./pkce.js";
|
|
8
|
+
import { promptLine } from "./prompt.js";
|
|
9
|
+
import { CLI_REQUESTED_SCOPE } from "./scopes.js";
|
|
10
|
+
import { sleep } from "./timers.js";
|
|
11
|
+
import { deviceLabel } from "./context.js";
|
|
12
|
+
const BROWSER_FLOW_TIMEOUT_MS = 10 * 60 * 1000;
|
|
13
|
+
const PASTE_PROMPT = " Or paste the code shown on the approval page and press Enter: ";
|
|
14
|
+
export async function login(context, options) {
|
|
15
|
+
const raw = deviceLabel();
|
|
16
|
+
const label = raw === null ? null : sanitizeDeviceLabel(raw);
|
|
17
|
+
if (options.device) {
|
|
18
|
+
return await deviceLogin(context, label, null);
|
|
19
|
+
}
|
|
20
|
+
const blocked = headlessReason(context.platform, context.env);
|
|
21
|
+
if (blocked !== null) {
|
|
22
|
+
return await deviceLogin(context, label, blocked);
|
|
23
|
+
}
|
|
24
|
+
return await browserLogin(context, label);
|
|
25
|
+
}
|
|
26
|
+
async function browserLogin(context, label) {
|
|
27
|
+
const { output } = context;
|
|
28
|
+
const pkce = createPkcePair();
|
|
29
|
+
let listener;
|
|
30
|
+
try {
|
|
31
|
+
listener = await startLoopbackListener();
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
output.warn(error instanceof CliError
|
|
35
|
+
? `${error.message} Falling back to the device flow.`
|
|
36
|
+
: "Could not open a local port for the browser redirect. Falling back to the device flow.");
|
|
37
|
+
return await deviceLogin(context, label, "a local port could not be opened");
|
|
38
|
+
}
|
|
39
|
+
let requestId;
|
|
40
|
+
try {
|
|
41
|
+
const opened = await startLoopbackAuthorization({
|
|
42
|
+
endpoints: context.endpoints,
|
|
43
|
+
codeChallenge: pkce.challenge,
|
|
44
|
+
scope: CLI_REQUESTED_SCOPE,
|
|
45
|
+
redirectUri: listener.redirectUri,
|
|
46
|
+
deviceLabel: label,
|
|
47
|
+
});
|
|
48
|
+
if (!opened.ok) {
|
|
49
|
+
listener.close();
|
|
50
|
+
return await deviceLogin(context, label, "this deployment does not offer the browser redirect flow yet");
|
|
51
|
+
}
|
|
52
|
+
requestId = opened.requestId;
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
listener.close();
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
const consentUrl = context.endpoints.consentUrl(requestId);
|
|
59
|
+
const launch = await openBrowser(consentUrl, context.platform, context.env);
|
|
60
|
+
if (launch.opened) {
|
|
61
|
+
output.info("Opened your browser to approve this terminal.");
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
output.info(launch.reason
|
|
65
|
+
? `Could not open a browser (${launch.reason}). Open this URL yourself:`
|
|
66
|
+
: "Open this URL to approve this terminal:");
|
|
67
|
+
}
|
|
68
|
+
output.detail(consentUrl);
|
|
69
|
+
output.info("Waiting for approval. This request expires in 10 minutes.");
|
|
70
|
+
const prompt = promptLine(PASTE_PROMPT);
|
|
71
|
+
const outcome = await raceBrowserOutcome(listener.callback, prompt.answer);
|
|
72
|
+
prompt.cancel();
|
|
73
|
+
listener.close();
|
|
74
|
+
if (outcome.kind === "timeout") {
|
|
75
|
+
throw new CliError("auth_timeout", "The sign-in was not approved within 10 minutes.", "Run `runinfra login` again.");
|
|
76
|
+
}
|
|
77
|
+
const { code } = resolveBrowserCode(outcome, output);
|
|
78
|
+
const result = await exchangeAuthorizationCode({
|
|
79
|
+
endpoints: context.endpoints,
|
|
80
|
+
code,
|
|
81
|
+
codeVerifier: pkce.verifier,
|
|
82
|
+
redirectUri: listener.redirectUri,
|
|
83
|
+
});
|
|
84
|
+
if (!result.ok)
|
|
85
|
+
throw tokenRefusalError(result.code);
|
|
86
|
+
await persist(context, result.session);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
async function raceBrowserOutcome(callback, pasted) {
|
|
90
|
+
let timer = null;
|
|
91
|
+
const deadline = new Promise((resolve) => {
|
|
92
|
+
timer = setTimeout(() => resolve({ kind: "timeout" }), BROWSER_FLOW_TIMEOUT_MS);
|
|
93
|
+
});
|
|
94
|
+
try {
|
|
95
|
+
return await Promise.race([
|
|
96
|
+
callback.then((result) => ({ kind: "callback", result })),
|
|
97
|
+
pasted.then((value) => value === null || value.length === 0
|
|
98
|
+
? new Promise(() => undefined)
|
|
99
|
+
: { kind: "pasted", value }),
|
|
100
|
+
deadline,
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
if (timer !== null)
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function resolveBrowserCode(outcome, output) {
|
|
109
|
+
if (outcome.kind === "callback") {
|
|
110
|
+
const result = outcome.result;
|
|
111
|
+
if (!result.ok) {
|
|
112
|
+
if (result.reason === "authorization_error") {
|
|
113
|
+
throw new CliError("auth_denied", `The browser reported: ${result.description}`, null);
|
|
114
|
+
}
|
|
115
|
+
throw new CliError("auth_failed", "The browser redirect did not carry an authorization code.", "Run `runinfra login` again, or use `runinfra login --device`.");
|
|
116
|
+
}
|
|
117
|
+
return { code: result.code };
|
|
118
|
+
}
|
|
119
|
+
const pasted = outcome.value.trim();
|
|
120
|
+
if (pasted.includes("://")) {
|
|
121
|
+
const parsed = parseCallback(pasted);
|
|
122
|
+
if (!parsed.ok) {
|
|
123
|
+
throw new CliError("auth_failed", "That URL does not contain an authorization code.", "Paste just the code from the approval page.");
|
|
124
|
+
}
|
|
125
|
+
return { code: parsed.code };
|
|
126
|
+
}
|
|
127
|
+
if (!isWellFormedAuthorizationCode(pasted)) {
|
|
128
|
+
throw new CliError("auth_failed", "That does not look like an authorization code.", "Copy the whole code from the approval page and try `runinfra login` again.");
|
|
129
|
+
}
|
|
130
|
+
output.warn("Using the pasted code. The redirect never reached this terminal.");
|
|
131
|
+
return { code: pasted };
|
|
132
|
+
}
|
|
133
|
+
async function deviceLogin(context, label, reason) {
|
|
134
|
+
const { output } = context;
|
|
135
|
+
if (reason !== null) {
|
|
136
|
+
output.info(`Using the device flow because ${reason}.`);
|
|
137
|
+
}
|
|
138
|
+
const pkce = createPkcePair();
|
|
139
|
+
const authorization = await startDeviceAuthorization({
|
|
140
|
+
endpoints: context.endpoints,
|
|
141
|
+
codeChallenge: pkce.challenge,
|
|
142
|
+
scope: CLI_REQUESTED_SCOPE,
|
|
143
|
+
deviceLabel: label,
|
|
144
|
+
});
|
|
145
|
+
output.info("To connect this terminal:");
|
|
146
|
+
output.detail(`1. On any device, open ${authorization.verificationUri}`);
|
|
147
|
+
output.detail(`2. Type this code: ${authorization.userCode}`);
|
|
148
|
+
output.info("The page will not fill the code in for you. If a link ever arrives with the code already in it, it did not come from RunInfra.");
|
|
149
|
+
const session = await pollForDeviceApproval(context, authorization);
|
|
150
|
+
await persist(context, session);
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
async function pollForDeviceApproval(context, authorization) {
|
|
154
|
+
const deadline = Date.now() + authorization.expiresInSeconds * 1000;
|
|
155
|
+
let intervalSeconds = authorization.intervalSeconds;
|
|
156
|
+
while (Date.now() < deadline) {
|
|
157
|
+
await sleep(intervalSeconds * 1000);
|
|
158
|
+
const remaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
|
|
159
|
+
context.output.status(` Waiting for approval, ${remaining}s left`);
|
|
160
|
+
const result = await pollDeviceCode({
|
|
161
|
+
endpoints: context.endpoints,
|
|
162
|
+
deviceCode: authorization.deviceCode,
|
|
163
|
+
});
|
|
164
|
+
if (result.ok) {
|
|
165
|
+
context.output.endStatus();
|
|
166
|
+
return result.session;
|
|
167
|
+
}
|
|
168
|
+
if (result.code === "authorization_pending")
|
|
169
|
+
continue;
|
|
170
|
+
if (result.code === "slow_down") {
|
|
171
|
+
intervalSeconds += 5;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (result.code === "rate_limited" || result.code === "temporarily_unavailable") {
|
|
175
|
+
intervalSeconds = Math.max(intervalSeconds, result.retryAfterSeconds ?? intervalSeconds + 5);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
context.output.endStatus();
|
|
179
|
+
throw tokenRefusalError(result.code);
|
|
180
|
+
}
|
|
181
|
+
context.output.endStatus();
|
|
182
|
+
throw new CliError("auth_timeout", "The code expired before it was approved.", "Run `runinfra login --device` again to get a new code.");
|
|
183
|
+
}
|
|
184
|
+
async function persist(context, session) {
|
|
185
|
+
const write = await writeCredentials({
|
|
186
|
+
location: context.location,
|
|
187
|
+
platform: context.platform,
|
|
188
|
+
env: context.env,
|
|
189
|
+
credentials: {
|
|
190
|
+
format: CREDENTIALS_FORMAT,
|
|
191
|
+
apiBase: context.apiBase,
|
|
192
|
+
apiKey: session.apiKey,
|
|
193
|
+
keyPrefix: session.keyPrefix,
|
|
194
|
+
workspaceId: session.workspaceId,
|
|
195
|
+
scope: session.scope,
|
|
196
|
+
expiresAt: session.expiresAt,
|
|
197
|
+
createdAt: new Date().toISOString(),
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
context.output.info(`Connected to workspace ${session.workspaceId}.`);
|
|
201
|
+
context.output.detail(`Credentials stored at ${context.location.file}`);
|
|
202
|
+
context.output.detail(`This access expires ${session.expiresAt}.`);
|
|
203
|
+
if (write.warning !== null) {
|
|
204
|
+
context.output.warn(write.warning);
|
|
205
|
+
}
|
|
206
|
+
context.output.detail("Revoke this terminal any time from Settings, API keys.");
|
|
207
|
+
}
|
package/dist/logout.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { deleteCredentials, readCredentials } from "./credentials.js";
|
|
2
|
+
import { redact } from "./redact.js";
|
|
3
|
+
export async function logout(context) {
|
|
4
|
+
const credentials = await readCredentials(context.location);
|
|
5
|
+
if (credentials === null) {
|
|
6
|
+
const removed = await deleteCredentials(context.location);
|
|
7
|
+
context.output.info(removed
|
|
8
|
+
? "Removed an unreadable credentials file. This machine is signed out."
|
|
9
|
+
: "This machine was not signed in.");
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
await deleteCredentials(context.location);
|
|
13
|
+
context.output.info("Removed this machine's credentials.");
|
|
14
|
+
context.output.detail(`The key ${credentials.keyPrefix} (${redact(credentials.apiKey)}) is still valid until ${credentials.expiresAt}.`);
|
|
15
|
+
context.output.detail("To end its access now, revoke it in Settings, API keys on runinfra.ai.");
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
package/dist/loopback.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { CliError } from "./errors.js";
|
|
3
|
+
export const LOOPBACK_CALLBACK_PATH = "/callback";
|
|
4
|
+
export const LOOPBACK_BIND_ATTEMPTS = 3;
|
|
5
|
+
export function loopbackRedirectUri(family, port, path = LOOPBACK_CALLBACK_PATH) {
|
|
6
|
+
const host = family === "IPv6" ? "[::1]" : "127.0.0.1";
|
|
7
|
+
return `http://${host}:${port}${path}`;
|
|
8
|
+
}
|
|
9
|
+
export function parseCallback(requestTarget) {
|
|
10
|
+
let url;
|
|
11
|
+
try {
|
|
12
|
+
url = new URL(requestTarget, "http://127.0.0.1");
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return { ok: false, reason: "wrong_path", description: null };
|
|
16
|
+
}
|
|
17
|
+
if (url.pathname !== LOOPBACK_CALLBACK_PATH) {
|
|
18
|
+
return { ok: false, reason: "wrong_path", description: null };
|
|
19
|
+
}
|
|
20
|
+
const error = url.searchParams.get("error");
|
|
21
|
+
if (error) {
|
|
22
|
+
const description = url.searchParams.get("error_description");
|
|
23
|
+
return {
|
|
24
|
+
ok: false,
|
|
25
|
+
reason: "authorization_error",
|
|
26
|
+
description: description && description.length > 0 ? description : error,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const code = url.searchParams.get("code");
|
|
30
|
+
if (!code)
|
|
31
|
+
return { ok: false, reason: "missing_code", description: null };
|
|
32
|
+
return { ok: true, code, state: url.searchParams.get("state") };
|
|
33
|
+
}
|
|
34
|
+
export function callbackPage(kind, headline, body) {
|
|
35
|
+
const accent = kind === "ok" ? "#76b900" : "#c76f6f";
|
|
36
|
+
return [
|
|
37
|
+
"<!doctype html>",
|
|
38
|
+
'<html lang="en"><head><meta charset="utf-8">',
|
|
39
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
|
40
|
+
"<title>RunInfra CLI</title>",
|
|
41
|
+
"<style>",
|
|
42
|
+
"body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;",
|
|
43
|
+
"background:#0f0f0e;color:#f7f7f6;font:14px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif}",
|
|
44
|
+
"main{max-width:26rem;padding:2rem;border-left:2px solid " + accent + "}",
|
|
45
|
+
"h1{font-size:20px;font-weight:500;margin:0 0 .75rem}",
|
|
46
|
+
"p{margin:0;color:#9a998e}",
|
|
47
|
+
"</style></head><body><main>",
|
|
48
|
+
`<h1>${escapeHtml(headline)}</h1>`,
|
|
49
|
+
`<p>${escapeHtml(body)}</p>`,
|
|
50
|
+
"</main></body></html>",
|
|
51
|
+
].join("");
|
|
52
|
+
}
|
|
53
|
+
function escapeHtml(value) {
|
|
54
|
+
return value
|
|
55
|
+
.replace(/&/gu, "&")
|
|
56
|
+
.replace(/</gu, "<")
|
|
57
|
+
.replace(/>/gu, ">")
|
|
58
|
+
.replace(/"/gu, """);
|
|
59
|
+
}
|
|
60
|
+
async function bindOnce(host) {
|
|
61
|
+
const server = createServer();
|
|
62
|
+
return await new Promise((resolve, reject) => {
|
|
63
|
+
const onError = (error) => {
|
|
64
|
+
server.close();
|
|
65
|
+
reject(error);
|
|
66
|
+
};
|
|
67
|
+
server.once("error", onError);
|
|
68
|
+
server.listen({ host, port: 0, exclusive: true }, () => {
|
|
69
|
+
server.removeListener("error", onError);
|
|
70
|
+
const address = server.address();
|
|
71
|
+
if (address === null || typeof address === "string") {
|
|
72
|
+
server.close();
|
|
73
|
+
reject(new Error("the loopback socket did not report a port"));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
resolve({
|
|
77
|
+
server,
|
|
78
|
+
family: address.family === "IPv6" ? "IPv6" : "IPv4",
|
|
79
|
+
port: address.port,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
export async function startLoopbackListener() {
|
|
85
|
+
let bound = null;
|
|
86
|
+
let lastError = null;
|
|
87
|
+
for (let attempt = 0; attempt < LOOPBACK_BIND_ATTEMPTS && bound === null; attempt += 1) {
|
|
88
|
+
for (const host of ["127.0.0.1", "::1"]) {
|
|
89
|
+
try {
|
|
90
|
+
bound = await bindOnce(host);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
lastError = error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (bound === null) {
|
|
99
|
+
const detail = lastError instanceof Error ? lastError.message : String(lastError);
|
|
100
|
+
throw new CliError("network", `Could not open a loopback listener for the browser redirect: ${detail}`, "Run `runinfra login --device` instead; it needs no local port.");
|
|
101
|
+
}
|
|
102
|
+
const { server, family, port } = bound;
|
|
103
|
+
const redirectUri = loopbackRedirectUri(family, port);
|
|
104
|
+
const expectedHosts = new Set([`127.0.0.1:${port}`, `[::1]:${port}`, `localhost:${port}`]);
|
|
105
|
+
let settle = null;
|
|
106
|
+
const callback = new Promise((resolve) => {
|
|
107
|
+
settle = resolve;
|
|
108
|
+
});
|
|
109
|
+
server.on("request", (request, response) => {
|
|
110
|
+
const host = request.headers.host ?? "";
|
|
111
|
+
if (!expectedHosts.has(host)) {
|
|
112
|
+
response.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
|
|
113
|
+
response.end("bad host\n");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const parsed = parseCallback(request.url ?? "/");
|
|
117
|
+
if (!parsed.ok && parsed.reason === "wrong_path") {
|
|
118
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
119
|
+
response.end("not found\n");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const page = parsed.ok
|
|
123
|
+
? callbackPage("ok", "Terminal connected", "You can close this tab and return to your terminal.")
|
|
124
|
+
: callbackPage("error", "Sign-in was not completed", parsed.reason === "authorization_error"
|
|
125
|
+
? parsed.description
|
|
126
|
+
: "The browser did not send an authorization code. Return to your terminal for the next step.");
|
|
127
|
+
response.writeHead(parsed.ok ? 200 : 400, {
|
|
128
|
+
"content-type": "text/html; charset=utf-8",
|
|
129
|
+
"cache-control": "no-store",
|
|
130
|
+
});
|
|
131
|
+
response.end(page);
|
|
132
|
+
settle?.(parsed);
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
redirectUri,
|
|
136
|
+
callback,
|
|
137
|
+
close() {
|
|
138
|
+
server.close();
|
|
139
|
+
server.closeAllConnections();
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { parseArgs } from "./args.js";
|
|
2
|
+
import { createContext } from "./context.js";
|
|
3
|
+
import { describeError, exitCodeFor, CliError } from "./errors.js";
|
|
4
|
+
import { helpText } from "./help.js";
|
|
5
|
+
import { login } from "./login.js";
|
|
6
|
+
import { logout } from "./logout.js";
|
|
7
|
+
import { createOutput } from "./output.js";
|
|
8
|
+
import { pull } from "./pull.js";
|
|
9
|
+
import { CLI_NAME, CLI_VERSION, isSupportedNodeVersion, MINIMUM_NODE_MAJOR } from "./version.js";
|
|
10
|
+
import { whoami } from "./whoami.js";
|
|
11
|
+
export async function run(argv, options = {}) {
|
|
12
|
+
const output = options.output ?? createOutput();
|
|
13
|
+
const parsed = parseArgs(argv);
|
|
14
|
+
if (!parsed.ok) {
|
|
15
|
+
output.error(parsed.message);
|
|
16
|
+
return 2;
|
|
17
|
+
}
|
|
18
|
+
if (parsed.args.command === "help") {
|
|
19
|
+
output.result(helpText());
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
if (parsed.args.command === "version") {
|
|
23
|
+
output.result(`${CLI_NAME} ${CLI_VERSION}`);
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
const nodeVersion = options.nodeVersion ?? process.versions.node;
|
|
27
|
+
if (!isSupportedNodeVersion(nodeVersion)) {
|
|
28
|
+
output.error(`Node ${MINIMUM_NODE_MAJOR} or newer is required, found ${nodeVersion}.`);
|
|
29
|
+
return 2;
|
|
30
|
+
}
|
|
31
|
+
const signal = options.signal ?? neverAborted();
|
|
32
|
+
try {
|
|
33
|
+
const context = createContext({
|
|
34
|
+
output,
|
|
35
|
+
...(options.env ? { env: options.env } : {}),
|
|
36
|
+
...(options.platform ? { platform: options.platform } : {}),
|
|
37
|
+
});
|
|
38
|
+
switch (parsed.args.command) {
|
|
39
|
+
case "login":
|
|
40
|
+
return await login(context, { device: parsed.args.device });
|
|
41
|
+
case "logout":
|
|
42
|
+
return await logout(context);
|
|
43
|
+
case "whoami":
|
|
44
|
+
return await whoami(context);
|
|
45
|
+
case "pull": {
|
|
46
|
+
const slug = parsed.args.slug;
|
|
47
|
+
if (slug === null) {
|
|
48
|
+
output.error("runinfra pull needs a package slug.");
|
|
49
|
+
return 2;
|
|
50
|
+
}
|
|
51
|
+
return await pull(context, { slug, out: parsed.args.out, concurrency: parsed.args.concurrency }, signal);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
output.endStatus();
|
|
57
|
+
const described = describeError(error);
|
|
58
|
+
output.error(described.message);
|
|
59
|
+
if (described.hint !== null)
|
|
60
|
+
output.detail(described.hint);
|
|
61
|
+
if (described.code === "unexpected") {
|
|
62
|
+
output.detail("This is a bug in the CLI. Report it with the command you ran and this message.");
|
|
63
|
+
}
|
|
64
|
+
return exitCodeFor(error);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function neverAborted() {
|
|
68
|
+
return new AbortController().signal;
|
|
69
|
+
}
|
|
70
|
+
export { CliError };
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const STATUS_LOG_INTERVAL_MS = 10_000;
|
|
2
|
+
function isTty(stream) {
|
|
3
|
+
return stream.isTTY === true;
|
|
4
|
+
}
|
|
5
|
+
function columnsOf(stream) {
|
|
6
|
+
const columns = stream.columns;
|
|
7
|
+
return typeof columns === "number" && columns > 20 ? columns : 80;
|
|
8
|
+
}
|
|
9
|
+
export function createOutput(streams = { stdout: process.stdout, stderr: process.stderr }, now = Date.now) {
|
|
10
|
+
const interactive = isTty(streams.stderr);
|
|
11
|
+
let statusActive = false;
|
|
12
|
+
let lastStatusLogMs = 0;
|
|
13
|
+
const endStatus = () => {
|
|
14
|
+
if (!statusActive)
|
|
15
|
+
return;
|
|
16
|
+
statusActive = false;
|
|
17
|
+
if (interactive)
|
|
18
|
+
streams.stderr.write("\n");
|
|
19
|
+
};
|
|
20
|
+
const writeLine = (stream, text) => {
|
|
21
|
+
endStatus();
|
|
22
|
+
stream.write(`${text}\n`);
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
interactive,
|
|
26
|
+
result(text) {
|
|
27
|
+
endStatus();
|
|
28
|
+
streams.stdout.write(`${text}\n`);
|
|
29
|
+
},
|
|
30
|
+
info(text) {
|
|
31
|
+
writeLine(streams.stderr, text);
|
|
32
|
+
},
|
|
33
|
+
detail(text) {
|
|
34
|
+
writeLine(streams.stderr, ` ${text}`);
|
|
35
|
+
},
|
|
36
|
+
warn(text) {
|
|
37
|
+
writeLine(streams.stderr, `warning: ${text}`);
|
|
38
|
+
},
|
|
39
|
+
error(text) {
|
|
40
|
+
writeLine(streams.stderr, `error: ${text}`);
|
|
41
|
+
},
|
|
42
|
+
status(text) {
|
|
43
|
+
if (!interactive) {
|
|
44
|
+
const nowMs = now();
|
|
45
|
+
if (nowMs - lastStatusLogMs < STATUS_LOG_INTERVAL_MS)
|
|
46
|
+
return;
|
|
47
|
+
lastStatusLogMs = nowMs;
|
|
48
|
+
streams.stderr.write(`${text}\n`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const width = columnsOf(streams.stderr) - 1;
|
|
52
|
+
const clipped = text.length > width ? `${text.slice(0, width - 1)}~` : text.padEnd(width, " ");
|
|
53
|
+
streams.stderr.write(`\r${clipped}`);
|
|
54
|
+
statusActive = true;
|
|
55
|
+
},
|
|
56
|
+
endStatus,
|
|
57
|
+
};
|
|
58
|
+
}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { posix as posixPath, win32 as winPath } from "node:path";
|
|
2
|
+
export const CONFIG_DIR_ENV = "RUNINFRA_CONFIG_DIR";
|
|
3
|
+
export const CREDENTIALS_FILE_NAME = "credentials.json";
|
|
4
|
+
export function credentialLocation(input) {
|
|
5
|
+
const override = input.env[CONFIG_DIR_ENV]?.trim();
|
|
6
|
+
const isWindows = input.platform === "win32";
|
|
7
|
+
const path = isWindows ? winPath : posixPath;
|
|
8
|
+
if (override) {
|
|
9
|
+
return { dir: override, file: path.join(override, CREDENTIALS_FILE_NAME) };
|
|
10
|
+
}
|
|
11
|
+
if (isWindows) {
|
|
12
|
+
const localAppData = input.env.LOCALAPPDATA?.trim() ||
|
|
13
|
+
winPath.join(input.homeDir, "AppData", "Local");
|
|
14
|
+
const dir = winPath.join(localAppData, "runinfra");
|
|
15
|
+
return { dir, file: winPath.join(dir, CREDENTIALS_FILE_NAME) };
|
|
16
|
+
}
|
|
17
|
+
const configHome = input.env.XDG_CONFIG_HOME?.trim() || posixPath.join(input.homeDir, ".config");
|
|
18
|
+
const dir = posixPath.join(configHome, "runinfra");
|
|
19
|
+
return { dir, file: posixPath.join(dir, CREDENTIALS_FILE_NAME) };
|
|
20
|
+
}
|
package/dist/pkce.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
export function createPkcePair() {
|
|
3
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
4
|
+
return { verifier, challenge: deriveChallenge(verifier) };
|
|
5
|
+
}
|
|
6
|
+
export function deriveChallenge(verifier) {
|
|
7
|
+
return createHash("sha256").update(verifier, "utf8").digest("base64url");
|
|
8
|
+
}
|
|
9
|
+
export function createState() {
|
|
10
|
+
return randomBytes(32).toString("base64url");
|
|
11
|
+
}
|
|
12
|
+
export function statesMatch(expected, received) {
|
|
13
|
+
const left = Buffer.from(expected, "utf8");
|
|
14
|
+
const right = Buffer.from(received, "utf8");
|
|
15
|
+
if (left.length !== right.length)
|
|
16
|
+
return false;
|
|
17
|
+
return timingSafeEqual(left, right);
|
|
18
|
+
}
|
|
19
|
+
export function isWellFormedCodeVerifier(verifier) {
|
|
20
|
+
return /^[A-Za-z0-9._~-]{43,128}$/u.test(verifier);
|
|
21
|
+
}
|
|
22
|
+
export function isWellFormedAuthorizationCode(code) {
|
|
23
|
+
return /^[A-Za-z0-9_-]{43}$/u.test(code);
|
|
24
|
+
}
|