@sifurai/cli 0.1.0 → 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/README.md CHANGED
@@ -16,19 +16,22 @@ the results and screenshots go to Sifur.
16
16
 
17
17
  ## Install
18
18
 
19
- Nothing to install up front:
19
+ Install it once to get the `sifur` command:
20
20
 
21
21
  ```sh
22
- npx @sifurai/cli login
22
+ npm install -g @sifurai/cli
23
+ sifur --version
23
24
  ```
24
25
 
25
- Or install it globally to get a `sifur` command:
26
+ Update it with:
26
27
 
27
28
  ```sh
28
- npm install -g @sifurai/cli
29
- sifur login
29
+ npm install -g @sifurai/cli@latest
30
30
  ```
31
31
 
32
+ (`npx @sifurai/cli …` works too, but npx can keep running an older version it has cached, so an
33
+ installed `sifur` is the reliable way.)
34
+
32
35
  ## Sign in
33
36
 
34
37
  ```sh
@@ -50,6 +53,36 @@ To sign out (and revoke the session):
50
53
  sifur logout
51
54
  ```
52
55
 
56
+ ## Apply Sifur's work before you deploy
57
+
58
+ Sifur pushes the ticket's branch only when you approve it after acceptance testing, so your CI
59
+ isn't set off by every change along the way. Until then, `sifur apply` puts Sifur's changes in your
60
+ checkout, for you to deploy to your test server and test:
61
+
62
+ ```sh
63
+ cd ~/src/azalea
64
+ sifur apply AZA-12
65
+ ```
66
+
67
+ It works on the checkout you're in when it's the ticket's repository (as for automated cases,
68
+ below), or on `--workdir <repository>=<dir>` for each repository. The checkout needs no uncommitted
69
+ changes (commit or stash them first) and must have the commit Sifur's work starts from; if it
70
+ doesn't, the CLI tells you to `git fetch` and check out the ticket's branch. The changes are
71
+ applied with `git apply --3way` and left uncommitted (staged); the CLI prints how many files each
72
+ repository's changes touch.
73
+
74
+ To take them back out:
75
+
76
+ ```sh
77
+ sifur apply AZA-12 --revert
78
+ ```
79
+
80
+ `apply` keeps a copy of the patch in the cache folder (`<cache>/AZA-12/applied/`) for `--revert`,
81
+ which deletes it once the changes are out.
82
+
83
+ `sifur accept AZA-12 --apply --url …` applies the changes first, then runs the cases. It doesn't
84
+ take them out afterwards.
85
+
53
86
  ## Run a ticket's acceptance cases
54
87
 
55
88
  When a ticket is in **Acceptance testing**, its Overview shows the branch and the commit of each
@@ -66,21 +99,59 @@ sifur accept AZA-12 --url https://test.example.internal
66
99
  it), taking a screenshot after every step. Add `--headed` to watch.
67
100
  3. For a step that needs a person's eye, shows you the screenshot's path and asks whether it looks
68
101
  right.
69
- 4. Runs automated cases in your checkout when you pass `--workdir <repository>=<dir>` (repeat it
70
- for each repository); otherwise asks you for the result.
102
+ 4. Runs automated cases in your checkout of their repository (see below); without one, asks you
103
+ for the result.
71
104
  5. For a case with no script, shows its steps and expected result and asks you for the result:
72
105
  passed, failed or blocked, with a note.
73
106
  6. Uploads the screenshots and results, prints a summary, and links to the ticket.
74
107
 
