@secureci/cli 0.5.0 → 0.6.0

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.
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.killProcessTree = killProcessTree;
36
37
  exports.executeStep = executeStep;
37
38
  exports.executeInJobContainer = executeInJobContainer;
38
39
  const fs = __importStar(require("fs"));
@@ -47,6 +48,38 @@ const shell_1 = require("./shell");
47
48
  const job_runtime_1 = require("./job-runtime");
48
49
  const actions_1 = require("./actions");
49
50
  const execAsync = (0, util_1.promisify)(child_process_1.exec);
51
+ /**
52
+ * SIGKILL a process and all its descendants (found with `pgrep -P`), so a
53
+ * killed step's shell doesn't leave its commands running.
54
+ */
55
+ function killProcessTree(pid) {
56
+ const tree = [];
57
+ const visit = (parent) => {
58
+ tree.push(parent);
59
+ let children;
60
+ try {
61
+ children = (0, child_process_1.execFileSync)('pgrep', ['-P', String(parent)], {
62
+ encoding: 'utf-8',
63
+ stdio: ['ignore', 'pipe', 'ignore']
64
+ });
65
+ }
66
+ catch {
67
+ return; // no children (or no pgrep)
68
+ }
69
+ for (const child of children.split('\n').filter(Boolean)) {
70
+ visit(Number(child));
71
+ }
72
+ };
73
+ visit(pid);
74
+ for (const id of tree) {
75
+ try {
76
+ process.kill(id, 'SIGKILL');
77
+ }
78
+ catch {
79
+ // already gone
80
+ }
81
+ }
82
+ }
50
83
  /**
51
84
  * Execute a single step
52
85
  */
