@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/login.js ADDED
@@ -0,0 +1,123 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { hostname, userInfo } from 'node:os';
3
+ import { Api, ApiError, CliError, DEFAULT_API } from './api.js';
4
+ import { deleteCredentials, loadCredentials, saveCredentials } from './config.js';
5
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
6
+ export function openInBrowser(url) {
7
+ const [command, args] = process.platform === 'darwin'
8
+ ? ['open', [url]]
9
+ : process.platform === 'win32'
10
+ ? ['cmd', ['/c', 'start', '""', url]]
11
+ : ['xdg-open', [url]];
12
+ try {
13
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
14
+ child.on('error', () => { });
15
+ child.unref();
16
+ }
17
+ catch {
18
+ // The URL is printed too; a machine without a browser just doesn't open one.
19
+ }
20
+ }
21
+ export function defaultClientName() {
22
+ let user = '';
23
+ try {
24
+ user = userInfo().username;
25
+ }
26
+ catch {
27
+ user = '';
28
+ }
29
+ return user ? `${user}@${hostname()}` : hostname();
30
+ }
31
+ export function withCode(verificationUrl, userCode) {
32
+ try {
33
+ const url = new URL(verificationUrl);
34
+ if (!url.searchParams.has('code'))
35
+ url.searchParams.set('code', userCode);
36
+ return url.toString();
37
+ }
38
+ catch {
39
+ return verificationUrl;
40
+ }
41
+ }
42
+ export async function login(api, deps) {
43
+ const { io } = deps;
44
+ const sleep = deps.sleep ?? wait;
45
+ const now = deps.now ?? Date.now;
46
+ const notSifur = new CliError(`${api.base} isn't Sifur's API. --api is Sifur's address (for example ${DEFAULT_API}); your test server goes in \`accept --url\`.`);
47
+ let start;
48
+ try {
49
+ start = await api.request('auth/device', {
50
+ method: 'POST',
51
+ body: { clientName: (deps.clientName ?? defaultClientName()).slice(0, 100) },
52
+ });
53
+ }
54
+ catch (error) {
55
+ if (error instanceof ApiError && (!error.json || [404, 405, 419].includes(error.status)))
56
+ throw notSifur;
57
+ throw error;
58
+ }
59
+ if (!start?.deviceCode || !start.userCode || !start.verificationUrl)
60
+ throw notSifur;
61
+ const url = withCode(start.verificationUrl, start.userCode);
62
+ io.out(`Your code: ${start.userCode}`);
63
+ io.out(`Approve it at ${url}`);
64
+ io.out('Waiting for approval…');
65
+ (deps.openBrowser ?? openInBrowser)(url);
66
+ let interval = Math.max(1, start.interval ?? 5) * 1000;
67
+ const deadline = now() + (start.expiresIn ?? 600) * 1000;
68
+ for (;;) {
69
+ await sleep(interval);
70
+ try {
71
+ const granted = await api.request('auth/device/token', {
72
+ method: 'POST',
73
+ body: { deviceCode: start.deviceCode },
74
+ });
75
+ let consoleOrigin = null;
76
+ try {
77
+ consoleOrigin = new URL(start.verificationUrl).origin;
78
+ }
79
+ catch {
80
+ consoleOrigin = null;
81
+ }
82
+ const credentials = { api: api.base, token: granted.token, console: consoleOrigin };
83
+ const path = await saveCredentials(credentials, deps.env);
84
+ const who = granted.user?.email ?? granted.user?.name;
85
+ io.out(`Signed in${who ? ` as ${who}` : ''}. Saved to ${path}.`);
86
+ return credentials;
87
+ }
88
+ catch (error) {
89
+ if (!(error instanceof ApiError))
90
+ throw error;
91
+ if (error.code === 'authorization_pending' || error.status === 428) {
92
+ if (now() >= deadline)
93
+ throw new CliError('The code expired before it was approved. Run `sifur login` again.');
94
+ continue;
95
+ }
96
+ if (error.status === 429 || error.code === 'slow_down') {
97
+ interval = Math.max(interval + 5000, (error.retryAfterSeconds ?? 0) * 1000);
98
+ continue;
99
+ }
100
+ if (error.code === 'access_denied' || error.status === 403)
101
+ throw new CliError('The sign-in was denied.');
102
+ if (error.code === 'expired_token' || error.status === 410) {
103
+ throw new CliError('The code expired before it was approved. Run `sifur login` again.');
104
+ }
105
+ throw error;
106
+ }
107
+ }
108
+ }
109
+ export async function logout(io, env = process.env) {
110
+ const credentials = await loadCredentials(env);
111
+ if (!credentials) {
112
+ io.out('Not signed in.');
113
+ return;
114
+ }
115
+ try {
116
+ await new Api(credentials.api, credentials.token).request('account/logout', { method: 'POST' });
117
+ }
118
+ catch {
119
+ // The session may already be revoked or Sifur unreachable; the local token goes either way.
120
+ }
121
+ await deleteCredentials(env);
122
+ io.out('Signed out.');
123
+ }
@@ -0,0 +1,36 @@
1
+ import { ApiError, CliError } from './api.js';
2
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3
+ const KEY = /^([A-Za-z][A-Za-z0-9]*)-(\d+)$/;
4
+ export function parseTicketRef(ref) {
5
+ if (UUID.test(ref))
6
+ return { uuid: ref.toLowerCase() };
7
+ const match = KEY.exec(ref);
8
+ if (!match)
9
+ throw new CliError(`"${ref}" is neither a ticket key (like AZA-12) nor a ticket uuid.`);
10
+ return { projectKey: match[1].toUpperCase(), number: Number(match[2]) };
11
+ }
12
+ /** Asks each of the person's organizations in turn; the first that has the ticket scopes every later call. */
13
+ export async function resolveTicket(api, ref) {
14
+ const parsed = parseTicketRef(ref);
15
+ const organizations = (await api.request('account/organizations')) ?? [];
16
+ if (organizations.length === 0)
17
+ throw new CliError('Your account is not in any organization.');
18
+ const path = 'uuid' in parsed
19
+ ? `tickets/${parsed.uuid}`
20
+ : `ticket-links/${encodeURIComponent(parsed.projectKey)}/${parsed.number}`;
21
+ for (const organization of organizations) {
22
+ try {
23
+ const ticket = await api.request(path, { organization: organization.uuid });
24
+ if (!ticket?.uuid)
25
+ throw new CliError(`Sifur found ${ref} but didn't say which ticket it is.`);
26
+ const key = ticket.key ?? ('projectKey' in parsed ? `${parsed.projectKey}-${parsed.number}` : null);
27
+ return { ticketUuid: ticket.uuid, key, organization };
28
+ }
29
+ catch (error) {
30
+ if (error instanceof ApiError && [403, 404].includes(error.status))
31
+ continue;
32
+ throw error;
33
+ }
34
+ }
35
+ throw new CliError(`No ticket ${ref} in your organizations (${organizations.map((o) => o.name).join(', ')}).`);
36
+ }
@@ -0,0 +1,32 @@
1
+ /** `screenshots` maps a local screenshot path to the uuid its upload returned. */
2
+ export function buildResultsPayload(stateVersion, baseUrl, results, screenshots) {
3
+ return {
4
+ stateVersion,
5
+ via: 'cli',
6
+ baseUrl,
7
+ results: results.map((result) => ({
8
+ caseUuid: result.caseUuid,
9
+ status: result.status,
10
+ notes: result.notes,
11
+ steps: result.steps.map((step) => ({
12
+ n: step.n,
13
+ caption: step.caption,
14
+ ok: step.ok,
15
+ error: step.error,
16
+ screenshotUuid: step.screenshot ? (screenshots.get(step.screenshot) ?? null) : null,
17
+ })),
18
+ ...(result.output !== undefined ? { output: result.output } : {}),
19
+ })),
20
+ };
21
+ }
22
+ /** Failed outranks blocked: a case that both failed a check and couldn't finish another still failed. */
23
+ export function combine(statuses) {
24
+ if (statuses.includes('failed'))
25
+ return 'failed';
26
+ if (statuses.includes('blocked'))
27
+ return 'blocked';
28
+ return 'passed';
29
+ }
30
+ export function outcome(results) {
31
+ return combine(results.map((result) => result.status));
32
+ }
package/dist/runner.js ADDED
@@ -0,0 +1,195 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { newRunToken, redact, resolveStep } from './secrets.js';
4
+ const ASSERTIONS = new Set(['expectText', 'expectVisible', 'expectUrl']);
5
+ const TIMEOUT = 60_000;
6
+ // An assertion after an action waits for the page to settle, as Playwright's own do: a
7
+ // response that lands half a second later still counts.
8
+ export async function eventually(check, timeoutMs = 10_000) {
9
+ const deadline = Date.now() + timeoutMs;
10
+ for (;;) {
11
+ try {
12
+ return await check();
13
+ }
14
+ catch (error) {
15
+ if (Date.now() >= deadline)
16
+ throw error;
17
+ await new Promise((resolve) => setTimeout(resolve, 250));
18
+ }
19
+ }
20
+ }
21
+ /**
22
+ * The first visible match in the page or any of its frames: older apps put whole screens in
23
+ * iframes, where the top page's locator never looks.
24
+ */
25
+ async function find(page, selector, timeoutMs) {
26
+ if (!selector)
27
+ throw new Error('the step has no selector');
28
+ return eventually(async () => {
29
+ for (const frame of page.frames()) {
30
+ const match = frame.locator(selector).first();
31
+ if ((await match.count()) > 0 && (await match.isVisible().catch(() => false)))
32
+ return match;
33
+ }
34
+ throw new Error(`nothing visible matches ${selector}, in the page or its frames`);
35
+ }, timeoutMs);
36
+ }
37
+ /** The text of every frame's body: what a person reading the screen sees. */
38
+ async function screenText(page) {
39
+ const texts = await Promise.all(page.frames().map((frame) => frame.locator('body').innerText().catch(() => '')));
40
+ return texts.join('\n');
41
+ }
42
+ /**
43
+ * A case's path under `--url`, which may be the app's folder rather than the site's root
44
+ * (`https://test.example/team/app/`): `/login.php`, `login.php` and `#list` all open under it.
45
+ * A full URL is used as is.
46
+ */
47
+ export function caseUrl(path, baseUrl) {
48
+ if (/^[a-z][a-z0-9+.-]*:/i.test(path))
49
+ return path;
50
+ const base = new URL(baseUrl);
51
+ base.search = '';
52
+ base.hash = '';
53
+ // `…/app/login.php` is a page in the app's folder; `…/app` is the folder itself.
54
+ if (!base.pathname.endsWith('/')) {
55
+ base.pathname = /\.[a-z0-9]+$/i.test(base.pathname)
56
+ ? base.pathname.replace(/[^/]*$/, '')
57
+ : `${base.pathname}/`;
58
+ }
59
+ return new URL(path.replace(/^\/+/, ''), base).toString();
60
+ }
61
+ async function perform(page, step, baseUrl, settleMs) {
62
+ const url = step.url ? caseUrl(step.url, baseUrl) : null;
63
+ switch (step.action) {
64
+ case 'goto':
65
+ if (!url)
66
+ throw new Error('the step has no url');
67
+ // `load`, not `networkidle`: an app that polls never goes quiet, least of all over a VPN.
68
+ await page.goto(url, { waitUntil: 'load' });
69
+ break;
70
+ case 'click':
71
+ await (await find(page, step.selector, settleMs)).click();
72
+ break;
73
+ case 'fill':
74
+ await (await find(page, step.selector, settleMs)).fill(String(step.value ?? ''));
75
+ break;
76
+ case 'press':
77
+ await (await find(page, step.selector, settleMs)).press(String(step.value));
78
+ break;
79
+ case 'select':
80
+ await (await find(page, step.selector, settleMs)).selectOption(String(step.value));
81
+ break;
82
+ case 'wait':
83
+ await page.waitForTimeout(Math.min(Number(step.value ?? 500), 10_000));
84
+ break;
85
+ case 'expectText': {
86
+ const selector = step.selector;
87
+ await eventually(async () => {
88
+ const text = selector ? await (await find(page, selector, 0)).innerText() : await screenText(page);
89
+ if (!text.includes(String(step.text)))
90
+ throw new Error(`expected "${step.text}" in ${selector ?? 'the page'}`);
91
+ }, settleMs);
92
+ break;
93
+ }
94
+ case 'expectVisible':
95
+ await find(page, step.selector, settleMs);
96
+ break;
97
+ case 'expectUrl':
98
+ await eventually(async () => {
99
+ if (!page.url().includes(String(step.text))) {
100
+ throw new Error(`expected the URL to contain "${step.text}", got ${page.url()}`);
101
+ }
102
+ }, settleMs);
103
+ break;
104
+ case 'judge':
105
+ break;
106
+ default:
107
+ throw new Error(`unknown action ${step.action}`);
108
+ }
109
+ }
110
+ /** What a step does, for one with no caption: a screenshot labeled "expectText" says nothing. */
111
+ export function describe(step) {
112
+ const target = step.selector ?? '';
113
+ switch (step.action) {
114
+ case 'goto':
115
+ return `Open ${step.url ?? ''}`.trim();
116
+ case 'click':
117
+ return `Click ${target}`.trim();
118
+ case 'fill':
119
+ return `Type into ${target}`.trim();
120
+ case 'press':
121
+ return `Press ${step.value ?? ''} in ${target}`.trim();
122
+ case 'select':
123
+ return `Choose ${step.value ?? ''} in ${target}`.trim();
124
+ case 'wait':
125
+ return `Wait ${Number(step.value ?? 500) / 1000}s`;
126
+ case 'expectText':
127
+ return `Expect "${step.text ?? ''}"${target ? ` in ${target}` : ''}`;
128
+ case 'expectVisible':
129
+ return `Expect ${target} to show`;
130
+ case 'expectUrl':
131
+ return `Expect the address to contain "${step.text ?? ''}"`;
132
+ case 'judge':
133
+ return 'Check the screen';
134
+ default:
135
+ return step.action;
136
+ }
137
+ }
138
+ /** One case's UI script, as the sandbox's runner plays it: a screenshot after every step, stopping at the first failure. */
139
+ export async function runUiCase(browser, testCase, options) {
140
+ const secrets = options.secrets ?? {};
141
+ const runToken = options.runToken ?? newRunToken();
142
+ const dir = join(options.outDir, testCase.id);
143
+ await mkdir(dir, { recursive: true });
144
+ // Light, always: an app that follows the system theme is judged in one, the same every run.
145
+ const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: 'light' });
146
+ const page = await context.newPage();
147
+ page.setDefaultTimeout(options.timeoutMs ?? TIMEOUT);
148
+ const steps = [];
149
+ const notes = [];
150
+ let status = 'passed';
151
+ try {
152
+ for (const [index, step] of (testCase.uiScript ?? []).entries()) {
153
+ const n = index + 1;
154
+ const caption = redact(step.caption ?? describe(step), secrets);
155
+ let ok = true;
156
+ let error = null;
157
+ options.onStep?.(n, caption);
158
+ try {
159
+ await perform(page, resolveStep(step, secrets, runToken), options.baseUrl, options.timeoutMs ?? TIMEOUT);
160
+ }
161
+ catch (caught) {
162
+ ok = false;
163
+ error = redact(String(caught?.message ?? caught).split('\n')[0] ?? '', secrets);
164
+ }
165
+ let screenshot = join(dir, `${String(n).padStart(2, '0')}.png`);
166
+ await page.screenshot({ path: screenshot, fullPage: true }).catch(() => {
167
+ screenshot = null;
168
+ });
169
+ let judged = false;
170
+ if (ok && step.action === 'judge') {
171
+ const answer = await options.judge(step, screenshot, testCase);
172
+ judged = true;
173
+ if (answer.note)
174
+ notes.push(`Step ${n} (${caption}): ${answer.note}`);
175
+ if (!answer.ok) {
176
+ ok = false;
177
+ error = answer.note ? `The engineer judged it wrong: ${answer.note}` : 'The engineer judged it wrong.';
178
+ }
179
+ }
180
+ steps.push({ n, action: step.action, caption, ok, error, screenshot });
181
+ if (!ok) {
182
+ // A failed assertion (or a judged screenshot that's wrong) is a failed case; anything
183
+ // failing before it means the case couldn't be run as written, so it's blocked.
184
+ status = ASSERTIONS.has(step.action) || judged ? 'failed' : 'blocked';
185
+ if (!judged)
186
+ notes.push(`Step ${n} (${caption}): ${error}`);
187
+ break;
188
+ }
189
+ }
190
+ }
191
+ finally {
192
+ await context.close().catch(() => { });
193
+ }
194
+ return { status, steps, notes };
195
+ }
@@ -0,0 +1,83 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ const PLACEHOLDER = /\{\{([A-Z][A-Z0-9_]*)\}\}/g;
3
+ /** Not a secret: each run's own token, for data a case creates and can create again next run. */
4
+ export const RUN = 'RUN';
5
+ const ECHOED = /USER|EMAIL|LOGIN/;
6
+ const HIDDEN = /PASSWORD|TOKEN|SECRET/;
7
+ /** Where a step's secrets go: what it types, the page it opens, and the text it checks for. */
8
+ function secretFields(step) {
9
+ switch (step.action) {
10
+ case 'fill':
11
+ return ['value'];
12
+ case 'goto':
13
+ return ['url'];
14
+ case 'expectText':
15
+ case 'expectUrl':
16
+ return ['text'];
17
+ default:
18
+ return [];
19
+ }
20
+ }
21
+ /** Each secret the scripts use, with the first step that needs it. */
22
+ export function secretUses(cases) {
23
+ const uses = new Map();
24
+ for (const testCase of cases) {
25
+ for (const [index, step] of (testCase.uiScript ?? []).entries()) {
26
+ for (const field of secretFields(step)) {
27
+ for (const match of String(step[field] ?? '').matchAll(PLACEHOLDER)) {
28
+ const name = match[1];
29
+ if (name !== RUN && !uses.has(name))
30
+ uses.set(name, { name, caseId: testCase.id, step: index + 1, caption: step.caption });
31
+ }
32
+ }
33
+ }
34
+ }
35
+ return [...uses.values()].sort((a, b) => a.name.localeCompare(b.name));
36
+ }
37
+ export function secretNames(cases) {
38
+ return secretUses(cases).map((use) => use.name);
39
+ }
40
+ /** From `SIFUR_SECRET_<NAME>`, or typed. Kept in memory only: never written or sent to Sifur. */
41
+ export async function collectSecrets(uses, io, env = process.env) {
42
+ const secrets = {};
43
+ for (const { name, caseId, step, caption } of uses) {
44
+ const fromEnv = env[`SIFUR_SECRET_${name}`];
45
+ if (fromEnv !== undefined) {
46
+ secrets[name] = fromEnv;
47
+ continue;
48
+ }
49
+ io.out(`${caseId} uses {{${name}}} in step ${step}${caption ? ` (${caption})` : ''}.`);
50
+ const prompt = `${name} (or set SIFUR_SECRET_${name}): `;
51
+ secrets[name] = ECHOED.test(name) && !HIDDEN.test(name) ? await io.ask(prompt) : await io.askHidden(prompt);
52
+ }
53
+ return secrets;
54
+ }
55
+ export function withSecrets(value, secrets) {
56
+ return value.replace(PLACEHOLDER, (match, name) => secrets[name] ?? match);
57
+ }
58
+ export function newRunToken() {
59
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
60
+ return [...randomBytes(8)].map((byte) => alphabet[byte % alphabet.length]).join('');
61
+ }
62
+ /** The step as it's played: this run's token in every field, secrets where they belong. */
63
+ export function resolveStep(step, secrets, runToken) {
64
+ const resolved = { ...step };
65
+ for (const field of ['url', 'selector', 'value', 'text']) {
66
+ const value = resolved[field];
67
+ if (typeof value === 'string')
68
+ resolved[field] = value.replaceAll(`{{${RUN}}}`, runToken);
69
+ }
70
+ for (const field of secretFields(step)) {
71
+ const value = resolved[field];
72
+ if (value !== undefined)
73
+ resolved[field] = withSecrets(String(value), secrets);
74
+ }
75
+ return resolved;
76
+ }
77
+ export function redact(text, secrets) {
78
+ let redacted = text;
79
+ for (const value of Object.values(secrets))
80
+ if (value)
81
+ redacted = redacted.replaceAll(value, '[secret]');
82
+ return redacted;
83
+ }
package/dist/store.js ADDED
@@ -0,0 +1,89 @@
1
+ import { mkdir, readdir, readFile, rm, rmdir, writeFile } from 'node:fs/promises';
2
+ import { isAbsolute, join, relative, resolve } from 'node:path';
3
+ import { CliError } from './api.js';
4
+ /** Where 0.1.0 kept a ticket's files, in the directory it ran from. Never read or written now. */
5
+ export const LEGACY_STORE = '.sifur';
6
+ /** Folder of a patch `sifur apply` put in a checkout, kept until `--revert` needs it. */
7
+ export const APPLIED = 'applied';
8
+ export function ticketDir(root, key) {
9
+ return join(root, key.replace(/[^A-Za-z0-9_-]/g, '_'));
10
+ }
11
+ export async function saveCases(root, saved) {
12
+ const dir = ticketDir(root, saved.acceptance.ticket.key);
13
+ await mkdir(dir, { recursive: true });
14
+ await writeFile(join(dir, 'cases.json'), `${JSON.stringify(saved, null, 2)}\n`);
15
+ return dir;
16
+ }
17
+ function mapScreenshots(results, map) {
18
+ return {
19
+ ...results,
20
+ results: results.results.map((result) => ({
21
+ ...result,
22
+ steps: result.steps.map((step) => ({ ...step, screenshot: step.screenshot ? map(step.screenshot) : null })),
23
+ })),
24
+ };
25
+ }
26
+ /** Screenshot paths are kept relative to the ticket's folder, so the folder can move between --run and --upload. */
27
+ export async function saveResults(dir, results) {
28
+ const path = join(dir, 'results.json');
29
+ const portable = mapScreenshots(results, (screenshot) => (isAbsolute(screenshot) ? relative(dir, screenshot) : screenshot));
30
+ await writeFile(path, `${JSON.stringify(portable, null, 2)}\n`);
31
+ return path;
32
+ }
33
+ async function readJson(path) {
34
+ try {
35
+ return JSON.parse(await readFile(path, 'utf8'));
36
+ }
37
+ catch (error) {
38
+ if (error.code === 'ENOENT')
39
+ return null;
40
+ throw new CliError(`${path} isn't readable JSON: ${error.message}`);
41
+ }
42
+ }
43
+ /** A key names its folder; a uuid is looked for among the downloaded tickets. */
44
+ export async function findTicketDir(root, ref) {
45
+ const direct = ticketDir(root, ref.toUpperCase());
46
+ if (await readJson(join(direct, 'cases.json')))
47
+ return direct;
48
+ let entries;
49
+ try {
50
+ entries = await readdir(root);
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ for (const entry of entries) {
56
+ const saved = await readJson(join(root, entry, 'cases.json'));
57
+ if (saved?.acceptance.ticket.uuid === ref.toLowerCase())
58
+ return join(root, entry);
59
+ }
60
+ return null;
61
+ }
62
+ export async function loadCases(dir) {
63
+ const saved = await readJson(join(dir, 'cases.json'));
64
+ if (!saved)
65
+ throw new CliError(`No downloaded cases in ${dir}. Run with --download first.`);
66
+ return saved;
67
+ }
68
+ export async function loadResults(dir) {
69
+ const saved = await readJson(join(dir, 'results.json'));
70
+ if (!saved)
71
+ throw new CliError(`No results in ${dir}. Run with --run first.`);
72
+ return mapScreenshots(saved, (screenshot) => resolve(dir, screenshot));
73
+ }
74
+ async function removeIfEmpty(dir) {
75
+ await rmdir(dir).catch(() => { });
76
+ }
77
+ /** Removes a ticket's folder, and the store's own folder when nothing else is left in it. */
78
+ export async function pruneTicketDir(store, dir) {
79
+ await removeIfEmpty(dir);
80
+ await removeIfEmpty(store);
81
+ }
82
+ /** Everything kept for a ticket once its results are in, except a patch that's still applied. */
83
+ export async function removeTicketFiles(store, dir) {
84
+ const entries = await readdir(dir).catch(() => []);
85
+ for (const entry of entries)
86
+ if (entry !== APPLIED)
87
+ await rm(join(dir, entry), { recursive: true, force: true });
88
+ await pruneTicketDir(store, dir);
89
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};