@hixumo/hive 0.0.0-stage → 0.0.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.
@@ -0,0 +1,94 @@
1
+ import { refuseServiceToken, signedIn } from "../callers.js";
2
+ import { withConfirm } from "../device-flow.js";
3
+ import { CliError, EXIT, plain, UsageError } from "../output.js";
4
+ import { ago, day, inTime } from "../time.js";
5
+ function numbered({ cli, website }) {
6
+ return [
7
+ ...cli.map((signIn, at) => ({ number: at + 1, kind: 'cli', signIn })),
8
+ ...website.map((signIn, at) => ({ number: cli.length + at + 1, kind: 'website', signIn })),
9
+ ];
10
+ }
11
+ export async function sessions(ctx, options) {
12
+ refuseServiceToken(ctx, 'hive sessions');
13
+ const { api } = await signedIn(ctx);
14
+ const list = await api.signIns();
15
+ const rows = numbered(list);
16
+ if (options.json) {
17
+ const of = (kind) => rows.filter((row) => row.kind === kind).map(({ number, signIn }) => ({ number, ...signIn }));
18
+ ctx.print(JSON.stringify({ cli: of('cli'), website: of('website') }, null, 2));
19
+ return EXIT.done;
20
+ }
21
+ const now = ctx.now();
22
+ const cli = rows.filter((row) => row.kind === 'cli');
23
+ const website = rows.filter((row) => row.kind === 'website');
24
+ const width = String(rows.length).length;
25
+ const section = (title, part) => [
26
+ `${title} (${part.length})`,
27
+ ...(part.length ? table(part.map((row) => [String(row.number).padStart(width), ...columnsOf(row, now)])) : [' None.']),
28
+ ];
29
+ ctx.print([...section('CLI sign-ins', cli), '', ...section('Website sign-ins', website)].join('\n'));
30
+ return EXIT.done;
31
+ }
32
+ export async function revokeSession(ctx, options) {
33
+ const { ui } = ctx;
34
+ refuseServiceToken(ctx, 'hive sessions revoke');
35
+ if (!ui.interactive && !options.yes) {
36
+ throw new UsageError('Without a terminal to ask in, hive sessions revoke needs --yes.');
37
+ }
38
+ const { api } = await signedIn(ctx);
39
+ const rows = numbered(await api.signIns());
40
+ const target = options.target.trim();
41
+ const byNumber = /^\d+$/.test(target);
42
+ const row = rows.find((row) => (byNumber ? row.number === Number(target) : row.signIn.id === target));
43
+ if (!row) {
44
+ throw new CliError(`${byNumber ? `No sign-in numbered ${target}` : `No sign-in with the id ${plain(target)}`}. Run \`hive sessions\` to see the list.`);
45
+ }
46
+ if (row.kind === 'cli' && row.signIn.current) {
47
+ throw new CliError("That is this computer's own sign-in: use `hive logout` to end it.");
48
+ }
49
+ const now = ctx.now();
50
+ const which = row.kind === 'cli'
51
+ ? `the CLI sign-in ${plain(row.signIn.deviceName)} (made ${day(row.signIn.createdAt, now)}, last used ${ago(row.signIn.lastUsedAt, now)}${from(row.signIn.lastUsedIp, ', from ')})`
52
+ : `the website sign-in ${plain(row.signIn.device)} (started ${ago(row.signIn.createdAt, now)}${from(row.signIn.ip, ', from ')})`;
53
+ if (!options.yes && !(await ui.ask(`End ${which}?`, false))) {
54
+ ui.say('Nothing was ended.');
55
+ return EXIT.failed;
56
+ }
57
+ // Hive asks for "Confirm it is you" within 10 minutes before a CLI sign-in ends another one,
58
+ // so a stolen CLI token cannot keep its owner out.
59
+ const end = () => row.kind === 'cli' ? api.revokeCliSignIn(row.signIn.id) : api.endWebsiteSignIn(row.signIn.id);
60
+ await withConfirm(ctx, api, end, {
61
+ why: 'Ending a sign-in needs "Confirm it is you".',
62
+ noTerminal: 'Ending a sign-in needs "Confirm it is you", in a terminal. Run `hive sessions revoke` in one, or end it on the website\'s "Your sign-ins" page.',
63
+ });
64
+ ui.say(row.kind === 'cli'
65
+ ? `${ui.paint.green('✓')} Ended the CLI sign-in ${plain(row.signIn.deviceName)}. It is refused from its next request.`
66
+ : `${ui.paint.green('✓')} Ended the website sign-in ${plain(row.signIn.device)}. Its browser is sent to sign in at its next request.`);
67
+ return EXIT.done;
68
+ }
69
+ function columnsOf(row, now) {
70
+ if (row.kind === 'cli') {
71
+ const { deviceName, createdAt, lastUsedAt, lastUsedIp, current } = row.signIn;
72
+ return [
73
+ `${plain(deviceName)}${current ? ' (this computer)' : ''}`,
74
+ `made ${day(createdAt, now)}`,
75
+ `last used ${ago(lastUsedAt, now)}`,
76
+ from(lastUsedIp, 'from '),
77
+ ];
78
+ }
79
+ const { device, createdAt, expiresAt, ip, current } = row.signIn;
80
+ return [
81
+ `${plain(device)}${current ? ' (this browser)' : ''}`,
82
+ `started ${ago(createdAt, now)}`,
83
+ `ends ${inTime(expiresAt, now)}`,
84
+ from(ip, 'from '),
85
+ ];
86
+ }
87
+ function from(ip, prefix) {
88
+ return ip ? `${prefix}${plain(ip)}` : '';
89
+ }
90
+ /** Rows as aligned columns, two spaces apart, indented by two; no trailing spaces. */
91
+ function table(rows) {
92
+ const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
93
+ return rows.map((row) => ` ${row.map((cell, column) => cell.padEnd(widths[column])).join(' ')}`.trimEnd());
94
+ }
@@ -0,0 +1,39 @@
1
+ import { callerOf, signedIn, tokenApi } from "../callers.js";
2
+ import { apiOrigin, writeConfig } from "../config.js";
3
+ import { withWorkflowAccess } from "../github-actions.js";
4
+ import { EXIT, plain } from "../output.js";
5
+ import { day } from "../time.js";
6
+ import { githubTarget } from "../values.js";
7
+ /**
8
+ * `hive whoami`: the account signed in here; with HIVE_TOKEN, what the token reads; in GitHub
9
+ * Actions, the repository and what its run may read (the access it takes is ended at once): the
10
+ * environment from `--env`, else HIVE_ENV, else `.hive.json`, as `hive run` reads it.
11
+ */
12
+ export async function whoami(ctx, options = {}) {
13
+ const caller = callerOf(ctx.env);
14
+ if (caller.kind === 'token') {
15
+ const info = await tokenApi(ctx, caller.token).tokenInfo();
16
+ ctx.print(`HIVE_TOKEN reads ${plain(info.environment.name)} of ${plain(info.project.name)}, in ${plain(info.space.name)}.`);
17
+ ctx.print(info.expiresAt ? `Expires ${day(info.expiresAt, ctx.now())}.` : 'Never expires.');
18
+ ctx.print(`Hive: ${apiOrigin(ctx.env)}`);
19
+ return EXIT.done;
20
+ }
21
+ if (caller.kind === 'github') {
22
+ const info = await withWorkflowAccess(ctx, caller.request, githubTarget(ctx, { env: options.env }), (api) => api.tokenInfo());
23
+ const who = info.repository ? `GitHub Actions (${plain(info.repository)})` : 'GitHub Actions';
24
+ ctx.print(`${who} reads ${plain(info.environment.name)} of ${plain(info.project.name)}, in ${plain(info.space.name)}.`);
25
+ ctx.print(`Hive: ${apiOrigin(ctx.env)}`);
26
+ return EXIT.done;
27
+ }
28
+ const { api, origin } = await signedIn(ctx);
29
+ const me = await api.me();
30
+ // The name may have changed at Accounts since `hive login`.
31
+ writeConfig(ctx.configDir, { account: { id: me.id, email: me.email, name: me.name }, apiUrl: origin });
32
+ ctx.print(`Signed in as ${plain(me.name)} (${plain(me.email)})`);
33
+ ctx.print(`Hive: ${origin}`);
34
+ if (me.spaces.length) {
35
+ const spaces = me.spaces.map(({ name, role, status }) => `${plain(name)} (${role}${status === 'paused' ? ', paused' : ''})`);
36
+ ctx.print(`Spaces: ${spaces.join(', ')}`);
37
+ }
38
+ return EXIT.done;
39
+ }
package/dist/config.js ADDED
@@ -0,0 +1,60 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ import { apiOriginOf } from "./api-address.js";
5
+ import { BUILT_API_URL } from "./build-info.js";
6
+ import { ensurePrivateDir, writePrivateFile } from "./files.js";
7
+ import { CliError } from "./output.js";
8
+ const FOLDER = 'hixumo-hive';
9
+ export function configDirOf(env, platform = process.platform, home = homedir()) {
10
+ if (env.HIVE_CONFIG_DIR)
11
+ return resolve(env.HIVE_CONFIG_DIR);
12
+ if (platform === 'win32' && env.APPDATA)
13
+ return join(env.APPDATA, FOLDER);
14
+ if (env.XDG_CONFIG_HOME)
15
+ return join(env.XDG_CONFIG_HOME, FOLDER);
16
+ return join(home, '.config', FOLDER);
17
+ }
18
+ export function readConfig(dir) {
19
+ let parsed;
20
+ try {
21
+ parsed = JSON.parse(readFileSync(join(dir, 'config.json'), 'utf8'));
22
+ }
23
+ catch {
24
+ return {};
25
+ }
26
+ if (typeof parsed !== 'object' || parsed === null)
27
+ return {};
28
+ const { account, apiUrl } = parsed;
29
+ const config = {};
30
+ if (isAccount(account))
31
+ config.account = { id: account.id, email: account.email, name: account.name };
32
+ if (typeof apiUrl === 'string')
33
+ config.apiUrl = apiUrl;
34
+ return config;
35
+ }
36
+ function isAccount(value) {
37
+ const account = value;
38
+ return (typeof account === 'object' &&
39
+ account !== null &&
40
+ typeof account.id === 'string' &&
41
+ typeof account.email === 'string' &&
42
+ typeof account.name === 'string');
43
+ }
44
+ export function writeConfig(dir, config) {
45
+ ensurePrivateDir(dir);
46
+ writePrivateFile(join(dir, 'config.json'), `${JSON.stringify(config, null, 2)}\n`);
47
+ }
48
+ /** HIVE_API_URL when it is set, else the address this build was made for. */
49
+ export function apiOrigin(env, built = BUILT_API_URL) {
50
+ const address = env.HIVE_API_URL?.trim() || built;
51
+ if (!address) {
52
+ throw new CliError("This copy of hive was built without the Hive API's address. Set HIVE_API_URL to it, like https://hive-api.example.com.");
53
+ }
54
+ try {
55
+ return apiOriginOf(address);
56
+ }
57
+ catch (error) {
58
+ throw new CliError(error.message);
59
+ }
60
+ }
@@ -0,0 +1,56 @@
1
+ import { hostname } from 'node:os';
2
+ import { isatty } from 'node:tty';
3
+ import { openBrowser } from "./browser.js";
4
+ import { askYesNo, chooseByNumber, chooseWithKeys } from "./choose.js";
5
+ import { configDirOf } from "./config.js";
6
+ import { credentialStore } from "./credentials.js";
7
+ import { paintFor } from "./output.js";
8
+ /**
9
+ * The terminal. Whether there is one is asked of the file descriptors, without opening
10
+ * process.stdin: `hive run`'s command must get the terminal untouched.
11
+ */
12
+ export function terminalUi(env) {
13
+ const interactive = isatty(0) && isatty(2);
14
+ const paint = paintFor(isatty(2), env);
15
+ const io = () => ({ input: process.stdin, output: process.stderr });
16
+ return {
17
+ interactive,
18
+ paint,
19
+ say(text) {
20
+ process.stderr.write(`${text}\n`);
21
+ },
22
+ warn(text) {
23
+ process.stderr.write(`${paint.yellow('Warning:')} ${text}\n`);
24
+ },
25
+ ask(question, defaultYes = false) {
26
+ return askYesNo(io(), question, defaultYes);
27
+ },
28
+ choose(title, choices) {
29
+ // A terminal that cannot move the cursor gets the numbered list.
30
+ return env.TERM === 'dumb'
31
+ ? chooseByNumber(io(), title, choices)
32
+ : chooseWithKeys({ input: process.stdin, output: process.stderr, paint }, title, choices);
33
+ },
34
+ };
35
+ }
36
+ export function realContext() {
37
+ const env = process.env;
38
+ const ui = terminalUi(env);
39
+ const configDir = configDirOf(env);
40
+ return {
41
+ env,
42
+ cwd: process.cwd(),
43
+ ui,
44
+ print(text) {
45
+ process.stdout.write(`${text}\n`);
46
+ },
47
+ configDir,
48
+ credentials: credentialStore({ dir: configDir, env, warn: (text) => ui.warn(text) }),
49
+ // Any of the three: `hive run -- cmd > log` at a terminal still gets the terminal's Ctrl+C.
50
+ atTerminal: [0, 1, 2].some((fd) => isatty(fd)),
51
+ openBrowser,
52
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
53
+ now: () => new Date(),
54
+ hostname,
55
+ };
56
+ }
@@ -0,0 +1,116 @@
1
+ import { readFileSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { ensurePrivateDir, writePrivateFile } from "./files.js";
4
+ import { CliError, plain } from "./output.js";
5
+ /**
6
+ * The CLI sign-in's token: in the system keychain (macOS Keychain, Windows Credential Manager,
7
+ * the Linux Secret Service), as `gh` keeps its own; in `credentials.json`, readable only by the
8
+ * person, where there is no keychain, with a warning when it is saved. HIVE_KEYRING=off skips
9
+ * the keychain (tests and local runs).
10
+ *
11
+ * Every token is kept with the Hive API origin it was made for, and handed out only for that
12
+ * origin: the keychain has one entry per origin (the account is the origin), and
13
+ * `credentials.json` holds `{ apiUrl, token }`. So a config folder whose config.json names
14
+ * another Hive, or a sign-in made from another config folder, can never carry a token anywhere
15
+ * but where it came from, nor replace another Hive's.
16
+ */
17
+ export const KEYCHAIN_SERVICE = 'hixumo-hive';
18
+ /**
19
+ * The keychain library is an optional dependency with a native part per system, which may be
20
+ * missing (an unusual platform, `--no-optional`): it is loaded only when needed, and its absence
21
+ * is the same as no keychain.
22
+ */
23
+ export const openSystemKeyring = async (account) => {
24
+ try {
25
+ const keyring = await import('@napi-rs/keyring');
26
+ const AsyncEntry = keyring.AsyncEntry ?? keyring.default?.AsyncEntry;
27
+ return AsyncEntry ? new AsyncEntry(KEYCHAIN_SERVICE, account) : undefined;
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ };
33
+ export function credentialStore({ dir, env, warn, openKeyring = openSystemKeyring }) {
34
+ const file = join(dir, 'credentials.json');
35
+ const keyringOff = env.HIVE_KEYRING?.trim().toLowerCase() === 'off';
36
+ const opened = new Map();
37
+ const keychain = (origin) => {
38
+ if (keyringOff)
39
+ return Promise.resolve(undefined);
40
+ let entry = opened.get(origin);
41
+ if (!entry) {
42
+ entry = openKeyring(origin).catch(() => undefined);
43
+ opened.set(origin, entry);
44
+ }
45
+ return entry;
46
+ };
47
+ return {
48
+ async load(origin) {
49
+ const entry = await keychain(origin);
50
+ if (entry) {
51
+ try {
52
+ const token = await entry.getPassword();
53
+ if (token)
54
+ return token;
55
+ }
56
+ catch {
57
+ // Locked or unreachable: the file may still hold it, from a time it could not be used.
58
+ }
59
+ }
60
+ const kept = readSignInFile(file);
61
+ return kept?.apiUrl === origin ? kept.token : undefined;
62
+ },
63
+ async save(origin, token) {
64
+ const kept = readSignInFile(file);
65
+ const entry = await keychain(origin);
66
+ if (entry) {
67
+ try {
68
+ await entry.setPassword(token);
69
+ // A copy of this Hive's left from a time the keychain could not be used would outlive
70
+ // a logout. Another Hive's is that Hive's sign-in, and stays.
71
+ if (!kept || kept.apiUrl === origin)
72
+ rmSync(file, { force: true });
73
+ return undefined;
74
+ }
75
+ catch {
76
+ // No Secret Service on this Linux, say: the file below.
77
+ }
78
+ }
79
+ ensurePrivateDir(dir);
80
+ writePrivateFile(file, `${JSON.stringify({ apiUrl: origin, token })}\n`);
81
+ if (!keyringOff) {
82
+ warn(`No system keychain could be used here, so the sign-in is kept in ${file}, readable only by you.`);
83
+ }
84
+ return kept && kept.apiUrl !== origin ? kept : undefined;
85
+ },
86
+ async forget(origin) {
87
+ const entry = await keychain(origin);
88
+ let refusal;
89
+ if (entry) {
90
+ try {
91
+ // False means there was nothing there, which is forgotten too.
92
+ await entry.deletePassword();
93
+ }
94
+ catch (error) {
95
+ refusal = error;
96
+ }
97
+ }
98
+ if (readSignInFile(file)?.apiUrl === origin)
99
+ rmSync(file, { force: true });
100
+ if (refusal !== undefined) {
101
+ const why = refusal instanceof Error && refusal.message ? ` (${plain(refusal.message)})` : '';
102
+ throw new CliError(`The system keychain did not let the sign-in be removed${why}, so it is still kept there: service ${KEYCHAIN_SERVICE}, account ${origin}. Remove it there yourself.`);
103
+ }
104
+ },
105
+ };
106
+ }
107
+ /** `credentials.json`, when it holds a token and the origin it was made for; else nothing. */
108
+ function readSignInFile(file) {
109
+ try {
110
+ const { apiUrl, token } = JSON.parse(readFileSync(file, 'utf8'));
111
+ return typeof apiUrl === 'string' && apiUrl && typeof token === 'string' && token ? { apiUrl, token } : undefined;
112
+ }
113
+ catch {
114
+ return undefined;
115
+ }
116
+ }
@@ -0,0 +1,93 @@
1
+ import { ApiError } from "./api.js";
2
+ import { CliError, plain } from "./output.js";
3
+ /**
4
+ * The browser half of a device request (OAuth 2.0 device authorization, RFC 8628, as
5
+ * `gh auth login`): for `hive login`, and for "Confirm it is you" before a protected
6
+ * environment's values. The code and the address go to stderr, the browser opens on the
7
+ * approval page, and the CLI polls at the interval Hive gave until the person approves or
8
+ * denies, or the request runs out.
9
+ */
10
+ const EXPIRED = 'The code ran out before it was approved. Run the command again for a new one.';
11
+ /** Polls in a row that may fail to reach Hive (a network that dropped for a moment). */
12
+ const UNREACHED_POLLS = 3;
13
+ export async function approveInBrowser(ctx, start, poll, options) {
14
+ const { ui } = ctx;
15
+ const url = plain(start.verificationUrl);
16
+ ui.say(`Your one-time code: ${ui.paint.bold(plain(start.userCode))}`);
17
+ if (options.browser) {
18
+ ui.say(`Opening ${url} in your browser. Check the code there matches this one, then approve.`);
19
+ ctx.openBrowser(start.verificationUrl);
20
+ }
21
+ else {
22
+ ui.say(`Open ${url} in a browser, check the code there matches this one, then approve.`);
23
+ }
24
+ ui.say(ui.paint.dim('Waiting for approval (Ctrl+C to stop)…'));
25
+ const deadline = ctx.now().getTime() + positive(start.expiresIn, 600) * 1000;
26
+ let interval = positive(start.interval, 5) * 1000;
27
+ let unreached = 0;
28
+ for (;;) {
29
+ await ctx.sleep(interval);
30
+ if (ctx.now().getTime() >= deadline)
31
+ throw new CliError(EXPIRED);
32
+ try {
33
+ const answer = await poll(start.deviceCode);
34
+ unreached = 0;
35
+ if (answer.status !== 'pending')
36
+ return answer;
37
+ }
38
+ catch (error) {
39
+ if (!(error instanceof ApiError))
40
+ throw error;
41
+ if (error.code === 'slow_down' || error.status === 429) {
42
+ // RFC 8628: five seconds more, for this poll and every one after it.
43
+ interval += 5000;
44
+ }
45
+ else if (error.code === 'denied') {
46
+ throw new CliError('The request was denied in the browser.');
47
+ }
48
+ else if (error.code === 'expired' || error.status === 410 || error.status === 404) {
49
+ throw new CliError(EXPIRED);
50
+ }
51
+ else if (error.status === 0 && ++unreached < UNREACHED_POLLS) {
52
+ continue;
53
+ }
54
+ else {
55
+ throw error;
56
+ }
57
+ }
58
+ }
59
+ }
60
+ /**
61
+ * A call Hive refuses with 403 `reauth_required` until this sign-in has confirmed it is the
62
+ * person within the last 10 minutes: reading a protected environment, and ending a sign-in (so a
63
+ * stolen CLI token cannot keep its owner out). The CLI asks for a confirm request, the person
64
+ * approves it in the browser, and the call is tried once more.
65
+ */
66
+ export async function withConfirm(ctx, api, call, words) {
67
+ try {
68
+ return await call();
69
+ }
70
+ catch (error) {
71
+ if (!needsConfirm(error))
72
+ throw error;
73
+ }
74
+ if (!ctx.ui.interactive)
75
+ throw new CliError(words.noTerminal);
76
+ ctx.ui.say(`${words.why} Confirm it's you in the browser.`);
77
+ const start = await api.startConfirm();
78
+ await approveInBrowser(ctx, start, (deviceCode) => api.pollConfirm(deviceCode), { browser: true });
79
+ try {
80
+ return await call();
81
+ }
82
+ catch (error) {
83
+ if (needsConfirm(error))
84
+ throw new CliError("Hive still asks to confirm it's you. Run the command again.");
85
+ throw error;
86
+ }
87
+ }
88
+ function needsConfirm(error) {
89
+ return error instanceof ApiError && error.code === 'reauth_required';
90
+ }
91
+ function positive(value, fallback) {
92
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
93
+ }
package/dist/dotenv.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The `.env` text `hive pull` writes: one line per key, in the order Hive sends them (the
3
+ * environment's own order: its .env's order as uploaded or edited), each value written so dotenv
4
+ * reads it back exactly. The same rules as the website's "Copy as .env" (hixumo-hive-backend's
5
+ * `writeDotenv` in src/modules/variables/dotenv-text.ts), so both give the same file; the tests
6
+ * read every kind of value back with dotenv's own parse.
7
+ *
8
+ * dotenv turns only `\n` and `\r` back into line breaks, and only inside double quotes; it never
9
+ * unescapes `\\` or `\"`. It reads a backslash just before a closing quote as escaping it, so no
10
+ * quoted value here ever ends in one. A value goes, in this order:
11
+ * - bare, when dotenv reads it back exactly and it holds no white space;
12
+ * - in double quotes, line breaks as `\n`, when it holds no `"` and no `\n` or `\r` of its own;
13
+ * - in single quotes, then backticks, as it is, when it holds no such quote and no carriage
14
+ * return (which dotenv would turn into a line break);
15
+ * - bare, when dotenv reads it back exactly (a space inside and a backslash at the end);
16
+ * - escaped on one line, as most other readers expect, for what no dotenv line can hold
17
+ * exactly; ending in a backslash, it gets a space before its closing quote.
18
+ */
19
+ /** What dotenv reads back unchanged without quotes. */
20
+ const READS_BARE = /^(?![\s"'`])[^#\n\r\u2028\u2029]*(?<!\s)$/;
21
+ export function writeDotenv(values) {
22
+ // Hive's order, as the API's answer lists them (object keys keep their order: a key can't be
23
+ // a number, which is the only kind JavaScript would move).
24
+ return Object.keys(values)
25
+ .map((key) => `${key}=${quoted(values[key])}\n`)
26
+ .join('');
27
+ }
28
+ function quoted(value) {
29
+ const endsInBackslash = value.endsWith('\\');
30
+ if (READS_BARE.test(value) && !/\s/.test(value)) {
31
+ return value;
32
+ }
33
+ if (!value.includes('"') && !/\\[nr]/.test(value) && !endsInBackslash) {
34
+ return `"${escapeLineBreaks(value)}"`;
35
+ }
36
+ if (!value.includes('\r') && !endsInBackslash) {
37
+ for (const quote of ["'", '`']) {
38
+ if (!value.includes(quote)) {
39
+ return `${quote}${value}${quote}`;
40
+ }
41
+ }
42
+ }
43
+ if (READS_BARE.test(value)) {
44
+ return value;
45
+ }
46
+ const escaped = escapeLineBreaks(value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'));
47
+ return `"${escaped}${endsInBackslash ? ' ' : ''}"`;
48
+ }
49
+ function escapeLineBreaks(value) {
50
+ return value.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
51
+ }
@@ -0,0 +1,40 @@
1
+ import { ApiError } from "./api.js";
2
+ import { CliError, plain } from "./output.js";
3
+ /** A project and its environments as the person sees them, for `link`, `run` and `pull`. */
4
+ /** Environment names are unique ignoring case, so `--env Production` finds `production`. */
5
+ export function sameName(a, b) {
6
+ return a.toLowerCase() === b.toLowerCase();
7
+ }
8
+ /** The project, or a sentence that says it is not there for this person (`where` names the id's source). */
9
+ export async function projectOf(api, projectId, where) {
10
+ try {
11
+ return await api.project(projectId);
12
+ }
13
+ catch (error) {
14
+ if (error instanceof ApiError && error.status === 404) {
15
+ throw new CliError(`The project in ${where} was not found, or you don't have access to it. Run \`hive link\` to pick one.`);
16
+ }
17
+ throw error;
18
+ }
19
+ }
20
+ export function environmentNamed(project, name) {
21
+ const found = project.environments.find((environment) => sameName(environment.name, name));
22
+ if (!found) {
23
+ const names = project.environments.map((environment) => plain(environment.name)).join(', ');
24
+ throw new CliError(`No environment named ${plain(name)} in ${plain(project.name)}. Its environments: ${names}.`);
25
+ }
26
+ return found;
27
+ }
28
+ /** `read` and `write` show values; `names` shows keys only. */
29
+ export function mayRead(environment) {
30
+ return environment.level === 'read' || environment.level === 'write';
31
+ }
32
+ export function assertReadable(project, environment) {
33
+ const where = `${plain(environment.name)} of ${plain(project.name)}`;
34
+ if (environment.level === 'none') {
35
+ throw new CliError(`You don't have access to ${where}. Ask the space's Owner or an Admin.`);
36
+ }
37
+ if (!mayRead(environment)) {
38
+ throw new CliError(`You can only see the names in ${where}, not the values. Ask the space's Owner or an Admin for read access.`);
39
+ }
40
+ }
package/dist/files.js ADDED
@@ -0,0 +1,28 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { basename, dirname, join } from 'node:path';
4
+ /** A folder only its owner can open (made so; one that is already there is left as it is). */
5
+ export function ensurePrivateDir(dir) {
6
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
7
+ }
8
+ /**
9
+ * Writes a file readable only by its owner (0600), whole or not at all: a new file next to it,
10
+ * then renamed over it. Writing in place would keep the mode of a file that was already there,
11
+ * and a stopped write would leave half a file of secrets.
12
+ */
13
+ export function writePrivateFile(path, text) {
14
+ const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
15
+ try {
16
+ writeFileSync(temporary, text, { mode: 0o600, flag: 'wx' });
17
+ renameSync(temporary, path);
18
+ }
19
+ catch (error) {
20
+ rmSync(temporary, { force: true });
21
+ throw error;
22
+ }
23
+ }
24
+ /** The error's code (`ENOENT`), for a sentence; never its message, which may quote what was read. */
25
+ export function codeOf(error) {
26
+ const code = error?.code;
27
+ return typeof code === 'string' ? code : 'unknown error';
28
+ }