108
+ Every case passed: the ticket moves on to approval. Any failed or blocked: it goes back to be fixed.
109
+
110
+ `--url` is the root of your test server: the cases' paths are opened under it. The CLI warns when
111
+ it has a path or a query string, since that's rarely what's wanted.
112
+
113
+ Until they're uploaded, the cases, screenshots and results are kept outside your checkout, in a
114
+ cache folder per user:
115
+
116
+ - macOS and Linux: `~/.cache/sifur/AZA-12/` (or `$XDG_CACHE_HOME/sifur/AZA-12/`)
117
+ - Windows: `%LOCALAPPDATA%\sifur\AZA-12\`
118
+
119
+ The CLI prints where. Once Sifur has the results, it deletes that folder, leaving nothing behind
120
+ (except a patch from `sifur apply` that's still applied, which `--revert` needs). If the upload
121
+ fails, everything is kept: send it again with `sifur accept AZA-12 --upload`, from any directory.
122
+
123
+ The CLI never reads or writes a `.sifur` folder in your checkout. Version 0.1.0 kept its files
124
+ there; if one is left over, the CLI says so, and you can delete it.
125
+
126
+ ## Automated cases
127
+
128
+ An automated case runs its test command in your checkout of the case's repository. Run `accept`
129
+ from inside that checkout and the CLI uses it: the top of the git checkout you're in is used for a
130
+ repository when the cases name only that one repository, or when the checkout's folder has the
131
+ repository's name. It prints which folder it uses for which repository. For anything else, name
132
+ the folder with `--workdir <repository>=<dir>`, repeated for each repository; `--workdir` always
133
+ wins:
134
+
75
135
  ```sh
76
136
  sifur accept AZA-12 --url https://test.example.internal \
77
137
  --workdir api=~/src/azalea-api --workdir web=~/src/azalea-web
78
138
  ```
79
139
 
80
- Every case passed: the ticket moves on to approval. Any failed or blocked: it goes back to be fixed.
140
+ A case's command is written for Sifur's sandbox, where the repository is checked out at
141
+ `/workspace/<repository>`. On your machine, a leading `cd /workspace/<repository> &&` is dropped,
142
+ any other `/workspace/<repository>` path becomes your checkout's, and the command runs in your
143
+ checkout.
81
144
 
82
- Screenshots and results are kept in `./.sifur/AZA-12/` (add `.sifur/` to your `.gitignore`). If the
83
- upload fails, send them again with `sifur accept AZA-12 --upload`.
145
+ If the command can't run in your checkout at all (the shell says the program isn't there or isn't
146
+ executable, exit 127 or 126, say because `vendor/` or `node_modules/` isn't installed), that isn't
147
+ recorded as a failure. The CLI says what's missing (with a hint like "run `composer install`?"),
148
+ and asks you for the result as it does for a case with no checkout. This is the same with `--run`.
149
+
150
+ Some test files Sifur writes for a ticket are kept out of the repository, so they never show up in
151
+ your branch. Before a repository's automated cases run, the CLI puts them in your checkout, and
152
+ afterwards it removes them again (also on Ctrl-C), along with any folder it made for them, so
153
+ `git status` is just as it was. A file of yours at the same path is set aside and put back; one
154
+ that's already identical is left alone. A path outside the checkout is refused.
84
155
 
85
156
  ## VPN: split tunnel or full tunnel
86
157
 
@@ -100,16 +171,26 @@ sifur accept AZA-12 --run --url https://test.example.internal
100
171
  sifur accept AZA-12 --upload
101
172
  ```
102
173
 
103
- `--download` saves the cases in `./.sifur/AZA-12/`, `--run` runs them from there and saves the
104
- results next to them, and `--upload` sends them. Run all three from the same directory.
174
+ `--download` saves the cases (and the test files Sifur keeps out of the repository) in the cache
175
+ folder, `--run` runs them from there and saves the results next to them, and `--upload` sends them
176
+ and then deletes them.
177
+ They don't need to run from the same directory; run `--run` from your checkout to have automated
178
+ cases run in it.
105
179
 
106
180
  ## Secrets
107
181
 
108
- A case can use a placeholder such as `{{ADMIN_PASSWORD}}` for a value it types, like a test
109
- account's password. The CLI takes it from the environment variable `SIFUR_SECRET_ADMIN_PASSWORD`
110
- or, when that isn't set, asks you for it without echoing. Secrets stay on your machine: they are
111
- never written to `.sifur/`, never sent to Sifur, and are replaced with `[secret]` in any error
112
- message.
182
+ A UI case can use a placeholder such as `{{APP_USERNAME}}` or `{{APP_PASSWORD}}` for a test
183
+ account's sign-in, in what it types, the page it opens, and the text or URL it checks for. The CLI
184
+ takes each from the environment variable `SIFUR_SECRET_<NAME>` (for example
185
+ `SIFUR_SECRET_APP_PASSWORD`) or, when that isn't set, asks you for it once per run, after saying
186
+ which case and step needs it. A name with `USER`, `EMAIL` or `LOGIN` in it shows what you type;
187
+ any other, and always one with `PASSWORD`, `TOKEN` or `SECRET`, doesn't. Secrets stay on your
188
+ machine: they are never written to disk, never sent to Sifur, and are replaced with `[secret]` in
189
+ any error message.
190
+
191
+ `{{RUN}}` isn't a secret and is never asked for: it's replaced with a short token, new for each
192
+ run and the same in every case and step of it, so a case can create data that's unique each time
193
+ (`qa-{{RUN}}@example.com`) and check for it afterwards.
113
194
 
