@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.
- package/LICENSE +24 -0
- package/README.md +320 -2
- package/dist/api-address.js +24 -0
- package/dist/api.js +164 -0
- package/dist/browser.js +47 -0
- package/dist/build-info.js +3 -0
- package/dist/callers.js +89 -0
- package/dist/choose.js +137 -0
- package/dist/cli.js +10 -0
- package/dist/commands/link.js +79 -0
- package/dist/commands/login.js +69 -0
- package/dist/commands/logout.js +54 -0
- package/dist/commands/pull.js +81 -0
- package/dist/commands/run.js +82 -0
- package/dist/commands/sessions.js +94 -0
- package/dist/commands/whoami.js +39 -0
- package/dist/config.js +60 -0
- package/dist/context.js +56 -0
- package/dist/credentials.js +116 -0
- package/dist/device-flow.js +93 -0
- package/dist/dotenv.js +51 -0
- package/dist/environments.js +40 -0
- package/dist/files.js +28 -0
- package/dist/github-actions.js +142 -0
- package/dist/gitignore.js +169 -0
- package/dist/link-file.js +67 -0
- package/dist/main.js +243 -0
- package/dist/output.js +53 -0
- package/dist/time.js +42 -0
- package/dist/values.js +111 -0
- package/dist/version.js +6 -0
- package/package.json +29 -4
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { ApiError, HiveApi } from "./api.js";
|
|
2
|
+
import { explain } from "./callers.js";
|
|
3
|
+
import { apiOrigin } from "./config.js";
|
|
4
|
+
import { CliError, plain } from "./output.js";
|
|
5
|
+
import { USER_AGENT } from "./version.js";
|
|
6
|
+
/**
|
|
7
|
+
* GitHub Actions without a stored token (phase 2c). A job with `permissions: id-token: write`
|
|
8
|
+
* gets two variables from GitHub's runner; with them the CLI asks GitHub for an OIDC token for
|
|
9
|
+
* this run, meant for this Hive (its audience is the API's origin), trades it at
|
|
10
|
+
* `POST /github/access` for a workflow access (`hive_gh_…`: one environment, read only, 5
|
|
11
|
+
* minutes), reads with it as an access token reads, and ends it at `POST /token/end` as soon as
|
|
12
|
+
* the read is done, whether it worked or not. The command `hive run` starts never runs while the
|
|
13
|
+
* access is alive.
|
|
14
|
+
*
|
|
15
|
+
* Neither GitHub's token nor the access is ever printed, written down or passed to the command.
|
|
16
|
+
*/
|
|
17
|
+
const WORKFLOW_ACCESS_PREFIX = 'hive_gh_';
|
|
18
|
+
const TIMEOUT_MS = 20_000;
|
|
19
|
+
const RETRY_DELAY_MS = 500;
|
|
20
|
+
const THIS_COMPUTER = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
21
|
+
/**
|
|
22
|
+
* Trades GitHub's token for a workflow access, gives it to `read`, and ends it right after, also
|
|
23
|
+
* when `read` throws. Every refusal comes back as a CliError that says what to do.
|
|
24
|
+
*/
|
|
25
|
+
export async function withWorkflowAccess(ctx, request, target, read) {
|
|
26
|
+
const origin = apiOrigin(ctx.env);
|
|
27
|
+
try {
|
|
28
|
+
const githubToken = await githubTokenFor(ctx, request, origin);
|
|
29
|
+
const answer = await new HiveApi({ origin }).githubAccess({ githubToken, ...target });
|
|
30
|
+
if (typeof answer?.token !== 'string' || !answer.token.startsWith(WORKFLOW_ACCESS_PREFIX)) {
|
|
31
|
+
throw new CliError(`Hive answered with something that is not a workflow access, so nothing was read. Check HIVE_API_URL (${origin}).`);
|
|
32
|
+
}
|
|
33
|
+
const api = new HiveApi({ origin, token: answer.token });
|
|
34
|
+
try {
|
|
35
|
+
return await read(api);
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
await end(ctx, api);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw explainGithub(error, target, origin);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Ending a workflow access that cannot be ended stops nothing: it runs out by itself. */
|
|
46
|
+
async function end(ctx, api) {
|
|
47
|
+
try {
|
|
48
|
+
await api.endToken();
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
const why = error instanceof ApiError ? ` ${error.message}` : '';
|
|
52
|
+
ctx.ui.warn(`Could not end the workflow access at Hive.${why} It runs out by itself within 5 minutes.`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* GitHub's OIDC token for this run (`{ value }`), asked of the runner's address with its bearer,
|
|
57
|
+
* for the audience Hive checks. Its answer is never shown: only its status, when it refuses.
|
|
58
|
+
*/
|
|
59
|
+
async function githubTokenFor(ctx, request, audience) {
|
|
60
|
+
let url;
|
|
61
|
+
try {
|
|
62
|
+
url = new URL(request.url);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
throw new CliError('ACTIONS_ID_TOKEN_REQUEST_URL is not an address. It is set by GitHub Actions; leave it as GitHub sets it.');
|
|
66
|
+
}
|
|
67
|
+
// The runner's bearer goes with the request: never in the clear, except to this computer.
|
|
68
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && THIS_COMPUTER.has(url.hostname))) {
|
|
69
|
+
throw new CliError('ACTIONS_ID_TOKEN_REQUEST_URL must be an https address. It is set by GitHub Actions; leave it as GitHub sets it.');
|
|
70
|
+
}
|
|
71
|
+
url.searchParams.set('audience', audience);
|
|
72
|
+
let response;
|
|
73
|
+
let text;
|
|
74
|
+
for (let attempt = 1;; attempt += 1) {
|
|
75
|
+
try {
|
|
76
|
+
response = await fetch(url, {
|
|
77
|
+
headers: { Authorization: `bearer ${request.token}`, Accept: 'application/json', 'User-Agent': USER_AGENT },
|
|
78
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
79
|
+
});
|
|
80
|
+
text = await response.text();
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (attempt === 1 && error instanceof TypeError) {
|
|
85
|
+
await ctx.sleep(RETRY_DELAY_MS);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const timedOut = error?.name === 'TimeoutError';
|
|
89
|
+
throw new CliError(timedOut
|
|
90
|
+
? `GitHub did not give a token for this run within ${TIMEOUT_MS / 1000} seconds. Run the job again.`
|
|
91
|
+
: 'Could not reach GitHub to get a token for this run. Run the job again.');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
throw new CliError(`GitHub did not give a token for this run (${response.status}). Check that the workflow (or job) has \`permissions: id-token: write\`.`);
|
|
96
|
+
}
|
|
97
|
+
let value;
|
|
98
|
+
try {
|
|
99
|
+
value = JSON.parse(text).value;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Not JSON: said below.
|
|
103
|
+
}
|
|
104
|
+
if (typeof value !== 'string' || value === '') {
|
|
105
|
+
throw new CliError("GitHub's answer held no token for this run. Run the job again.");
|
|
106
|
+
}
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
/** A refusal as the sentence for a GitHub Actions run; anything else as callers.ts says it. */
|
|
110
|
+
function explainGithub(error, target, origin) {
|
|
111
|
+
if (!(error instanceof ApiError))
|
|
112
|
+
return error;
|
|
113
|
+
switch (error.code) {
|
|
114
|
+
case 'github_not_allowed':
|
|
115
|
+
return new CliError([
|
|
116
|
+
`No GitHub Actions rule of ${plain(target.environment)} allows this. Add one in the project's Machine access tab.`,
|
|
117
|
+
...claimLines(error.claims),
|
|
118
|
+
].join('\n'));
|
|
119
|
+
case 'github_token_invalid':
|
|
120
|
+
if (error.reason === 'audience') {
|
|
121
|
+
return new CliError(`${error.message} The CLI asked GitHub for a token meant for ${origin}: check that HIVE_API_URL is Hive's public API address.`);
|
|
122
|
+
}
|
|
123
|
+
return new CliError(error.reason === 'expired' || error.reason === 'reused' ? `${error.message} Run the job again.` : error.message);
|
|
124
|
+
case 'token_invalid':
|
|
125
|
+
return new CliError(`${error.message} A workflow access lasts 5 minutes; run the job again.`);
|
|
126
|
+
}
|
|
127
|
+
return new CliError(explain(error));
|
|
128
|
+
}
|
|
129
|
+
/** The run as GitHub described it, one claim a line; text from the API, so plain. */
|
|
130
|
+
function claimLines(claims) {
|
|
131
|
+
if (!claims)
|
|
132
|
+
return [];
|
|
133
|
+
const shown = (value) => (typeof value === 'string' && value !== '' ? plain(value) : '(none)');
|
|
134
|
+
const rows = [
|
|
135
|
+
['repository', claims.repository],
|
|
136
|
+
['ref', claims.ref],
|
|
137
|
+
['environment', claims.environment],
|
|
138
|
+
['event', claims.event],
|
|
139
|
+
['workflow', claims.workflow],
|
|
140
|
+
];
|
|
141
|
+
return ['This run, as GitHub describes it:', ...rows.map(([name, value]) => ` ${name.padEnd(13)}${shown(value)}`)];
|
|
142
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
3
|
+
/** The lines of one .gitignore as rules, comments and blank lines left out. */
|
|
4
|
+
function rulesOf(lines) {
|
|
5
|
+
const rules = [];
|
|
6
|
+
for (const raw of lines) {
|
|
7
|
+
// Trailing spaces do not count unless escaped.
|
|
8
|
+
let line = raw.replace(/(?<!\\)\s+$/, '');
|
|
9
|
+
if (line === '' || line.startsWith('#'))
|
|
10
|
+
continue;
|
|
11
|
+
const negated = line.startsWith('!');
|
|
12
|
+
if (negated)
|
|
13
|
+
line = line.slice(1);
|
|
14
|
+
const foldersOnly = line.endsWith('/');
|
|
15
|
+
if (foldersOnly)
|
|
16
|
+
line = line.slice(0, -1);
|
|
17
|
+
const anchored = line.includes('/');
|
|
18
|
+
if (line.startsWith('/'))
|
|
19
|
+
line = line.slice(1);
|
|
20
|
+
if (line === '')
|
|
21
|
+
continue;
|
|
22
|
+
rules.push({ pattern: globToRegExp(line), negated, foldersOnly, anchored });
|
|
23
|
+
}
|
|
24
|
+
return rules;
|
|
25
|
+
}
|
|
26
|
+
function globToRegExp(glob) {
|
|
27
|
+
let source = '';
|
|
28
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
29
|
+
const char = glob[i];
|
|
30
|
+
if (char === '*') {
|
|
31
|
+
const atStart = i === 0 || glob[i - 1] === '/';
|
|
32
|
+
if (glob[i + 1] === '*' && atStart && (i + 2 === glob.length || glob[i + 2] === '/')) {
|
|
33
|
+
if (i + 2 === glob.length) {
|
|
34
|
+
// `dir/**`: everything inside.
|
|
35
|
+
source += '.*';
|
|
36
|
+
i += 1;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
// `**/`: any number of folders, none included.
|
|
40
|
+
source += '(?:.*/)?';
|
|
41
|
+
i += 2;
|
|
42
|
+
}
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
source += '[^/]*';
|
|
46
|
+
while (glob[i + 1] === '*')
|
|
47
|
+
i += 1;
|
|
48
|
+
}
|
|
49
|
+
else if (char === '?') {
|
|
50
|
+
source += '[^/]';
|
|
51
|
+
}
|
|
52
|
+
else if (char === '[' && glob.indexOf(']', i + 2) > 0) {
|
|
53
|
+
const end = glob.indexOf(']', i + 2);
|
|
54
|
+
let set = glob.slice(i + 1, end).replace(/\\/g, '\\\\');
|
|
55
|
+
if (set.startsWith('!'))
|
|
56
|
+
set = `^${set.slice(1)}`;
|
|
57
|
+
source += `[${set}]`;
|
|
58
|
+
i = end;
|
|
59
|
+
}
|
|
60
|
+
else if (char === '\\' && i + 1 < glob.length) {
|
|
61
|
+
i += 1;
|
|
62
|
+
source += escapeRegExp(glob[i]);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
source += escapeRegExp(char);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return new RegExp(`^${source}$`);
|
|
69
|
+
}
|
|
70
|
+
function escapeRegExp(text) {
|
|
71
|
+
return text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Whether `path` (from the repository root, `/`-separated) is left out. Git does not look inside
|
|
75
|
+
* a folder it leaves out, so a file there cannot be taken back with `!`: each folder on the way
|
|
76
|
+
* is decided first, then the file; within that, a deeper .gitignore and a later line win.
|
|
77
|
+
*/
|
|
78
|
+
function leftOut(sets, path) {
|
|
79
|
+
const parts = path.split('/');
|
|
80
|
+
for (let length = 1; length <= parts.length; length += 1) {
|
|
81
|
+
const here = parts.slice(0, length).join('/');
|
|
82
|
+
const isFolder = length < parts.length;
|
|
83
|
+
let ignored = false;
|
|
84
|
+
for (const { base, rules } of sets) {
|
|
85
|
+
if (base && !here.startsWith(`${base}/`))
|
|
86
|
+
continue;
|
|
87
|
+
const local = base ? here.slice(base.length + 1) : here;
|
|
88
|
+
const name = local.slice(local.lastIndexOf('/') + 1);
|
|
89
|
+
for (const rule of rules) {
|
|
90
|
+
if (rule.foldersOnly && !isFolder)
|
|
91
|
+
continue;
|
|
92
|
+
if (rule.pattern.test(rule.anchored ? local : name))
|
|
93
|
+
ignored = !rule.negated;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (ignored)
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
/** One .gitignore's lines against a path from its folder. */
|
|
102
|
+
export function ignores(lines, path) {
|
|
103
|
+
return leftOut([{ base: '', rules: rulesOf(lines) }], path);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Reads every .gitignore from the file's folder up to the repository's root (the nearest folder
|
|
107
|
+
* with `.git`, a folder or, in a worktree or a submodule, a file; the filesystem root when there
|
|
108
|
+
* is none). The line, when one is needed, goes in the linked folder's .gitignore (`linkDir`,
|
|
109
|
+
* where `.hive.json` is) when the file is inside it and it is inside the repository, since git
|
|
110
|
+
* reads no .gitignore above the root; else the repository root's; with no repository, the
|
|
111
|
+
* linked folder's when the file is inside it, else the file's own folder's.
|
|
112
|
+
*/
|
|
113
|
+
export function checkGitignore(target, linkDir) {
|
|
114
|
+
const folders = [];
|
|
115
|
+
let root;
|
|
116
|
+
for (let dir = dirname(target);; dir = dirname(dir)) {
|
|
117
|
+
folders.push(dir);
|
|
118
|
+
// A file check only: git itself is never run.
|
|
119
|
+
if (existsSync(join(dir, '.git'))) {
|
|
120
|
+
root = dir;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
if (dirname(dir) === dir)
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
const top = folders[folders.length - 1];
|
|
127
|
+
const sets = folders
|
|
128
|
+
.reverse()
|
|
129
|
+
.map((dir) => ({ dir, text: readIfThere(join(dir, '.gitignore')) }))
|
|
130
|
+
.filter(({ text }) => text !== undefined)
|
|
131
|
+
.map(({ dir, text }) => ({ base: posix(relative(top, dir)), rules: rulesOf(text.split(/\r?\n/)) }));
|
|
132
|
+
// A .hive.json above the repository (in a folder of several projects, say) is no place for it.
|
|
133
|
+
const home = linkDir && isInside(target, linkDir) && (root === undefined || within(linkDir, root))
|
|
134
|
+
? linkDir
|
|
135
|
+
: (root ?? dirname(target));
|
|
136
|
+
return {
|
|
137
|
+
covered: leftOut(sets, posix(relative(top, target))),
|
|
138
|
+
file: join(home, '.gitignore'),
|
|
139
|
+
line: lineFor(posix(relative(home, target))),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
export function addToGitignore(check) {
|
|
143
|
+
const text = readIfThere(check.file) ?? '';
|
|
144
|
+
const joiner = text === '' || text.endsWith('\n') ? '' : '\n';
|
|
145
|
+
writeFileSync(check.file, `${text}${joiner}${check.line}\n`);
|
|
146
|
+
}
|
|
147
|
+
function readIfThere(path) {
|
|
148
|
+
try {
|
|
149
|
+
return readFileSync(path, 'utf8');
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function isInside(path, dir) {
|
|
156
|
+
const from = relative(dir, path);
|
|
157
|
+
return from !== '' && !from.startsWith('..') && !from.startsWith(sep) && !/^[A-Za-z]:/.test(from);
|
|
158
|
+
}
|
|
159
|
+
/** The folder itself, or one inside it. */
|
|
160
|
+
function within(path, dir) {
|
|
161
|
+
return relative(dir, path) === '' || isInside(path, dir);
|
|
162
|
+
}
|
|
163
|
+
function posix(path) {
|
|
164
|
+
return path.split(sep).join('/');
|
|
165
|
+
}
|
|
166
|
+
/** The path as a .gitignore line: glob characters and a leading `#` or `!` taken literally. */
|
|
167
|
+
function lineFor(path) {
|
|
168
|
+
return path.replace(/[*?[\\]/g, '\\$&').replace(/^[#!]/, '\\$&');
|
|
169
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { codeOf } from "./files.js";
|
|
4
|
+
import { CliError } from "./output.js";
|
|
5
|
+
/**
|
|
6
|
+
* `.hive.json`: which project a folder runs with, and its default environment, by name (contract
|
|
7
|
+
* C4). It is committed and holds no secret: a project id opens nothing by itself.
|
|
8
|
+
*/
|
|
9
|
+
export const LINK_FILE = '.hive.json';
|
|
10
|
+
const PROJECT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
/** Project ids are UUIDs; anything else is refused before it can become part of a path. */
|
|
12
|
+
export function isProjectId(text) {
|
|
13
|
+
return PROJECT_ID.test(text);
|
|
14
|
+
}
|
|
15
|
+
/** The nearest `.hive.json`: in this folder, then each one above it up to the filesystem root. */
|
|
16
|
+
export function findLink(from) {
|
|
17
|
+
for (let dir = resolve(from);; dir = dirname(dir)) {
|
|
18
|
+
const path = join(dir, LINK_FILE);
|
|
19
|
+
let text;
|
|
20
|
+
try {
|
|
21
|
+
text = readFileSync(path, 'utf8');
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
const code = codeOf(error);
|
|
25
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
|
26
|
+
throw new CliError(`Could not read ${path} (${code}).`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (text !== undefined)
|
|
30
|
+
return { path, dir, link: parseLink(text, path) };
|
|
31
|
+
if (dirname(dir) === dir)
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function parseLink(text, path) {
|
|
36
|
+
const broken = (why) => new CliError(`${path} is not a Hive link file: ${why}. Run \`hive link\` to write it again.`);
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(text);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw broken('it is not JSON');
|
|
43
|
+
}
|
|
44
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
45
|
+
throw broken('it is not an object');
|
|
46
|
+
}
|
|
47
|
+
const { project, environment } = parsed;
|
|
48
|
+
if (typeof project !== 'string' || !isProjectId(project)) {
|
|
49
|
+
throw broken('"project" is not a project id');
|
|
50
|
+
}
|
|
51
|
+
if (typeof environment !== 'string' || environment.trim() === '') {
|
|
52
|
+
throw broken('"environment" is not an environment name');
|
|
53
|
+
}
|
|
54
|
+
return { project: project.toLowerCase(), environment: environment.trim() };
|
|
55
|
+
}
|
|
56
|
+
/** Writes `.hive.json` in `dir` (two fields, nothing else) and says where. */
|
|
57
|
+
export function writeLink(dir, link) {
|
|
58
|
+
const path = join(dir, LINK_FILE);
|
|
59
|
+
const text = `${JSON.stringify({ project: link.project, environment: link.environment }, null, 2)}\n`;
|
|
60
|
+
try {
|
|
61
|
+
writeFileSync(path, text);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
throw new CliError(`Could not write ${path} (${codeOf(error)}).`);
|
|
65
|
+
}
|
|
66
|
+
return path;
|
|
67
|
+
}
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { ApiError } from "./api.js";
|
|
3
|
+
import { explain } from "./callers.js";
|
|
4
|
+
import { link } from "./commands/link.js";
|
|
5
|
+
import { login } from "./commands/login.js";
|
|
6
|
+
import { logout } from "./commands/logout.js";
|
|
7
|
+
import { pull } from "./commands/pull.js";
|
|
8
|
+
import { run } from "./commands/run.js";
|
|
9
|
+
import { revokeSession, sessions } from "./commands/sessions.js";
|
|
10
|
+
import { whoami } from "./commands/whoami.js";
|
|
11
|
+
import { CliError, EXIT, UsageError } from "./output.js";
|
|
12
|
+
import { VERSION } from "./version.js";
|
|
13
|
+
const ENV_OPTION = { env: { type: 'string' } };
|
|
14
|
+
const COMMANDS = {
|
|
15
|
+
login: {
|
|
16
|
+
usage: 'hive login [--no-browser]',
|
|
17
|
+
summary: 'Sign in on this computer, once, approving it in the browser',
|
|
18
|
+
details: [
|
|
19
|
+
'Shows a one-time code and opens the Hive website to approve it (--no-browser only prints',
|
|
20
|
+
'the address). The sign-in is kept in the system keychain and lasts until `hive logout`,',
|
|
21
|
+
'until it is ended from "Your sign-ins", or until it is 90 days unused.',
|
|
22
|
+
].join('\n'),
|
|
23
|
+
options: { 'no-browser': { type: 'boolean' } },
|
|
24
|
+
},
|
|
25
|
+
logout: {
|
|
26
|
+
usage: 'hive logout',
|
|
27
|
+
summary: "End this computer's sign-in",
|
|
28
|
+
details: 'Ends the sign-in at Hive, then forgets it here (even when Hive cannot be reached).',
|
|
29
|
+
options: {},
|
|
30
|
+
},
|
|
31
|
+
whoami: {
|
|
32
|
+
usage: 'hive whoami [--env <name>]',
|
|
33
|
+
summary: 'Who is signed in, or what HIVE_TOKEN or GitHub Actions reads',
|
|
34
|
+
details: [
|
|
35
|
+
"With HIVE_TOKEN set: the token's space, project and environment. In GitHub Actions (with",
|
|
36
|
+
'`permissions: id-token: write`): the repository, and the environment, project and space its',
|
|
37
|
+
'run may read. There --env picks the environment to check (else HIVE_ENV, else .hive.json);',
|
|
38
|
+
'elsewhere it is not used.',
|
|
39
|
+
].join('\n'),
|
|
40
|
+
options: ENV_OPTION,
|
|
41
|
+
},
|
|
42
|
+
link: {
|
|
43
|
+
usage: 'hive link [--project <id>] [--env <name>] [--yes]',
|
|
44
|
+
summary: 'Link this folder to a project and its default environment',
|
|
45
|
+
details: [
|
|
46
|
+
'Pick the space, the project and the environment, and .hive.json is written in this folder.',
|
|
47
|
+
'It holds no secret: commit it. With --project and --env it asks nothing. A .hive.json',
|
|
48
|
+
'already here is replaced only after you say yes; --yes replaces it without asking, and is',
|
|
49
|
+
'needed for that without a terminal.',
|
|
50
|
+
].join('\n'),
|
|
51
|
+
options: { project: { type: 'string' }, ...ENV_OPTION, yes: { type: 'boolean' } },
|
|
52
|
+
},
|
|
53
|
+
run: {
|
|
54
|
+
usage: 'hive run [--env <name>] -- <command…>',
|
|
55
|
+
summary: "Run a command with the environment's values",
|
|
56
|
+
details: [
|
|
57
|
+
"The values are added to the command's environment (Hive's win over the shell's); no file is",
|
|
58
|
+
'written. The project comes from .hive.json (this folder or one above it), the environment',
|
|
59
|
+
'from --env, HIVE_ENV or .hive.json. With HIVE_TOKEN set, the token decides both; the',
|
|
60
|
+
'command gets the values, not HIVE_TOKEN itself.',
|
|
61
|
+
'In GitHub Actions, `permissions: id-token: write` replaces the token: GitHub vouches for the',
|
|
62
|
+
"run, one of the project's GitHub Actions rules must allow it, the project comes from",
|
|
63
|
+
'HIVE_PROJECT or .hive.json, and the access is ended before the command starts.',
|
|
64
|
+
'Example: hive run -- npm run dev',
|
|
65
|
+
'In a workflow: hive run --env production -- npm run build',
|
|
66
|
+
].join('\n'),
|
|
67
|
+
options: ENV_OPTION,
|
|
68
|
+
},
|
|
69
|
+
pull: {
|
|
70
|
+
usage: 'hive pull [--env <name>] [--out <file>] [--yes]',
|
|
71
|
+
summary: 'Write the values to a .env file',
|
|
72
|
+
details: [
|
|
73
|
+
'Writes --out (default .env) readable only by you. Asks before replacing a file, and offers',
|
|
74
|
+
'to add it to .gitignore. Without a terminal, --yes is needed (it answers yes to both).',
|
|
75
|
+
].join('\n'),
|
|
76
|
+
options: { ...ENV_OPTION, out: { type: 'string' }, yes: { type: 'boolean' } },
|
|
77
|
+
},
|
|
78
|
+
sessions: {
|
|
79
|
+
usage: 'hive sessions [--json]\n hive sessions revoke <number or id> [--yes]',
|
|
80
|
+
summary: 'Every sign-in of yours, CLI and website; end one',
|
|
81
|
+
details: [
|
|
82
|
+
'Lists them numbered (--json: with their ids). `revoke` ends one after showing which and',
|
|
83
|
+
"asking (--yes skips the question). This computer's own is ended with `hive logout`.",
|
|
84
|
+
].join('\n'),
|
|
85
|
+
options: { json: { type: 'boolean' }, yes: { type: 'boolean' } },
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
function isName(word) {
|
|
89
|
+
return Object.hasOwn(COMMANDS, word);
|
|
90
|
+
}
|
|
91
|
+
function helpText() {
|
|
92
|
+
const lines = [
|
|
93
|
+
['hive login [--no-browser]', COMMANDS.login.summary],
|
|
94
|
+
['hive logout', COMMANDS.logout.summary],
|
|
95
|
+
['hive whoami [--env <name>]', COMMANDS.whoami.summary],
|
|
96
|
+
['hive link [--project <id>] [--env <name>] [--yes]', COMMANDS.link.summary],
|
|
97
|
+
['hive run [--env <name>] -- <command…>', COMMANDS.run.summary],
|
|
98
|
+
['hive pull [--env <name>] [--out <file>] [--yes]', COMMANDS.pull.summary],
|
|
99
|
+
['hive sessions [--json]', 'Every sign-in of yours, CLI and website'],
|
|
100
|
+
['hive sessions revoke <number or id> [--yes]', 'End one of them'],
|
|
101
|
+
['hive help [command]', 'Help for one command'],
|
|
102
|
+
['hive --version', 'The version of this CLI'],
|
|
103
|
+
];
|
|
104
|
+
const width = Math.max(...lines.map(([usage]) => usage.length));
|
|
105
|
+
return [
|
|
106
|
+
"hive: the Hixumo Hive CLI. Your project's environment values, without a .env file.",
|
|
107
|
+
'',
|
|
108
|
+
'Commands:',
|
|
109
|
+
...lines.map(([usage, summary]) => ` ${usage.padEnd(width)} ${summary}`),
|
|
110
|
+
'',
|
|
111
|
+
'In GitHub Actions, give the workflow `permissions: id-token: write`: no token to keep.',
|
|
112
|
+
'On other servers and in other CI, set HIVE_TOKEN to an access token instead of signing in.',
|
|
113
|
+
'Settings: HIVE_TOKEN, HIVE_ENV, HIVE_PROJECT, HIVE_API_URL, HIVE_CONFIG_DIR, HIVE_KEYRING=off,',
|
|
114
|
+
'NO_COLOR.',
|
|
115
|
+
].join('\n');
|
|
116
|
+
}
|
|
117
|
+
function commandHelp(name) {
|
|
118
|
+
const { usage, summary, details } = COMMANDS[name];
|
|
119
|
+
return `Usage:\n ${usage}\n\n${summary}.\n\n${details}`;
|
|
120
|
+
}
|
|
121
|
+
export async function main(argv, ctx) {
|
|
122
|
+
let name;
|
|
123
|
+
try {
|
|
124
|
+
const [first, ...rest] = argv;
|
|
125
|
+
if (first === undefined) {
|
|
126
|
+
ctx.ui.say(helpText());
|
|
127
|
+
return EXIT.usage;
|
|
128
|
+
}
|
|
129
|
+
if (first === '--version' || first === '-v') {
|
|
130
|
+
ctx.print(VERSION);
|
|
131
|
+
return EXIT.done;
|
|
132
|
+
}
|
|
133
|
+
if (first === 'help' || first === '--help' || first === '-h') {
|
|
134
|
+
return help(ctx, rest);
|
|
135
|
+
}
|
|
136
|
+
if (!isName(first))
|
|
137
|
+
throw new UsageError(`Unknown command "${first}".`);
|
|
138
|
+
name = first;
|
|
139
|
+
return await dispatch(ctx, name, rest);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
return report(ctx, error, name);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function help(ctx, rest) {
|
|
146
|
+
if (rest.length === 0) {
|
|
147
|
+
ctx.print(helpText());
|
|
148
|
+
return EXIT.done;
|
|
149
|
+
}
|
|
150
|
+
const [name, ...extra] = rest;
|
|
151
|
+
if (!isName(name) || extra.length)
|
|
152
|
+
throw new UsageError(`No help for "${rest.join(' ')}".`);
|
|
153
|
+
ctx.print(commandHelp(name));
|
|
154
|
+
return EXIT.done;
|
|
155
|
+
}
|
|
156
|
+
async function dispatch(ctx, name, rest) {
|
|
157
|
+
// Everything after `--` is the command `hive run` starts, its own options included.
|
|
158
|
+
let args = rest;
|
|
159
|
+
let command;
|
|
160
|
+
if (name === 'run') {
|
|
161
|
+
const end = rest.indexOf('--');
|
|
162
|
+
if (end >= 0) {
|
|
163
|
+
args = rest.slice(0, end);
|
|
164
|
+
command = rest.slice(end + 1);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const parsed = parse(args, COMMANDS[name].options);
|
|
168
|
+
const values = parsed.values;
|
|
169
|
+
const positionals = parsed.positionals;
|
|
170
|
+
if (values.help) {
|
|
171
|
+
ctx.print(commandHelp(name));
|
|
172
|
+
return EXIT.done;
|
|
173
|
+
}
|
|
174
|
+
const text = (key) => values[key];
|
|
175
|
+
const flag = (key) => values[key] === true;
|
|
176
|
+
const none = () => {
|
|
177
|
+
if (positionals.length)
|
|
178
|
+
throw new UsageError(`hive ${name} takes no arguments (got "${positionals.join(' ')}").`);
|
|
179
|
+
};
|
|
180
|
+
switch (name) {
|
|
181
|
+
case 'login':
|
|
182
|
+
none();
|
|
183
|
+
return login(ctx, { browser: !flag('no-browser') });
|
|
184
|
+
case 'logout':
|
|
185
|
+
none();
|
|
186
|
+
return logout(ctx);
|
|
187
|
+
case 'whoami':
|
|
188
|
+
none();
|
|
189
|
+
return whoami(ctx, { env: text('env') });
|
|
190
|
+
case 'link':
|
|
191
|
+
none();
|
|
192
|
+
return link(ctx, { project: text('project'), env: text('env'), yes: flag('yes') });
|
|
193
|
+
case 'run':
|
|
194
|
+
none();
|
|
195
|
+
if (!command?.length)
|
|
196
|
+
throw new UsageError('Put the command after --, as in: hive run -- npm run dev');
|
|
197
|
+
return run(ctx, { env: text('env'), command });
|
|
198
|
+
case 'pull':
|
|
199
|
+
none();
|
|
200
|
+
return pull(ctx, { env: text('env'), out: text('out'), yes: flag('yes') });
|
|
201
|
+
case 'sessions': {
|
|
202
|
+
if (positionals.length === 0)
|
|
203
|
+
return sessions(ctx, { json: flag('json') });
|
|
204
|
+
const [action, target, ...extra] = positionals;
|
|
205
|
+
if (action !== 'revoke')
|
|
206
|
+
throw new UsageError(`Unknown: "hive sessions ${action}".`);
|
|
207
|
+
if (!target || extra.length)
|
|
208
|
+
throw new UsageError('hive sessions revoke takes one number or id, from `hive sessions`.');
|
|
209
|
+
return revokeSession(ctx, { target, yes: flag('yes') });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function parse(args, options) {
|
|
214
|
+
try {
|
|
215
|
+
return parseArgs({
|
|
216
|
+
args,
|
|
217
|
+
options: { ...options, help: { type: 'boolean', short: 'h' } },
|
|
218
|
+
allowPositionals: true,
|
|
219
|
+
strict: true,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
// parseArgs' own first sentence ("Unknown option '--x'"), without its advice about `--`.
|
|
224
|
+
const message = error.message.split(/\. /)[0].replace(/\.$/, '');
|
|
225
|
+
throw new UsageError(`${message}.`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function report(ctx, error, name) {
|
|
229
|
+
const { ui } = ctx;
|
|
230
|
+
if (error instanceof CliError) {
|
|
231
|
+
ui.say(`${ui.paint.red('Error:')} ${error.message}`);
|
|
232
|
+
if (error.exitCode === EXIT.usage)
|
|
233
|
+
ui.say(`Run \`hive help${name ? ` ${name}` : ''}\` to see how to use it.`);
|
|
234
|
+
return error.exitCode;
|
|
235
|
+
}
|
|
236
|
+
if (error instanceof ApiError) {
|
|
237
|
+
ui.say(`${ui.paint.red('Error:')} ${explain(error)}`);
|
|
238
|
+
return EXIT.failed;
|
|
239
|
+
}
|
|
240
|
+
// A fault in the CLI itself. Its message only, never a stack full of state.
|
|
241
|
+
ui.say(`${ui.paint.red('Error:')} hive stopped unexpectedly: ${error?.message ?? String(error)}`);
|
|
242
|
+
return EXIT.failed;
|
|
243
|
+
}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit codes, errors and colour. Messages go to stderr (so `hive run` leaves the command's stdout
|
|
3
|
+
* alone); what a command was asked for (a list, `whoami`) goes to stdout. Neither ever holds a
|
|
4
|
+
* value or a token.
|
|
5
|
+
*/
|
|
6
|
+
export const EXIT = {
|
|
7
|
+
done: 0,
|
|
8
|
+
/** Refused, failed, or answered "no". */
|
|
9
|
+
failed: 1,
|
|
10
|
+
usage: 2,
|
|
11
|
+
/** Stopped with Ctrl+C. */
|
|
12
|
+
stopped: 130,
|
|
13
|
+
};
|
|
14
|
+
/** A stop with a sentence for the person: what happened and what to do. */
|
|
15
|
+
export class CliError extends Error {
|
|
16
|
+
exitCode;
|
|
17
|
+
constructor(message, exitCode = EXIT.failed) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'CliError';
|
|
20
|
+
this.exitCode = exitCode;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Wrong usage: a missing argument, an unknown option, a flag needed without a terminal. */
|
|
24
|
+
export class UsageError extends CliError {
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super(message, EXIT.usage);
|
|
27
|
+
this.name = 'UsageError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const same = (text) => text;
|
|
31
|
+
export const plainPaint = { bold: same, dim: same, red: same, green: same, yellow: same, cyan: same };
|
|
32
|
+
/** Colour only on a terminal, never with NO_COLOR (any value but empty, as no-color.org says). */
|
|
33
|
+
export function paintFor(isTerminal, env) {
|
|
34
|
+
if (!isTerminal || env.NO_COLOR || env.TERM === 'dumb')
|
|
35
|
+
return plainPaint;
|
|
36
|
+
const wrap = (open, close) => (text) => `\u001b[${open}m${text}\u001b[${close}m`;
|
|
37
|
+
return {
|
|
38
|
+
bold: wrap(1, 22),
|
|
39
|
+
dim: wrap(2, 22),
|
|
40
|
+
red: wrap(31, 39),
|
|
41
|
+
green: wrap(32, 39),
|
|
42
|
+
yellow: wrap(33, 39),
|
|
43
|
+
cyan: wrap(36, 39),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Text from the API (a device name, a project's name, a refusal) as one plain line: without
|
|
48
|
+
* control characters or direction overrides, so nothing it holds can move the cursor, recolour
|
|
49
|
+
* the terminal or make a line read as something else.
|
|
50
|
+
*/
|
|
51
|
+
export function plain(text) {
|
|
52
|
+
return String(text).replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '');
|
|
53
|
+
}
|