@refleet-it/runner 0.1.196 → 0.1.199
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/README.md +13 -5
- package/dist/backend-client.js +21 -26
- package/dist/cli.js +13 -55
- package/dist/config.js +1 -1
- package/dist/detect.js +1 -1
- package/dist/fleet/gitlab.js +30 -0
- package/dist/fleet/workspace.js +8 -2
- package/dist/login.js +130 -0
- package/package.json +2 -1
- package/dist/prompt.js +0 -110
package/README.md
CHANGED
|
@@ -22,20 +22,28 @@ twice in Refleet.
|
|
|
22
22
|
npm install -g @refleet-it/runner
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
The command is `refleet`; `refleet-runner` still works as an alias for scripts written
|
|
26
|
+
against earlier releases.
|
|
27
|
+
|
|
25
28
|
## Connect and run
|
|
26
29
|
|
|
27
30
|
```bash
|
|
28
|
-
refleet
|
|
29
|
-
refleet
|
|
31
|
+
refleet login # opens refleet.it, you approve in the browser → an API key is stored locally
|
|
32
|
+
refleet run
|
|
30
33
|
```
|
|
31
34
|
|
|
35
|
+
`login` never asks for a password: it prints a link (and opens it), you approve the request
|
|
36
|
+
while signed in, and the CLI collects a fresh API key. `--api-url <url>` targets another
|
|
37
|
+
instance (`REFLEET_API_URL` works too); `--no-browser` only prints the link, for headless boxes
|
|
38
|
+
and SSH sessions.
|
|
39
|
+
|
|
32
40
|
`login` keeps its API key in `~/.config/refleet/runner.json` (`XDG_CONFIG_HOME` is honoured).
|
|
33
41
|
Anything headless — a server, a container, CI — should instead set `REFLEET_API_URL` and
|
|
34
42
|
`REFLEET_API_KEY` directly; when both are set they take precedence over the stored login.
|
|
35
43
|
|
|
36
44
|
```bash
|
|
37
|
-
refleet
|
|
38
|
-
refleet
|
|
45
|
+
refleet whoami # which credentials are in effect, and whether they still work
|
|
46
|
+
refleet logout # revokes the stored key on the server and forgets it locally
|
|
39
47
|
```
|
|
40
48
|
|
|
41
49
|
`run` has no idle mode: without credentials it exits immediately, and a job in progress is
|
|
@@ -48,7 +56,7 @@ Description=Refleet runner
|
|
|
48
56
|
After=network-online.target
|
|
49
57
|
|
|
50
58
|
[Service]
|
|
51
|
-
ExecStart=/usr/bin/env refleet
|
|
59
|
+
ExecStart=/usr/bin/env refleet run
|
|
52
60
|
Environment=REFLEET_API_URL=https://refleet.it/api
|
|
53
61
|
Environment=REFLEET_API_KEY=…
|
|
54
62
|
Environment=ANTHROPIC_API_KEY=…
|
package/dist/backend-client.js
CHANGED
|
@@ -9,42 +9,37 @@ function baseUrl(apiUrl) {
|
|
|
9
9
|
return apiUrl.replace(/\/+$/, '');
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
|
-
* POST /identity/
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* directly instead of a bare HTTP status.
|
|
12
|
+
* POST /identity/cli-authorizations — opens a browser-approved login (OAuth device-flow
|
|
13
|
+
* shape). Public: the CLI has nothing to authenticate with yet. The device secret is
|
|
14
|
+
* the CLI's half; the URL carries only the user code the browser needs.
|
|
16
15
|
*/
|
|
17
|
-
export async function
|
|
18
|
-
const response = await fetchFn(`${baseUrl(apiUrl)}/identity/
|
|
16
|
+
export async function startCliAuthorization(apiUrl, runnerName, fetchFn = fetch) {
|
|
17
|
+
const response = await fetchFn(`${baseUrl(apiUrl)}/identity/cli-authorizations`, {
|
|
19
18
|
method: 'POST',
|
|
20
19
|
headers: { 'Content-Type': 'application/json' },
|
|
21
|
-
body: JSON.stringify({
|
|
20
|
+
body: JSON.stringify({ runnerName }),
|
|
22
21
|
});
|
|
23
|
-
if (429 === response.status) {
|
|
24
|
-
throw new BackendRequestError('too many failed login attempts — try again in a minute', response.status);
|
|
25
|
-
}
|
|
26
|
-
if (401 === response.status || 422 === response.status) {
|
|
27
|
-
throw new BackendRequestError('invalid email or password', response.status);
|
|
28
|
-
}
|
|
29
22
|
if (!response.ok) {
|
|
30
|
-
throw new BackendRequestError(`login
|
|
23
|
+
throw new BackendRequestError(`could not start a login (status ${String(response.status)})`, response.status);
|
|
31
24
|
}
|
|
32
|
-
|
|
33
|
-
const jwtToken = body.token?.jwtToken;
|
|
34
|
-
if (!jwtToken) {
|
|
35
|
-
throw new BackendRequestError('login succeeded but the response had no JWT');
|
|
36
|
-
}
|
|
37
|
-
return { jwtToken };
|
|
25
|
+
return (await response.json());
|
|
38
26
|
}
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
|
|
27
|
+
/**
|
|
28
|
+
* POST /identity/cli-authorizations/claim — one poll. The secret travels in the body so
|
|
29
|
+
* it never lands in an access log. `approved` comes with the key exactly once; the
|
|
30
|
+
* backend forgets the authorization right after, so a replay is a 404.
|
|
31
|
+
*/
|
|
32
|
+
export async function claimCliAuthorization(apiUrl, deviceSecret, apiKeyName, fetchFn = fetch) {
|
|
33
|
+
const response = await fetchFn(`${baseUrl(apiUrl)}/identity/cli-authorizations/claim`, {
|
|
42
34
|
method: 'POST',
|
|
43
|
-
headers: { 'Content-Type': 'application/json'
|
|
44
|
-
body: JSON.stringify({
|
|
35
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({ deviceSecret, apiKeyName }),
|
|
45
37
|
});
|
|
38
|
+
if (404 === response.status) {
|
|
39
|
+
throw new BackendRequestError('this login request is no longer known to the server', response.status);
|
|
40
|
+
}
|
|
46
41
|
if (!response.ok) {
|
|
47
|
-
throw new BackendRequestError(`
|
|
42
|
+
throw new BackendRequestError(`polling the login failed (status ${String(response.status)})`, response.status);
|
|
48
43
|
}
|
|
49
44
|
return (await response.json());
|
|
50
45
|
}
|
package/dist/cli.js
CHANGED
|
@@ -1,55 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { realpathSync } from 'node:fs';
|
|
3
|
-
import { hostname } from 'node:os';
|
|
4
3
|
import { pathToFileURL } from 'node:url';
|
|
5
|
-
import { clearStoredConfig, loadStoredConfig, resolveFleetConfig
|
|
6
|
-
import {
|
|
4
|
+
import { clearStoredConfig, loadStoredConfig, resolveFleetConfig } from './config.js';
|
|
5
|
+
import { listApiKeys, revokeApiKey } from './backend-client.js';
|
|
7
6
|
import { FleetStartupError, fleetOptionsFromEnv, runFleet } from './fleet/loop.js';
|
|
8
|
-
import {
|
|
9
|
-
const DEFAULT_API_URL = 'http://localhost/api';
|
|
7
|
+
import { parseLoginArgs, runLogin } from './login.js';
|
|
10
8
|
function errorMessage(err) {
|
|
11
9
|
return err instanceof Error ? err.message : String(err);
|
|
12
10
|
}
|
|
13
|
-
/**
|
|
14
|
-
* Interactive login: email+password against /identity/login, then mints a fresh
|
|
15
|
-
* long-lived API key via /identity/api-keys and saves it locally — the "CLI mode"
|
|
16
|
-
* counterpart to setting REFLEET_API_URL/REFLEET_API_KEY by hand for automatic/cloud
|
|
17
|
-
* mode. Revokes a previous local login's key first (best-effort) so re-running
|
|
18
|
-
* `login` doesn't litter the account's key list every time.
|
|
19
|
-
*/
|
|
20
|
-
async function runLogin() {
|
|
21
|
-
const existing = loadStoredConfig();
|
|
22
|
-
const prompter = createPrompter();
|
|
23
|
-
let apiUrl, email, password;
|
|
24
|
-
try {
|
|
25
|
-
const apiUrlInput = await prompter.question(`API URL [${DEFAULT_API_URL}]: `);
|
|
26
|
-
apiUrl = '' === apiUrlInput ? DEFAULT_API_URL : apiUrlInput;
|
|
27
|
-
email = await prompter.question('Email: ');
|
|
28
|
-
password = await prompter.questionHidden('Password: ');
|
|
29
|
-
}
|
|
30
|
-
finally {
|
|
31
|
-
prompter.close();
|
|
32
|
-
}
|
|
33
|
-
const { jwtToken } = await login(apiUrl, email, password);
|
|
34
|
-
const created = await createApiKey(apiUrl, jwtToken, `refleet-runner (${hostname()})`);
|
|
35
|
-
if (existing) {
|
|
36
|
-
try {
|
|
37
|
-
await revokeApiKey(existing.apiUrl, existing.apiKey, existing.apiKeyId);
|
|
38
|
-
}
|
|
39
|
-
catch (err) {
|
|
40
|
-
process.stderr.write(`warning: failed to revoke the previous local API key: ${errorMessage(err)}\n`);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
saveStoredConfig({
|
|
44
|
-
apiUrl,
|
|
45
|
-
apiKey: created.token,
|
|
46
|
-
apiKeyId: created.id,
|
|
47
|
-
accountEmail: email,
|
|
48
|
-
createdAt: created.createdAt,
|
|
49
|
-
});
|
|
50
|
-
process.stdout.write(`Logged in as ${email}. API key ${created.prefix}… saved to your local config.\n`);
|
|
51
|
-
process.stdout.write("Run 'refleet-runner run' to start the runner.\n");
|
|
52
|
-
}
|
|
53
11
|
/** Revokes the locally saved key on the backend (best-effort) and clears the local config. */
|
|
54
12
|
async function runLogout() {
|
|
55
13
|
const existing = loadStoredConfig();
|
|
@@ -70,11 +28,11 @@ async function runLogout() {
|
|
|
70
28
|
async function runWhoami() {
|
|
71
29
|
const resolved = resolveFleetConfig(process.env);
|
|
72
30
|
if (!resolved) {
|
|
73
|
-
process.stdout.write("Not logged in, and REFLEET_API_URL/REFLEET_API_KEY aren't set. Run 'refleet
|
|
31
|
+
process.stdout.write("Not logged in, and REFLEET_API_URL/REFLEET_API_KEY aren't set. Run 'refleet login'.\n");
|
|
74
32
|
process.exitCode = 1;
|
|
75
33
|
return;
|
|
76
34
|
}
|
|
77
|
-
const sourceLabel = 'env' === resolved.source ? 'environment variables (automatic mode)' : 'refleet
|
|
35
|
+
const sourceLabel = 'env' === resolved.source ? 'environment variables (automatic mode)' : 'refleet login';
|
|
78
36
|
process.stdout.write(`API URL: ${resolved.apiUrl}\n`);
|
|
79
37
|
process.stdout.write(`API key: ${resolved.apiKey.slice(0, 12)}…\n`);
|
|
80
38
|
process.stdout.write(`Source: ${sourceLabel}\n`);
|
|
@@ -96,11 +54,11 @@ async function runWhoami() {
|
|
|
96
54
|
async function runRunner() {
|
|
97
55
|
const credentials = resolveFleetConfig(process.env);
|
|
98
56
|
if (!credentials) {
|
|
99
|
-
throw new Error("REFLEET_API_URL and REFLEET_API_KEY are both required — this runner only supports fleet mode. Run 'refleet
|
|
57
|
+
throw new Error("REFLEET_API_URL and REFLEET_API_KEY are both required — this runner only supports fleet mode. Run 'refleet login' (or set them directly) first.");
|
|
100
58
|
}
|
|
101
59
|
const controller = new AbortController();
|
|
102
60
|
const stop = () => {
|
|
103
|
-
process.stdout.write('refleet
|
|
61
|
+
process.stdout.write('refleet: shutting down after the current job\n');
|
|
104
62
|
controller.abort();
|
|
105
63
|
};
|
|
106
64
|
process.once('SIGINT', stop);
|
|
@@ -114,7 +72,7 @@ async function runRunner() {
|
|
|
114
72
|
}
|
|
115
73
|
// Reached only through an unexpected exception inside a poll loop: exit non-zero so
|
|
116
74
|
// the supervisor (Docker restart policy, systemd) restarts the whole runner.
|
|
117
|
-
process.stderr.write(`refleet
|
|
75
|
+
process.stderr.write(`refleet: fleet loop died: ${errorMessage(err)}\n`);
|
|
118
76
|
process.exitCode = 1;
|
|
119
77
|
}
|
|
120
78
|
}
|
|
@@ -122,7 +80,7 @@ async function main() {
|
|
|
122
80
|
const command = process.argv[2];
|
|
123
81
|
switch (command) {
|
|
124
82
|
case 'login':
|
|
125
|
-
await runLogin();
|
|
83
|
+
await runLogin(parseLoginArgs(process.argv.slice(3), process.env));
|
|
126
84
|
return;
|
|
127
85
|
case 'logout':
|
|
128
86
|
await runLogout();
|
|
@@ -134,13 +92,13 @@ async function main() {
|
|
|
134
92
|
await runRunner();
|
|
135
93
|
return;
|
|
136
94
|
default:
|
|
137
|
-
process.stderr.write(`refleet
|
|
138
|
-
process.stderr.write('Usage: refleet-
|
|
95
|
+
process.stderr.write(`refleet: unknown command ${JSON.stringify(command ?? '')}\n`);
|
|
96
|
+
process.stderr.write('Usage: refleet <login [--api-url <url>] [--no-browser]|logout|whoami|run>\n');
|
|
139
97
|
process.exitCode = 1;
|
|
140
98
|
}
|
|
141
99
|
}
|
|
142
100
|
/**
|
|
143
|
-
* npm links bin entries as symlinks, so argv[1] is node_modules/.bin/refleet
|
|
101
|
+
* npm links bin entries as symlinks, so argv[1] is node_modules/.bin/refleet while
|
|
144
102
|
* import.meta.url is the resolved dist/cli.js — comparing them raw made the installed CLI a
|
|
145
103
|
* silent no-op. Resolve the symlink, and build the URL rather than concatenating it so paths
|
|
146
104
|
* with spaces compare correctly too.
|
|
@@ -159,7 +117,7 @@ function isMainModule() {
|
|
|
159
117
|
}
|
|
160
118
|
if (isMainModule()) {
|
|
161
119
|
main().catch((err) => {
|
|
162
|
-
process.stderr.write(`refleet
|
|
120
|
+
process.stderr.write(`refleet: ${errorMessage(err)}\n`);
|
|
163
121
|
process.exitCode = 1;
|
|
164
122
|
});
|
|
165
123
|
}
|
package/dist/config.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
/**
|
|
5
|
-
* Local login credentials saved by `refleet
|
|
5
|
+
* Local login credentials saved by `refleet login`, read back by `whoami`/
|
|
6
6
|
* `logout` and used by `run` when REFLEET_API_URL/REFLEET_API_KEY aren't set.
|
|
7
7
|
* Lives under XDG_CONFIG_HOME (falling back to ~/.config, the convention
|
|
8
8
|
* most CLI tools on the host already follow) rather than inside this repo, since
|
package/dist/detect.js
CHANGED
|
@@ -46,7 +46,7 @@ export function resolveExecutable(command, env, isExecutable = defaultIsExecutab
|
|
|
46
46
|
}
|
|
47
47
|
/**
|
|
48
48
|
* Detects which of the supported agent CLIs are available on this host — the
|
|
49
|
-
* auto-detection this whole runner redesign is for. Called once when `refleet
|
|
49
|
+
* auto-detection this whole runner redesign is for. Called once when `refleet
|
|
50
50
|
* run` starts (see fleet/loop.ts); a CLI installed after that requires a restart, not
|
|
51
51
|
* a live re-probe.
|
|
52
52
|
*/
|
package/dist/fleet/gitlab.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every merge request a runner opens carries this label so customers can tell Refleet's
|
|
3
|
+
* work from their own. The backend creates it in the group at sync time; the runner only
|
|
4
|
+
* falls back to a project label when it is missing, because GitLab would otherwise invent
|
|
5
|
+
* one in a random colour the moment the MR is created.
|
|
6
|
+
*/
|
|
7
|
+
export const REFLEET_LABEL = { name: 'refleet', color: '#000000', description: 'Merge requests opened by Refleet' };
|
|
1
8
|
export class GitLabRequestError extends Error {
|
|
2
9
|
status;
|
|
3
10
|
constructor(message, status) {
|
|
@@ -31,6 +38,28 @@ async function findOpenMergeRequest(credentials, projectId, spec, fetchFn) {
|
|
|
31
38
|
const list = (await response.json());
|
|
32
39
|
return list[0]?.web_url ?? null;
|
|
33
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Returns true when the label is available to the project (its own or inherited from an
|
|
43
|
+
* ancestor group) by the time it returns. Never throws: a missing label is worth a warning,
|
|
44
|
+
* not a failed job — the MR itself is what the shift is about.
|
|
45
|
+
*/
|
|
46
|
+
export async function ensureRefleetLabel(credentials, projectId, fetchFn = fetch) {
|
|
47
|
+
try {
|
|
48
|
+
const query = new URLSearchParams({ search: REFLEET_LABEL.name, include_ancestor_groups: 'true', per_page: '100' });
|
|
49
|
+
const listed = await gitlabRequest(credentials, fetchFn, 'GET', `/projects/${projectId}/labels?${query.toString()}`);
|
|
50
|
+
if (listed.ok) {
|
|
51
|
+
const labels = (await listed.json());
|
|
52
|
+
if (labels.some(label => label.name === REFLEET_LABEL.name)) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const created = await gitlabRequest(credentials, fetchFn, 'POST', `/projects/${projectId}/labels`, REFLEET_LABEL);
|
|
57
|
+
return created.ok || 409 === created.status;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
34
63
|
/**
|
|
35
64
|
* Opens a merge request straight through the GitLab REST API — the branch has already
|
|
36
65
|
* been pushed by publishChange, so unlike `glab mr create --push` this never needs
|
|
@@ -46,6 +75,7 @@ export async function createMergeRequest(credentials, projectId, spec, fetchFn =
|
|
|
46
75
|
target_branch: spec.targetBranch,
|
|
47
76
|
title: spec.title,
|
|
48
77
|
description: spec.description,
|
|
78
|
+
...(spec.labels ? { labels: spec.labels.join(',') } : {}),
|
|
49
79
|
remove_source_branch: true,
|
|
50
80
|
});
|
|
51
81
|
if (409 === response.status) {
|
package/dist/fleet/workspace.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { gitAuthConfig } from './git.js';
|
|
4
|
-
import { createMergeRequest } from './gitlab.js';
|
|
4
|
+
import { createMergeRequest, ensureRefleetLabel, REFLEET_LABEL } from './gitlab.js';
|
|
5
5
|
const LAST_USED_MARKER = '.slipway-last-used';
|
|
6
6
|
function directorySizeBytes(dir) {
|
|
7
7
|
let total = 0;
|
|
@@ -119,6 +119,12 @@ export async function publishChange(workspace, credentials, repoDir, project, sh
|
|
|
119
119
|
}
|
|
120
120
|
await workspace.git(['-c', 'user.name=Slipway', '-c', 'user.email=slipway@localhost', 'commit', '-m', title], repoDir);
|
|
121
121
|
await workspace.git([...gitAuthConfig(credentials.accessToken), 'push', '--force', 'origin', `HEAD:refs/heads/${branchName}`], repoDir);
|
|
122
|
-
|
|
122
|
+
// Without the label in place GitLab would create it in a random colour, so the MR
|
|
123
|
+
// rather goes out unlabelled than off-brand.
|
|
124
|
+
const labelled = await ensureRefleetLabel(credentials, project.externalId, fetchFn);
|
|
125
|
+
if (!labelled) {
|
|
126
|
+
workspace.log(`could not ensure the "${REFLEET_LABEL.name}" label on ${project.path}; opening the merge request without it`);
|
|
127
|
+
}
|
|
128
|
+
const mergeRequestUrl = await createMergeRequest(credentials, project.externalId, { sourceBranch: branchName, targetBranch: project.defaultBranch, title, description, labels: labelled ? [REFLEET_LABEL.name] : undefined }, fetchFn);
|
|
123
129
|
return { branchName, mergeRequestUrl };
|
|
124
130
|
}
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import { claimCliAuthorization, revokeApiKey, startCliAuthorization, } from './backend-client.js';
|
|
4
|
+
import { loadStoredConfig, saveStoredConfig } from './config.js';
|
|
5
|
+
export const DEFAULT_API_URL = 'https://api.refleet.it/api';
|
|
6
|
+
/**
|
|
7
|
+
* `refleet login [--api-url <url>] [--no-browser]`. The URL falls back to REFLEET_API_URL
|
|
8
|
+
* and then to production, so a plain `refleet login` just works and a self-hosted or
|
|
9
|
+
* local stack is one flag away. `--no-browser` only prints the link, for SSH sessions
|
|
10
|
+
* and machines without a desktop — the link opens fine from any other device.
|
|
11
|
+
*/
|
|
12
|
+
export function parseLoginArgs(args, env) {
|
|
13
|
+
let apiUrl = env.REFLEET_API_URL?.trim() || DEFAULT_API_URL;
|
|
14
|
+
let openBrowser = true;
|
|
15
|
+
for (let i = 0; i < args.length; i++) {
|
|
16
|
+
const arg = args[i] ?? '';
|
|
17
|
+
if ('--no-browser' === arg) {
|
|
18
|
+
openBrowser = false;
|
|
19
|
+
}
|
|
20
|
+
else if ('--api-url' === arg) {
|
|
21
|
+
const value = args[i + 1];
|
|
22
|
+
if (undefined === value || value.startsWith('--')) {
|
|
23
|
+
throw new Error('--api-url needs a value, e.g. --api-url https://api.refleet.it/api');
|
|
24
|
+
}
|
|
25
|
+
apiUrl = value;
|
|
26
|
+
i++;
|
|
27
|
+
}
|
|
28
|
+
else if (arg.startsWith('--api-url=')) {
|
|
29
|
+
apiUrl = arg.slice('--api-url='.length);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
throw new Error(`unknown option ${JSON.stringify(arg)}\nUsage: refleet login [--api-url <url>] [--no-browser]`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if ('' === apiUrl) {
|
|
36
|
+
throw new Error('--api-url must not be empty');
|
|
37
|
+
}
|
|
38
|
+
return { apiUrl, openBrowser };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Best-effort: a failure here is not a failure of the login, the URL is printed either
|
|
42
|
+
* way. Detached and unref'd so a browser that outlives the CLI never keeps it alive.
|
|
43
|
+
*/
|
|
44
|
+
export function openInBrowser(url, platform = process.platform) {
|
|
45
|
+
const [command, args] = 'darwin' === platform
|
|
46
|
+
? ['open', [url]]
|
|
47
|
+
: 'win32' === platform
|
|
48
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
49
|
+
: ['xdg-open', [url]];
|
|
50
|
+
try {
|
|
51
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
52
|
+
child.on('error', () => undefined);
|
|
53
|
+
child.unref();
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const defaultDeps = {
|
|
61
|
+
fetchFn: fetch,
|
|
62
|
+
sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
63
|
+
open: openInBrowser,
|
|
64
|
+
now: Date.now,
|
|
65
|
+
stdout: line => process.stdout.write(`${line}\n`),
|
|
66
|
+
stderr: line => process.stderr.write(`${line}\n`),
|
|
67
|
+
runnerName: hostname(),
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Start → show the link → poll until the browser answers → store the key. Revokes the
|
|
71
|
+
* key of a previous local login first (best-effort) so re-running `login` doesn't
|
|
72
|
+
* litter the account's key list; the new key is saved before that so a failed revoke
|
|
73
|
+
* can never leave the machine with nothing.
|
|
74
|
+
*/
|
|
75
|
+
export async function runLogin(options, overrides = {}) {
|
|
76
|
+
const deps = { ...defaultDeps, ...overrides };
|
|
77
|
+
const existing = loadStoredConfig(deps.configDir);
|
|
78
|
+
const started = await startCliAuthorization(options.apiUrl, deps.runnerName, deps.fetchFn);
|
|
79
|
+
announce(started, options, deps);
|
|
80
|
+
const claimed = await waitForDecision(options.apiUrl, started, deps);
|
|
81
|
+
const credentials = {
|
|
82
|
+
apiUrl: options.apiUrl,
|
|
83
|
+
apiKey: claimed.apiKey.token,
|
|
84
|
+
apiKeyId: claimed.apiKey.id,
|
|
85
|
+
accountEmail: claimed.accountEmail,
|
|
86
|
+
createdAt: claimed.apiKey.createdAt,
|
|
87
|
+
};
|
|
88
|
+
saveStoredConfig(credentials, deps.configDir);
|
|
89
|
+
if (existing) {
|
|
90
|
+
try {
|
|
91
|
+
await revokeApiKey(existing.apiUrl, existing.apiKey, existing.apiKeyId, deps.fetchFn);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
deps.stderr(`warning: failed to revoke the previous local API key: ${err instanceof Error ? err.message : String(err)}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
deps.stdout(`Logged in as ${claimed.accountEmail}. API key ${claimed.apiKey.prefix}… saved to your local config.`);
|
|
98
|
+
deps.stdout("Run 'refleet run' to start the runner.");
|
|
99
|
+
return credentials;
|
|
100
|
+
}
|
|
101
|
+
function announce(started, options, deps) {
|
|
102
|
+
const opened = options.openBrowser && deps.open(started.verificationUrl);
|
|
103
|
+
deps.stdout(opened ? 'Opening your browser to finish logging in. If nothing happened, open this link:' : 'Open this link in a browser to finish logging in:');
|
|
104
|
+
deps.stdout('');
|
|
105
|
+
deps.stdout(` ${started.verificationUrl}`);
|
|
106
|
+
deps.stdout('');
|
|
107
|
+
deps.stdout(`Waiting for approval (this link expires ${describeExpiry(started.expiresAt, deps.now())})…`);
|
|
108
|
+
}
|
|
109
|
+
async function waitForDecision(apiUrl, started, deps) {
|
|
110
|
+
const intervalMs = Math.max(1, started.pollIntervalSeconds) * 1000;
|
|
111
|
+
const apiKeyName = `refleet-runner (${deps.runnerName})`;
|
|
112
|
+
for (;;) {
|
|
113
|
+
await deps.sleep(intervalMs);
|
|
114
|
+
const claimed = await claimCliAuthorization(apiUrl, started.deviceSecret, apiKeyName, deps.fetchFn);
|
|
115
|
+
switch (claimed.status) {
|
|
116
|
+
case 'approved':
|
|
117
|
+
return claimed;
|
|
118
|
+
case 'denied':
|
|
119
|
+
throw new Error('login denied in the browser');
|
|
120
|
+
case 'expired':
|
|
121
|
+
throw new Error("login link expired before it was approved — run 'refleet login' again");
|
|
122
|
+
case 'pending':
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function describeExpiry(expiresAt, now) {
|
|
128
|
+
const minutes = Math.round((Date.parse(expiresAt) - now) / 60_000);
|
|
129
|
+
return Number.isFinite(minutes) && minutes > 0 ? `in ${String(minutes)} min` : 'soon';
|
|
130
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@refleet-it/runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.199",
|
|
4
4
|
"description": "Refleet fleet runner: claims code-modernisation jobs from the Refleet API and runs them through Claude Code or Kiro on your own machine.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"refleet",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"license": "MIT",
|
|
23
23
|
"type": "module",
|
|
24
24
|
"bin": {
|
|
25
|
+
"refleet": "dist/cli.js",
|
|
25
26
|
"refleet-runner": "dist/cli.js"
|
|
26
27
|
},
|
|
27
28
|
"engines": {
|
package/dist/prompt.js
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
const CTRL_C = String.fromCharCode(3);
|
|
2
|
-
const BACKSPACE = String.fromCharCode(8);
|
|
3
|
-
const DELETE = String.fromCharCode(127);
|
|
4
|
-
function stripTrailingCr(line) {
|
|
5
|
-
return line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
6
|
-
}
|
|
7
|
-
/**
|
|
8
|
-
* Deliberately not `node:readline/promises`: its `question()` attaches a one-shot
|
|
9
|
-
* 'line' listener per call, but the interface starts consuming stdin as soon as
|
|
10
|
-
* it's constructed — when a whole multi-line answer set is already sitting in a
|
|
11
|
-
* pipe (piped/test input, not a human typing live), the first chunk gets parsed and
|
|
12
|
-
* emitted as 'line' events before a listener exists for the 2nd/3rd question, and
|
|
13
|
-
* those lines are silently lost (confirmed by hand: a second `rl.question()` call
|
|
14
|
-
* hung forever after piping 3 lines in one go). This hand-rolled buffer only ever
|
|
15
|
-
* attaches a listener while a read is actually pending, so nothing can be dropped.
|
|
16
|
-
*/
|
|
17
|
-
export function createPrompter(input = process.stdin, output = process.stdout) {
|
|
18
|
-
let buffer = '';
|
|
19
|
-
function takeBufferedLine() {
|
|
20
|
-
const newlineIndex = buffer.indexOf('\n');
|
|
21
|
-
if (-1 === newlineIndex) {
|
|
22
|
-
return null;
|
|
23
|
-
}
|
|
24
|
-
const line = buffer.slice(0, newlineIndex);
|
|
25
|
-
buffer = buffer.slice(newlineIndex + 1);
|
|
26
|
-
return stripTrailingCr(line);
|
|
27
|
-
}
|
|
28
|
-
function readLine() {
|
|
29
|
-
const buffered = takeBufferedLine();
|
|
30
|
-
if (null !== buffered) {
|
|
31
|
-
return Promise.resolve(buffered);
|
|
32
|
-
}
|
|
33
|
-
return new Promise(resolve => {
|
|
34
|
-
const cleanup = () => {
|
|
35
|
-
input.removeListener('data', onData);
|
|
36
|
-
input.removeListener('end', onEnd);
|
|
37
|
-
};
|
|
38
|
-
const onData = (chunk) => {
|
|
39
|
-
buffer += chunk.toString('utf8');
|
|
40
|
-
const line = takeBufferedLine();
|
|
41
|
-
if (null !== line) {
|
|
42
|
-
cleanup();
|
|
43
|
-
resolve(line);
|
|
44
|
-
}
|
|
45
|
-
};
|
|
46
|
-
const onEnd = () => {
|
|
47
|
-
cleanup();
|
|
48
|
-
const rest = buffer;
|
|
49
|
-
buffer = '';
|
|
50
|
-
resolve(stripTrailingCr(rest));
|
|
51
|
-
};
|
|
52
|
-
input.on('data', onData);
|
|
53
|
-
input.on('end', onEnd);
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
async function question(text) {
|
|
57
|
-
output.write(text);
|
|
58
|
-
return (await readLine()).trim();
|
|
59
|
-
}
|
|
60
|
-
async function questionHidden(text) {
|
|
61
|
-
if (!input.isTTY) {
|
|
62
|
-
return question(text);
|
|
63
|
-
}
|
|
64
|
-
return readMasked(text, input, output);
|
|
65
|
-
}
|
|
66
|
-
function close() {
|
|
67
|
-
input.pause();
|
|
68
|
-
}
|
|
69
|
-
return { question, questionHidden, close };
|
|
70
|
-
}
|
|
71
|
-
function readMasked(text, input, output) {
|
|
72
|
-
output.write(text);
|
|
73
|
-
return new Promise(resolve => {
|
|
74
|
-
let value = '';
|
|
75
|
-
const cleanup = () => {
|
|
76
|
-
input.setRawMode(false);
|
|
77
|
-
input.pause();
|
|
78
|
-
input.removeListener('data', onData);
|
|
79
|
-
};
|
|
80
|
-
const onData = (chunk) => {
|
|
81
|
-
for (const char of chunk.toString('utf8')) {
|
|
82
|
-
if (CTRL_C === char) {
|
|
83
|
-
cleanup();
|
|
84
|
-
output.write('\n');
|
|
85
|
-
process.exit(130);
|
|
86
|
-
}
|
|
87
|
-
else if ('\r' === char || '\n' === char) {
|
|
88
|
-
cleanup();
|
|
89
|
-
output.write('\n');
|
|
90
|
-
resolve(value);
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
else if (DELETE === char || BACKSPACE === char) {
|
|
94
|
-
if (value.length > 0) {
|
|
95
|
-
value = value.slice(0, -1);
|
|
96
|
-
output.write(`${BACKSPACE} ${BACKSPACE}`);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
else {
|
|
100
|
-
value += char;
|
|
101
|
-
output.write('*');
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
input.setRawMode(true);
|
|
106
|
-
input.resume();
|
|
107
|
-
input.setEncoding('utf8');
|
|
108
|
-
input.on('data', onData);
|
|
109
|
-
});
|
|
110
|
-
}
|