114
195
  ```sh
115
196
  SIFUR_SECRET_ADMIN_PASSWORD='…' sifur accept AZA-12 --url https://test.example.internal
@@ -121,9 +202,23 @@ SIFUR_SECRET_ADMIN_PASSWORD='…' sifur accept AZA-12 --url https://test.example
121
202
  |---|---|
122
203
  | `--url <url>` | Your test server, where the ticket's branch is deployed |
123
204
  | `--headed` | Show the browser while the cases run |
124
- | `--workdir <repository>=<dir>` | Run that repository's automated cases in `<dir>`; repeatable |
205
+ | `--timeout <seconds>` | How long each browser step may take, page loads included (default 60). Raise it when the test server is slow, for example over a VPN |
206
+ | `--workdir <repository>=<dir>` | Run that repository's automated cases in `<dir>`; repeatable. Default: the checkout you're in, when it's that repository's |
125
207
  | `--download` / `--run` / `--upload` | The three steps for a full-tunnel VPN |
126
- | `--api <url>` | Sifur's API, for `login` (default `https://api.sifur.ai`, or `SIFUR_API_URL`) |
208
+ | `--apply` | With `accept`: put Sifur's changes in your checkouts first, as `sifur apply` does |
209
+ | `--revert` | With `apply`: take the changes back out |
210
+ | `--api <url>` | Sifur's API, for `login` (default `https://api.sifur.ai`, or `SIFUR_API_URL`). Not your test server: that's `--url` |
211
+
212
+ ## Troubleshooting
213
+
214
+ **`<url> isn't Sifur's API`** from `sifur login --api <url>`: `--api` is Sifur's own address (for
215
+ example `https://api.sifur.ai`), and is only needed for a Sifur other than the default. Your test
216
+ server goes in `sifur accept --url`.
217
+
218
+ **`unable to get local issuer certificate`** or **`self-signed certificate`**: Node doesn't trust the
219
+ certificate of the Sifur it's talking to. If this is a local or company Sifur with its own
220
+ certificate, run with `NODE_OPTIONS=--use-system-ca` (Node 22.15+), or point Node at the root
221
+ certificate with `NODE_EXTRA_CA_CERTS=<root cert>`.
127
222
 
128
223
  ## Exit codes
129
224
 
