@cloud-cli/on 0.1.7 → 1.2.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.
@@ -0,0 +1,47 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import YAML from 'yaml';
4
+ export class WorkflowIncludeResolver {
5
+ baseDir;
6
+ maxDepth;
7
+ constructor(baseDir, maxDepth = 5) {
8
+ this.baseDir = path.resolve(baseDir);
9
+ this.maxDepth = maxDepth;
10
+ }
11
+ /**
12
+ * Recursively loads and merges YAML workflows while guarding against cycles.
13
+ */
14
+ resolve(filePath, visited = new Set(), depth = 0) {
15
+ if (depth > this.maxDepth) {
16
+ throw new Error(`Max include depth of ${this.maxDepth} exceeded at ${filePath}`);
17
+ }
18
+ const absolutePath = path.resolve(this.baseDir, filePath);
19
+ // 1. Security Check: Prevent Directory Traversal outside workspace
20
+ if (!absolutePath.startsWith(this.baseDir)) {
21
+ throw new Error(`Security Violation: Include path '${filePath}' is outside workspace directory.`);
22
+ }
23
+ // 2. Cycle Detection
24
+ if (visited.has(absolutePath)) {
25
+ const cycleTrail = Array.from(visited).concat(absolutePath).join(' -> ');
26
+ throw new Error(`Circular 'includes' dependency detected: ${cycleTrail}`);
27
+ }
28
+ visited.add(absolutePath);
29
+ if (!fs.existsSync(absolutePath)) {
30
+ throw new Error(`Included workflow partial not found: ${filePath}`);
31
+ }
32
+ // 3. Parse File
33
+ const content = fs.readFileSync(absolutePath, 'utf-8');
34
+ const parsed = YAML.parse(content);
35
+ // 4. Handle Nested Includes (Depth First)
36
+ if (Array.isArray(parsed.includes)) {
37
+ for (const subInclude of parsed.includes) {
38
+ const partialData = this.resolve(subInclude, new Set(visited), depth + 1);
39
+ // Merge strategy: Append steps, deep-merge env and trigger properties
40
+ parsed.steps = [...(partialData.steps || []), ...(parsed.steps || [])];
41
+ parsed.env = { ...partialData.env, ...parsed.env };
42
+ }
43
+ delete parsed.includes; // Clean up top-level key after merging
44
+ }
45
+ return parsed;
46
+ }
47
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Expands a workflow definition containing a `strategy.matrix` into dynamic single-instance workflow jobs.
3
+ */
4
+ export function expandMatrix(workflow) {
5
+ // If no matrix strategy is defined, return workflow as a single-element array
6
+ if (!workflow.strategy?.matrix || Object.keys(workflow.strategy.matrix).length === 0) {
7
+ return [workflow];
8
+ }
9
+ const matrix = workflow.strategy.matrix;
10
+ const keys = Object.keys(matrix);
11
+ // Calculate Cartesian Product across all matrix keys
12
+ const combinations = keys.reduce((acc, key) => {
13
+ const values = Array.isArray(matrix[key]) ? matrix[key] : [matrix[key]];
14
+ return acc.flatMap((combination) => values.map((val) => ({
15
+ ...combination,
16
+ [key]: val,
17
+ })));
18
+ }, [{}]);
19
+ // Clone workflow instance for each Cartesian matrix combination
20
+ return combinations.map((combination, index) => {
21
+ const clone = JSON.parse(JSON.stringify(workflow));
22
+ // Remove top-level strategy key after expansion
23
+ delete clone.strategy;
24
+ // Create matrix identity label: "node=20, os=ubuntu"
25
+ const matrixLabel = Object.entries(combination)
26
+ .map(([k, v]) => `${k}=${v}`)
27
+ .join(', ');
28
+ // Append matrix label to workflow name & generate unique workflow ID
29
+ clone.name = `${workflow.name} (${matrixLabel})`;
30
+ clone.matrixContext = combination;
31
+ // Convert matrix values to environment variables (e.g., MATRIX_NODE_VERSION="20")
32
+ const matrixEnv = {};
33
+ for (const [k, v] of Object.entries(combination)) {
34
+ const envKey = `MATRIX_${k.toUpperCase().replace(/[^A-Z0-9_]/g, '_')}`;
35
+ matrixEnv[envKey] = String(v);
36
+ }
37
+ clone.env = {
38
+ ...(clone.env || {}),
39
+ ...matrixEnv,
40
+ };
41
+ return clone;
42
+ });
43
+ }
@@ -0,0 +1,45 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { isAbsolute, join, resolve } from 'node:path';
3
+ import { WorkflowIncludeResolver } from './include-resolver.js';
4
+ import { expandMatrix } from './matrix-expander.js';
5
+ export class YamlLoader {
6
+ static async from(path) {
7
+ const absolutePath = isAbsolute(path) ? resolve('/', path) : join(process.cwd(), resolve('/', path));
8
+ const list = await readdir(absolutePath, { withFileTypes: true });
9
+ const files = list
10
+ .filter((f) => f.isFile() && (f.name.endsWith('.yml') || f.name.endsWith('.yaml')))
11
+ .map((f) => join(absolutePath, f.name));
12
+ const workflows = [];
13
+ const resolver = new WorkflowIncludeResolver(path);
14
+ for (const file of files) {
15
+ workflows.push(...(await YamlLoader.loadFile(file, resolver)));
16
+ }
17
+ return workflows;
18
+ }
19
+ static loadFile(path, resolver) {
20
+ const workflows = [];
21
+ try {
22
+ // Resolve includes & partials
23
+ const resolved = resolver.resolve(path);
24
+ // Expand matrix strategy into concrete job specs
25
+ const expandedWorkflows = expandMatrix(resolved);
26
+ for (const wf of expandedWorkflows) {
27
+ workflows.push({
28
+ id: wf.id || wf.name.toLowerCase().replace(/[^a-z0-9]/g, '-'),
29
+ name: wf.name,
30
+ on: {
31
+ provider: Object.keys(wf.on || {})[0] || 'generic',
32
+ if: wf.on?.[Object.keys(wf.on || {})[0]]?.if,
33
+ },
34
+ concurrency: wf.concurrency,
35
+ steps: wf.steps,
36
+ });
37
+ }
38
+ }
39
+ catch (err) {
40
+ console.error(`❌ Error parsing workflow '${path}':`, err.message);
41
+ return [];
42
+ }
43
+ return workflows;
44
+ }
45
+ }
@@ -0,0 +1,19 @@
1
+ export class GitHubStatusPlugin {
2
+ name = 'github-commit-status';
3
+ async onWorkflowStart(wf) {
4
+ if (!wf.inputs.commit_sha || !wf.inputs.clone_url)
5
+ return;
6
+ await this.updateStatus(wf, 'pending', 'Workflow build has started.');
7
+ }
8
+ async onWorkflowFinish(wf, status) {
9
+ if (!wf.inputs.commit_sha || !wf.inputs.clone_url)
10
+ return;
11
+ const state = status === 'success' ? 'success' : 'failure';
12
+ const description = status === 'success' ? 'All workflow steps passed!' : 'Workflow failed.';
13
+ await this.updateStatus(wf, state, description);
14
+ }
15
+ async updateStatus(wf, state, description) {
16
+ // Calls GitHub REST API using inputs.commit_sha
17
+ console.log(`[GitHub Plugin] Setting commit status for ${wf.inputs.commit_sha} -> ${state}: ${description}`);
18
+ }
19
+ }
@@ -0,0 +1,21 @@
1
+ export class PluginManager {
2
+ plugins = [];
3
+ register(plugin) {
4
+ console.log(`🔌 Registered Plugin: ${plugin.name}`);
5
+ this.plugins.push(plugin);
6
+ }
7
+ async triggerWorkflowStart(wf) {
8
+ for (const plugin of this.plugins) {
9
+ if (plugin.onWorkflowStart) {
10
+ await plugin.onWorkflowStart(wf).catch(err => console.error(`[Plugin Error] ${plugin.name}.onWorkflowStart:`, err));
11
+ }
12
+ }
13
+ }
14
+ async triggerWorkflowFinish(wf, status) {
15
+ for (const plugin of this.plugins) {
16
+ if (plugin.onWorkflowFinish) {
17
+ await plugin.onWorkflowFinish(wf, status).catch(err => console.error(`[Plugin Error] ${plugin.name}.onWorkflowFinish:`, err));
18
+ }
19
+ }
20
+ }
21
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,112 @@
1
+ import db from '../db-client.js';
2
+ export class QueueManager {
3
+ workerId;
4
+ constructor(workerId) {
5
+ this.workerId = workerId;
6
+ }
7
+ /**
8
+ * Initializes the database schema.
9
+ */
10
+ async init() {
11
+ await db.run(`
12
+ CREATE TABLE IF NOT EXISTS jobs (
13
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
14
+ workflow_id TEXT NOT NULL,
15
+ payload TEXT NOT NULL,
16
+ status TEXT NOT NULL DEFAULT 'pending',
17
+ concurrency_key TEXT,
18
+ worker_id TEXT,
19
+ report TEXT, -- Stores WorkflowExecutionReport JSON
20
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
21
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
22
+ )
23
+ `);
24
+ await this.clearStaleJobs();
25
+ }
26
+ /**
27
+ * Enqueues a new job into the database.
28
+ * Includes simple GitHub-style concurrency cancellation.
29
+ */
30
+ async enqueue(workflowId, payload, concurrencyKey) {
31
+ // If a concurrency key is provided, cancel existing pending/running jobs in that group
32
+ if (concurrencyKey) {
33
+ await db.run(`
34
+ UPDATE jobs
35
+ SET status = 'cancelling'
36
+ WHERE concurrency_key = ? AND status IN ('pending', 'running');
37
+ `, [concurrencyKey]);
38
+ }
39
+ const res = await db.run(`
40
+ INSERT INTO jobs (workflow_id, concurrency_key, payload)
41
+ VALUES (?, ?, ?);
42
+ `, [workflowId, concurrencyKey || '', JSON.stringify(payload)]);
43
+ return res;
44
+ }
45
+ /**
46
+ * ATOMICALY claims the oldest pending job.
47
+ * Requires SQLite >= 3.35 for the RETURNING clause.
48
+ */
49
+ async claimNextJob() {
50
+ // This query is completely immune to HTTP/Network race conditions.
51
+ // It locks the row, updates it, and returns the data in one transaction.
52
+ const result = await db.get(`
53
+ UPDATE jobs
54
+ SET
55
+ status = 'running',
56
+ worker_id = ?,
57
+ started_at = CURRENT_TIMESTAMP
58
+ WHERE id = (
59
+ SELECT id FROM jobs
60
+ WHERE status = 'pending'
61
+ ORDER BY created_at ASC
62
+ LIMIT 1
63
+ )
64
+ RETURNING *;
65
+ `, [this.workerId]);
66
+ return result ? result : null;
67
+ }
68
+ /**
69
+ * Marks a job as completed or failed
70
+ */
71
+ async finishJob(jobId, status) {
72
+ await db.run(`
73
+ UPDATE jobs
74
+ SET status = ?, finished_at = CURRENT_TIMESTAMP
75
+ WHERE id = ?;
76
+ `, [status, jobId]);
77
+ }
78
+ /**
79
+ * Checks if the current job has been marked for cancellation by another event
80
+ */
81
+ async isCancelled(jobId) {
82
+ const job = await db.get(`SELECT status FROM jobs WHERE id = ?;`, [jobId]);
83
+ return job?.status === 'cancelling';
84
+ }
85
+ async clearStaleJobs() {
86
+ return await db.run(`UPDATE jobs SET status = 'pending', worker_id = NULL WHERE status = 'running' AND started_at < datetime('now', '-1 hour');`);
87
+ }
88
+ /**
89
+ * Save complete execution report JSON to DB
90
+ */
91
+ async saveReport(jobId, report) {
92
+ await db.run(`UPDATE jobs SET report = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [
93
+ JSON.stringify(report),
94
+ jobId,
95
+ ]);
96
+ }
97
+ /**
98
+ * Fetch job details + report by ID
99
+ */
100
+ async getJob(jobId) {
101
+ return db.get(`SELECT * FROM jobs WHERE id = ?`, [jobId]);
102
+ }
103
+ /**
104
+ * List recent jobs for dashboard status monitoring
105
+ */
106
+ async listJobs(limit = 50) {
107
+ return db.all(`SELECT id, workflow_id, status, concurrency_key, worker_id, created_at, updated_at, report
108
+ FROM jobs
109
+ ORDER BY id DESC
110
+ LIMIT ?`, [limit]);
111
+ }
112
+ }
@@ -0,0 +1,108 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { AnsiUp } from 'ansi_up';
4
+ export class HtmlReporter {
5
+ name = 'html-reporter';
6
+ outputDir;
7
+ ansiUp;
8
+ constructor(options) {
9
+ this.outputDir = options.outputDir;
10
+ this.ansiUp = new AnsiUp();
11
+ this.ansiUp.use_classes = false; // Inline CSS styling for portability
12
+ }
13
+ async report(execReport) {
14
+ fs.mkdirSync(this.outputDir, { recursive: true });
15
+ const htmlContent = this.generateHtml(execReport);
16
+ const filePath = path.join(this.outputDir, `run-${execReport.jobId}.html`);
17
+ fs.writeFileSync(filePath, htmlContent, 'utf-8');
18
+ console.log(`📊 HTML Execution Report generated: ${filePath}`);
19
+ }
20
+ /**
21
+ * Render standalone HTML template using Tailwind CSS via CDN
22
+ */
23
+ generateHtml(execReport) {
24
+ const statusColor = execReport.status === 'success'
25
+ ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20'
26
+ : execReport.status === 'failed'
27
+ ? 'bg-rose-500/10 text-rose-400 border-rose-500/20'
28
+ : 'bg-amber-500/10 text-amber-400 border-amber-500/20';
29
+ // Render Steps & Logs
30
+ const stepRows = execReport.steps
31
+ .map((step, idx) => {
32
+ let rawLog = '';
33
+ if (step.logFilePath && fs.existsSync(step.logFilePath)) {
34
+ try {
35
+ rawLog = fs.readFileSync(step.logFilePath, 'utf-8');
36
+ }
37
+ catch { }
38
+ }
39
+ const htmlLog = rawLog
40
+ ? this.ansiUp.ansi_to_html(rawLog)
41
+ : '<span class="text-gray-500">(No terminal log output recorded for this step)</span>';
42
+ const stepBadge = step.status === 'success'
43
+ ? 'text-emerald-400 bg-emerald-500/10'
44
+ : step.status === 'failed'
45
+ ? 'text-rose-400 bg-rose-500/10'
46
+ : 'text-gray-400 bg-gray-500/10';
47
+ return `
48
+ <div class="border border-gray-800 rounded-xl overflow-hidden bg-gray-900/50 mb-4">
49
+ <div class="flex items-center justify-between p-4 bg-gray-800/40 border-b border-gray-800">
50
+ <div class="flex items-center gap-3">
51
+ <span class="text-xs font-mono text-gray-500">#${idx + 1}</span>
52
+ <h3 class="font-semibold text-gray-200">${step.name}</h3>
53
+ <span class="px-2.5 py-0.5 rounded-full text-xs font-medium ${stepBadge}">
54
+ ${step.status.toUpperCase()}
55
+ </span>
56
+ </div>
57
+ <div class="text-sm font-mono text-gray-400">${step.durationMs}ms</div>
58
+ </div>
59
+ <div class="p-4 bg-gray-950 font-mono text-xs overflow-x-auto text-gray-300 leading-relaxed max-h-96">
60
+ <pre class="whitespace-pre-wrap">${htmlLog}</pre>
61
+ </div>
62
+ </div>
63
+ `;
64
+ })
65
+ .join('');
66
+ return `<!DOCTYPE html>
67
+ <html lang="en" class="dark">
68
+ <head>
69
+ <meta charset="UTF-8">
70
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
71
+ <title>Run #${execReport.jobId} - ${execReport.workflowName}</title>
72
+ <script src="https://cdn.tailwindcss.com"></script>
73
+ </head>
74
+ <body class="bg-gray-950 text-gray-100 min-h-screen p-6 font-sans">
75
+ <div class="max-w-5xl mx-auto space-y-6">
76
+
77
+ <!-- Top Header -->
78
+ <div class="flex items-center justify-between border-b border-gray-800 pb-6">
79
+ <div>
80
+ <a href="/runs" class="text-xs text-indigo-400 hover:underline mb-1 inline-block">← Back to Dashboard</a>
81
+ <h1 class="text-2xl font-bold text-white flex items-center gap-3">
82
+ ${execReport.workflowName}
83
+ <span class="text-sm font-mono text-gray-500">#${execReport.jobId}</span>
84
+ </h1>
85
+ <p class="text-xs text-gray-400 mt-1">Started ${execReport.startedAt} • Finished in ${execReport.durationMs}ms</p>
86
+ </div>
87
+ <span class="px-4 py-1.5 rounded-full text-sm font-semibold border ${statusColor}">
88
+ ${execReport.status.toUpperCase()}
89
+ </span>
90
+ </div>
91
+
92
+ <!-- Inputs Overview -->
93
+ <div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
94
+ <h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Trigger Inputs</h2>
95
+ <pre class="font-mono text-xs text-indigo-300 bg-gray-950 p-3 rounded-lg overflow-x-auto">${JSON.stringify(execReport.inputs, null, 2)}</pre>
96
+ </div>
97
+
98
+ <!-- Steps Timeline -->
99
+ <div>
100
+ <h2 class="text-lg font-semibold text-white mb-4">Execution Steps</h2>
101
+ ${stepRows}
102
+ </div>
103
+
104
+ </div>
105
+ </body>
106
+ </html>`;
107
+ }
108
+ }
@@ -0,0 +1,16 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export class JsonFileReporter {
4
+ name = 'json-file-reporter';
5
+ outputDir;
6
+ constructor(options) {
7
+ this.outputDir = options.outputDir;
8
+ }
9
+ async report(execReport) {
10
+ fs.mkdirSync(this.outputDir, { recursive: true });
11
+ const filePath = path.join(this.outputDir, `run-${execReport.jobId}.json`);
12
+ // Save pretty-printed execution report
13
+ fs.writeFileSync(filePath, JSON.stringify(execReport, null, 2), 'utf-8');
14
+ console.log(`📊 Execution report saved to: ${filePath}`);
15
+ }
16
+ }
@@ -0,0 +1,25 @@
1
+ export class SlackReporter {
2
+ name = 'slack-reporter';
3
+ token;
4
+ channel;
5
+ notifyOn;
6
+ constructor(options) {
7
+ this.token = options.token;
8
+ this.channel = options.channel;
9
+ this.notifyOn = options.notifyOn || ['failed']; // Default: notify on failure only
10
+ }
11
+ async report(execReport) {
12
+ if (!this.notifyOn.includes(execReport.status))
13
+ return;
14
+ const emoji = execReport.status === 'success' ? '✅' : '❌';
15
+ const text = `${emoji} *Workflow ${execReport.workflowName} (#${execReport.jobId})* finished with status: *${execReport.status.toUpperCase()}* (${execReport.durationMs}ms)`;
16
+ await fetch('https://slack.com/api/chat.postMessage', {
17
+ method: 'POST',
18
+ headers: {
19
+ 'Authorization': `Bearer ${this.token}`,
20
+ 'Content-Type': 'application/json'
21
+ },
22
+ body: JSON.stringify({ channel: this.channel, text })
23
+ });
24
+ }
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseEnv } from 'node:util';
4
+ export async function executeStepAndCollectState(stepCtx, driver, currentWorkflowEnv) {
5
+ // 1. Create temporary state files for this step
6
+ const envFilePath = path.join(stepCtx.workspacePath, `.step-${stepCtx.stepId}.env`);
7
+ const outputFilePath = path.join(stepCtx.workspacePath, `.step-${stepCtx.stepId}.out`);
8
+ fs.writeFileSync(envFilePath, '');
9
+ fs.writeFileSync(outputFilePath, '');
10
+ let newEnv = {};
11
+ let outputs = {};
12
+ try {
13
+ // 2. Inject environment file paths into step execution context
14
+ const stepEnv = {
15
+ ...currentWorkflowEnv,
16
+ ...stepCtx.env,
17
+ WORKFLOW_ENV: envFilePath,
18
+ WORKFLOW_OUTPUT: outputFilePath,
19
+ };
20
+ // 3. Execute step
21
+ const handle = await driver.execute({ ...stepCtx, env: stepEnv });
22
+ const result = await handle.done;
23
+ // 4. Parse step-exported environment variables using Node's built-in parseEnv
24
+ if (fs.existsSync(envFilePath)) {
25
+ newEnv = parseEnv(fs.readFileSync(envFilePath, 'utf-8'));
26
+ }
27
+ if (fs.existsSync(outputFilePath)) {
28
+ outputs = parseEnv(fs.readFileSync(outputFilePath, 'utf-8'));
29
+ }
30
+ if (result.exitCode !== 0) {
31
+ return { result, newEnv: {}, outputs: {} };
32
+ }
33
+ return { result, newEnv, outputs };
34
+ }
35
+ finally {
36
+ // ALWAYS cleans up temp state files, regardless of success or failure
37
+ if (fs.existsSync(envFilePath))
38
+ fs.unlinkSync(envFilePath);
39
+ if (fs.existsSync(outputFilePath))
40
+ fs.unlinkSync(outputFilePath);
41
+ }
42
+ }
@@ -0,0 +1,40 @@
1
+ import dotenv from 'dotenv';
2
+ import fs from 'node:fs';
3
+ export class SecretStore {
4
+ envFilePath;
5
+ secrets = new Map();
6
+ /**
7
+ * Initialize secrets from host environment or a specified .env file
8
+ */
9
+ constructor(envFilePath) {
10
+ this.envFilePath = envFilePath;
11
+ }
12
+ reload() {
13
+ // 1. Load host process.env variables prefixed with SECRET_
14
+ for (const [key, val] of Object.entries(process.env)) {
15
+ if (key.startsWith('SECRET_') && val) {
16
+ // Strip prefix: SECRET_SLACK_TOKEN -> SLACK_TOKEN
17
+ this.secrets.set(key.replace('SECRET_', ''), val);
18
+ }
19
+ }
20
+ // 2. Override/add from .env file if present
21
+ if (this.envFilePath && fs.existsSync(this.envFilePath)) {
22
+ const parsed = dotenv.parse(fs.readFileSync(this.envFilePath));
23
+ for (const [key, val] of Object.entries(parsed)) {
24
+ this.secrets.set(key, val);
25
+ }
26
+ }
27
+ }
28
+ get(key) {
29
+ return this.secrets.get(key);
30
+ }
31
+ getAll() {
32
+ return Object.fromEntries(this.secrets);
33
+ }
34
+ /**
35
+ * Returns a list of secret values to be redacted from logs
36
+ */
37
+ getSecretValuesForRedaction() {
38
+ return Array.from(this.secrets.values()).filter((v) => v.length > 3); // Avoid masking tiny strings
39
+ }
40
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};