@secureci/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.
@@ -0,0 +1,250 @@
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.executeStep = executeStep;
37
+ exports.executeInJobContainer = executeInJobContainer;
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const logger_1 = require("../utils/logger");
41
+ const child_process_1 = require("child_process");
42
+ const util_1 = require("util");
43
+ const expression_1 = require("./expression");
44
+ const container_runtime_1 = require("./container-runtime");
45
+ const runner_env_1 = require("./runner-env");
46
+ const shell_1 = require("./shell");
47
+ const job_runtime_1 = require("./job-runtime");
48
+ const actions_1 = require("./actions");
49
+ const execAsync = (0, util_1.promisify)(child_process_1.exec);
50
+ /**
51
+ * Execute a single step
52
+ */
53
+ async function executeStep(step, options, runtime, ctx) {
54
+ const startTime = Date.now();
55
+ try {
56
+ const displayName = (0, job_runtime_1.stepDisplayName)(step, ctx);
57
+ // Don't echo a bare run: command before the step runs — it may contain
58
+ // a value the step is about to register with ::add-mask::. Named and
59
+ // uses steps have a safe label.
60
+ if (step.name || step.uses) {
61
+ logger_1.logger.info(`Running: ${displayName}`);
62
+ }
63
+ // Handle 'uses' actions - fetch and execute remote actions
64
+ if (step.uses) {
65
+ return await (0, actions_1.executeAction)(step, options, startTime, runtime, ctx);
66
+ }
67
+ // Handle 'run' commands
68
+ if (step.run) {
69
+ let command = step.run;
70
+ if (ctx) {
71
+ // Resolve all ${{ }} expressions (env/inputs/secrets/steps/
72
+ // github/matrix) against the full step context.
73
+ command = (0, expression_1.substituteExpressions)(command, ctx);
74
+ }
75
+ else {
76
+ // No context available (e.g. action steps run outside a job) -
77
+ // fall back to direct inputs/secrets substitution.
78
+ if (options.inputs) {
79
+ for (const [key, value] of Object.entries(options.inputs)) {
80
+ command = command.replace(new RegExp(`\\$\\{\\{\\s*inputs\\.${key}\\s*\\}\\}`, 'g'), value);
81
+ }
82
+ }
83
+ if (options.secrets) {
84
+ for (const [key, value] of Object.entries(options.secrets)) {
85
+ command = command.replace(new RegExp(`\\$\\{\\{\\s*secrets\\.${key}\\s*\\}\\}`, 'g'), value);
86
+ }
87
+ }
88
+ }
89
+ logger_1.logger.debug(`Executing: ${command}`);
90
+ // ctx.env already combines workflow, job and step `env:` plus
91
+ // `$GITHUB_ENV` additions (see executeWorkflow). Fall back to
92
+ // step.env alone when no context is available.
93
+ const mergedEnv = ctx
94
+ ? ctx.env
95
+ : { ...runtime.env, ...(0, expression_1.substituteRecord)(step.env, (0, expression_1.createContext)()) };
96
+ if (runtime.container) {
97
+ return await executeInJobContainer(runtime.container, runtime, step, options, command, startTime, mergedEnv, displayName);
98
+ }
99
+ const files = (0, runner_env_1.createStepFiles)();
100
+ const shell = (0, shell_1.resolveShell)(step.shell ?? runtime.defaults.shell, {
101
+ bashAvailable: (0, job_runtime_1.hostHasBash)()
102
+ });
103
+ const scriptPath = path.join(files.dir, `script${shell.extension}`);
104
+ fs.writeFileSync(scriptPath, command);
105
+ const workingDirectory = step['working-directory'] ?? runtime.defaults.workingDirectory;
106
+ const limit = (0, job_runtime_1.effectiveTimeout)(runtime);
107
+ try {
108
+ const { stdout, stderr } = await execAsync((0, shell_1.shellCommandLine)(shell.template, scriptPath), {
109
+ timeout: limit.ms,
110
+ killSignal: 'SIGKILL',
111
+ cwd: workingDirectory
112
+ ? path.resolve(process.cwd(), workingDirectory)
113
+ : process.cwd(),
114
+ env: {
115
+ ...(0, runner_env_1.hostBaseEnv)(),
116
+ ...runtime.runnerEnv,
117
+ ...runtime.actionEnv,
118
+ ...mergedEnv,
119
+ PATH: (0, runner_env_1.prependPath)(runtime.path, mergedEnv.PATH ?? process.env.PATH),
120
+ ...(0, job_runtime_1.fileCommandEnv)(files)
121
+ }
122
+ });
123
+ const { outputs } = (0, job_runtime_1.finishStepOutput)(stdout, files, runtime);
124
+ if (stderr) {
125
+ logger_1.logger.warn(stderr.trim());
126
+ }
127
+ logger_1.logger.success(`Step completed: ${displayName}`);
128
+ return {
129
+ step,
130
+ success: true,
131
+ output: stdout,
132
+ outputs,
133
+ duration: Date.now() - startTime
134
+ };
135
+ }
136
+ catch (execError) {
137
+ const error = execError;
138
+ if (error.killed && limit.ms)
139
+ error.message = limit.message;
140
+ logger_1.logger.error(`Step failed: ${error.message}`);
141
+ const { outputs } = (0, job_runtime_1.finishStepOutput)(error.stdout, files, runtime);
142
+ if (error.stderr) {
143
+ logger_1.logger.error(error.stderr.trim());
144
+ }
145
+ return {
146
+ step,
147
+ success: false,
148
+ error: error.message,
149
+ output: error.stdout,
150
+ outputs,
151
+ duration: Date.now() - startTime
152
+ };
153
+ }
154
+ finally {
155
+ (0, runner_env_1.cleanupStepFiles)(files);
156
+ }
157
+ }
158
+ // No action to take
159
+ return {
160
+ step,
161
+ success: true,
162
+ output: 'No operation',
163
+ duration: Date.now() - startTime
164
+ };
165
+ }
166
+ catch (error) {
167
+ return {
168
+ step,
169
+ success: false,
170
+ error: error instanceof Error ? error.message : 'Unknown error',
171
+ duration: Date.now() - startTime
172
+ };
173
+ }
174
+ }
175
+ /**
176
+ * Execute a `run:` step's command inside the job's container, so files and
177
+ * tools from earlier steps in the job are still there. File commands are
178
+ * exchanged through a per-step directory in the mounted exchange dir.
179
+ */
180
+ async function executeInJobContainer(container, runtime, step, options, command, startTime, mergedEnv, displayName = (0, job_runtime_1.stepDisplayName)(step)) {
181
+ const { files, containerDir } = (0, job_runtime_1.createContainerStepFiles)(container);
182
+ try {
183
+ const workingDirectory = step['working-directory'] ?? runtime.defaults.workingDirectory;
184
+ const workingDir = workingDirectory
185
+ ? path.posix.resolve(container_runtime_1.CONTAINER_WORKDIR, workingDirectory)
186
+ : container_runtime_1.CONTAINER_WORKDIR;
187
+ const shell = (0, shell_1.resolveShell)(step.shell ?? runtime.defaults.shell, {
188
+ bashAvailable: container.bash
189
+ });
190
+ const scriptName = `script${shell.extension}`;
191
+ fs.writeFileSync(path.join(files.dir, scriptName), command, {
192
+ mode: 0o644
193
+ });
194
+ const limit = (0, job_runtime_1.effectiveTimeout)(runtime, options.containerTimeoutMs);
195
+ const result = await container.runtime.exec(container.id, {
196
+ command: (0, job_runtime_1.containerPathPrefix)(runtime) +
197
+ (0, shell_1.shellCommandLine)(shell.template, `${containerDir}/${scriptName}`),
198
+ workingDir,
199
+ env: {
200
+ ...(0, job_runtime_1.containerRunnerEnv)(runtime),
201
+ ...runtime.actionEnv,
202
+ ...mergedEnv,
203
+ ...(0, job_runtime_1.fileCommandEnv)(files, containerDir),
204
+ SECURECI_STEP: path.basename(files.dir)
205
+ },
206
+ timeoutMs: limit.ms
207
+ });
208
+ if (result.timedOut) {
209
+ await (0, job_runtime_1.killStepProcesses)(container, path.basename(files.dir));
210
+ }
211
+ const { outputs } = (0, job_runtime_1.finishStepOutput)(result.stdout, files, runtime);
212
+ if (result.stderr) {
213
+ logger_1.logger[result.success ? 'warn' : 'error'](result.stderr.trim());
214
+ }
215
+ if (result.timedOut) {
216
+ logger_1.logger.error(`${limit.message} (container: ${container.runtime.name})`);
217
+ return {
218
+ step,
219
+ success: false,
220
+ error: limit.message,
221
+ output: result.stdout,
222
+ outputs,
223
+ duration: Date.now() - startTime
224
+ };
225
+ }
226
+ if (!result.success) {
227
+ logger_1.logger.error(`Step failed (container: ${container.runtime.name}, exit code: ${result.exitCode})`);
228
+ return {
229
+ step,
230
+ success: false,
231
+ error: `Command exited with code ${result.exitCode}`,
232
+ output: result.stdout,
233
+ outputs,
234
+ duration: Date.now() - startTime
235
+ };
236
+ }
237
+ logger_1.logger.success(`Step completed: ${displayName} (container: ${container.runtime.name})`);
238
+ return {
239
+ step,
240
+ success: true,
241
+ output: result.stdout,
242
+ outputs,
243
+ duration: Date.now() - startTime
244
+ };
245
+ }
246
+ finally {
247
+ (0, runner_env_1.cleanupStepFiles)(files);
248
+ }
249
+ }
250
+ //# sourceMappingURL=steps.js.map
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ // TypeScript types for GitHub Actions and Workflows
3
+ // Types are designed to align with the official GitHub Workflow schema:
4
+ // https://json.schemastore.org/github-workflow.json
5
+ // https://json.schemastore.org/github-action.json
6
+ //
7
+ // Note: These types focus on the subset of features needed for local execution.
8
+ // Full schema compliance would include additional properties not relevant for SecureCI's use case.
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ /**
3
+ * Minimal terminal colouring for log output. Replaces `chalk`, whose
4
+ * transitive dependencies (supports-color, has-flag, ansi-styles, ...) pull in
5
+ * an unmaintained package and add supply-chain surface for a few log colours.
6
+ *
7
+ * Colour is used only on a terminal, and is turned off by NO_COLOR or
8
+ * TERM=dumb, or turned on by FORCE_COLOR, following the same conventions chalk
9
+ * followed.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.colors = void 0;
13
+ exports.createColors = createColors;
14
+ // [open, close] SGR codes.
15
+ const CODES = {
16
+ gray: [90, 39],
17
+ red: [31, 39],
18
+ green: [32, 39],
19
+ yellow: [33, 39],
20
+ blue: [34, 39],
21
+ cyan: [36, 39]
22
+ };
23
+ function colorEnabled(env, isTTY) {
24
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== '')
25
+ return false;
26
+ if (env.TERM === 'dumb')
27
+ return false;
28
+ if (env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== '0')
29
+ return true;
30
+ return isTTY;
31
+ }
32
+ /** Build the colour functions for a given environment and output stream. */
33
+ function createColors(env, isTTY) {
34
+ const enabled = colorEnabled(env, isTTY);
35
+ const paint = ([open, close]) => (text) => enabled ? `\u001b[${open}m${text}\u001b[${close}m` : text;
36
+ return {
37
+ gray: paint(CODES.gray),
38
+ red: paint(CODES.red),
39
+ green: paint(CODES.green),
40
+ yellow: paint(CODES.yellow),
41
+ blue: paint(CODES.blue),
42
+ cyan: paint(CODES.cyan)
43
+ };
44
+ }
45
+ /** Colours for the process's stdout. */
46
+ exports.colors = createColors(process.env, Boolean(process.stdout.isTTY));
47
+ //# sourceMappingURL=ansi.js.map
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseKeyValuePairs = parseKeyValuePairs;
4
+ /**
5
+ * Parse repeatable `key=value` CLI arguments (`--input`, `--secret`) into a
6
+ * record. Splits on the first `=` only, so values may themselves contain `=`
7
+ * (e.g. base64-padded tokens). Empty values are allowed; a missing `=` (unless
8
+ * `fromEnv` is set) or an empty key is an error.
9
+ */
10
+ function parseKeyValuePairs(pairs, flagName, options = {}) {
11
+ const result = {};
12
+ if (pairs === undefined) {
13
+ return result;
14
+ }
15
+ for (const pair of Array.isArray(pairs) ? pairs : [pairs]) {
16
+ const separator = pair.indexOf('=');
17
+ if (separator === -1 && options.fromEnv && pair !== '') {
18
+ const value = options.fromEnv[pair];
19
+ if (value === undefined) {
20
+ throw new Error(`${flagName} ${pair}: environment variable ${pair} is not set`);
21
+ }
22
+ result[pair] = value;
23
+ continue;
24
+ }
25
+ if (separator <= 0) {
26
+ const shown = options.redact ? '' : ` "${pair}"`;
27
+ throw new Error(`Invalid ${flagName} argument${shown}: expected key=value`);
28
+ }
29
+ result[pair.slice(0, separator)] = pair.slice(separator + 1);
30
+ }
31
+ return result;
32
+ }
33
+ //# sourceMappingURL=key-value.js.map
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.logger = exports.LogLevel = void 0;
4
+ const ansi_1 = require("./ansi");
5
+ var LogLevel;
6
+ (function (LogLevel) {
7
+ LogLevel[LogLevel["DEBUG"] = 0] = "DEBUG";
8
+ LogLevel[LogLevel["INFO"] = 1] = "INFO";
9
+ LogLevel[LogLevel["WARN"] = 2] = "WARN";
10
+ LogLevel[LogLevel["ERROR"] = 3] = "ERROR";
11
+ })(LogLevel || (exports.LogLevel = LogLevel = {}));
12
+ class Logger {
13
+ constructor() {
14
+ this.level = LogLevel.INFO;
15
+ this.secrets = new Set();
16
+ }
17
+ setLevel(level) {
18
+ this.level = level;
19
+ }
20
+ /**
21
+ * Register secret values so they get masked (replaced with `***`) in any
22
+ * subsequent log output, mirroring GitHub Actions' own secret masking.
23
+ * Short/empty values are ignored to avoid masking incidental characters.
24
+ */
25
+ registerSecrets(values) {
26
+ if (!values)
27
+ return;
28
+ for (const value of Object.values(values)) {
29
+ if (value && value.length >= 3) {
30
+ this.secrets.add(value);
31
+ }
32
+ }
33
+ }
34
+ clearSecrets() {
35
+ this.secrets.clear();
36
+ }
37
+ mask(text) {
38
+ if (this.secrets.size === 0)
39
+ return text;
40
+ let masked = text;
41
+ for (const secret of this.secrets) {
42
+ masked = masked.split(secret).join('***');
43
+ }
44
+ return masked;
45
+ }
46
+ maskArg(arg) {
47
+ return typeof arg === 'string' ? this.mask(arg) : arg;
48
+ }
49
+ debug(message, ...args) {
50
+ if (this.level <= LogLevel.DEBUG) {
51
+ console.log(ansi_1.colors.gray(`[DEBUG] ${this.mask(message)}`), ...args.map((a) => this.maskArg(a)));
52
+ }
53
+ }
54
+ info(message, ...args) {
55
+ if (this.level <= LogLevel.INFO) {
56
+ console.log(ansi_1.colors.blue(`[INFO] ${this.mask(message)}`), ...args.map((a) => this.maskArg(a)));
57
+ }
58
+ }
59
+ success(message, ...args) {
60
+ if (this.level <= LogLevel.INFO) {
61
+ console.log(ansi_1.colors.green(`✓ ${this.mask(message)}`), ...args.map((a) => this.maskArg(a)));
62
+ }
63
+ }
64
+ warn(message, ...args) {
65
+ if (this.level <= LogLevel.WARN) {
66
+ console.warn(ansi_1.colors.yellow(`[WARN] ${this.mask(message)}`), ...args.map((a) => this.maskArg(a)));
67
+ }
68
+ }
69
+ error(message, ...args) {
70
+ if (this.level <= LogLevel.ERROR) {
71
+ console.error(ansi_1.colors.red(`[ERROR] ${this.mask(message)}`), ...args.map((a) => this.maskArg(a)));
72
+ }
73
+ }
74
+ step(message) {
75
+ console.log(ansi_1.colors.cyan(`\n→ ${this.mask(message)}`));
76
+ }
77
+ result(message, success) {
78
+ const icon = success ? '✓' : '✗';
79
+ const color = success ? ansi_1.colors.green : ansi_1.colors.red;
80
+ console.log(color(`${icon} ${this.mask(message)}`));
81
+ }
82
+ }
83
+ exports.logger = new Logger();
84
+ //# sourceMappingURL=logger.js.map
@@ -0,0 +1,51 @@
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.repositoryRoot = repositoryRoot;
37
+ const path = __importStar(require("path"));
38
+ /**
39
+ * The repository root that `./` references in a workflow resolve against
40
+ * (as on GitHub, where they're relative to the repository, not the file):
41
+ * the directory holding `.github/workflows/<file>`, else the current
42
+ * directory.
43
+ */
44
+ function repositoryRoot(workflowPath) {
45
+ const dir = path.dirname(path.resolve(workflowPath));
46
+ return path.basename(dir) === 'workflows' &&
47
+ path.basename(path.dirname(dir)) === '.github'
48
+ ? path.dirname(path.dirname(dir))
49
+ : process.cwd();
50
+ }
51
+ //# sourceMappingURL=repository.js.map
package/package.json CHANGED
@@ -1,6 +1,78 @@
1
1
  {
2
2
  "name": "@secureci/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "SecureCI: validate, check against pipeline standards, and run GitHub Actions workflows locally",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "secureci": "./dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist/**/*.js",
11
+ "!dist/**/*.test.js",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "test": "vitest run",
18
+ "test:watch": "vitest",
19
+ "test:coverage": "vitest run --coverage",
20
+ "dev": "tsc --watch",
21
+ "lint": "eslint src --ext .ts",
22
+ "format": "prettier --write .",
23
+ "prepare": "npm run build"
24
+ },
25
+ "keywords": [
26
+ "github-actions",
27
+ "workflow",
28
+ "ci",
29
+ "cli",
30
+ "security",
31
+ "policy-as-code",
32
+ "secureci"
33
+ ],
34
+ "author": {
35
+ "name": "SecureCI",
36
+ "email": "hello@secure-ci.dev"
37
+ },
38
+ "contributors": [
39
+ {
40
+ "name": "Jabran Rafique",
41
+ "email": "hello@jabran.me"
42
+ }
43
+ ],
44
+ "license": "MIT",
45
+ "homepage": "https://www.secure-ci.dev",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/secure-ci/cli.git"
49
+ },
50
+ "bugs": {
51
+ "url": "https://github.com/secure-ci/cli/issues"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "engines": {
57
+ "node": ">=20"
58
+ },
59
+ "prettier": "@jabraf/prettier",
60
+ "dependencies": {
61
+ "commander": "^11.1.0",
62
+ "yaml": "^2.3.4"
63
+ },
64
+ "devDependencies": {
65
+ "@jabraf/dev": "^0.1.4",
66
+ "@jabraf/prettier": "^3.0.1",
67
+ "@types/node": "^20.10.6",
68
+ "@typescript-eslint/eslint-plugin": "^8.71.1",
69
+ "@typescript-eslint/parser": "^8.71.1",
70
+ "@vitest/coverage-v8": "^4.1.11",
71
+ "eslint": "^8.56.0",
72
+ "typescript": "^5.3.3",
73
+ "vitest": "^4.1.11"
74
+ },
75
+ "volta": {
76
+ "node": "24.0.0"
77
+ }
78
+ }