@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
package/dist/callers.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { ApiError, HiveApi } from "./api.js";
|
|
2
|
+
import { apiOrigin, readConfig } from "./config.js";
|
|
3
|
+
import { CliError, UsageError } from "./output.js";
|
|
4
|
+
/**
|
|
5
|
+
* Who is calling, in this order: an access token in HIVE_TOKEN (servers, CI); else a GitHub
|
|
6
|
+
* Actions run that may ask GitHub for a token (`permissions: id-token: write`), which GitHub
|
|
7
|
+
* vouches for (github-actions.ts); else this computer's sign-in from `hive login`. The first one
|
|
8
|
+
* there is used and the others are ignored.
|
|
9
|
+
*/
|
|
10
|
+
export const NOT_SIGNED_IN = 'Run `hive login` first, or set HIVE_TOKEN.';
|
|
11
|
+
export const NEEDS_ID_TOKEN = 'Add `permissions: id-token: write` to this workflow (or job) so Hive can check who is running it.';
|
|
12
|
+
const SERVICE_TOKEN_PREFIX = 'hive_st_';
|
|
13
|
+
export function callerOf(env) {
|
|
14
|
+
const token = serviceTokenOf(env);
|
|
15
|
+
if (token)
|
|
16
|
+
return { kind: 'token', token };
|
|
17
|
+
const request = oidcRequestOf(env);
|
|
18
|
+
if (request)
|
|
19
|
+
return { kind: 'github', request };
|
|
20
|
+
// In GitHub Actions there is no sign-in to fall back on: what is missing is the permission.
|
|
21
|
+
if (env.GITHUB_ACTIONS === 'true')
|
|
22
|
+
throw new UsageError(NEEDS_ID_TOKEN);
|
|
23
|
+
return { kind: 'signed-in' };
|
|
24
|
+
}
|
|
25
|
+
/** Both variables, or none: GitHub sets them together, only for a job with `id-token: write`. */
|
|
26
|
+
export function oidcRequestOf(env) {
|
|
27
|
+
const url = env.ACTIONS_ID_TOKEN_REQUEST_URL?.trim();
|
|
28
|
+
const token = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN?.trim();
|
|
29
|
+
return url && token ? { url, token } : undefined;
|
|
30
|
+
}
|
|
31
|
+
/** HIVE_TOKEN when it is set; refused before it is sent anywhere when it is not an access token. */
|
|
32
|
+
export function serviceTokenOf(env) {
|
|
33
|
+
const token = env.HIVE_TOKEN?.trim();
|
|
34
|
+
if (!token)
|
|
35
|
+
return undefined;
|
|
36
|
+
if (!token.startsWith(SERVICE_TOKEN_PREFIX)) {
|
|
37
|
+
throw new CliError(`HIVE_TOKEN is not a Hive access token (those start with ${SERVICE_TOKEN_PREFIX}). Make one in the project's Machine access tab.`);
|
|
38
|
+
}
|
|
39
|
+
return token;
|
|
40
|
+
}
|
|
41
|
+
export function tokenApi(ctx, token) {
|
|
42
|
+
return new HiveApi({ origin: apiOrigin(ctx.env), token });
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* This computer's sign-in, sent only to the Hive it was made with: each token is saved with its
|
|
46
|
+
* own API origin and loaded only for it, so a HIVE_API_URL (or a config.json) pointing elsewhere
|
|
47
|
+
* finds nothing to send.
|
|
48
|
+
*/
|
|
49
|
+
export async function signedIn(ctx) {
|
|
50
|
+
const origin = apiOrigin(ctx.env);
|
|
51
|
+
const { apiUrl, account } = readConfig(ctx.configDir);
|
|
52
|
+
const token = await ctx.credentials.load(origin);
|
|
53
|
+
if (!token) {
|
|
54
|
+
if (apiUrl && apiUrl !== origin) {
|
|
55
|
+
throw new CliError(`You are signed in to Hive at ${apiUrl}, but this run uses ${origin} (HIVE_API_URL). Run \`hive login\` to sign in there.`);
|
|
56
|
+
}
|
|
57
|
+
throw new CliError(NOT_SIGNED_IN);
|
|
58
|
+
}
|
|
59
|
+
// config.json says who signed in last from this folder; it is only theirs if it names this Hive.
|
|
60
|
+
return { api: new HiveApi({ origin, token }), origin, account: apiUrl === origin ? account : undefined };
|
|
61
|
+
}
|
|
62
|
+
/** For the commands that work only with the person's own sign-in. */
|
|
63
|
+
export function refuseServiceToken(ctx, what) {
|
|
64
|
+
if (ctx.env.HIVE_TOKEN?.trim()) {
|
|
65
|
+
throw new CliError(`${what} uses your own sign-in from \`hive login\`, not HIVE_TOKEN. Unset HIVE_TOKEN to use it.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** The API's sentence for a refusal, and what to do about it. */
|
|
69
|
+
export function explain(error) {
|
|
70
|
+
switch (error.code) {
|
|
71
|
+
case 'not_signed_in':
|
|
72
|
+
return "This computer's Hive sign-in has ended (signed out, revoked, or unused for 90 days). Run `hive login` to sign in again.";
|
|
73
|
+
case 'token_invalid':
|
|
74
|
+
return `${error.message} Make a new token in the project's Machine access tab and set it as HIVE_TOKEN.`;
|
|
75
|
+
case 'accounts_unreachable':
|
|
76
|
+
return `${error.message} Try again in a minute.`;
|
|
77
|
+
case 'not_for_cli':
|
|
78
|
+
return `${error.message} Do this on the Hive website.`;
|
|
79
|
+
}
|
|
80
|
+
if (error.status === 0)
|
|
81
|
+
return `${error.message} Check your connection, or HIVE_API_URL.`;
|
|
82
|
+
if (error.status === 429)
|
|
83
|
+
return `${error.message} Wait a minute and try again.`;
|
|
84
|
+
return error.message;
|
|
85
|
+
}
|
|
86
|
+
/** An ApiError as the CliError that says it; anything else unchanged. */
|
|
87
|
+
export function asCliError(error) {
|
|
88
|
+
return error instanceof ApiError ? new CliError(explain(error)) : error;
|
|
89
|
+
}
|
package/dist/choose.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { createInterface, emitKeypressEvents } from 'node:readline';
|
|
2
|
+
import { CliError, EXIT } from "./output.js";
|
|
3
|
+
/** What one key does to a picker at `index` of `count` lines. */
|
|
4
|
+
export function keyStep(key, index, count) {
|
|
5
|
+
if ((key.ctrl && key.name === 'c') || key.name === 'escape')
|
|
6
|
+
return { stop: true };
|
|
7
|
+
switch (key.name) {
|
|
8
|
+
case 'return':
|
|
9
|
+
case 'enter':
|
|
10
|
+
return { pick: index };
|
|
11
|
+
case 'up':
|
|
12
|
+
case 'k':
|
|
13
|
+
return { move: (index - 1 + count) % count };
|
|
14
|
+
case 'down':
|
|
15
|
+
case 'j':
|
|
16
|
+
case 'tab':
|
|
17
|
+
return { move: (index + 1) % count };
|
|
18
|
+
}
|
|
19
|
+
const number = key.sequence?.length === 1 ? Number(key.sequence) : NaN;
|
|
20
|
+
return number >= 1 && number <= count ? { move: number - 1 } : undefined;
|
|
21
|
+
}
|
|
22
|
+
const stopped = () => new CliError('Stopped.', EXIT.stopped);
|
|
23
|
+
/**
|
|
24
|
+
* Reads lines until `read` takes one, asking again with `retry` otherwise. Lines are read through
|
|
25
|
+
* the interface's iterator, which keeps the ones that arrive together, typed or piped. Ctrl+C
|
|
26
|
+
* stops the command; the input running out is a stop as well.
|
|
27
|
+
*/
|
|
28
|
+
async function askUntil(io, prompt, read, retry) {
|
|
29
|
+
// The prompt is readline's own, so it redraws it (not its default "> ") while a line is edited.
|
|
30
|
+
const terminal = io.output.isTTY === true;
|
|
31
|
+
const lines = createInterface({ input: io.input, output: io.output, terminal, prompt });
|
|
32
|
+
let interrupted = false;
|
|
33
|
+
let closed = false;
|
|
34
|
+
lines.on('SIGINT', () => {
|
|
35
|
+
interrupted = true;
|
|
36
|
+
lines.close();
|
|
37
|
+
});
|
|
38
|
+
lines.on('close', () => (closed = true));
|
|
39
|
+
// Piped answers that have all arrived close the interface while its lines are still read.
|
|
40
|
+
const ask = () => (closed ? io.output.write(prompt) : lines.prompt());
|
|
41
|
+
try {
|
|
42
|
+
ask();
|
|
43
|
+
for await (const line of lines) {
|
|
44
|
+
const answer = read(line);
|
|
45
|
+
if (answer !== undefined)
|
|
46
|
+
return answer;
|
|
47
|
+
io.output.write(`${retry}\n`);
|
|
48
|
+
ask();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
lines.close();
|
|
53
|
+
}
|
|
54
|
+
if (interrupted)
|
|
55
|
+
throw stopped();
|
|
56
|
+
throw new CliError('No answer was given.');
|
|
57
|
+
}
|
|
58
|
+
export function askYesNo(io, question, defaultYes) {
|
|
59
|
+
return askUntil(io, `${question} ${defaultYes ? '(Y/n)' : '(y/N)'} `, (line) => {
|
|
60
|
+
const answer = line.trim().toLowerCase();
|
|
61
|
+
if (answer === '')
|
|
62
|
+
return defaultYes;
|
|
63
|
+
if (answer === 'y' || answer === 'yes')
|
|
64
|
+
return true;
|
|
65
|
+
if (answer === 'n' || answer === 'no')
|
|
66
|
+
return false;
|
|
67
|
+
return undefined;
|
|
68
|
+
}, 'Type y or n.');
|
|
69
|
+
}
|
|
70
|
+
/** The picker as a numbered list: for a terminal that cannot draw one (TERM=dumb). */
|
|
71
|
+
export async function chooseByNumber(io, title, choices) {
|
|
72
|
+
const list = choices.map(({ label, hint }, index) => ` ${index + 1}) ${label}${hint ? ` ${hint}` : ''}`);
|
|
73
|
+
io.output.write(`${title}\n${list.join('\n')}\n`);
|
|
74
|
+
const picked = await askUntil(io, `Number (1-${choices.length}): `, (line) => {
|
|
75
|
+
const number = Number(line.trim());
|
|
76
|
+
return Number.isInteger(number) && number >= 1 && number <= choices.length ? number : undefined;
|
|
77
|
+
}, `Type a number from 1 to ${choices.length}.`);
|
|
78
|
+
return choices[picked - 1].value;
|
|
79
|
+
}
|
|
80
|
+
/** The arrow-key picker. Leaves one line in place of the list: the title and what was picked. */
|
|
81
|
+
export function chooseWithKeys({ input, output, paint }, title, choices) {
|
|
82
|
+
let index = 0;
|
|
83
|
+
let drawn = 0;
|
|
84
|
+
// One screen line per choice: a wrapped line would throw the redraw off by one.
|
|
85
|
+
const width = Math.max((output.columns || 80) - 4, 10);
|
|
86
|
+
const fit = (text) => (text.length > width ? `${text.slice(0, width - 1)}…` : text);
|
|
87
|
+
const lineOf = ({ label, hint }, current) => {
|
|
88
|
+
const full = hint ? `${label} ${hint}` : label;
|
|
89
|
+
const name = current ? paint.cyan(label) : label;
|
|
90
|
+
if (full.length > width)
|
|
91
|
+
return fit(full);
|
|
92
|
+
return hint ? `${name} ${paint.dim(hint)}` : name;
|
|
93
|
+
};
|
|
94
|
+
const draw = () => {
|
|
95
|
+
if (drawn)
|
|
96
|
+
output.write(`\u001b[${drawn}A\u001b[0J`);
|
|
97
|
+
const lines = [
|
|
98
|
+
`${paint.bold(fit(title))} ${paint.dim('(↑/↓, Enter)')}`,
|
|
99
|
+
...choices.map((choice, at) => `${at === index ? paint.cyan('❯') : ' '} ${lineOf(choice, at === index)}`),
|
|
100
|
+
];
|
|
101
|
+
output.write(`${lines.join('\n')}\n`);
|
|
102
|
+
drawn = lines.length;
|
|
103
|
+
};
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
const wasRaw = input.isRaw;
|
|
106
|
+
const finish = () => {
|
|
107
|
+
input.off('keypress', onKey);
|
|
108
|
+
input.setRawMode(wasRaw);
|
|
109
|
+
input.pause();
|
|
110
|
+
output.write(`\u001b[${drawn}A\u001b[0J\u001b[?25h`);
|
|
111
|
+
};
|
|
112
|
+
const onKey = (_text, key) => {
|
|
113
|
+
const step = keyStep(key ?? {}, index, choices.length);
|
|
114
|
+
if (!step)
|
|
115
|
+
return;
|
|
116
|
+
if ('stop' in step) {
|
|
117
|
+
finish();
|
|
118
|
+
reject(stopped());
|
|
119
|
+
}
|
|
120
|
+
else if ('move' in step) {
|
|
121
|
+
index = step.move;
|
|
122
|
+
draw();
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
finish();
|
|
126
|
+
output.write(`${fit(title)}: ${paint.cyan(choices[index].label)}\n`);
|
|
127
|
+
resolve(choices[index].value);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
emitKeypressEvents(input);
|
|
131
|
+
input.setRawMode(true);
|
|
132
|
+
input.resume();
|
|
133
|
+
output.write('\u001b[?25l');
|
|
134
|
+
draw();
|
|
135
|
+
input.on('keypress', onKey);
|
|
136
|
+
});
|
|
137
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realContext } from "./context.js";
|
|
3
|
+
import { main } from "./main.js";
|
|
4
|
+
// A reader that went away (`hive sessions | head -1`) is not an error worth a stack.
|
|
5
|
+
process.stdout.on('error', (error) => {
|
|
6
|
+
if (error.code === 'EPIPE')
|
|
7
|
+
process.exit(process.exitCode ?? 0);
|
|
8
|
+
throw error;
|
|
9
|
+
});
|
|
10
|
+
process.exitCode = await main(process.argv.slice(2), realContext());
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { refuseServiceToken, signedIn } from "../callers.js";
|
|
4
|
+
import { assertReadable, environmentNamed, mayRead, projectOf } from "../environments.js";
|
|
5
|
+
import { isProjectId, LINK_FILE, writeLink } from "../link-file.js";
|
|
6
|
+
import { CliError, EXIT, plain, UsageError } from "../output.js";
|
|
7
|
+
/**
|
|
8
|
+
* `hive link [--project <id>] [--env <name>] [--yes]`: picks space → project → environment and
|
|
9
|
+
* writes `.hive.json` in this folder. With both flags it asks nothing, so it works without a
|
|
10
|
+
* terminal; `--yes` replaces a `.hive.json` already here without asking.
|
|
11
|
+
*/
|
|
12
|
+
export async function link(ctx, options) {
|
|
13
|
+
const { ui } = ctx;
|
|
14
|
+
refuseServiceToken(ctx, 'hive link');
|
|
15
|
+
if (options.project !== undefined && !isProjectId(options.project)) {
|
|
16
|
+
throw new UsageError("--project takes a project's id, like 7f3a9c2e-1b2c-4d5e-8f90-123456789abc.");
|
|
17
|
+
}
|
|
18
|
+
if (!ui.interactive && (!options.project || !options.env)) {
|
|
19
|
+
throw new UsageError('Without a terminal to pick in, hive link needs both --project <id> and --env <name>.');
|
|
20
|
+
}
|
|
21
|
+
// Asked before anything is picked, so a "no" wastes nobody's time.
|
|
22
|
+
if (existsSync(join(ctx.cwd, LINK_FILE)) && !options.yes) {
|
|
23
|
+
if (!ui.interactive) {
|
|
24
|
+
throw new CliError(`This folder is already linked (${LINK_FILE}). Pass --yes to replace it, or run hive link in a terminal.`);
|
|
25
|
+
}
|
|
26
|
+
if (!(await ui.ask(`This folder is already linked (${LINK_FILE}). Replace it?`, false))) {
|
|
27
|
+
ui.say(`Left ${LINK_FILE} as it was.`);
|
|
28
|
+
return EXIT.failed;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const { api } = await signedIn(ctx);
|
|
32
|
+
const project = options.project ? await projectOf(api, options.project, '--project') : await pickProject(ctx, api);
|
|
33
|
+
const environment = options.env ? environmentNamed(project, options.env) : await pickEnvironment(ctx, project);
|
|
34
|
+
assertReadable(project, environment);
|
|
35
|
+
writeLink(ctx.cwd, { project: project.id, environment: environment.name });
|
|
36
|
+
ui.say(`${ui.paint.green('✓')} Linked this folder to ${plain(environment.name)} of ${plain(project.name)}. ${LINK_FILE} holds no secret: commit it.`);
|
|
37
|
+
return EXIT.done;
|
|
38
|
+
}
|
|
39
|
+
async function pickProject(ctx, api) {
|
|
40
|
+
const me = await api.me();
|
|
41
|
+
// A paused space is closed to its people until the Super Admin resumes it.
|
|
42
|
+
const spaces = me.spaces.filter(({ status }) => status === 'active');
|
|
43
|
+
if (spaces.length === 0) {
|
|
44
|
+
throw new CliError("You are in no active space. Make a project on the Hive website, or ask a space's Owner to add you.");
|
|
45
|
+
}
|
|
46
|
+
// In the order GET /me gives: the personal space first, then each organization by name.
|
|
47
|
+
const space = await pickOne(ctx, 'Space', 'Space', spaces.map(spaceChoice));
|
|
48
|
+
const { projects } = await api.projects(space.id);
|
|
49
|
+
if (projects.length === 0) {
|
|
50
|
+
throw new CliError(`${plain(space.name)} has no project you can use. Make one on the Hive website, or ask the space's Owner or an Admin.`);
|
|
51
|
+
}
|
|
52
|
+
const card = await pickOne(ctx, `Project in ${plain(space.name)}`, 'Project', projects.map((card) => ({ label: plain(card.name), value: card })));
|
|
53
|
+
return api.project(card.id);
|
|
54
|
+
}
|
|
55
|
+
/** A personal space by its name with "(personal)" after it; an organization by its name, the person's role dimmed. */
|
|
56
|
+
function spaceChoice(space) {
|
|
57
|
+
if (space.kind === 'personal')
|
|
58
|
+
return { label: `${plain(space.name)} (personal)`, value: space };
|
|
59
|
+
return { label: plain(space.name), hint: space.role, value: space };
|
|
60
|
+
}
|
|
61
|
+
async function pickEnvironment(ctx, project) {
|
|
62
|
+
const readable = project.environments.filter(mayRead);
|
|
63
|
+
if (readable.length === 0) {
|
|
64
|
+
throw new CliError(`You can't read the values of any environment of ${plain(project.name)}. Ask the space's Owner or an Admin.`);
|
|
65
|
+
}
|
|
66
|
+
return pickOne(ctx, `Environment of ${plain(project.name)}`, 'Environment', readable.map((environment) => ({
|
|
67
|
+
label: plain(environment.name),
|
|
68
|
+
hint: environment.protected ? 'protected' : undefined,
|
|
69
|
+
value: environment,
|
|
70
|
+
})));
|
|
71
|
+
}
|
|
72
|
+
/** One choice is taken without asking, and said. */
|
|
73
|
+
async function pickOne(ctx, title, noun, choices) {
|
|
74
|
+
if (choices.length === 1) {
|
|
75
|
+
ctx.ui.say(`${noun}: ${choices[0].label}`);
|
|
76
|
+
return choices[0].value;
|
|
77
|
+
}
|
|
78
|
+
return ctx.ui.choose(title, choices);
|
|
79
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { HiveApi } from "../api.js";
|
|
2
|
+
import { apiOriginOf } from "../api-address.js";
|
|
3
|
+
import { apiOrigin, readConfig, writeConfig } from "../config.js";
|
|
4
|
+
import { approveInBrowser } from "../device-flow.js";
|
|
5
|
+
import { CliError, EXIT, plain } from "../output.js";
|
|
6
|
+
/**
|
|
7
|
+
* `hive login [--no-browser]`: one sign-in per computer for each Hive, approved in the Hive
|
|
8
|
+
* website (spec section 9). Hive hands the token over once; it goes to the keychain under this
|
|
9
|
+
* Hive's address (or the 0600 file, with that address), and config.json keeps who it is and which
|
|
10
|
+
* Hive it was made with.
|
|
11
|
+
*/
|
|
12
|
+
export async function login(ctx, options) {
|
|
13
|
+
const { ui } = ctx;
|
|
14
|
+
if (!ui.interactive) {
|
|
15
|
+
throw new CliError('hive login needs a terminal: it shows a code and waits for you to approve it in the browser. On servers and in CI, set HIVE_TOKEN to an access token instead.');
|
|
16
|
+
}
|
|
17
|
+
const origin = apiOrigin(ctx.env);
|
|
18
|
+
// Only this Hive's sign-in is replaced; another Hive's keeps its own place.
|
|
19
|
+
const previous = await ctx.credentials.load(origin);
|
|
20
|
+
const saved = readConfig(ctx.configDir);
|
|
21
|
+
if (previous) {
|
|
22
|
+
const known = saved.apiUrl === origin ? saved.account : undefined;
|
|
23
|
+
const who = known ? `${plain(known.name)} (${plain(known.email)})` : 'someone';
|
|
24
|
+
if (!(await ui.ask(`You are signed in as ${who}. Sign in again, replacing it?`, false))) {
|
|
25
|
+
ui.say('Kept the current sign-in.');
|
|
26
|
+
return EXIT.failed;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const api = new HiveApi({ origin });
|
|
30
|
+
const start = await api.startLogin(deviceNameOf(ctx.hostname()));
|
|
31
|
+
const approved = (await approveInBrowser(ctx, start, (deviceCode) => api.pollLogin(deviceCode), options));
|
|
32
|
+
if (typeof approved.token !== 'string' || !approved.token.startsWith('hive_cli_')) {
|
|
33
|
+
throw new CliError('Hive approved the sign-in but sent no token. Run `hive login` again.');
|
|
34
|
+
}
|
|
35
|
+
const replaced = await ctx.credentials.save(origin, approved.token);
|
|
36
|
+
const { id, email, name } = approved.account;
|
|
37
|
+
writeConfig(ctx.configDir, { account: { id, email, name }, apiUrl: origin });
|
|
38
|
+
if (previous)
|
|
39
|
+
await endQuietly(previous, origin);
|
|
40
|
+
if (replaced) {
|
|
41
|
+
// credentials.json holds one sign-in, so the one for another Hive had to go.
|
|
42
|
+
const ended = await endQuietly(replaced.token, replaced.apiUrl);
|
|
43
|
+
ui.say(`Your sign-in to Hive at ${plain(replaced.apiUrl)} was replaced, as credentials.json keeps one${ended ? ', and ended there' : '; it could not be ended there, and ends by itself after 90 days unused'}. Use another HIVE_CONFIG_DIR to keep one for each Hive.`);
|
|
44
|
+
}
|
|
45
|
+
ui.say(`${ui.paint.green('✓')} Signed in as ${plain(name)} (${plain(email)}).`);
|
|
46
|
+
if (ctx.env.HIVE_TOKEN?.trim()) {
|
|
47
|
+
ui.warn('HIVE_TOKEN is set in this shell, so commands here use it instead of this sign-in.');
|
|
48
|
+
}
|
|
49
|
+
return EXIT.done;
|
|
50
|
+
}
|
|
51
|
+
/** "MacBook-Pro", as the sign-ins list shows it: without macOS's `.local`, at most 100 characters. */
|
|
52
|
+
function deviceNameOf(hostname) {
|
|
53
|
+
return hostname.replace(/\.local$/i, '').slice(0, 100) || 'Unknown computer';
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A replaced sign-in, ended at the Hive it was made with, so it does not stay live unused for
|
|
57
|
+
* 90 days. The new one is saved already; a failure here changes nothing for the person.
|
|
58
|
+
*/
|
|
59
|
+
async function endQuietly(token, origin) {
|
|
60
|
+
try {
|
|
61
|
+
// The address is read back from a file: held to the rule every address the CLI uses keeps.
|
|
62
|
+
await new HiveApi({ origin: apiOriginOf(origin), token }).logout();
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// It ends by itself after 90 days unused, and is listed in `hive sessions` until then.
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ApiError, HiveApi } from "../api.js";
|
|
2
|
+
import { explain } from "../callers.js";
|
|
3
|
+
import { apiOrigin, readConfig, writeConfig } from "../config.js";
|
|
4
|
+
import { EXIT } from "../output.js";
|
|
5
|
+
/**
|
|
6
|
+
* `hive logout`: ends this Hive's sign-in at Hive (`POST /cli/logout`), then forgets it here. It
|
|
7
|
+
* is forgotten even when Hive cannot be reached, and the person is told it may still be live.
|
|
8
|
+
* When the keychain will not let it go, that is said plainly instead.
|
|
9
|
+
*/
|
|
10
|
+
export async function logout(ctx) {
|
|
11
|
+
const { ui } = ctx;
|
|
12
|
+
const origin = apiOrigin(ctx.env);
|
|
13
|
+
const saved = readConfig(ctx.configDir);
|
|
14
|
+
// Saved with the Hive it was made for, so it is only ever sent back there.
|
|
15
|
+
const token = await ctx.credentials.load(origin);
|
|
16
|
+
if (!token) {
|
|
17
|
+
ui.say(saved.apiUrl && saved.apiUrl !== origin
|
|
18
|
+
? `You are not signed in to Hive at ${origin} on this computer. This folder's sign-in is for ${saved.apiUrl}: set HIVE_API_URL to it to sign out there.`
|
|
19
|
+
: 'You are not signed in on this computer.');
|
|
20
|
+
return EXIT.done;
|
|
21
|
+
}
|
|
22
|
+
let failure;
|
|
23
|
+
try {
|
|
24
|
+
await new HiveApi({ origin, token }).logout();
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
const ended = error instanceof ApiError && error.code === 'not_signed_in';
|
|
28
|
+
if (!ended)
|
|
29
|
+
failure = error instanceof ApiError ? explain(error) : error.message;
|
|
30
|
+
}
|
|
31
|
+
let kept;
|
|
32
|
+
try {
|
|
33
|
+
await ctx.credentials.forget(origin);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
kept = error.message;
|
|
37
|
+
}
|
|
38
|
+
if (!kept && (!saved.apiUrl || saved.apiUrl === origin))
|
|
39
|
+
writeConfig(ctx.configDir, {});
|
|
40
|
+
if (failure && kept) {
|
|
41
|
+
ui.warn(`Could not end it at Hive: ${failure} It is still saved on this computer as well. ${kept} It stays live until it is 90 days unused, or until you end it with \`hive sessions revoke\` on another computer or on the website's "Your sign-ins" page.`);
|
|
42
|
+
return EXIT.failed;
|
|
43
|
+
}
|
|
44
|
+
if (failure) {
|
|
45
|
+
ui.warn(`Could not end it at Hive: ${failure} It is forgotten here, but stays live until it is 90 days unused, or until you end it with \`hive sessions revoke\` on another computer or on the website's "Your sign-ins" page.`);
|
|
46
|
+
return EXIT.failed;
|
|
47
|
+
}
|
|
48
|
+
if (kept) {
|
|
49
|
+
ui.warn(`The sign-in is ended at Hive, so it no longer works, but it is still saved on this computer. ${kept}`);
|
|
50
|
+
return EXIT.failed;
|
|
51
|
+
}
|
|
52
|
+
ui.say('Signed out: the sign-in is ended at Hive and forgotten on this computer.');
|
|
53
|
+
return EXIT.done;
|
|
54
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { lstatSync } from 'node:fs';
|
|
2
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
3
|
+
import { writeDotenv } from "../dotenv.js";
|
|
4
|
+
import { codeOf, writePrivateFile } from "../files.js";
|
|
5
|
+
import { addToGitignore, checkGitignore } from "../gitignore.js";
|
|
6
|
+
import { findLink } from "../link-file.js";
|
|
7
|
+
import { CliError, EXIT, UsageError } from "../output.js";
|
|
8
|
+
import { readValues } from "../values.js";
|
|
9
|
+
/**
|
|
10
|
+
* `hive pull [--env <name>] [--out <file>] [--yes]`: the values as a `.env` file, for a tool
|
|
11
|
+
* that needs one. Readable only by the person (0600). Asks before replacing a file, and offers to
|
|
12
|
+
* add it to .gitignore when no line there covers it. Without a terminal it needs `--yes`, which
|
|
13
|
+
* answers yes to both. In GitHub Actions the workflow access is ended by readValues, before the
|
|
14
|
+
* file is written.
|
|
15
|
+
*/
|
|
16
|
+
export async function pull(ctx, options) {
|
|
17
|
+
const { ui } = ctx;
|
|
18
|
+
if (!ui.interactive && !options.yes) {
|
|
19
|
+
throw new UsageError('hive pull writes a file of values; without a terminal to ask in, pass --yes.');
|
|
20
|
+
}
|
|
21
|
+
const shown = options.out ?? '.env';
|
|
22
|
+
const file = resolve(ctx.cwd, shown);
|
|
23
|
+
// Checked and asked before reading, so neither a missing folder nor a "no" leaves a record of
|
|
24
|
+
// values shown for nothing.
|
|
25
|
+
if (!exists(dirname(file))) {
|
|
26
|
+
throw new CliError(`There is no folder ${relative(ctx.cwd, dirname(file)) || dirname(file)} to write ${shown} in.`);
|
|
27
|
+
}
|
|
28
|
+
if (exists(file) && !options.yes && !(await ui.ask(`${shown} already exists. Replace it?`, false))) {
|
|
29
|
+
ui.say(`Left ${shown} as it was.`);
|
|
30
|
+
return EXIT.failed;
|
|
31
|
+
}
|
|
32
|
+
const read = await readValues(ctx, { env: options.env });
|
|
33
|
+
try {
|
|
34
|
+
writePrivateFile(file, writeDotenv(read.values));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
throw new CliError(`Could not write ${shown} (${codeOf(error)}).`);
|
|
38
|
+
}
|
|
39
|
+
const count = Object.keys(read.values).length;
|
|
40
|
+
const of = read.project ? `${read.environment} (${read.project})` : read.environment;
|
|
41
|
+
ui.say(`${ui.paint.green('✓')} Wrote ${count} value${count === 1 ? '' : 's'} of ${of} to ${shown}, readable only by you.`);
|
|
42
|
+
await offerGitignore(ctx, file, shown, options.yes);
|
|
43
|
+
return EXIT.done;
|
|
44
|
+
}
|
|
45
|
+
function exists(path) {
|
|
46
|
+
try {
|
|
47
|
+
lstatSync(path);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function offerGitignore(ctx, file, shown, yes) {
|
|
55
|
+
const { ui } = ctx;
|
|
56
|
+
let linkDir;
|
|
57
|
+
try {
|
|
58
|
+
linkDir = findLink(ctx.cwd)?.dir;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// A broken .hive.json: only HIVE_TOKEN gets this far with one, and reading the values has
|
|
62
|
+
// warned about it already. The line goes by the repository.
|
|
63
|
+
}
|
|
64
|
+
const check = checkGitignore(file, linkDir);
|
|
65
|
+
if (check.covered)
|
|
66
|
+
return;
|
|
67
|
+
const gitignore = relative(ctx.cwd, check.file) || check.file;
|
|
68
|
+
ui.warn(`${shown} is not in .gitignore, so git could commit it.`);
|
|
69
|
+
const add = yes || (ui.interactive && (await ui.ask(`Add ${check.line} to ${gitignore}?`, true)));
|
|
70
|
+
if (!add) {
|
|
71
|
+
ui.say(`Add ${check.line} to ${gitignore} before you commit.`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
addToGitignore(check);
|
|
76
|
+
ui.say(`Added ${check.line} to ${gitignore}.`);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
ui.warn(`Could not add it to ${gitignore} (${codeOf(error)}). Add ${check.line} there yourself.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { constants } from 'node:os';
|
|
3
|
+
import { codeOf } from "../files.js";
|
|
4
|
+
import { CliError } from "../output.js";
|
|
5
|
+
import { readValues } from "../values.js";
|
|
6
|
+
/**
|
|
7
|
+
* `hive run [--env <name>] -- <command…>`: the command runs with the environment's values added
|
|
8
|
+
* to its own environment, Hive's winning, and its terminal untouched (stdin, stdout and stderr
|
|
9
|
+
* are its own). Signals sent to the CLI are passed on, and the CLI exits with the command's
|
|
10
|
+
* code; when a signal stopped it, 128 + the signal's number (130 for Ctrl+C), as a shell says.
|
|
11
|
+
* No file is written. In GitHub Actions the workflow access is ended by readValues, before the
|
|
12
|
+
* command starts: it never runs beside a live access, and never gets it.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* What a terminal, a process manager or a container sends to stop a program. One sent to the CLI
|
|
16
|
+
* alone (`kill`, `docker stop`) reaches the command only by being passed on.
|
|
17
|
+
*/
|
|
18
|
+
const PASSED_ON = process.platform === 'win32' ? ['SIGINT', 'SIGTERM', 'SIGBREAK'] : ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT', 'SIGUSR2'];
|
|
19
|
+
/**
|
|
20
|
+
* The keys a terminal turns into a signal for every process of the job (Ctrl+C, Ctrl+\, and
|
|
21
|
+
* Ctrl+Break on Windows): the command has it already, so passing it on would deliver it twice,
|
|
22
|
+
* and a second Ctrl+C makes many programs skip their clean stop.
|
|
23
|
+
*/
|
|
24
|
+
const SENT_BY_THE_TERMINAL = new Set(['SIGINT', 'SIGQUIT', 'SIGBREAK']);
|
|
25
|
+
export async function run(ctx, options) {
|
|
26
|
+
const { values } = await readValues(ctx, { env: options.env });
|
|
27
|
+
// Node would refuse it while starting the command, quoting the value in its message.
|
|
28
|
+
const unfit = Object.keys(values).find((key) => values[key].includes('\0'));
|
|
29
|
+
if (unfit) {
|
|
30
|
+
throw new CliError(`${unfit} holds a NUL character, which no environment variable can, so nothing was started.`);
|
|
31
|
+
}
|
|
32
|
+
// The command gets the values, not the key to them: the access token stays with the CLI. A
|
|
33
|
+
// HIVE_TOKEN among the environment's own values is one of those values, and is passed.
|
|
34
|
+
const { HIVE_TOKEN: _key, ...shell } = ctx.env;
|
|
35
|
+
return runCommand(options.command, { ...shell, ...values }, ctx.cwd, ctx.atTerminal);
|
|
36
|
+
}
|
|
37
|
+
function runCommand([file, ...args], env, cwd, atTerminal) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const cannotStart = (error) => new CliError(`Could not start ${file}: ${codeOf(error) === 'ENOENT' ? 'no such command' : codeOf(error)}.`);
|
|
40
|
+
let child;
|
|
41
|
+
try {
|
|
42
|
+
child = process.platform === 'win32'
|
|
43
|
+
? spawn(windowsCommandLine([file, ...args]), { stdio: 'inherit', env, cwd, shell: true })
|
|
44
|
+
: spawn(file, args, { stdio: 'inherit', env, cwd });
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
reject(cannotStart(error));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// The CLI stays up while the command decides what a signal means (a dev server may close
|
|
51
|
+
// its connections first); it learns the outcome from the command's exit. So every signal is
|
|
52
|
+
// caught, even the ones a terminal has already sent the command itself.
|
|
53
|
+
const passOn = (signal) => {
|
|
54
|
+
if (atTerminal && SENT_BY_THE_TERMINAL.has(signal))
|
|
55
|
+
return;
|
|
56
|
+
child.kill(signal);
|
|
57
|
+
};
|
|
58
|
+
for (const signal of PASSED_ON)
|
|
59
|
+
process.on(signal, passOn);
|
|
60
|
+
const stopPassing = () => {
|
|
61
|
+
for (const signal of PASSED_ON)
|
|
62
|
+
process.off(signal, passOn);
|
|
63
|
+
};
|
|
64
|
+
child.on('error', (error) => {
|
|
65
|
+
stopPassing();
|
|
66
|
+
reject(cannotStart(error));
|
|
67
|
+
});
|
|
68
|
+
child.on('exit', (code, signal) => {
|
|
69
|
+
stopPassing();
|
|
70
|
+
resolve(code ?? 128 + (signal ? (constants.signals[signal] ?? 0) : 0));
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Windows starts `npm` and the like (`npm.cmd`) only through cmd, so the command goes as one
|
|
76
|
+
* line, each part quoted where cmd would split or read it.
|
|
77
|
+
*/
|
|
78
|
+
function windowsCommandLine(parts) {
|
|
79
|
+
return parts
|
|
80
|
+
.map((part) => (part === '' || /[\s"&|<>^()%!]/.test(part) ? `"${part.replace(/"/g, '""')}"` : part))
|
|
81
|
+
.join(' ');
|
|
82
|
+
}
|