@@ -104,8 +137,9 @@ async function executeStep(step, options, runtime, ctx) {
104
137
  fs.writeFileSync(scriptPath, command);
105
138
  const workingDirectory = step['working-directory'] ?? runtime.defaults.workingDirectory;
106
139
  const limit = (0, job_runtime_1.effectiveTimeout)(runtime);
140
+ let stopOnAbort;
107
141
  try {
108
- const { stdout, stderr } = await execAsync((0, shell_1.shellCommandLine)(shell.template, scriptPath), {
142
+ const running = execAsync((0, shell_1.shellCommandLine)(shell.template, scriptPath), {
109
143
  timeout: limit.ms,
110
144
  killSignal: 'SIGKILL',
111
145
  cwd: workingDirectory
@@ -120,6 +154,17 @@ async function executeStep(step, options, runtime, ctx) {
120
154
  ...(0, job_runtime_1.fileCommandEnv)(files)
121
155
  }
122
156
  });
157
+ // A cancelled job (fail-fast) kills its running step, with
158
+ // everything the step started.
159
+ const child = running.child;
160
+ stopOnAbort = () => {
161
+ if (child.pid !== undefined)
162
+ killProcessTree(child.pid);
163
+ };
164
+ if (runtime.signal?.aborted)
165
+ stopOnAbort();
166
+ runtime.signal?.addEventListener('abort', stopOnAbort);
167
+ const { stdout, stderr } = await running;
123
168
  const { outputs } = (0, job_runtime_1.finishStepOutput)(stdout, files, runtime);
124
169
  if (stderr) {
125
170
  logger_1.logger.warn(stderr.trim());
@@ -135,8 +180,12 @@ async function executeStep(step, options, runtime, ctx) {
135
180
  }
136
181
  catch (execError) {
137
182
  const error = execError;
138
- if (error.killed && limit.ms)
183
+ if (runtime.signal?.aborted) {
184
+ error.message = 'The operation was canceled.';
185
+ }
186
+ else if (error.killed && limit.ms) {
139
187
  error.message = limit.message;
188
+ }
140
189
  logger_1.logger.error(`Step failed: ${error.message}`);
141
190
  const { outputs } = (0, job_runtime_1.finishStepOutput)(error.stdout, files, runtime);
142
191
  if (error.stderr) {
@@ -152,6 +201,9 @@ async function executeStep(step, options, runtime, ctx) {
152
201
  };
153
202
  }
154
203
  finally {
204
+ if (stopOnAbort) {
205
+ runtime.signal?.removeEventListener('abort', stopOnAbort);
206
+ }
155
207
  (0, runner_env_1.cleanupStepFiles)(files);
156
208
  }
157
209
  }
@@ -0,0 +1,185 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.parseEnvFile = parseEnvFile;
37
+ exports.readEnvFiles = readEnvFiles;
38
+ exports.loadKeyValues = loadKeyValues;
39
+ const fs = __importStar(require("fs"));
40
+ const key_value_1 = require("./key-value");
41
+ const logger_1 = require("./logger");
42
+ /**
43
+ * Names accepted as keys. Hyphens are allowed because input names may
44
+ * contain them; everything else follows shell variable naming.
45
+ */
46
+ const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/;
47
+ const DOUBLE_QUOTE_ESCAPES = {
48
+ n: '\n',
49
+ r: '\r',
50
+ t: '\t',
51
+ '"': '"',
52
+ '\\': '\\'
53
+ };
54
+ /**
55
+ * Parse `.env`-style content into a record (see DOCS.md for the format):
56
+ * `KEY=value` lines, an optional `export ` prefix, `#` comments, single
57
+ * quotes (literal) and double quotes (escapes, may span lines). Later keys
58
+ * win. Errors name `fileName:line` but never echo the line, since it may
59
+ * hold a secret.
60
+ */
61
+ function parseEnvFile(content, fileName) {
62
+ const result = {};
63
+ const lines = content
64
+ .replace(/^\uFEFF/, '')
65
+ .replace(/\r\n/g, '\n')
66
+ .split('\n');
67
+ let next = 0;
68
+ while (next < lines.length) {
69
+ const lineNo = next + 1;
70
+ const line = lines[next++];
71
+ const fail = (reason) => {
72
+ throw new Error(`${fileName}:${lineNo}: ${reason}`);
73
+ };
74
+ const trimmed = line.trim();
75
+ if (trimmed === '' || trimmed.startsWith('#'))
76
+ continue;
77
+ const body = trimmed.replace(/^export\s+/, '');
78
+ const separator = body.indexOf('=');
79
+ if (separator === -1)
80
+ fail('expected KEY=value');
81
+ const key = body.slice(0, separator).trim();
82
+ if (!KEY_PATTERN.test(key))
83
+ fail('invalid variable name');
84
+ const raw = body.slice(separator + 1);
85
+ const rest = raw.trimStart();
86
+ let value;
87
+ let tail = '';
88
+ if (rest.startsWith("'")) {
89
+ const close = rest.indexOf("'", 1);
90
+ if (close === -1)
91
+ fail('unterminated single-quoted value');
92
+ value = rest.slice(1, close);
93
+ tail = rest.slice(close + 1);
94
+ }
95
+ else if (rest.startsWith('"')) {
96
+ let text = rest;
97
+ let pos = 1;
98
+ value = '';
99
+ for (;;) {
100
+ if (pos >= text.length) {
101
+ if (next >= lines.length)
102
+ fail('unterminated double-quoted value');
103
+ value += '\n';
104
+ text = lines[next++];
105
+ pos = 0;
106
+ continue;
107
+ }
108
+ const char = text[pos];
109
+ if (char === '\\' && pos + 1 < text.length) {
110
+ const escaped = text[pos + 1];
111
+ value += DOUBLE_QUOTE_ESCAPES[escaped] ?? `\\${escaped}`;
112
+ pos += 2;
113
+ continue;
114
+ }
115
+ if (char === '"') {
116
+ tail = text.slice(pos + 1);
117
+ break;
118
+ }
119
+ value += char;
120
+ pos++;
121
+ }
122
+ }
123
+ else {
124
+ // A `#` starts a comment only after whitespace, so `a#b` stays intact.
125
+ const comment = raw.search(/\s#/);
126
+ value = (comment === -1 ? raw : raw.slice(0, comment)).trim();
127
+ }
128
+ const after = tail.trim();
129
+ if (after !== '' && !after.startsWith('#')) {
130
+ fail('unexpected text after closing quote');
131
+ }
132
+ result[key] = value;
133
+ }
134
+ return result;
135
+ }
136
+ /** Read and merge env files in order; later files override earlier ones. */
137
+ function readEnvFiles(files, options) {
138
+ const platform = options.platform ?? process.platform;
139
+ const result = {};
140
+ for (const file of files) {
141
+ if (!fs.existsSync(file)) {
142
+ throw new Error(`${options.flagName}: file not found: ${file}`);
143
+ }
144
+ // POSIX modes don't describe Windows ACLs, so the check would mislead.
145
+ if (options.secret && platform !== 'win32') {
146
+ const mode = fs.statSync(file).mode & 0o777;
147
+ if (mode & 0o077) {
148
+ logger_1.logger.warn(`Secret file ${file} is readable by other users (mode ${mode.toString(8).padStart(4, '0')}). Restrict it with: chmod 600 ${file}`);
149
+ }
150
+ }
151
+ Object.assign(result, parseEnvFile(fs.readFileSync(file, 'utf-8'), file));
152
+ }
153
+ return result;
154
+ }
155
+ function configFileList(value, key) {
156
+ if (value === undefined)
157
+ return [];
158
+ if (typeof value === 'string')
159
+ return [value];
160
+ if (Array.isArray(value) && value.every((v) => typeof v === 'string')) {
161
+ return value;
162
+ }
163
+ throw new Error(`Invalid ${key} in config: expected a file path or a list of file paths`);
164
+ }
165
+ /**
166
+ * Resolve one family of run values (env, secrets, inputs or vars): files
167
+ * from the flag (or, without one, from the config), merged in order, then
168
+ * explicit `key=value` flags on top.
169
+ */
170
+ function loadKeyValues(options) {
171
+ const configKey = options.fileFlagName.replace(/^--/, '');
172
+ const defaults = configFileList(options.configFiles, configKey);
173
+ const files = options.files && options.files.length > 0 ? options.files : defaults;
174
+ return {
175
+ ...readEnvFiles(files, {
176
+ flagName: options.fileFlagName,
177
+ secret: options.secret
178
+ }),
179
+ ...(0, key_value_1.parseKeyValuePairs)(options.pairs, options.flagName, {
180
+ redact: options.secret,
181
+ fromEnv: options.fromEnv
182
+ })
183
+ };
184
+ }
185
+ //# sourceMappingURL=env-file.js.map
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.logger = exports.LogLevel = void 0;
4
+ const async_hooks_1 = require("async_hooks");
4
5
  const ansi_1 = require("./ansi");
5
6
  var LogLevel;
6
7
  (function (LogLevel) {
@@ -13,10 +14,22 @@ class Logger {
13
14
  constructor() {
14
15
  this.level = LogLevel.INFO;
15
16
  this.secrets = new Set();
17
+ /** The labels of the job whose code is logging (see withJob). */
18
+ this.job = new async_hooks_1.AsyncLocalStorage();
16
19
  }
17
20
  setLevel(level) {
18
21
  this.level = level;
19
22
  }
23
+ getLevel() {
24
+ return this.level;
25
+ }
26
+ /**
27
+ * Run `hook` before each message is written (e.g. to clear an in-place
28
+ * progress line); undefined removes it.
29
+ */
30
+ setBeforeWrite(hook) {
31
+ this.beforeWrite = hook;
32
+ }
20
33
  /**
21
34
  * Register secret values so they get masked (replaced with `***`) in any
22
35
  * subsequent log output, mirroring GitHub Actions' own secret masking.
@@ -43,41 +56,74 @@ class Logger {
43
56
  }
44
57
  return masked;
45
58
  }
59
+ /**
60
+ * Run `fn` with every message it logs (across awaits, and in anything
61
+ * it starts) prefixed with `[label]`, so concurrent jobs' lines can be
62
+ * told apart. Nested calls (a called workflow's jobs) join the labels;
63
+ * with `replace`, `label` takes the place of the innermost one (a matrix
64
+ * combination's label instead of its job's).
65
+ */
66
+ withJob(label, fn, replace = false) {
67
+ const outer = this.job.getStore() ?? [];
68
+ return this.job.run([...(replace ? outer.slice(0, -1) : outer), label], fn);
69
+ }
70
+ /**
71
+ * `head` and the masked `message`, each non-empty line prefixed with the
72
+ * job label inside withJob.
73
+ */
74
+ format(head, message) {
75
+ const labels = this.job.getStore();
76
+ const masked = head + this.mask(message);
77
+ if (!labels)
78
+ return masked;
79
+ const prefix = `[${this.mask(labels.join(' / '))}] `;
80
+ return masked
81
+ .split('\n')
82
+ .map((line) => (line ? prefix + line : line))
83
+ .join('\n');
84
+ }
46
85
  maskArg(arg) {
47
86
  return typeof arg === 'string' ? this.mask(arg) : arg;
48
87
  }
49
88
  debug(message, ...args) {
50
89
  if (this.level <= LogLevel.DEBUG) {
51
- console.log(ansi_1.colors.gray(`[DEBUG] ${this.mask(message)}`), ...args.map((a) => this.maskArg(a)));
90
+ this.beforeWrite?.();
91
+ console.log(ansi_1.colors.gray(this.format('[DEBUG] ', message)), ...args.map((a) => this.maskArg(a)));
52
92
  }
53
93
  }
54
94
  info(message, ...args) {
55
95
  if (this.level <= LogLevel.INFO) {
56
- console.log(ansi_1.colors.blue(`[INFO] ${this.mask(message)}`), ...args.map((a) => this.maskArg(a)));
96
+ this.beforeWrite?.();
97
+ console.log(ansi_1.colors.blue(this.format('[INFO] ', message)), ...args.map((a) => this.maskArg(a)));
57
98
  }
58
99
  }
59
100
  success(message, ...args) {
60
101
  if (this.level <= LogLevel.INFO) {
61
- console.log(ansi_1.colors.green(`✓ ${this.mask(message)}`), ...args.map((a) => this.maskArg(a)));
102
+ this.beforeWrite?.();
103
+ console.log(ansi_1.colors.green(this.format('✓ ', message)), ...args.map((a) => this.maskArg(a)));
62
104
  }
63
105
  }
64
106
  warn(message, ...args) {
65
107
  if (this.level <= LogLevel.WARN) {
66
- console.warn(ansi_1.colors.yellow(`[WARN] ${this.mask(message)}`), ...args.map((a) => this.maskArg(a)));
108
+ this.beforeWrite?.();
109
+ console.warn(ansi_1.colors.yellow(this.format('[WARN] ', message)), ...args.map((a) => this.maskArg(a)));
67
110
  }
68
111
  }
69
112
  error(message, ...args) {
70
113
  if (this.level <= LogLevel.ERROR) {
71
- console.error(ansi_1.colors.red(`[ERROR] ${this.mask(message)}`), ...args.map((a) => this.maskArg(a)));
114
+ this.beforeWrite?.();
115
+ console.error(ansi_1.colors.red(this.format('[ERROR] ', message)), ...args.map((a) => this.maskArg(a)));
72
116
  }
73
117
  }
74
118
  step(message) {
75
- console.log(ansi_1.colors.cyan(`\n→ ${this.mask(message)}`));
119
+ this.beforeWrite?.();
120
+ console.log(ansi_1.colors.cyan(`\n${this.format('→ ', message)}`));
76
121
  }
77
122
  result(message, success) {
78
123
  const icon = success ? '✓' : '✗';
79
124
  const color = success ? ansi_1.colors.green : ansi_1.colors.red;
80
- console.log(color(`${icon} ${this.mask(message)}`));
125
+ this.beforeWrite?.();
126
+ console.log(color(this.format(`${icon} `, message)));
81
127
  }
82
128
  }
83
129
  exports.logger = new Logger();
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Progress = void 0;
4
+ const logger_1 = require("./logger");
5
+ const CLEAR_LINE = '\r\x1b[K';
6
+ /**
7
+ * Progress for a slow phase, e.g. resolving actions over the network. On a
8
+ * terminal it updates one line in place (`Label [3/10] item`); elsewhere
9
+ * (CI logs) it prints a line per item. The total can grow as work is found.
10
+ */
11
+ class Progress {
12
+ constructor(label, total, out = process.stderr) {
13
+ this.label = label;
14
+ this.total = total;
15
+ this.out = out;
16
+ this.count = 0;
17
+ this.drawn = false;
18
+ this.tty = !!out.isTTY;
19
+ // A log line would otherwise be appended to the half-drawn line.
20
+ if (this.tty)
21
+ logger_1.logger.setBeforeWrite(() => this.clear());
22
+ }
23
+ /** More items were found to process. */
24
+ grow(n = 1) {
25
+ this.total += n;
26
+ }
27
+ tick(item) {
28
+ this.count++;
29
+ if (this.count > this.total)
30
+ this.total = this.count;
31
+ const line = `${this.label} [${this.count}/${this.total}] ${item}`;
32
+ if (this.tty) {
33
+ this.out.write(CLEAR_LINE + line);
34
+ this.drawn = true;
35
+ }
36
+ else {
37
+ this.out.write(line + '\n');
38
+ }
39
+ }
40
+ /** End the phase, leaving `summary` (if any work was done). */
41
+ done(summary) {
42
+ if (this.tty)
43
+ logger_1.logger.setBeforeWrite(undefined);
44
+ if (this.count === 0)
45
+ return;
46
+ this.clear();
47
+ if (summary)
48
+ this.out.write(summary + '\n');
49
+ }
50
+ clear() {
51
+ if (this.drawn) {
52
+ this.out.write(CLEAR_LINE);
53
+ this.drawn = false;
54
+ }
55
+ }
56
+ }
57
+ exports.Progress = Progress;
58
+ //# sourceMappingURL=progress.js.map
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.readVersion = readVersion;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const EMBEDDED_VERSION = typeof __SECURECI_VERSION__ === 'string' ? __SECURECI_VERSION__ : undefined;
40
+ /**
41
+ * The CLI's version: embedded at bundle time in the standalone binary (where
42
+ * package.json isn't on disk), else read from the package.json next to
43
+ * `dist/`, else `0.0.0`.
44
+ */
45
+ function readVersion({ embedded = EMBEDDED_VERSION, packageDir = path.join(__dirname, '..') } = {}) {
46
+ if (embedded)
47
+ return embedded;
48
+ try {
49
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf-8'));
50
+ return String(pkg.version);
51
+ }
52
+ catch {
53
+ return '0.0.0';
54
+ }
55
+ }
56
+ //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@secureci/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "SecureCI: validate, check against pipeline standards, and run GitHub Actions workflows locally",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  ],
15
15
  "scripts": {
16
16
  "build": "tsc",
17
+ "build:sea": "node scripts/build-sea.mjs",
17
18
  "test": "vitest run",
18
19
  "test:watch": "vitest",
19
20
  "test:coverage": "vitest run --coverage",
@@ -69,6 +70,7 @@
69
70
  "@typescript-eslint/parser": "^8.71.1",
70
71
  "@vitest/coverage-v8": "^4.1.11",
71
72
  "eslint": "^8.56.0",
73
+ "postject": "1.0.0-alpha.6",
72
74
  "typescript": "^5.3.3",
73
75
  "vitest": "^4.1.11"
74
76
  },