@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.
@@ -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.1",
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",