package/dist/accept.js CHANGED
@@ -2,20 +2,22 @@ import { existsSync, statSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { basename, extname, join, resolve } from 'node:path';
4
4
  import { ApiError, CliError } from './api.js';
5
- import { runAutomated } from './automated.js';
5
+ import { applyChanges } from './apply.js';
6
+ import { COULD_NOT_RUN, missingProgram, runAutomated } from './automated.js';
6
7
  import { launchBrowser } from './browser.js';
7
8
  import { askStatus, confirm } from './io.js';
8
9
  import { resolveTicket } from './resolve.js';
9
10
  import { buildResultsPayload, combine, outcome } from './results.js';
10
11
  import { runUiCase } from './runner.js';
11
- import { collectSecrets, redact, secretNames } from './secrets.js';
12
- import { findTicketDir, loadCases, loadResults, saveCases, saveResults } from './store.js';
12
+ import { collectSecrets, newRunToken, redact, secretUses } from './secrets.js';
13
+ import { findTicketDir, loadCases, loadResults, removeTicketFiles, saveCases, saveResults } from './store.js';
14
+ import { checkFilePaths, defaultWorkdirs, gitTopLevel, localizeCommand, placeTestFiles, removeOnExit } from './workspace.js';
13
15
  export const MAX_SCREENSHOT_BYTES = 5 * 1024 * 1024;
14
16
  export async function download(api, ref) {
15
17
  const { ticketUuid, organization } = await resolveTicket(api, ref);
16
18
  try {
17
19
  const acceptance = await api.request(`tickets/${ticketUuid}/acceptance`, { organization: organization.uuid });
18
- return { api: api.base, organizationUuid: organization.uuid, acceptance };
20
+ return { api: api.base, organizationUuid: organization.uuid, acceptance: { ...acceptance, files: acceptance.files ?? [] } };
19
21
  }
20
22
  catch (error) {
21
23
  if (error instanceof ApiError && error.status === 409) {
@@ -68,6 +70,9 @@ function judgeWith(io) {
68
70
  return { ok: false, note: note || undefined };
69
71
  };
70
72
  }
73
+ function filesFor(testCase, files, workdirs) {
74
+ return files.filter((f) => f.repository === testCase.repository || (!testCase.repository && !workdirs[f.repository]));
75
+ }
71
76
  function workdirFor(testCase, workdirs) {
72
77
  if (testCase.repository && workdirs[testCase.repository])
73
78
  return workdirs[testCase.repository];
@@ -81,6 +86,46 @@ function summarize(status, steps, notes) {
81
86
  return `All ${steps.length} step${steps.length === 1 ? '' : 's'} passed.`;
82
87
  return status === 'passed' ? 'Passed.' : `The case ${status}.`;
83
88
  }
89
+ /**
90
+ * Before anything runs: the checkouts whose cases need dependencies that aren't installed, said once
91
+ * rather than as a prompt for every case. Quitting is the default; continuing asks for those cases.
92
+ */
93
+ async function checkDependencies(cases, workdirs, io) {
94
+ const missing = new Map();
95
+ for (const testCase of cases) {
96
+ if (testCase.check === 'ui' || !testCase.automatedTestRef)
97
+ continue;
98
+ const cwd = workdirFor(testCase, workdirs);
99
+ if (!cwd)
100
+ continue;
101
+ const workdir = resolve(cwd);
102
+ const command = localizeCommand(testCase.automatedTestRef, workdir);
103
+ const needs = [
104
+ /(^|[\s;&|])(npm|npx|yarn|pnpm)\s|node_modules\/\.bin\//.test(command) &&
105
+ !existsSync(join(workdir, 'node_modules'))
106
+ ? { folder: 'node_modules', setup: 'npm install' }
107
+ : null,
108
+ /(^|[\s/])vendor\/bin\//.test(command) && !existsSync(join(workdir, 'vendor'))
109
+ ? { folder: 'vendor', setup: 'composer install' }
110
+ : null,
111
+ ].filter((need) => need !== null);
112
+ for (const need of needs) {
113
+ const key = `${workdir}\0${need.folder}`;
114
+ const entry = missing.get(key) ?? { setup: `${need.setup} in ${workdir} (there's no ${need.folder})`, ids: [] };
115
+ entry.ids.push(testCase.id);
116
+ missing.set(key, entry);
117
+ }
118
+ }
119
+ if (missing.size === 0)
120
+ return;
121
+ io.out('Some cases need dependencies this checkout doesn\'t have yet:');
122
+ for (const entry of missing.values())
123
+ io.out(` ${entry.setup}: ${entry.ids.join(', ')}`);
124
+ const answer = (await io.ask('[q]uit to install them first, or [c]ontinue and answer those cases yourself: ')).trim().toLowerCase();
125
+ if (!answer.startsWith('c')) {
126
+ throw new CliError(`Install them, then run this again: ${[...missing.values()].map((entry) => entry.setup.split(' (')[0]).join('; ')}.`);
127
+ }
128
+ }
84
129
  /** Runs every case on this machine against the engineer's test server. Nothing here talks to Sifur. */
85
130
  export async function runCases(acceptance, options, deps) {
86
131
  const { io } = deps;
@@ -89,8 +134,14 @@ export async function runCases(acceptance, options, deps) {
89
134
  if (!existsSync(dir) || !statSync(dir).isDirectory())
90
135
  throw new CliError(`--workdir ${repository}=${dir}: no such directory.`);
91
136
  }
137
+ const files = acceptance.files ?? [];
138
+ checkFilePaths(files);
139
+ const placed = new Map();
140
+ let stopRemovingOnExit = null;
141
+ await checkDependencies(acceptance.cases, workdirs, io);
92
142
  const scripted = acceptance.cases.filter((c) => c.check !== 'automated' && (c.uiScript?.length ?? 0) > 0);
93
- const secrets = await collectSecrets(secretNames(scripted), io, deps.env);
143
+ const secrets = await collectSecrets(secretUses(scripted), io, deps.env);
144
+ const runToken = newRunToken();
94
145
  let browser = null;
95
146
  const results = [];
96
147
  try {
@@ -107,8 +158,10 @@ export async function runCases(acceptance, options, deps) {
107
158
  baseUrl: options.baseUrl,
108
159
  outDir: options.outDir,
109
160
  secrets,
161
+ runToken,
110
162
  judge: judgeWith(io),
111
163
  timeoutMs: deps.timeoutMs,
164
+ onStep: (n, caption) => io.out(` … ${n}. ${caption}`),
112
165
  });
113
166
  for (const step of ui.steps)
114
167
  io.out(` ${step.ok ? 'ok ' : 'FAIL'} ${step.n}. ${step.caption}${step.error ? `: ${step.error}` : ''}`);
@@ -127,9 +180,24 @@ export async function runCases(acceptance, options, deps) {
127
180
  const cwd = workdirFor(testCase, workdirs);
128
181
  let run;
129
182
  if (testCase.automatedTestRef && cwd) {
130
- io.out(` $ ${testCase.automatedTestRef} (in ${cwd})`);
131
- run = await runAutomated(testCase.automatedTestRef, resolve(cwd), { echo: deps.echo });
132
- run = { ...run, note: redact(run.note, secrets), output: redact(run.output ?? '', secrets) };
183
+ const workdir = resolve(cwd);
184
+ const key = `${testCase.repository ?? ''}\0${workdir}`;
185
+ const needed = filesFor(testCase, files, workdirs);
186
+ if (!placed.has(key) && needed.length > 0) {
187
+ stopRemovingOnExit ??= removeOnExit(() => [...placed.values()]);
188
+ placed.set(key, placeTestFiles(workdir, needed));
189
+ }
190
+ const command = localizeCommand(testCase.automatedTestRef, workdir);
191
+ io.out(` $ ${command} (in ${cwd})`);
192
+ run = await runAutomated(command, workdir, { echo: deps.echo });
193
+ if (run.exitCode !== undefined && run.exitCode !== null && COULD_NOT_RUN.has(run.exitCode)) {
194
+ io.out(` ${missingProgram(command, run.exitCode, workdir, run.output ?? '')}`);
195
+ const answer = await askEngineer(io, testCase, "The test couldn't run here, so it isn't recorded as failed");
196
+ run = { status: answer.status, note: answer.note };
197
+ }
198
+ else {
199
+ run = { ...run, note: redact(run.note, secrets), output: redact(run.output ?? '', secrets) };
200
+ }
133
201
  }
134
202
  else {
135
203
  const why = !testCase.automatedTestRef
@@ -159,6 +227,12 @@ export async function runCases(acceptance, options, deps) {
159
227
  }
160
228
  finally {
161
229
  await browser?.close().catch(() => { });
230
+ for (const files of placed.values()) {
231
+ files.remove();
232
+ const count = `${files.count} test file${files.count === 1 ? '' : 's'}`;
233
+ io.out(`Put ${count} that Sifur keeps out of the repository in ${files.workdir} for the run, and removed ${files.count === 1 ? 'it' : 'them'} again.`);
234
+ }
235
+ stopRemovingOnExit?.();
162
236
  }
163
237
  return { baseUrl: options.baseUrl, ranAt: new Date().toISOString(), results };
164
238
  }
@@ -223,11 +297,19 @@ export function ticketLink(consoleOrigin, ticketUuid) {
223
297
  /** The whole `accept` command; returns the exit code (0 all passed, 1 any not passed). Errors throw, for exit 2. */
224
298
  export async function accept(api, args, deps) {
225
299
  const { io } = deps;
300
+ if (args.apply) {
301
+ if (args.mode === 'run' || args.mode === 'upload')
302
+ throw new CliError(`--apply needs Sifur, so it can't go with --${args.mode}.`);
303
+ if (!api)
304
+ throw new CliError('Run `sifur login` first.');
305
+ await applyChanges(api, args.ref, { workdirs: args.workdirs, cwd: args.cwd, store: args.store }, io);
306
+ io.out();
307
+ }
226
308
  if (args.mode === 'download') {
227
309
  if (!api)
228
310
  throw new CliError('Run `sifur login` first.');
229
311
  const saved = await download(api, args.ref);
230
- const dir = await saveCases(args.cwd, saved);
312
+ const dir = await saveCases(args.store, saved);
231
313
  describeDeploy(saved.acceptance, io);
232
314
  io.out(`Saved to ${dir}. Run \`sifur accept ${saved.acceptance.ticket.key} --run --url <test url>\` next.`);
233
315
  return 0;
@@ -235,22 +317,26 @@ export async function accept(api, args, deps) {
235
317
  if (args.mode === 'upload') {
236
318
  if (!api)
237
319
  throw new CliError('Run `sifur login` first.');
238
- const dir = await findTicketDir(args.cwd, args.ref);
320
+ const dir = await findTicketDir(args.store, args.ref);
239
321
  if (!dir)
240
- throw new CliError(`Nothing downloaded for ${args.ref} in ${join(args.cwd, '.sifur')}.`);
322
+ throw new CliError(`Nothing downloaded for ${args.ref} in ${args.store}.`);
241
323
  const saved = await loadCases(dir);
242
324
  if (saved.api !== api.base)
243
325
  throw new CliError(`${args.ref} was downloaded from ${saved.api}, but you're signed in to ${api.base}.`);
244
326
  const results = await loadResults(dir);
245
- return finish(api, saved, results, args, io);
327
+ return finish(api, saved, results, dir, args, io);
246
328
  }
247
329
  if (!args.url)
248
330
  throw new CliError('--url <test url> is required: the test server the branch is deployed to.');
249
331
  const baseUrl = parseBaseUrl(args.url);
332
+ const parsedUrl = new URL(baseUrl);
333
+ if (parsedUrl.pathname !== '/' || parsedUrl.search || parsedUrl.hash) {
334
+ io.out(`Note: the cases' paths are opened under ${baseUrl}, so the root of the site (${parsedUrl.origin}/) is usually what's wanted.`);
335
+ }
250
336
  let saved;
251
337
  let dir;
252
338
  if (args.mode === 'run') {
253
- const found = await findTicketDir(args.cwd, args.ref);
339
+ const found = await findTicketDir(args.store, args.ref);
254
340
  if (!found)
255
341
  throw new CliError(`Nothing downloaded for ${args.ref}. Run with --download first.`);
256
342
  dir = found;
@@ -260,26 +346,34 @@ export async function accept(api, args, deps) {
260
346
  if (!api)
261
347
  throw new CliError('Run `sifur login` first.');
262
348
  saved = await download(api, args.ref);
263
- dir = await saveCases(args.cwd, saved);
349
+ dir = await saveCases(args.store, saved);
264
350
  }
265
351
  describeDeploy(saved.acceptance, io);
266
- const results = await runCases(saved.acceptance, { baseUrl, outDir: join(dir, 'screenshots'), headed: args.headed, workdirs: args.workdirs }, deps);
352
+ const { workdirs, chosen } = defaultWorkdirs(saved.acceptance.cases, args.workdirs ?? {}, gitTopLevel(args.cwd));
353
+ if (chosen)
354
+ io.out(`Running ${chosen.repository}'s automated cases in ${chosen.dir} (the checkout you're in). Use --workdir ${chosen.repository}=<dir> for another.`);
355
+ io.out(`Results and screenshots are kept in ${dir} until they're uploaded.`);
356
+ io.out();
357
+ const results = await runCases(saved.acceptance, { baseUrl, outDir: join(dir, 'screenshots'), headed: args.headed, workdirs }, deps);
267
358
  const resultsPath = await saveResults(dir, results);
268
359
  if (args.mode === 'run') {
269
360
  const overall = printSummary(io, results.results);
270
361
  io.out(`Saved to ${resultsPath}. Run \`sifur accept ${saved.acceptance.ticket.key} --upload\` to send them.`);
271
362
  return overall === 'passed' ? 0 : 1;
272
363
  }
364
+ return finish(api, saved, results, dir, args, io);
365
+ }
366
+ /** Uploads the results; once Sifur has them, nothing kept for the ticket is needed any more. */
367
+ async function finish(api, saved, results, dir, args, io) {
368
+ let response;
273
369
  try {
274
- return await finish(api, saved, results, args, io);
370
+ response = await upload(api, saved, results, io);
275
371
  }
276
372
  catch (error) {
277
- io.out(`The results are saved in ${resultsPath}; send them later with \`sifur accept ${saved.acceptance.ticket.key} --upload\`.`);
373
+ io.out(`The results are kept in ${dir}; send them later with \`sifur accept ${saved.acceptance.ticket.key} --upload\`.`);
278
374
  throw error;
279
375
  }
280
- }
281
- async function finish(api, saved, results, args, io) {
282
- const response = await upload(api, saved, results, io);
376
+ await removeTicketFiles(args.store, dir);
283
377
  io.out();
284
378
  const overall = printSummary(io, results.results);
285
379
  if (response?.run)
@@ -289,6 +383,7 @@ async function finish(api, saved, results, args, io) {
289
383
  const link = ticketLink(args.consoleOrigin, saved.acceptance.ticket.uuid);
290
384
  if (link)
291
385
  io.out(link);
386
+ io.out(`Cleaned up: removed the cases, results and screenshots kept in ${dir}.`);
292
387
  return overall === 'passed' ? 0 : 1;
293
388
  }
294
389
  export function parseBaseUrl(url) {
package/dist/api.js CHANGED
@@ -3,11 +3,19 @@ export class ApiError extends Error {
3
3
  status;
4
4
  code;
5
5
  retryAfterSeconds;
6
- constructor(message, status, code = null, retryAfterSeconds = null) {
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) {
7
13
  super(message);
8
14
  this.status = status;
9
15
  this.code = code;
10
16
  this.retryAfterSeconds = retryAfterSeconds;
17
+ this.json = json;
18
+ this.fromSifur = fromSifur;
11
19
  this.name = 'ApiError';
12
20
  }
13
21
  }
@@ -18,6 +26,19 @@ export class CliError extends Error {
18
26
  this.name = 'CliError';
19
27
  }
20
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
+ }
21
42
  export function normalizeApi(api) {
22
43
  return api.replace(/\/+$/, '');
23
44
  }
@@ -56,23 +77,31 @@ export class Api {
56
77
  });
57
78
  }
58
79
  catch (error) {
59
- const cause = error.cause?.message;
60
- throw new CliError(`Couldn't reach Sifur at ${this.base}${cause ? ` (${cause})` : ''}.`);
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}` : ''}`);
61
83
  }
62
84
  const text = await response.text();
63
85
  let json = null;
86
+ let isJson = true;
64
87
  try {
65
88
  json = text ? JSON.parse(text) : null;
66
89
  }
67
90
  catch {
68
91
  json = null;
92
+ isJson = false;
69
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}` : '';
70
97
  if (!response.ok) {
71
- const message = json?.message?.messages?.[0] ?? `Sifur answered ${response.status} ${response.statusText}`.trim();
98
+ const said = json?.message?.messages?.[0];
72
99
  const code = json?.code ?? json?.data?.code ?? null;
73
100
  const retryAfter = Number(response.headers.get('retry-after'));
74
- throw new ApiError(message, response.status, code, Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : null);
101
+ throw new ApiError(said ?? `Sifur answered ${status}${statusText}.`, status, code, Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : null, isJson, said !== undefined);
75
102
  }
103
+ if (!isJson)
104
+ throw new ApiError(`Sifur answered ${status}${statusText}, but not with JSON.`, status, null, null, false, false);
76
105
  return (json?.data ?? null);
77
106
  }
78
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
+ }