@sifurai/cli 0.0.0-stage → 0.1.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/dist/api.js ADDED
@@ -0,0 +1,107 @@
1
+ export const DEFAULT_API = 'https://api.sifur.ai';
2
+ export class ApiError extends Error {
3
+ status;
4
+ code;
5
+ retryAfterSeconds;
6
+ json;
7
+ fromSifur;
8
+ constructor(message, status, code = null, retryAfterSeconds = null,
9
+ /** False when the body wasn't JSON at all: an HTML error page, say, from something that isn't Sifur. */
10
+ json = true,
11
+ /** False when `message` is ours, made up from the status, rather than Sifur's own words. */
12
+ fromSifur = true) {
13
+ super(message);
14
+ this.status = status;
15
+ this.code = code;
16
+ this.retryAfterSeconds = retryAfterSeconds;
17
+ this.json = json;
18
+ this.fromSifur = fromSifur;
19
+ this.name = 'ApiError';
20
+ }
21
+ }
22
+ /** Something the engineer has to act on, printed without a stack trace. */
23
+ export class CliError extends Error {
24
+ constructor(message) {
25
+ super(message);
26
+ this.name = 'CliError';
27
+ }
28
+ }
29
+ const TLS_CODES = new Set([
30
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
31
+ 'UNABLE_TO_GET_ISSUER_CERT',
32
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
33
+ 'SELF_SIGNED_CERT_IN_CHAIN',
34
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
35
+ ]);
36
+ export const TLS_HINT = 'If this is a local or company Sifur with its own certificate, run with NODE_OPTIONS=--use-system-ca (Node 22.15+) or NODE_EXTRA_CA_CERTS=<root cert>.';
37
+ export function isTlsError(cause) {
38
+ if (!cause)
39
+ return false;
40
+ return TLS_CODES.has(cause.code ?? '') || /local issuer certificate|self[- ]signed certificate/i.test(cause.message ?? '');
41
+ }
42
+ export function normalizeApi(api) {
43
+ return api.replace(/\/+$/, '');
44
+ }
45
+ export class Api {
46
+ token;
47
+ userAgent;
48
+ base;
49
+ constructor(base, token = null, userAgent = 'sifur-cli') {
50
+ this.token = token;
51
+ this.userAgent = userAgent;
52
+ this.base = normalizeApi(base);
53
+ }
54
+ withToken(token) {
55
+ return new Api(this.base, token, this.userAgent);
56
+ }
57
+ async request(path, options = {}) {
58
+ const headers = { Accept: 'application/json', 'User-Agent': this.userAgent };
59
+ if (this.token)
60
+ headers.Authorization = `Bearer ${this.token}`;
61
+ if (options.organization)
62
+ headers['X-Organization'] = options.organization;
63
+ let body;
64
+ if (options.form) {
65
+ body = options.form;
66
+ }
67
+ else if (options.body !== undefined) {
68
+ headers['Content-Type'] = 'application/json';
69
+ body = JSON.stringify(options.body);
70
+ }
71
+ let response;
72
+ try {
73
+ response = await fetch(`${this.base}/api/v1/${path.replace(/^\//, '')}`, {
74
+ method: options.method ?? (body === undefined ? 'GET' : 'POST'),
75
+ headers,
76
+ body,
77
+ });
78
+ }
79
+ catch (error) {
80
+ const cause = error.cause;
81
+ const reason = cause?.message ? ` (${cause.message})` : '';
82
+ throw new CliError(`Couldn't reach Sifur at ${this.base}${reason}.${isTlsError(cause) ? `\n${TLS_HINT}` : ''}`);
83
+ }
84
+ const text = await response.text();
85
+ let json = null;
86
+ let isJson = true;
87
+ try {
88
+ json = text ? JSON.parse(text) : null;
89
+ }
90
+ catch {
91
+ json = null;
92
+ isJson = false;
93
+ }
94
+ const status = response.status;
95
+ // undici calls a status it has no name for, like Laravel's 419, "unknown status".
96
+ const statusText = response.statusText && response.statusText !== 'unknown status' ? ` ${response.statusText}` : '';
97
+ if (!response.ok) {
98
+ const said = json?.message?.messages?.[0];
99
+ const code = json?.code ?? json?.data?.code ?? null;
100
+ const retryAfter = Number(response.headers.get('retry-after'));
101
+ throw new ApiError(said ?? `Sifur answered ${status}${statusText}.`, status, code, Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : null, isJson, said !== undefined);
102
+ }
103
+ if (!isJson)
104
+ throw new ApiError(`Sifur answered ${status}${statusText}, but not with JSON.`, status, null, null, false, false);
105
+ return (json?.data ?? null);
106
+ }
107
+ }
package/dist/apply.js ADDED
@@ -0,0 +1,143 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, statSync } from 'node:fs';
3
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { ApiError, CliError } from './api.js';
6
+ import { parseTicketRef, resolveTicket } from './resolve.js';
7
+ import { APPLIED, pruneTicketDir, ticketDir } from './store.js';
8
+ import { defaultWorkdirFor, gitTopLevel } from './workspace.js';
9
+ const INDEX = 'checkouts.json';
10
+ function git(dir, args, input) {
11
+ const result = spawnSync('git', args, { cwd: dir, input, encoding: 'utf8', maxBuffer: 1024 * 1024 * 1024 });
12
+ if (result.error)
13
+ throw new CliError(`Couldn't run git in ${dir}: ${result.error.message}`);
14
+ return { ok: result.status === 0, stdout: result.stdout ?? '', stderr: (result.stderr ?? '').trim() };
15
+ }
16
+ const patchFile = (repository) => `${repository.replace(/[^A-Za-z0-9_.-]/g, '_')}.patch`;
17
+ function filesIn(dir, patch) {
18
+ const numstat = git(dir, ['apply', '--numstat'], patch);
19
+ return numstat.ok ? numstat.stdout.split('\n').filter(Boolean).length : 0;
20
+ }
21
+ async function readIndex(dir) {
22
+ try {
23
+ return JSON.parse(await readFile(join(dir, INDEX), 'utf8'));
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ /** A key names its folder; a uuid is looked for among the tickets with applied changes. */
30
+ async function findAppliedDir(store, ref) {
31
+ const direct = join(ticketDir(store, ref.toUpperCase()), APPLIED);
32
+ if (await readIndex(direct))
33
+ return direct;
34
+ let entries;
35
+ try {
36
+ entries = await readdir(store);
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ for (const entry of entries) {
42
+ const dir = join(store, entry, APPLIED);
43
+ if ((await readIndex(dir))?.ticketUuid === ref.toLowerCase())
44
+ return dir;
45
+ }
46
+ return null;
47
+ }
48
+ /**
49
+ * Puts Sifur's unpushed work for a ticket in the engineer's checkouts, uncommitted, so they can deploy it.
50
+ * Every checkout is checked before any patch is applied.
51
+ */
52
+ export async function applyChanges(api, ref, options, io) {
53
+ const { ticketUuid, key, organization } = await resolveTicket(api, ref);
54
+ let patches;
55
+ try {
56
+ patches = (await api.request(`tickets/${ticketUuid}/changes/patch`, { organization: organization.uuid })) ?? [];
57
+ }
58
+ catch (error) {
59
+ if (error instanceof ApiError && [404, 409].includes(error.status))
60
+ throw new CliError(`Sifur has no changes for ${ref} to apply: ${error.message}`);
61
+ throw error;
62
+ }
63
+ if (patches.length === 0)
64
+ io.out(`${ref}: nothing to apply.`);
65
+ const withChanges = patches.filter((p) => p.patch.trim() !== '');
66
+ for (const p of patches)
67
+ if (!withChanges.includes(p))
68
+ io.out(`${p.repository}: nothing to apply.`);
69
+ if (withChanges.length === 0)
70
+ return;
71
+ const appliedDir = join(ticketDir(options.store, key ?? ('uuid' in parseTicketRef(ref) ? ref : ref.toUpperCase())), APPLIED);
72
+ const index = (await readIndex(appliedDir)) ?? { ticketUuid, checkouts: {} };
73
+ let workdirs = options.workdirs ?? {};
74
+ const named = new Set(patches.map((p) => p.repository));
75
+ const top = gitTopLevel(options.cwd);
76
+ for (const p of withChanges) {
77
+ const picked = defaultWorkdirFor(named, [p.repository], workdirs, top);
78
+ if (picked.chosen)
79
+ io.out(`Applying ${picked.chosen.repository}'s changes in ${picked.chosen.dir} (the checkout you're in).`);
80
+ workdirs = picked.workdirs;
81
+ }
82
+ const plan = [];
83
+ for (const p of withChanges) {
84
+ const given = workdirs[p.repository];
85
+ if (!given)
86
+ throw new CliError(`No checkout of ${p.repository}: run this from inside it, or pass --workdir ${p.repository}=<dir>.`);
87
+ const dir = resolve(given);
88
+ if (!existsSync(dir) || !statSync(dir).isDirectory())
89
+ throw new CliError(`--workdir ${p.repository}=${given}: no such directory.`);
90
+ const saved = join(appliedDir, patchFile(p.repository));
91
+ if (existsSync(saved)) {
92
+ throw new CliError(`Sifur's changes to ${p.repository} are already applied. Run \`sifur apply ${key ?? ref} --revert\` first, or delete ${saved} if you've taken them out yourself.`);
93
+ }
94
+ const status = git(dir, ['status', '--porcelain', '--untracked-files=no']);
95
+ if (!status.ok)
96
+ throw new CliError(`${dir} isn't a git checkout: ${status.stderr}`);
97
+ if (status.stdout.trim())
98
+ throw new CliError(`${dir} has uncommitted changes. Commit or stash them, then run this again.`);
99
+ if (!git(dir, ['cat-file', '-e', `${p.base}^{commit}`]).ok) {
100
+ throw new CliError(`${dir} doesn't have ${p.base.slice(0, 12)}, the commit Sifur's changes to ${p.repository} start from. Run \`git fetch\` and check out ${p.branch} first.`);
101
+ }
102
+ plan.push({ patch: p, dir });
103
+ }
104
+ await mkdir(appliedDir, { recursive: true });
105
+ for (const { patch, dir } of plan) {
106
+ const applied = git(dir, ['apply', '--3way', '--whitespace=nowarn'], patch.patch);
107
+ if (!applied.ok) {
108
+ throw new CliError(`Couldn't apply Sifur's changes to ${patch.repository} in ${dir} cleanly:\n${applied.stderr}\nSee \`git status\` there: resolve the conflicts, or put the checkout back as it was with \`git reset --hard\`.`);
109
+ }
110
+ await writeFile(join(appliedDir, patchFile(patch.repository)), patch.patch);
111
+ index.checkouts[patch.repository] = dir;
112
+ await writeFile(join(appliedDir, INDEX), `${JSON.stringify(index, null, 2)}\n`);
113
+ const count = filesIn(dir, patch.patch);
114
+ io.out(`${patch.repository}: applied Sifur's changes to ${count} file${count === 1 ? '' : 's'} in ${dir} (${patch.head.slice(0, 7)} on ${patch.branch}). Nothing was committed.`);
115
+ }
116
+ }
117
+ /** Takes back out what `applyChanges` put in, from the patches it saved. Needs no connection to Sifur. */
118
+ export async function revertChanges(ref, options, io) {
119
+ const appliedDir = await findAppliedDir(options.store, ref);
120
+ const index = appliedDir ? await readIndex(appliedDir) : null;
121
+ const repositories = Object.keys(index?.checkouts ?? {}).filter((r) => existsSync(join(appliedDir, patchFile(r))));
122
+ if (!appliedDir || !index || repositories.length === 0) {
123
+ io.out(`Nothing from ${ref} is applied.`);
124
+ return;
125
+ }
126
+ for (const repository of repositories) {
127
+ const dir = resolve(options.workdirs?.[repository] ?? index.checkouts[repository]);
128
+ const saved = join(appliedDir, patchFile(repository));
129
+ const patch = await readFile(saved, 'utf8');
130
+ const reverted = git(dir, ['apply', '-R', '--3way', '--whitespace=nowarn'], patch);
131
+ if (!reverted.ok) {
132
+ throw new CliError(`Couldn't take Sifur's changes to ${repository} back out of ${dir}:\n${reverted.stderr}\nThe patch is kept in ${saved}.`);
133
+ }
134
+ await rm(saved);
135
+ delete index.checkouts[repository];
136
+ await writeFile(join(appliedDir, INDEX), `${JSON.stringify(index, null, 2)}\n`);
137
+ io.out(`${repository}: took Sifur's changes back out of ${dir}.`);
138
+ }
139
+ if (Object.keys(index.checkouts).length === 0) {
140
+ await rm(appliedDir, { recursive: true, force: true });
141
+ await pruneTicketDir(options.store, dirname(appliedDir));
142
+ }
143
+ }
@@ -0,0 +1,63 @@
1
+ import { spawn } from 'node:child_process';
2
+ const TAIL_LINES = 5;
3
+ export const MAX_OUTPUT_CHARS = 200_000;
4
+ /** The case's test command in the engineer's checkout; its output streams to the terminal, its tail goes in the note. */
5
+ export function runAutomated(command, cwd, options = {}) {
6
+ const echo = options.echo ?? ((chunk) => process.stdout.write(chunk));
7
+ return new Promise((resolve) => {
8
+ let output = '';
9
+ const child = spawn(command, { cwd, shell: true, stdio: ['ignore', 'pipe', 'pipe'] });
10
+ const collect = (chunk) => {
11
+ const text = chunk.toString();
12
+ echo(text);
13
+ output = (output + text).slice(-MAX_OUTPUT_CHARS);
14
+ };
15
+ child.stdout.on('data', collect);
16
+ child.stderr.on('data', collect);
17
+ child.on('error', (error) => resolve({ status: 'blocked', note: `\`${command}\` couldn't start in ${cwd}: ${error.message}` }));
18
+ child.on('close', (code, signal) => {
19
+ const tail = output.trimEnd().split('\n').slice(-TAIL_LINES).join('\n');
20
+ const ended = signal ? `was stopped by ${signal}` : `exited with ${code}`;
21
+ resolve({
22
+ status: code === 0 ? 'passed' : 'failed',
23
+ note: `\`${command}\` ${ended}.${tail ? `\n${tail}` : ''}`,
24
+ output,
25
+ exitCode: code,
26
+ });
27
+ });
28
+ });
29
+ }
30
+ /** The shell's "command not found" and "not executable": the checkout can't run the test, which says nothing about the ticket. */
31
+ export const COULD_NOT_RUN = new Set([126, 127]);
32
+ /** The program a command runs: its first word, past any `cd … &&` and VAR=value prefixes. */
33
+ export function programOf(command) {
34
+ const segment = command.split(/&&|\|\||;/).map((s) => s.trim()).find((s) => s && !/^cd(\s|$)/.test(s)) ?? command.trim();
35
+ const words = segment.split(/\s+/).filter((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w));
36
+ return words[0] ?? segment;
37
+ }
38
+ /** The program the shell said it couldn't find, as in `sh: mocha: command not found`. */
39
+ export function notFoundIn(output) {
40
+ const match = /(?:^|\n)[^\n:]*?:\s*(?:line \d+:\s*)?([^\s:]+):\s*(?:command )?not found/.exec(output);
41
+ return match?.[1] ?? null;
42
+ }
43
+ const SCRIPT_RUNNERS = new Set(['npm', 'yarn', 'pnpm']);
44
+ export function missingProgram(command, exitCode, workdir, output = '') {
45
+ const program = programOf(command);
46
+ const words = command.trim().split(/\s+/);
47
+ // `npm run x` ran: what's missing is the tool its script calls, installed by `npm install`.
48
+ const reported = notFoundIn(output);
49
+ if (reported && reported !== program && SCRIPT_RUNNERS.has(program)) {
50
+ return `\`${reported}\` isn't installed in ${workdir}: run \`${program} install\`?`;
51
+ }
52
+ if (program === 'npx') {
53
+ const runs = words.slice(words.indexOf('npx') + 1).find((w) => !w.startsWith('-')) ?? 'its package';
54
+ return `\`npx\` couldn't run ${runs} in ${workdir}: run \`npm install\`?`;
55
+ }
56
+ const where = program.includes('/') ? `in ${workdir}` : 'on your PATH';
57
+ let message = exitCode === 126 ? `\`${program}\` isn't executable ${where}` : `\`${program}\` isn't ${where}`;
58
+ if (/(^|\/)vendor\/bin\//.test(program))
59
+ message += ': run `composer install`?';
60
+ else if (/(^|\/)node_modules\/\.bin\//.test(program))
61
+ message += ': run `npm install`?';
62
+ return message;
63
+ }
package/dist/bin.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import { describeError, main } from './cli.js';
3
+ import { terminalIo } from './io.js';
4
+ const io = terminalIo();
5
+ let code;
6
+ try {
7
+ code = await main(process.argv.slice(2), io);
8
+ }
9
+ catch (error) {
10
+ process.stderr.write(`${describeError(error)}\n`);
11
+ code = 2;
12
+ }
13
+ finally {
14
+ io.close();
15
+ }
16
+ process.exit(code);
@@ -0,0 +1,16 @@
1
+ import { chromium } from 'playwright-core';
2
+ import { CliError } from './api.js';
3
+ const CHANNELS = ['chrome', 'msedge'];
4
+ /** The engineer's own Chrome, else Edge: playwright-core downloads no browser of its own. */
5
+ export async function launchBrowser(options = {}) {
6
+ const failures = [];
7
+ for (const channel of CHANNELS) {
8
+ try {
9
+ return await chromium.launch({ channel, headless: !options.headed });
10
+ }
11
+ catch (error) {
12
+ failures.push(`${channel}: ${String(error.message ?? error).split('\n')[0]}`);
13
+ }
14
+ }
15
+ throw new CliError(`Couldn't start Google Chrome or Microsoft Edge. Install one of them (https://www.google.com/chrome/) and run this again.\n ${failures.join('\n ')}`);
16
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,161 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import { accept } from './accept.js';
5
+ import { Api, ApiError, CliError, DEFAULT_API, normalizeApi } from './api.js';
6
+ import { applyChanges, revertChanges } from './apply.js';
7
+ import { cacheDir, loadCredentials } from './config.js';
8
+ import { login, logout } from './login.js';
9
+ import { LEGACY_STORE } from './store.js';
10
+ const USAGE = `Usage:
11
+ sifur login [--api <url>]
12
+ sifur logout
13
+ sifur apply <KEY|uuid> [--workdir <repo>=<dir> …]
14
+ sifur apply <KEY|uuid> --revert
15
+ sifur accept <KEY|uuid> --url <test url> [--apply] [--headed] [--workdir <repo>=<dir> …]
16
+ sifur accept <KEY|uuid> --download [--apply]
17
+ sifur accept <KEY|uuid> --run --url <test url> [--headed] [--workdir <repo>=<dir> …]
18
+ sifur accept <KEY|uuid> --upload
19
+
20
+ Options:
21
+ --api <url> Sifur's API (default ${DEFAULT_API}, or SIFUR_API_URL)
22
+ --url <url> Your test server, where the ticket's branch is deployed
23
+ --headed Show the browser while the cases run
24
+ --timeout <seconds> How long each browser step may take (default 60; raise it for a slow VPN)
25
+ --apply Put Sifur's changes in your checkouts first, uncommitted, as \`sifur apply\` does
26
+ --revert Take the changes \`sifur apply\` put in back out
27
+ --workdir <repo>=<dir> Run that repository's automated cases in <dir>; repeatable. Without it,
28
+ the git checkout you're in, when it's that repository's
29
+ --download Save the cases to ${cacheDir()}/<KEY>/ (before a full-tunnel VPN is up)
30
+ --run Run the saved cases and save the results (no connection to Sifur)
31
+ --upload Send the saved results (after the VPN is down)
32
+
33
+ Secrets: {{NAME}} in a case comes from SIFUR_SECRET_NAME, or is asked for. It never leaves this machine.
34
+ Exit codes: 0 every case passed, 1 any case failed or was blocked, 2 an error.`;
35
+ function version() {
36
+ try {
37
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
38
+ return pkg.version;
39
+ }
40
+ catch {
41
+ return 'unknown';
42
+ }
43
+ }
44
+ export function parseWorkdirs(values) {
45
+ const workdirs = {};
46
+ for (const value of values) {
47
+ const at = value.indexOf('=');
48
+ if (at <= 0 || at === value.length - 1)
49
+ throw new CliError(`--workdir ${value}: expected <repository>=<dir>.`);
50
+ workdirs[value.slice(0, at)] = value.slice(at + 1);
51
+ }
52
+ return workdirs;
53
+ }
54
+ const DEFAULT_TIMEOUT_SECONDS = 60;
55
+ export function timeoutSeconds(value) {
56
+ if (value === undefined)
57
+ return DEFAULT_TIMEOUT_SECONDS;
58
+ const seconds = Number(value);
59
+ if (!Number.isFinite(seconds) || seconds <= 0)
60
+ throw new CliError(`--timeout ${value}: expected a number of seconds.`);
61
+ return seconds;
62
+ }
63
+ export async function main(argv, io, env = process.env, cwd = process.cwd()) {
64
+ const { values, positionals } = parseArgs({
65
+ args: argv,
66
+ allowPositionals: true,
67
+ options: {
68
+ api: { type: 'string' },
69
+ url: { type: 'string' },
70
+ headed: { type: 'boolean', default: false },
71
+ timeout: { type: 'string' },
72
+ workdir: { type: 'string', multiple: true, default: [] },
73
+ download: { type: 'boolean', default: false },
74
+ run: { type: 'boolean', default: false },
75
+ upload: { type: 'boolean', default: false },
76
+ apply: { type: 'boolean', default: false },
77
+ revert: { type: 'boolean', default: false },
78
+ help: { type: 'boolean', short: 'h', default: false },
79
+ version: { type: 'boolean', short: 'v', default: false },
80
+ },
81
+ });
82
+ const [command, ...rest] = positionals;
83
+ if (values.version) {
84
+ io.out(version());
85
+ return 0;
86
+ }
87
+ if (values.help || !command || command === 'help') {
88
+ io.out(USAGE);
89
+ return command || values.help ? 0 : 2;
90
+ }
91
+ const explicitApi = values.api ?? env.SIFUR_API_URL;
92
+ const userAgent = `sifur-cli/${version()} node/${process.versions.node}`;
93
+ if ((command === 'accept' || command === 'apply') && existsSync(join(cwd, LEGACY_STORE))) {
94
+ io.out(`${join(cwd, LEGACY_STORE)} is left over from sifur 0.1.0, which kept its files there; nothing uses it now, so it can be deleted.`);
95
+ }
96
+ switch (command) {
97
+ case 'login': {
98
+ const api = new Api(explicitApi ?? DEFAULT_API, null, userAgent);
99
+ await login(api, { io, env });
100
+ return 0;
101
+ }
102
+ case 'logout':
103
+ await logout(io, env);
104
+ return 0;
105
+ case 'apply': {
106
+ const ref = rest[0];
107
+ if (!ref || rest.length > 1)
108
+ throw new CliError('Usage: sifur apply <KEY|uuid> [--workdir <repo>=<dir> …]');
109
+ const options = { workdirs: parseWorkdirs(values.workdir ?? []), cwd, store: cacheDir(env) };
110
+ if (values.revert) {
111
+ await revertChanges(ref, options, io);
112
+ return 0;
113
+ }
114
+ await applyChanges(signedIn(await loadCredentials(env), explicitApi, userAgent), ref, options, io);
115
+ return 0;
116
+ }
117
+ case 'accept': {
118
+ const ref = rest[0];
119
+ if (!ref || rest.length > 1)
120
+ throw new CliError('Usage: sifur accept <KEY|uuid> --url <test url>');
121
+ const modes = ['download', 'run', 'upload'].filter((mode) => values[mode]);
122
+ if (modes.length > 1)
123
+ throw new CliError('Use one of --download, --run and --upload at a time.');
124
+ const mode = modes[0] ?? 'full';
125
+ const credentials = await loadCredentials(env);
126
+ const api = mode !== 'run' || values.apply ? signedIn(credentials, explicitApi, userAgent) : null;
127
+ return accept(api, {
128
+ ref,
129
+ mode,
130
+ url: values.url,
131
+ headed: values.headed,
132
+ workdirs: parseWorkdirs(values.workdir ?? []),
133
+ cwd,
134
+ store: cacheDir(env),
135
+ apply: values.apply,
136
+ consoleOrigin: env.SIFUR_CONSOLE_URL ?? credentials?.console ?? null,
137
+ }, { io, env, timeoutMs: timeoutSeconds(values.timeout) * 1000 });
138
+ }
139
+ default:
140
+ throw new CliError(`Unknown command "${command}".\n\n${USAGE}`);
141
+ }
142
+ }
143
+ function signedIn(credentials, explicitApi, userAgent) {
144
+ if (!credentials)
145
+ throw new CliError('Not signed in. Run `sifur login` first.');
146
+ if (explicitApi && normalizeApi(explicitApi) !== normalizeApi(credentials.api)) {
147
+ throw new CliError(`You're signed in to ${credentials.api}, not ${explicitApi}. Run \`sifur login --api ${explicitApi}\`.`);
148
+ }
149
+ return new Api(credentials.api, credentials.token, userAgent);
150
+ }
151
+ export function describeError(error) {
152
+ if (error instanceof ApiError && error.status === 401)
153
+ return 'Your sign-in has expired or was revoked. Run `sifur login` again.';
154
+ if (error instanceof ApiError)
155
+ return error.fromSifur ? `Sifur answered ${error.status}: ${error.message}` : error.message;
156
+ if (error instanceof CliError)
157
+ return error.message;
158
+ if (error instanceof TypeError && error.code?.startsWith('ERR_PARSE_ARGS'))
159
+ return `${error.message}\n\n${USAGE}`;
160
+ return error instanceof Error ? (error.stack ?? error.message) : String(error);
161
+ }
package/dist/config.js ADDED
@@ -0,0 +1,39 @@
1
+ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ export function configDir(env = process.env, platform = process.platform) {
5
+ if (env.SIFUR_CONFIG_DIR)
6
+ return env.SIFUR_CONFIG_DIR;
7
+ if (platform === 'win32')
8
+ return join(env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'sifur');
9
+ return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'sifur');
10
+ }
11
+ /** Where downloaded cases, results and screenshots are kept: outside the engineer's checkout. */
12
+ export function cacheDir(env = process.env, platform = process.platform) {
13
+ if (platform === 'win32')
14
+ return join(env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'sifur');
15
+ return join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'sifur');
16
+ }
17
+ export function credentialsPath(env = process.env) {
18
+ return join(configDir(env), 'credentials.json');
19
+ }
20
+ export async function loadCredentials(env = process.env) {
21
+ try {
22
+ const parsed = JSON.parse(await readFile(credentialsPath(env), 'utf8'));
23
+ return parsed.api && parsed.token ? parsed : null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ export async function saveCredentials(credentials, env = process.env) {
30
+ const path = credentialsPath(env);
31
+ await mkdir(configDir(env), { recursive: true, mode: 0o700 });
32
+ await writeFile(path, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
33
+ // writeFile's mode only applies when it creates the file; an older one keeps its own.
34
+ await chmod(path, 0o600);
35
+ return path;
36
+ }
37
+ export async function deleteCredentials(env = process.env) {
38
+ await rm(credentialsPath(env), { force: true });
39
+ }
package/dist/io.js ADDED
@@ -0,0 +1,96 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { Writable } from 'node:stream';
3
+ import { CliError } from './api.js';
4
+ /**
5
+ * The terminal: one readline over stdin for every question, muted while a secret is typed. Lines are
6
+ * queued as they arrive, because piped input arrives all at once, before most questions are asked.
7
+ */
8
+ export function terminalIo() {
9
+ let muted = false;
10
+ let rl = null;
11
+ let ended = false;
12
+ const lines = [];
13
+ const waiting = [];
14
+ const output = new Writable({
15
+ write(chunk, encoding, callback) {
16
+ if (!muted)
17
+ process.stdout.write(chunk, encoding);
18
+ callback();
19
+ },
20
+ });
21
+ const inputEnded = () => new CliError('Input ended before every question was answered.');
22
+ function readline() {
23
+ if (!rl) {
24
+ rl = createInterface({ input: process.stdin, output, terminal: Boolean(process.stdin.isTTY) });
25
+ rl.on('line', (line) => {
26
+ const next = waiting.shift();
27
+ if (next)
28
+ next.resolve(line);
29
+ else
30
+ lines.push(line);
31
+ });
32
+ // In terminal mode readline swallows Ctrl-C; without this it would pause instead of quitting.
33
+ rl.on('SIGINT', () => {
34
+ rl?.close();
35
+ process.stdout.write('\n');
36
+ process.exit(130);
37
+ });
38
+ rl.on('close', () => {
39
+ ended = true;
40
+ for (const next of waiting.splice(0))
41
+ next.reject(inputEnded());
42
+ });
43
+ }
44
+ return rl;
45
+ }
46
+ function nextLine() {
47
+ readline();
48
+ const queued = lines.shift();
49
+ if (queued !== undefined)
50
+ return Promise.resolve(queued);
51
+ if (ended)
52
+ return Promise.reject(inputEnded());
53
+ return new Promise((resolve, reject) => waiting.push({ resolve, reject }));
54
+ }
55
+ function question(prompt) {
56
+ const reader = readline();
57
+ reader.setPrompt(prompt);
58
+ reader.prompt();
59
+ return nextLine();
60
+ }
61
+ return {
62
+ out: (line = '') => void process.stdout.write(`${line}\n`),
63
+ ask: (prompt) => question(prompt),
64
+ async askHidden(prompt) {
65
+ const reader = readline();
66
+ process.stdout.write(prompt);
67
+ reader.setPrompt('');
68
+ muted = true;
69
+ try {
70
+ return await nextLine();
71
+ }
72
+ finally {
73
+ muted = false;
74
+ process.stdout.write('\n');
75
+ }
76
+ },
77
+ close: () => rl?.close(),
78
+ };
79
+ }
80
+ export async function confirm(io, prompt) {
81
+ for (;;) {
82
+ const answer = (await io.ask(`${prompt} [y/n] `)).trim().toLowerCase();
83
+ if (['y', 'yes'].includes(answer))
84
+ return true;
85
+ if (['n', 'no'].includes(answer))
86
+ return false;
87
+ }
88
+ }
89
+ export async function askStatus(io, prompt) {
90
+ const choices = { p: 'passed', passed: 'passed', f: 'failed', failed: 'failed', b: 'blocked', blocked: 'blocked' };
91
+ for (;;) {
92
+ const answer = choices[(await io.ask(`${prompt} [p]assed / [f]ailed / [b]locked: `)).trim().toLowerCase()];
93
+ if (answer)
94
+ return answer;
95
+ }
96
+ }