@sifurai/cli 0.1.0 → 0.1.2

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/secrets.js CHANGED
@@ -1,28 +1,82 @@
1
+ import { randomBytes } from 'node:crypto';
1
2
  const PLACEHOLDER = /\{\{([A-Z][A-Z0-9_]*)\}\}/g;
2
- export function secretNames(cases) {
3
- const names = new Set();
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();
4
24
  for (const testCase of cases) {
5
- for (const step of testCase.uiScript ?? []) {
6
- if (step.action !== 'fill')
7
- continue;
8
- for (const match of String(step.value ?? '').matchAll(PLACEHOLDER))
9
- names.add(match[1]);
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
+ }
10
33
  }
11
34
  }
12
- return [...names].sort();
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);
13
39
  }
14
- /** From `SIFUR_SECRET_<NAME>`, or typed without echo. Kept in memory only: never written or sent to Sifur. */
15
- export async function collectSecrets(names, io, env = process.env) {
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) {
16
42
  const secrets = {};
17
- for (const name of names) {
43
+ for (const { name, caseId, step, caption } of uses) {
18
44
  const fromEnv = env[`SIFUR_SECRET_${name}`];
19
- secrets[name] = fromEnv !== undefined ? fromEnv : await io.askHidden(`${name} (or set 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}; Enter to skip the cases that need it): `;
51
+ const value = ECHOED.test(name) && !HIDDEN.test(name) ? await io.ask(prompt) : await io.askHidden(prompt);
52
+ // Left out: the cases that need it are skipped, never run with a blank login.
53
+ if (value !== '')
54
+ secrets[name] = value;
20
55
  }
21
56
  return secrets;
22
57
  }
23
58
  export function withSecrets(value, secrets) {
24
59
  return value.replace(PLACEHOLDER, (match, name) => secrets[name] ?? match);
25
60
  }
61
+ export function newRunToken() {
62
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
63
+ return [...randomBytes(8)].map((byte) => alphabet[byte % alphabet.length]).join('');
64
+ }
65
+ /** The step as it's played: this run's token in every field, secrets where they belong. */
66
+ export function resolveStep(step, secrets, runToken) {
67
+ const resolved = { ...step };
68
+ for (const field of ['url', 'selector', 'value', 'text']) {
69
+ const value = resolved[field];
70
+ if (typeof value === 'string')
71
+ resolved[field] = value.replaceAll(`{{${RUN}}}`, runToken);
72
+ }
73
+ for (const field of secretFields(step)) {
74
+ const value = resolved[field];
75
+ if (value !== undefined)
76
+ resolved[field] = withSecrets(String(value), secrets);
77
+ }
78
+ return resolved;
79
+ }
26
80
  export function redact(text, secrets) {
27
81
  let redacted = text;
28
82
  for (const value of Object.values(secrets))
package/dist/store.js CHANGED
@@ -1,9 +1,12 @@
1
- import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { mkdir, readdir, readFile, rm, rmdir, writeFile } from 'node:fs/promises';
2
2
  import { isAbsolute, join, relative, resolve } from 'node:path';
3
3
  import { CliError } from './api.js';
4
- export const STORE = '.sifur';
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';
5
8
  export function ticketDir(root, key) {
6
- return join(root, STORE, key.replace(/[^A-Za-z0-9_-]/g, '_'));
9
+ return join(root, key.replace(/[^A-Za-z0-9_-]/g, '_'));
7
10
  }
8
11
  export async function saveCases(root, saved) {
9
12
  const dir = ticketDir(root, saved.acceptance.ticket.key);
@@ -44,15 +47,15 @@ export async function findTicketDir(root, ref) {
44
47
  return direct;
45
48
  let entries;
46
49
  try {
47
- entries = await readdir(join(root, STORE));
50
+ entries = await readdir(root);
48
51
  }
49
52
  catch {
50
53
  return null;
51
54
  }
52
55
  for (const entry of entries) {
53
- const saved = await readJson(join(root, STORE, entry, 'cases.json'));
56
+ const saved = await readJson(join(root, entry, 'cases.json'));
54
57
  if (saved?.acceptance.ticket.uuid === ref.toLowerCase())
55
- return join(root, STORE, entry);
58
+ return join(root, entry);
56
59
  }
57
60
  return null;
58
61
  }
@@ -68,3 +71,19 @@ export async function loadResults(dir) {
68
71
  throw new CliError(`No results in ${dir}. Run with --run first.`);
69
72
  return mapScreenshots(saved, (screenshot) => resolve(dir, screenshot));
70
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
+ }
@@ -0,0 +1,142 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readFileSync, rmdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { basename, dirname, isAbsolute, posix, relative, resolve, win32 } from 'node:path';
4
+ import { CliError } from './api.js';
5
+ export function gitTopLevel(cwd) {
6
+ try {
7
+ const top = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
8
+ return top ? resolve(top) : null;
9
+ }
10
+ catch {
11
+ return null;
12
+ }
13
+ }
14
+ function sameRepository(folder, repository) {
15
+ const name = repository.split('/').pop() ?? repository;
16
+ return folder.toLowerCase() === name.toLowerCase();
17
+ }
18
+ /**
19
+ * The checkout the CLI was run from, for the first repository in `needing` that's clearly its: the
20
+ * repositories named are only that one, or the folder has its name.
21
+ */
22
+ export function defaultWorkdirFor(named, needing, workdirs, topLevel) {
23
+ if (!topLevel)
24
+ return { workdirs, chosen: null };
25
+ const repository = needing.find((r) => !workdirs[r] && (named.size === 1 || sameRepository(basename(topLevel), r)));
26
+ if (!repository)
27
+ return { workdirs, chosen: null };
28
+ return { workdirs: { ...workdirs, [repository]: topLevel }, chosen: { repository, dir: topLevel } };
29
+ }
30
+ /** For a repository whose automated cases have no --workdir. */
31
+ export function defaultWorkdirs(cases, workdirs, topLevel) {
32
+ const named = new Set(cases.map((c) => c.repository).filter((r) => Boolean(r)));
33
+ const needing = cases.filter((c) => c.check !== 'ui' && c.automatedTestRef && c.repository).map((c) => c.repository);
34
+ return defaultWorkdirFor(named, needing, workdirs, topLevel);
35
+ }
36
+ export function shellQuote(value, platform = process.platform) {
37
+ if (/^[A-Za-z0-9_\-./:@%+,=~]+$/.test(value))
38
+ return value;
39
+ if (platform === 'win32')
40
+ return `"${value.replaceAll('"', '""')}"`;
41
+ return `'${value.replaceAll("'", `'\\''`)}'`;
42
+ }
43
+ const LEADING_CD = /^\s*cd\s+(["']?)\/workspace(?:\/[^\s"';&|]*)?\1\s*&&\s*/;
44
+ const SANDBOX_PATH = /(^|[^\w./-])(["']?)\/workspace\/[^\s/"';&|]+/g;
45
+ /** Sifur's sandbox checks a repository out at /workspace/<name>; here it's the engineer's own checkout. */
46
+ export function localizeCommand(command, workdir, platform = process.platform) {
47
+ return command.replace(LEADING_CD, '').replace(SANDBOX_PATH, (_match, before, quote) => {
48
+ if (quote === '"')
49
+ return `${before}"${workdir.replace(/["$`\\]/g, '\\$&')}`;
50
+ if (quote === "'")
51
+ return `${before}'${workdir.replaceAll("'", `'\\''`)}`;
52
+ return `${before}${shellQuote(workdir, platform)}`;
53
+ });
54
+ }
55
+ /** Refuses, before anything is written, a path that would land outside its repository. */
56
+ export function checkFilePaths(files) {
57
+ for (const file of files) {
58
+ const outside = !file.path ||
59
+ posix.isAbsolute(file.path) ||
60
+ win32.isAbsolute(file.path) ||
61
+ file.path.split(/[\\/]/).includes('..');
62
+ if (outside)
63
+ throw new CliError(`Sifur sent a test file to put at "${file.path}", outside the ${file.repository} repository. Refusing to write it.`);
64
+ }
65
+ }
66
+ /**
67
+ * Writes the test files Sifur keeps out of the repository into the checkout for the run. `remove` puts
68
+ * the checkout back as it was: created files and the folders made for them go, replaced files come back.
69
+ */
70
+ export function placeTestFiles(workdir, files) {
71
+ checkFilePaths(files);
72
+ const root = resolve(workdir);
73
+ const undo = [];
74
+ let removed = false;
75
+ const remove = () => {
76
+ if (removed)
77
+ return;
78
+ removed = true;
79
+ for (const step of undo.reverse()) {
80
+ try {
81
+ step();
82
+ }
83
+ catch {
84
+ // A folder that isn't empty any more, or a file already gone, is left as it is.
85
+ }
86
+ }
87
+ };
88
+ try {
89
+ for (const file of files) {
90
+ const target = resolve(root, file.path);
91
+ const inside = relative(root, target);
92
+ if (!inside || inside.startsWith('..') || isAbsolute(inside)) {
93
+ throw new CliError(`Sifur sent a test file to put at "${file.path}", outside ${root}. Refusing to write it.`);
94
+ }
95
+ const missing = [];
96
+ for (let dir = dirname(target); dir !== root && !existsSync(dir); dir = dirname(dir))
97
+ missing.unshift(dir);
98
+ for (const dir of missing) {
99
+ mkdirSync(dir);
100
+ undo.push(() => rmdirSync(dir));
101
+ }
102
+ const content = Buffer.from(file.content, 'utf8');
103
+ if (existsSync(target)) {
104
+ if (!statSync(target).isFile())
105
+ throw new CliError(`Can't put a test file at ${target}: something else is there.`);
106
+ const before = readFileSync(target);
107
+ if (before.equals(content))
108
+ continue;
109
+ writeFileSync(target, content);
110
+ undo.push(() => writeFileSync(target, before));
111
+ }
112
+ else {
113
+ writeFileSync(target, content);
114
+ undo.push(() => rmSync(target, { force: true }));
115
+ }
116
+ }
117
+ }
118
+ catch (error) {
119
+ remove();
120
+ throw error;
121
+ }
122
+ return { workdir: root, count: files.length, remove };
123
+ }
124
+ /** Removes the placed files however the run ends: normally, on Ctrl-C or SIGTERM, or through process.exit. */
125
+ export function removeOnExit(placed) {
126
+ const removeAll = () => {
127
+ for (const files of placed())
128
+ files.remove();
129
+ };
130
+ const onSignal = (signal) => {
131
+ removeAll();
132
+ process.exit(signal === 'SIGINT' ? 130 : 143);
133
+ };
134
+ process.on('exit', removeAll);
135
+ process.on('SIGINT', onSignal);
136
+ process.on('SIGTERM', onSignal);
137
+ return () => {
138
+ process.off('exit', removeAll);
139
+ process.off('SIGINT', onSignal);
140
+ process.off('SIGTERM', onSignal);
141
+ };
142
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sifurai/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Run a Sifur ticket's acceptance cases against your own test server and upload the results.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://sifur.ai",