@refleet-it/runner 0.1.197 → 0.1.200

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 CHANGED
@@ -28,10 +28,15 @@ against earlier releases.
28
28
  ## Connect and run
29
29
 
30
30
  ```bash
31
- refleet login # API URL, email, passwordmints an API key and stores it locally
31
+ refleet login # opens refleet.it, you approve in the browser → an API key is stored locally
32
32
  refleet run
33
33
  ```
34
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
+
35
40
  `login` keeps its API key in `~/.config/refleet/runner.json` (`XDG_CONFIG_HOME` is honoured).
36
41
  Anything headless — a server, a container, CI — should instead set `REFLEET_API_URL` and
37
42
  `REFLEET_API_KEY` directly; when both are set they take precedence over the stored login.
@@ -9,42 +9,37 @@ function baseUrl(apiUrl) {
9
9
  return apiUrl.replace(/\/+$/, '');
10
10
  }
11
11
  /**
12
- * POST /identity/loginthe same endpoint the frontend's own login form uses.
13
- * Maps the two failure modes LoginController documents (see
14
- * backend/.../Auth/Login/LoginController.php) to messages a CLI user acts on
15
- * directly instead of a bare HTTP status.
12
+ * POST /identity/cli-authorizationsopens 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 login(apiUrl, email, password, fetchFn = fetch) {
18
- const response = await fetchFn(`${baseUrl(apiUrl)}/identity/login`, {
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({ email, password }),
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 failed (status ${String(response.status)})`, response.status);
23
+ throw new BackendRequestError(`could not start a login (status ${String(response.status)})`, response.status);
31
24
  }
32
- const body = (await response.json());
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
- /** POST /identity/api-keys, authenticated with the short-lived JWT from login(). */
40
- export async function createApiKey(apiUrl, jwtToken, name, fetchFn = fetch) {
41
- const response = await fetchFn(`${baseUrl(apiUrl)}/identity/api-keys`, {
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', Authorization: `Bearer ${jwtToken}` },
44
- body: JSON.stringify({ name }),
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(`failed to create an API key (status ${String(response.status)})`, response.status);
42
+ throw new BackendRequestError(`polling the login failed (status ${String(response.status)})`, response.status);
48
43
  }
49
44
  return (await response.json());
50
45
  }
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { AgentExecutionError } from './types.js';
2
+ import { agentEnv, AgentExecutionError } from './types.js';
3
3
  /**
4
4
  * Builds the `claude` CLI argument list for one job kind. Ported 1:1 from
5
5
  * runner/claude/entrypoint.sh's build_claude_args/build_kind_claude_args: qualification
@@ -107,6 +107,7 @@ export class ClaudeBackend {
107
107
  let buffer = '';
108
108
  const child = this.spawnFn(this.executablePath, args, {
109
109
  cwd: opts.cwd,
110
+ env: agentEnv(this.env),
110
111
  stdio: ['ignore', 'pipe', 'inherit'],
111
112
  });
112
113
  child.stdout.setEncoding('utf8');
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { AgentExecutionError } from './types.js';
2
+ import { agentEnv, AgentExecutionError } from './types.js';
3
3
  /**
4
4
  * Pulls the ACP session id out of a `session/new` (or `session/load`) response.
5
5
  * Pure/testable in isolation from the actual JSON-RPC transport.
@@ -177,7 +177,7 @@ export class KiroBackend {
177
177
  // protocol-native session/request_permission (see AcpConnection.handlePeerRequest).
178
178
  const child = this.spawnFn(this.executablePath, ['acp'], {
179
179
  cwd: opts.cwd,
180
- env: this.env,
180
+ env: agentEnv(this.env),
181
181
  stdio: ['pipe', 'pipe', 'inherit'],
182
182
  });
183
183
  const chunks = [];
@@ -1,2 +1,11 @@
1
1
  export class AgentExecutionError extends Error {
2
2
  }
3
+ /**
4
+ * The agent runs untrusted code and prompts from a customer repository with tool
5
+ * permissions switched off, so anything it can read from its environment it can also be
6
+ * talked into exfiltrating. The runner's own credentials — the API key that fetches
7
+ * GitLab tokens — have no business there.
8
+ */
9
+ export function agentEnv(env) {
10
+ return Object.fromEntries(Object.entries(env).filter(([name]) => !name.startsWith('REFLEET_')));
11
+ }
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, saveStoredConfig } from './config.js';
6
- import { createApiKey, listApiKeys, login, revokeApiKey } from './backend-client.js';
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 { createPrompter } from './prompt.js';
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 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();
@@ -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();
@@ -135,7 +93,7 @@ async function main() {
135
93
  return;
136
94
  default:
137
95
  process.stderr.write(`refleet: unknown command ${JSON.stringify(command ?? '')}\n`);
138
- process.stderr.write('Usage: refleet <login|logout|whoami|run>\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
  }
package/dist/fleet/api.js CHANGED
@@ -92,8 +92,10 @@ export class FleetApi {
92
92
  }
93
93
  }
