@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.
@@ -0,0 +1,62 @@
1
+ const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
2
+ const WINDOWS_RESERVED = new Set([
3
+ "con",
4
+ "prn",
5
+ "aux",
6
+ "nul",
7
+ "com1",
8
+ "com2",
9
+ "com3",
10
+ "com4",
11
+ "com5",
12
+ "com6",
13
+ "com7",
14
+ "com8",
15
+ "com9",
16
+ "lpt1",
17
+ "lpt2",
18
+ "lpt3",
19
+ "lpt4",
20
+ "lpt5",
21
+ "lpt6",
22
+ "lpt7",
23
+ "lpt8",
24
+ "lpt9",
25
+ ]);
26
+ const MAX_NAME_LENGTH = 120;
27
+ export function fallbackArtifactName(slug, packageVersion) {
28
+ const safeSlug = slug
29
+ .replace(/[^A-Za-z0-9-]+/gu, "-")
30
+ .replace(/-{2,}/gu, "-")
31
+ .replace(/^-+|-+$/gu, "")
32
+ .slice(0, MAX_NAME_LENGTH);
33
+ return `${safeSlug || "package"}-v${packageVersion}.kit`;
34
+ }
35
+ export function artifactFileName(input) {
36
+ let candidate = null;
37
+ try {
38
+ const pathname = new URL(input.url).pathname;
39
+ const segment = pathname.split("/").filter(Boolean).pop();
40
+ if (segment !== undefined) {
41
+ candidate = decodeURIComponent(segment);
42
+ }
43
+ }
44
+ catch {
45
+ candidate = null;
46
+ }
47
+ if (candidate === null)
48
+ return fallbackArtifactName(input.slug, input.packageVersion);
49
+ const cleaned = candidate.trim().slice(0, MAX_NAME_LENGTH);
50
+ if (!SAFE_NAME.test(cleaned))
51
+ return fallbackArtifactName(input.slug, input.packageVersion);
52
+ const stem = (cleaned.split(".")[0] ?? "").toLowerCase();
53
+ if (WINDOWS_RESERVED.has(stem))
54
+ return fallbackArtifactName(input.slug, input.packageVersion);
55
+ return cleaned;
56
+ }
57
+ export function partFileName(finalName) {
58
+ return `${finalName}.part`;
59
+ }
60
+ export function sidecarFileName(finalName) {
61
+ return `${finalName}.part.json`;
62
+ }
@@ -0,0 +1,177 @@
1
+ import { CliError } from "./errors.js";
2
+ import { requestJson } from "./http.js";
3
+ const TOKEN_REFUSALS = new Set([
4
+ "invalid_request",
5
+ "invalid_grant",
6
+ "expired_token",
7
+ "access_denied",
8
+ "authorization_pending",
9
+ "slow_down",
10
+ "rate_limited",
11
+ "temporarily_unavailable",
12
+ "server_error",
13
+ "unsupported_code_challenge_method",
14
+ "invalid_scope",
15
+ ]);
16
+ function isRecord(value) {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+ function readString(value) {
20
+ return typeof value === "string" && value.length > 0 ? value : null;
21
+ }
22
+ export function parseSession(body) {
23
+ if (!isRecord(body))
24
+ return null;
25
+ const apiKey = readString(body.apiKey);
26
+ const workspaceId = readString(body.workspaceId);
27
+ const expiresAt = readString(body.expiresAt);
28
+ if (!apiKey || !workspaceId || !expiresAt)
29
+ return null;
30
+ if (!Number.isFinite(Date.parse(expiresAt)))
31
+ return null;
32
+ return {
33
+ apiKey,
34
+ keyPrefix: readString(body.keyPrefix) ?? apiKey.slice(0, 8),
35
+ scope: readString(body.scope) ?? "",
36
+ workspaceId,
37
+ expiresAt,
38
+ };
39
+ }
40
+ export function parseTokenRefusal(body, headers = {}) {
41
+ const raw = isRecord(body) && typeof body.error === "string" ? body.error : null;
42
+ if (raw === null || !TOKEN_REFUSALS.has(raw))
43
+ return null;
44
+ const advertised = Number.parseInt(headers["retry-after"] ?? "", 10);
45
+ return {
46
+ code: raw,
47
+ retryAfterSeconds: Number.isFinite(advertised) && advertised > 0 ? advertised : null,
48
+ };
49
+ }
50
+ export function tokenRefusalError(code) {
51
+ switch (code) {
52
+ case "access_denied":
53
+ return new CliError("auth_denied", "The sign-in was rejected in the browser. Nothing was granted.", "Run `runinfra login` again if that was not you.");
54
+ case "expired_token":
55
+ return new CliError("auth_expired", "The sign-in request expired before it was approved.", "Run `runinfra login` again.");
56
+ case "invalid_grant":
57
+ return new CliError("auth_failed", "That sign-in could not be completed. The code was already used, was never approved, or does not belong to this terminal.", "Run `runinfra login` again to start a fresh request.");
58
+ case "invalid_request":
59
+ return new CliError("auth_failed", "The server refused the sign-in request as malformed.", "Update the CLI (`npm i -g @runinfra/cli`) and try again.");
60
+ case "unsupported_code_challenge_method":
61
+ return new CliError("auth_failed", "The server does not accept this CLI's PKCE method.", "Update the CLI (`npm i -g @runinfra/cli`) and try again.");
62
+ case "invalid_scope":
63
+ return new CliError("auth_failed", "The server does not recognise the access this CLI asked for.", "Update the CLI (`npm i -g @runinfra/cli`) and try again.");
64
+ case "temporarily_unavailable":
65
+ return new CliError("server_error", "Sign-in is temporarily unavailable. Nothing was granted, and nothing was consumed.", "Try again in a few minutes.");
66
+ case "rate_limited":
67
+ return new CliError("rate_limited", "Too many sign-in attempts from this machine.", "Wait a minute and run `runinfra login` again.");
68
+ case "authorization_pending":
69
+ case "slow_down":
70
+ return new CliError("auth_timeout", "The sign-in was not approved in time.", "Run `runinfra login` again.");
71
+ case "server_error":
72
+ return new CliError("server_error", "The sign-in failed on the server.", "Try again shortly. If it keeps failing, contact support.");
73
+ }
74
+ }
75
+ function unexpected(status, what) {
76
+ return new CliError("server_error", `${what} failed with HTTP ${status}.`, "Try again shortly. If it keeps failing, contact support with this status.");
77
+ }
78
+ function malformed(what) {
79
+ return new CliError("server_error", `${what} did not return a response this CLI understands. The endpoint answered, but not with RunInfra data.`, "Check RUNINFRA_API_BASE, and whether a proxy is intercepting the request.");
80
+ }
81
+ function outcomeFor(status, body, headers, what) {
82
+ if (status === 200) {
83
+ const session = parseSession(body);
84
+ if (session === null)
85
+ throw malformed(what);
86
+ return { ok: true, session };
87
+ }
88
+ const refusal = parseTokenRefusal(body, headers);
89
+ if (refusal === null)
90
+ throw unexpected(status, what);
91
+ return { ok: false, ...refusal };
92
+ }
93
+ export async function startLoopbackAuthorization(input) {
94
+ const response = await requestJson({
95
+ method: "POST",
96
+ url: input.endpoints.loopbackInit,
97
+ json: {
98
+ codeChallenge: input.codeChallenge,
99
+ codeChallengeMethod: "S256",
100
+ scope: input.scope,
101
+ redirectUri: input.redirectUri,
102
+ deviceLabel: input.deviceLabel,
103
+ },
104
+ });
105
+ if (response.status === 404 || response.status === 501 || response.status === 405) {
106
+ return { ok: false, reason: "not_available" };
107
+ }
108
+ if (response.status !== 200 && response.status !== 201) {
109
+ const refusal = parseTokenRefusal(response.body, response.headers);
110
+ if (refusal !== null)
111
+ throw tokenRefusalError(refusal.code);
112
+ throw unexpected(response.status, "Starting the browser sign-in");
113
+ }
114
+ const body = response.body;
115
+ const requestId = isRecord(body) ? readString(body.requestId) : null;
116
+ if (requestId === null)
117
+ throw malformed("Starting the browser sign-in");
118
+ return { ok: true, requestId };
119
+ }
120
+ export async function exchangeAuthorizationCode(input) {
121
+ const response = await requestJson({
122
+ method: "POST",
123
+ url: input.endpoints.token,
124
+ json: {
125
+ code: input.code,
126
+ codeVerifier: input.codeVerifier,
127
+ redirectUri: input.redirectUri,
128
+ },
129
+ });
130
+ return outcomeFor(response.status, response.body, response.headers, "The sign-in exchange");
131
+ }
132
+ export async function startDeviceAuthorization(input) {
133
+ const response = await requestJson({
134
+ method: "POST",
135
+ url: input.endpoints.deviceInit,
136
+ json: {
137
+ codeChallenge: input.codeChallenge,
138
+ codeChallengeMethod: "S256",
139
+ scope: input.scope,
140
+ ...(input.deviceLabel === null ? {} : { deviceLabel: input.deviceLabel }),
141
+ },
142
+ });
143
+ if (response.status !== 200 && response.status !== 201) {
144
+ const refusal = parseTokenRefusal(response.body, response.headers);
145
+ if (refusal !== null)
146
+ throw tokenRefusalError(refusal.code);
147
+ throw unexpected(response.status, "Starting the device sign-in");
148
+ }
149
+ const authorization = parseDeviceAuthorization(response.body);
150
+ if (authorization === null)
151
+ throw malformed("Starting the device sign-in");
152
+ return authorization;
153
+ }
154
+ export function parseDeviceAuthorization(body) {
155
+ if (!isRecord(body))
156
+ return null;
157
+ const deviceCode = readString(body.deviceCode);
158
+ const userCode = readString(body.userCode);
159
+ const verificationUri = readString(body.verificationUri);
160
+ if (!deviceCode || !userCode || !verificationUri)
161
+ return null;
162
+ const expiresInSeconds = typeof body.expiresIn === "number" && body.expiresIn > 0
163
+ ? Math.floor(body.expiresIn)
164
+ : 900;
165
+ const intervalSeconds = typeof body.interval === "number" && body.interval > 0
166
+ ? Math.ceil(body.interval)
167
+ : 5;
168
+ return { deviceCode, userCode, verificationUri, expiresInSeconds, intervalSeconds };
169
+ }
170
+ export async function pollDeviceCode(input) {
171
+ const response = await requestJson({
172
+ method: "POST",
173
+ url: input.endpoints.devicePoll,
174
+ json: { deviceCode: input.deviceCode },
175
+ });
176
+ return outcomeFor(response.status, response.body, response.headers, "The device sign-in");
177
+ }
@@ -0,0 +1,70 @@
1
+ import { spawn } from "node:child_process";
2
+ export function browserCommandFor(platform, url) {
3
+ if (platform === "win32") {
4
+ return {
5
+ command: "cmd.exe",
6
+ args: ["/d", "/s", "/c", `start "" "${url}"`],
7
+ verbatim: true,
8
+ };
9
+ }
10
+ if (platform === "darwin") {
11
+ return { command: "open", args: [url], verbatim: false };
12
+ }
13
+ if (platform === "linux" || platform === "freebsd" || platform === "openbsd") {
14
+ return { command: "xdg-open", args: [url], verbatim: false };
15
+ }
16
+ return null;
17
+ }
18
+ export function headlessReason(platform, env) {
19
+ if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) {
20
+ return "this is an SSH session";
21
+ }
22
+ if (env.CI) {
23
+ return "this is a CI environment";
24
+ }
25
+ if (browserCommandFor(platform, "https://example.com") === null) {
26
+ return `there is no known way to open a browser on ${platform}`;
27
+ }
28
+ if (platform !== "win32" && platform !== "darwin") {
29
+ if (!env.DISPLAY && !env.WAYLAND_DISPLAY) {
30
+ return "there is no graphical display on this machine";
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+ export async function openBrowser(url, platform = process.platform, env = process.env) {
36
+ const blocked = headlessReason(platform, env);
37
+ if (blocked !== null)
38
+ return { opened: false, reason: blocked };
39
+ const command = browserCommandFor(platform, url);
40
+ if (command === null) {
41
+ return { opened: false, reason: `there is no known way to open a browser on ${platform}` };
42
+ }
43
+ return await new Promise((resolve) => {
44
+ let settled = false;
45
+ const finish = (result) => {
46
+ if (settled)
47
+ return;
48
+ settled = true;
49
+ resolve(result);
50
+ };
51
+ try {
52
+ const child = spawn(command.command, [...command.args], {
53
+ stdio: "ignore",
54
+ detached: platform !== "win32",
55
+ windowsVerbatimArguments: command.verbatim,
56
+ });
57
+ child.on("error", (error) => {
58
+ finish({ opened: false, reason: error.message });
59
+ });
60
+ child.unref();
61
+ setTimeout(() => finish({ opened: true, reason: null }), 250);
62
+ }
63
+ catch (error) {
64
+ finish({
65
+ opened: false,
66
+ reason: error instanceof Error ? error.message : String(error),
67
+ });
68
+ }
69
+ });
70
+ }
@@ -0,0 +1,89 @@
1
+ import { headResource, streamRange } from "./http.js";
2
+ import { CliError } from "./errors.js";
3
+ import { downloadFailure, parseLeaseResponse } from "./lease.js";
4
+ import { requestJson } from "./http.js";
5
+ export async function requestLease(endpoints, apiKey, slug, signal) {
6
+ const response = await requestJson({
7
+ method: "GET",
8
+ url: endpoints.download(slug),
9
+ headers: { authorization: `Bearer ${apiKey}` },
10
+ ...(signal ? { signal } : {}),
11
+ });
12
+ if (response.status !== 200) {
13
+ throw downloadFailure(response.status, response.body, slug);
14
+ }
15
+ const lease = parseLeaseResponse(response.body);
16
+ if (lease === null) {
17
+ throw new CliError("server_error", `The download response for ${slug} was not in the expected format.`, "Try again shortly. If it keeps failing, contact support.");
18
+ }
19
+ return lease;
20
+ }
21
+ export function parseContentRangeTotal(value) {
22
+ if (!value)
23
+ return null;
24
+ const match = /^bytes\s+\d+-\d+\/(\d+)$/iu.exec(value.trim());
25
+ if (!match)
26
+ return null;
27
+ const total = Number.parseInt(match[1] ?? "", 10);
28
+ return Number.isSafeInteger(total) && total > 0 ? total : null;
29
+ }
30
+ export function parseContentLength(value) {
31
+ if (!value)
32
+ return null;
33
+ const length = Number.parseInt(value.trim(), 10);
34
+ return Number.isSafeInteger(length) && length >= 0 ? length : null;
35
+ }
36
+ export function supportsRanges(headers) {
37
+ const value = headers["accept-ranges"];
38
+ return typeof value === "string" && value.toLowerCase().includes("bytes");
39
+ }
40
+ export function readEtag(headers) {
41
+ const value = headers.etag;
42
+ return typeof value === "string" && value.length > 0 ? value : null;
43
+ }
44
+ export async function probeArtifact(url, signal) {
45
+ const head = await headResource(url, signal ? { signal } : {});
46
+ if (head.status === 200) {
47
+ const sizeBytes = parseContentLength(head.headers["content-length"]);
48
+ if (sizeBytes !== null && sizeBytes > 0) {
49
+ return {
50
+ ok: true,
51
+ probe: {
52
+ sizeBytes,
53
+ etag: readEtag(head.headers),
54
+ rangesSupported: supportsRanges(head.headers),
55
+ },
56
+ };
57
+ }
58
+ }
59
+ const probe = await streamRange({
60
+ url,
61
+ start: 0,
62
+ endInclusive: 0,
63
+ onChunk: () => undefined,
64
+ ...(signal ? { signal } : {}),
65
+ });
66
+ if (probe.status === 206) {
67
+ const total = parseContentRangeTotal(probe.headers["content-range"]);
68
+ if (total === null)
69
+ return { ok: false, reason: "unusable", status: probe.status };
70
+ return {
71
+ ok: true,
72
+ probe: { sizeBytes: total, etag: readEtag(probe.headers), rangesSupported: true },
73
+ };
74
+ }
75
+ if (probe.status === 200) {
76
+ const sizeBytes = parseContentLength(probe.headers["content-length"]);
77
+ if (sizeBytes === null || sizeBytes <= 0) {
78
+ return { ok: false, reason: "unusable", status: probe.status };
79
+ }
80
+ return {
81
+ ok: true,
82
+ probe: { sizeBytes, etag: readEtag(probe.headers), rangesSupported: false },
83
+ };
84
+ }
85
+ if (probe.status === 401 || probe.status === 403) {
86
+ return { ok: false, reason: "lease_expired" };
87
+ }
88
+ return { ok: false, reason: "unusable", status: probe.status };
89
+ }
@@ -0,0 +1,28 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ export function isSha256Hex(value) {
4
+ return /^[a-f0-9]{64}$/iu.test(value);
5
+ }
6
+ export function judgeChecksum(expected, actual) {
7
+ if (expected === null || !isSha256Hex(expected))
8
+ return "unverifiable";
9
+ if (!isSha256Hex(actual))
10
+ return "unverifiable";
11
+ return expected.toLowerCase() === actual.toLowerCase() ? "match" : "mismatch";
12
+ }
13
+ export async function sha256File(path, onProgress, signal) {
14
+ const hash = createHash("sha256");
15
+ const stream = createReadStream(path, signal ? { signal } : {});
16
+ let hashed = 0;
17
+ await new Promise((resolve, reject) => {
18
+ stream.on("data", (chunk) => {
19
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
20
+ hash.update(buffer);
21
+ hashed += buffer.length;
22
+ onProgress?.(hashed);
23
+ });
24
+ stream.on("end", resolve);
25
+ stream.on("error", reject);
26
+ });
27
+ return hash.digest("hex");
28
+ }
@@ -0,0 +1,52 @@
1
+ import { homedir, hostname, userInfo } from "node:os";
2
+ import { CliError } from "./errors.js";
3
+ import { endpointsFor, resolveApiBase } from "./endpoints.js";
4
+ import { credentialsExpired, readCredentials, } from "./credentials.js";
5
+ import { credentialLocation } from "./paths.js";
6
+ export function createContext(input) {
7
+ const env = input.env ?? process.env;
8
+ const platform = input.platform ?? process.platform;
9
+ const base = resolveApiBase(env);
10
+ if (!base.ok)
11
+ throw new CliError("usage", base.message, null);
12
+ return {
13
+ apiBase: base.base,
14
+ endpoints: endpointsFor(base.base),
15
+ location: credentialLocation({ platform, env, homeDir: input.homeDir ?? homedir() }),
16
+ output: input.output,
17
+ env,
18
+ platform,
19
+ };
20
+ }
21
+ export async function requireCredentials(context) {
22
+ const credentials = await readCredentials(context.location);
23
+ if (credentials === null) {
24
+ throw new CliError("not_authenticated", "This machine is not signed in.", "Run `runinfra login` first.");
25
+ }
26
+ if (credentialsExpired(credentials)) {
27
+ throw new CliError("auth_expired", `This terminal's access expired on ${credentials.expiresAt}.`, "Run `runinfra login` to connect it again.");
28
+ }
29
+ if (credentials.apiBase !== context.apiBase) {
30
+ context.output.warn(`Signed in against ${credentials.apiBase}, which is not the configured ${context.apiBase}. Using the one the key belongs to.`);
31
+ }
32
+ return { credentials, endpoints: endpointsFor(credentials.apiBase) };
33
+ }
34
+ export function deviceLabel() {
35
+ let user = null;
36
+ try {
37
+ user = userInfo().username || null;
38
+ }
39
+ catch {
40
+ user = null;
41
+ }
42
+ let host = null;
43
+ try {
44
+ host = hostname() || null;
45
+ }
46
+ catch {
47
+ host = null;
48
+ }
49
+ if (user && host)
50
+ return `${user}@${host}`;
51
+ return user ?? host;
52
+ }
@@ -0,0 +1,136 @@
1
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { spawn } from "node:child_process";
3
+ import { CliError } from "./errors.js";
4
+ export const CREDENTIALS_FORMAT = 1;
5
+ function isRecord(value) {
6
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7
+ }
8
+ export function serializeCredentials(credentials) {
9
+ return `${JSON.stringify(credentials, null, 2)}\n`;
10
+ }
11
+ export function parseCredentials(raw) {
12
+ let value;
13
+ try {
14
+ value = JSON.parse(raw);
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ if (!isRecord(value))
20
+ return null;
21
+ if (value.format !== CREDENTIALS_FORMAT)
22
+ return null;
23
+ const { apiBase, apiKey, keyPrefix, workspaceId, expiresAt, createdAt } = value;
24
+ for (const field of [apiBase, apiKey, keyPrefix, workspaceId, expiresAt, createdAt]) {
25
+ if (typeof field !== "string" || field.length === 0)
26
+ return null;
27
+ }
28
+ if (typeof value.scope !== "string")
29
+ return null;
30
+ if (!Number.isFinite(Date.parse(expiresAt)))
31
+ return null;
32
+ return {
33
+ format: CREDENTIALS_FORMAT,
34
+ apiBase: apiBase,
35
+ apiKey: apiKey,
36
+ keyPrefix: keyPrefix,
37
+ workspaceId: workspaceId,
38
+ scope: value.scope,
39
+ expiresAt: expiresAt,
40
+ createdAt: createdAt,
41
+ };
42
+ }
43
+ export function credentialsExpired(credentials, nowMs = Date.now()) {
44
+ const expiresAtMs = Date.parse(credentials.expiresAt);
45
+ if (!Number.isFinite(expiresAtMs))
46
+ return false;
47
+ return expiresAtMs <= nowMs;
48
+ }
49
+ export async function readCredentials(location) {
50
+ let raw;
51
+ try {
52
+ raw = await readFile(location.file, "utf8");
53
+ }
54
+ catch (error) {
55
+ if (error.code === "ENOENT")
56
+ return null;
57
+ throw new CliError("storage_unwritable", `Could not read ${location.file}: ${error.message}`, "Check the file's permissions, or delete it and run `runinfra login` again.");
58
+ }
59
+ return parseCredentials(raw);
60
+ }
61
+ export function windowsAclArgs(dir, principal) {
62
+ return [dir, "/inheritance:r", "/grant:r", `${principal}:(OI)(CI)F`];
63
+ }
64
+ export function windowsPrincipal(env) {
65
+ const user = env.USERNAME?.trim();
66
+ if (!user)
67
+ return null;
68
+ const domain = env.USERDOMAIN?.trim();
69
+ return domain ? `${domain}\\${user}` : user;
70
+ }
71
+ async function restrictWindowsDirectory(dir, env) {
72
+ const principal = windowsPrincipal(env);
73
+ if (principal === null) {
74
+ return {
75
+ restricted: false,
76
+ warning: "USERNAME is not set, so the credentials directory keeps its inherited permissions.",
77
+ };
78
+ }
79
+ const failed = await new Promise((resolve) => {
80
+ const child = spawn("icacls", windowsAclArgs(dir, principal), { stdio: "ignore" });
81
+ child.on("error", (error) => resolve(error.message));
82
+ child.on("close", (code) => resolve(code === 0 ? null : `icacls exited with code ${code ?? "unknown"}`));
83
+ });
84
+ if (failed === null)
85
+ return { restricted: true, warning: null };
86
+ return {
87
+ restricted: false,
88
+ warning: `Could not restrict ${dir} (${failed}). The API key is protected only by your Windows profile permissions.`,
89
+ };
90
+ }
91
+ export async function writeCredentials(input) {
92
+ const platform = input.platform ?? process.platform;
93
+ const env = input.env ?? process.env;
94
+ const { dir, file } = input.location;
95
+ const isWindows = platform === "win32";
96
+ try {
97
+ await mkdir(dir, { recursive: true, ...(isWindows ? {} : { mode: 0o700 }) });
98
+ if (!isWindows) {
99
+ await chmod(dir, 0o700);
100
+ }
101
+ }
102
+ catch (error) {
103
+ throw new CliError("storage_unwritable", `Could not create ${dir}: ${error.message}`, "Set RUNINFRA_CONFIG_DIR to a directory you can write to, then try again.");
104
+ }
105
+ const restriction = isWindows
106
+ ? await restrictWindowsDirectory(dir, env)
107
+ : { restricted: true, warning: null };
108
+ const temp = `${file}.tmp`;
109
+ try {
110
+ await writeFile(temp, serializeCredentials(input.credentials), {
111
+ encoding: "utf8",
112
+ mode: 0o600,
113
+ });
114
+ if (!isWindows)
115
+ await chmod(temp, 0o600);
116
+ await rename(temp, file);
117
+ if (!isWindows)
118
+ await chmod(file, 0o600);
119
+ }
120
+ catch (error) {
121
+ await rm(temp, { force: true }).catch(() => undefined);
122
+ throw new CliError("storage_unwritable", `Could not write ${file}: ${error.message}`, "Set RUNINFRA_CONFIG_DIR to a directory you can write to, then try again.");
123
+ }
124
+ return restriction;
125
+ }
126
+ export async function deleteCredentials(location) {
127
+ try {
128
+ await rm(location.file);
129
+ return true;
130
+ }
131
+ catch (error) {
132
+ if (error.code === "ENOENT")
133
+ return false;
134
+ throw new CliError("storage_unwritable", `Could not remove ${location.file}: ${error.message}`, "Delete the file by hand to finish signing out.");
135
+ }
136
+ }
package/dist/disk.js ADDED
@@ -0,0 +1,33 @@
1
+ import { statfs } from "node:fs/promises";
2
+ import { CliError } from "./errors.js";
3
+ import { formatBytes } from "./progress.js";
4
+ export const FREE_SPACE_HEADROOM = 1.05;
5
+ export function requiredFreeBytes(remainingBytes) {
6
+ if (!Number.isFinite(remainingBytes) || remainingBytes <= 0)
7
+ return 0;
8
+ return Math.ceil(remainingBytes * FREE_SPACE_HEADROOM);
9
+ }
10
+ export function judgeFreeSpace(freeBytes, remainingBytes) {
11
+ const required = requiredFreeBytes(remainingBytes);
12
+ if (freeBytes === null)
13
+ return { ok: true, freeBytes: null };
14
+ if (freeBytes >= required)
15
+ return { ok: true, freeBytes };
16
+ return { ok: false, freeBytes, requiredBytes: required };
17
+ }
18
+ export async function readFreeBytes(dir) {
19
+ try {
20
+ const stats = await statfs(dir);
21
+ const free = Number(stats.bavail) * Number(stats.bsize);
22
+ return Number.isFinite(free) && free >= 0 ? free : null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ export async function assertFreeSpace(dir, remainingBytes) {
29
+ const verdict = judgeFreeSpace(await readFreeBytes(dir), remainingBytes);
30
+ if (verdict.ok)
31
+ return;
32
+ throw new CliError("disk_space", `Not enough free space in ${dir}: ${formatBytes(verdict.freeBytes)} available, ${formatBytes(verdict.requiredBytes)} needed.`, "Free some space or pass --out to a larger volume, then run the same command again to resume.");
33
+ }