94
94
  /**
95
- * Fetched per job rather than once at startup so a reconnected/rotated GitLab
96
- * token is picked up without restarting the runner.
95
+ * Fetched right before every git/GitLab call rather than once per job or at startup:
96
+ * OAuth-backed connections hand out short-lived access tokens, and the backend refreshes
97
+ * one only when asked for it, so a token fetched before a long agent run may be dead by
98
+ * the time the branch is pushed.
97
99
  */
98
100
  async fetchGitLabCredentials() {
99
101
  const response = await this.request('GET', '/runner/gitlab-credentials');
package/dist/fleet/git.js CHANGED
@@ -11,18 +11,25 @@ export class GitError extends Error {
11
11
  }
12
12
  }
13
13
  /**
14
- * GitLab's git-http backend (unlike its REST API) does not accept the PRIVATE-TOKEN
14
+ * GitLab's git-http backend (unlike its REST API) does not accept a bearer/private token
15
15
  * header — it only honors HTTP Basic auth, so the token is sent as a Basic credential
16
- * (any non-empty username works). Passed per invocation as `-c http.extraHeader` so it
17
- * is never written into the cached checkout's git config.
16
+ * (the `oauth2` username works for both OAuth and personal/group access tokens).
17
+ * Handed to git through GIT_CONFIG_* environment variables rather than `-c` on the
18
+ * command line, so it is never written into the cached checkout's git config, never
19
+ * visible in `ps`, and never part of the argv a GitError echoes back into logs and job
20
+ * failure reports.
18
21
  */
19
- export function gitAuthConfig(accessToken) {
22
+ export function gitAuthEnv(accessToken) {
20
23
  const basic = Buffer.from(`oauth2:${accessToken}`).toString('base64');
21
- return ['-c', `http.extraHeader=Authorization: Basic ${basic}`];
24
+ return {
25
+ GIT_CONFIG_COUNT: '1',
26
+ GIT_CONFIG_KEY_0: 'http.extraHeader',
27
+ GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}`,
28
+ };
22
29
  }
23
30
  export function createGitRunner(execFileFn = execFile) {
24
- return (args, cwd) => new Promise((resolve, reject) => {
25
- execFileFn('git', args, { cwd, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }, (error, stdout, stderr) => {
31
+ return (args, cwd, env = {}) => new Promise((resolve, reject) => {
32
+ execFileFn('git', args, { cwd, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', ...env } }, (error, stdout, stderr) => {
26
33
  if (error) {
27
34
  const code = 'code' in error && 'number' === typeof error.code ? error.code : null;
28
35
  reject(new GitError(args, code, String(stderr)));
@@ -19,7 +19,7 @@ async function gitlabRequest(credentials, fetchFn, method, path, body) {
19
19
  return fetchFn(`${apiBase(credentials)}${path}`, {
20
20
  method,
21
21
  headers: {
22
- 'PRIVATE-TOKEN': credentials.accessToken,
22
+ Authorization: `Bearer ${credentials.accessToken}`,
23
23
  'Content-Type': 'application/json',
24
24
  },
25
25
  body: undefined === body ? undefined : JSON.stringify(body),
package/dist/fleet/job.js CHANGED
@@ -83,9 +83,16 @@ export async function claimAndRunJob(deps) {
83
83
  }
84
84
  return;
85
85
  }
86
- await publishAndReport(deps, job, credentials, repoDir, project, output, report);
86
+ await publishAndReport(deps, job, repoDir, project, output, report);
87
87
  }
88
- async function publishAndReport(deps, job, credentials, repoDir, project, output, report) {
88
+ async function publishAndReport(deps, job, repoDir, project, output, report) {
89
+ // The agent may have run for longer than the access token fetched for the clone lives.
90
+ const credentials = await deps.api.fetchGitLabCredentials();
91
+ if (!credentials) {
92
+ deps.log(`job ${job.jobId}: failed to fetch GitLab credentials from Slipway before publishing`);
93
+ await report('failure', 'Failed to fetch GitLab credentials from Slipway', { errorMessage: 'gitlab credentials fetch failed' });
94
+ return;
95
+ }
89
96
  let published;
90
97
  try {
91
98
  published = await (deps.publishChange ?? defaultPublishChange)(deps.workspace, credentials, repoDir, project, job.ownerTargetId ?? '', `Apply Slipway shift ${job.ownerId ?? ''}`, tail(output), deps.fetchFn);
@@ -1,6 +1,6 @@
1
1
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { gitAuthConfig } from './git.js';
3
+ import { gitAuthEnv } from './git.js';
4
4
  import { createMergeRequest, ensureRefleetLabel, REFLEET_LABEL } from './gitlab.js';
5
5
  const LAST_USED_MARKER = '.slipway-last-used';
6
6
  function directorySizeBytes(dir) {
@@ -82,10 +82,10 @@ function excludeMarkerFromGit(repoDir) {
82
82
  /** Returns the ready-to-use working directory; throws (GitError) when any git step fails. */
83
83
  export async function syncRepo(workspace, credentials, project) {
84
84
  const repoDir = join(workspace.cacheDir, project.externalId);
85
- const auth = gitAuthConfig(credentials.accessToken);
85
+ const auth = gitAuthEnv(credentials.accessToken);
86
86
  if (existsSync(join(repoDir, '.git'))) {
87
87
  workspace.log(`reusing cached checkout for ${project.path}`);
88
- await workspace.git([...auth, 'fetch', '--prune', 'origin'], repoDir);
88
+ await workspace.git(['fetch', '--prune', 'origin'], repoDir, auth);
89
89
  await workspace.git(['checkout', '-f', project.defaultBranch], repoDir);
90
90
  await workspace.git(['reset', '--hard', `origin/${project.defaultBranch}`], repoDir);
91
91
  await workspace.git(['clean', '-fdx'], repoDir);
@@ -95,7 +95,7 @@ export async function syncRepo(workspace, credentials, project) {
95
95
  evictLruUntilUnderCap(workspace);
96
96
  workspace.log(`cloning ${project.path} (first use on this runner)`);
97
97
  const cloneUrl = `${credentials.baseUrl.replace(/\/+$/, '')}/${project.path}.git`;
98
- await workspace.git([...auth, 'clone', '--origin', 'origin', cloneUrl, repoDir]);
98
+ await workspace.git(['clone', '--origin', 'origin', cloneUrl, repoDir], undefined, auth);
99
99
  }
100
100
  touchMarker(repoDir);
101
101
  excludeMarkerFromGit(repoDir);
@@ -118,7 +118,7 @@ export async function publishChange(workspace, credentials, repoDir, project, sh
118
118
  return null;
119
119
  }
120
120
  await workspace.git(['-c', 'user.name=Slipway', '-c', 'user.email=slipway@localhost', 'commit', '-m', title], repoDir);
121
- await workspace.git([...gitAuthConfig(credentials.accessToken), 'push', '--force', 'origin', `HEAD:refs/heads/${branchName}`], repoDir);
121
+ await workspace.git(['push', '--force', 'origin', `HEAD:refs/heads/${branchName}`], repoDir, gitAuthEnv(credentials.accessToken));
122
122
  // Without the label in place GitLab would create it in a random colour, so the MR
123
123
  // rather goes out unlabelled than off-brand.
124
124
  const labelled = await ensureRefleetLabel(credentials, project.externalId, fetchFn);
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.197",
3
+ "version": "0.1.200",
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",
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
- }