@weldr/runr 0.4.0 → 0.7.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/CHANGELOG.md +127 -1
- package/README.md +124 -165
- package/dist/audit/classifier.js +331 -0
- package/dist/cli.js +570 -300
- package/dist/commands/audit.js +259 -0
- package/dist/commands/bundle.js +180 -0
- package/dist/commands/continue.js +276 -0
- package/dist/commands/doctor.js +430 -45
- package/dist/commands/hooks.js +352 -0
- package/dist/commands/init.js +368 -8
- package/dist/commands/intervene.js +109 -0
- package/dist/commands/meta.js +245 -0
- package/dist/commands/mode.js +157 -0
- package/dist/commands/orchestrate.js +29 -0
- package/dist/commands/packs.js +47 -0
- package/dist/commands/preflight.js +8 -5
- package/dist/commands/resume.js +421 -3
- package/dist/commands/run.js +63 -4
- package/dist/commands/status.js +47 -0
- package/dist/commands/submit.js +374 -0
- package/dist/config/schema.js +61 -1
- package/dist/diagnosis/analyzer.js +86 -1
- package/dist/diagnosis/formatter.js +3 -0
- package/dist/diagnosis/index.js +1 -0
- package/dist/diagnosis/stop-explainer.js +267 -0
- package/dist/diagnostics/stop-explainer.js +267 -0
- package/dist/guards/checkpoint.js +119 -0
- package/dist/journal/builder.js +36 -3
- package/dist/journal/renderer.js +19 -0
- package/dist/orchestrator/artifacts.js +17 -2
- package/dist/orchestrator/receipt.js +304 -0
- package/dist/output/stop-footer.js +185 -0
- package/dist/packs/actions.js +176 -0
- package/dist/packs/loader.js +200 -0
- package/dist/packs/renderer.js +46 -0
- package/dist/receipt/intervention.js +465 -0
- package/dist/receipt/writer.js +296 -0
- package/dist/redaction/redactor.js +95 -0
- package/dist/repo/context.js +147 -20
- package/dist/review/check-parser.js +211 -0
- package/dist/store/checkpoint-metadata.js +111 -0
- package/dist/store/run-store.js +21 -0
- package/dist/supervisor/runner.js +130 -10
- package/dist/tasks/task-metadata.js +74 -1
- package/dist/ux/brain.js +528 -0
- package/dist/ux/render.js +123 -0
- package/dist/ux/safe-commands.js +133 -0
- package/dist/ux/state.js +193 -0
- package/dist/ux/telemetry.js +110 -0
- package/package.json +3 -1
- package/packs/pr/pack.json +50 -0
- package/packs/pr/templates/AGENTS.md.tmpl +120 -0
- package/packs/pr/templates/CLAUDE.md.tmpl +101 -0
- package/packs/pr/templates/bundle.md.tmpl +27 -0
- package/packs/solo/pack.json +82 -0
- package/packs/solo/templates/AGENTS.md.tmpl +80 -0
- package/packs/solo/templates/CLAUDE.md.tmpl +126 -0
- package/packs/solo/templates/bundle.md.tmpl +27 -0
- package/packs/solo/templates/claude-cmd-bundle.md.tmpl +40 -0
- package/packs/solo/templates/claude-cmd-resume.md.tmpl +43 -0
- package/packs/solo/templates/claude-cmd-submit.md.tmpl +51 -0
- package/packs/solo/templates/claude-skill.md.tmpl +96 -0
- package/packs/trunk/pack.json +50 -0
- package/packs/trunk/templates/AGENTS.md.tmpl +87 -0
- package/packs/trunk/templates/CLAUDE.md.tmpl +126 -0
- package/packs/trunk/templates/bundle.md.tmpl +27 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import fsPromises from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { renderTemplate } from './renderer.js';
|
|
5
|
+
/**
|
|
6
|
+
* Ensure .gitignore contains the specified entry.
|
|
7
|
+
* Idempotent: only adds if not already present.
|
|
8
|
+
*/
|
|
9
|
+
async function ensureGitignoreEntry(action, context) {
|
|
10
|
+
const gitignorePath = path.join(context.repoPath, action.path);
|
|
11
|
+
const entry = action.line;
|
|
12
|
+
try {
|
|
13
|
+
let content = '';
|
|
14
|
+
try {
|
|
15
|
+
content = await fsPromises.readFile(gitignorePath, 'utf-8');
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// File doesn't exist, will be created
|
|
19
|
+
}
|
|
20
|
+
// Check if entry already exists
|
|
21
|
+
const lines = content.split('\n');
|
|
22
|
+
const hasEntry = lines.some(line => line.trim() === entry.trim());
|
|
23
|
+
if (hasEntry) {
|
|
24
|
+
return {
|
|
25
|
+
action,
|
|
26
|
+
executed: false,
|
|
27
|
+
message: `Entry "${entry}" already in ${action.path}`
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (context.dryRun) {
|
|
31
|
+
return {
|
|
32
|
+
action,
|
|
33
|
+
executed: false,
|
|
34
|
+
message: `[DRY RUN] Would add "${entry}" to ${action.path}`
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
// Add entry
|
|
38
|
+
const newContent = content.endsWith('\n') || content === ''
|
|
39
|
+
? `${content}${entry}\n`
|
|
40
|
+
: `${content}\n${entry}\n`;
|
|
41
|
+
await fsPromises.writeFile(gitignorePath, newContent);
|
|
42
|
+
return {
|
|
43
|
+
action,
|
|
44
|
+
executed: true,
|
|
45
|
+
message: `Added "${entry}" to ${action.path}`
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
return {
|
|
50
|
+
action,
|
|
51
|
+
executed: false,
|
|
52
|
+
message: `Failed to update ${action.path}`,
|
|
53
|
+
error: error instanceof Error ? error.message : String(error)
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Create a file from template if it doesn't already exist.
|
|
59
|
+
* Idempotent: only creates if file is missing.
|
|
60
|
+
*/
|
|
61
|
+
async function createFileIfMissing(action, context) {
|
|
62
|
+
const targetPath = path.join(context.repoPath, action.path);
|
|
63
|
+
// Check "when" condition
|
|
64
|
+
if (action.when?.flag) {
|
|
65
|
+
const flagValue = context.flags[action.when.flag];
|
|
66
|
+
if (!flagValue) {
|
|
67
|
+
return {
|
|
68
|
+
action,
|
|
69
|
+
executed: false,
|
|
70
|
+
message: `Skipped ${action.path} (flag "${action.when.flag}" not set)`
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Check if file already exists
|
|
75
|
+
if (fs.existsSync(targetPath)) {
|
|
76
|
+
return {
|
|
77
|
+
action,
|
|
78
|
+
executed: false,
|
|
79
|
+
message: `File ${action.path} already exists`
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
// Load template
|
|
84
|
+
const templateRelPath = context.templates[action.template];
|
|
85
|
+
if (!templateRelPath) {
|
|
86
|
+
return {
|
|
87
|
+
action,
|
|
88
|
+
executed: false,
|
|
89
|
+
message: `Template "${action.template}" not found in pack`,
|
|
90
|
+
error: 'Template not found in manifest'
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const templatePath = path.join(context.packDir, templateRelPath);
|
|
94
|
+
// Security: Verify template path is within pack directory (prevent directory traversal)
|
|
95
|
+
const resolvedTemplatePath = path.resolve(templatePath);
|
|
96
|
+
const resolvedPackDir = path.resolve(context.packDir);
|
|
97
|
+
if (!resolvedTemplatePath.startsWith(resolvedPackDir + path.sep)) {
|
|
98
|
+
return {
|
|
99
|
+
action,
|
|
100
|
+
executed: false,
|
|
101
|
+
message: `Template path escapes pack directory: ${templateRelPath}`,
|
|
102
|
+
error: 'Invalid template path'
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (!fs.existsSync(templatePath)) {
|
|
106
|
+
return {
|
|
107
|
+
action,
|
|
108
|
+
executed: false,
|
|
109
|
+
message: `Template file not found: ${templateRelPath}`,
|
|
110
|
+
error: 'Template file missing'
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (context.dryRun) {
|
|
114
|
+
return {
|
|
115
|
+
action,
|
|
116
|
+
executed: false,
|
|
117
|
+
message: `[DRY RUN] Would create ${action.path} from template ${action.template}`
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// Read and render template
|
|
121
|
+
const templateContent = await fsPromises.readFile(templatePath, 'utf-8');
|
|
122
|
+
const rendered = renderTemplate(templateContent, context.templateContext);
|
|
123
|
+
// Ensure parent directory exists
|
|
124
|
+
const parentDir = path.dirname(targetPath);
|
|
125
|
+
await fsPromises.mkdir(parentDir, { recursive: true });
|
|
126
|
+
// Write file
|
|
127
|
+
await fsPromises.writeFile(targetPath, rendered);
|
|
128
|
+
// Set permissions if specified
|
|
129
|
+
if (action.mode) {
|
|
130
|
+
const mode = parseInt(action.mode, 8);
|
|
131
|
+
await fsPromises.chmod(targetPath, mode);
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
action,
|
|
135
|
+
executed: true,
|
|
136
|
+
message: `Created ${action.path} from template ${action.template}`
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
return {
|
|
141
|
+
action,
|
|
142
|
+
executed: false,
|
|
143
|
+
message: `Failed to create ${action.path}`,
|
|
144
|
+
error: error instanceof Error ? error.message : String(error)
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Execute a single init action
|
|
150
|
+
*/
|
|
151
|
+
export async function executeAction(action, context) {
|
|
152
|
+
switch (action.type) {
|
|
153
|
+
case 'ensure_gitignore_entry':
|
|
154
|
+
return ensureGitignoreEntry(action, context);
|
|
155
|
+
case 'create_file_if_missing':
|
|
156
|
+
return createFileIfMissing(action, context);
|
|
157
|
+
default:
|
|
158
|
+
return {
|
|
159
|
+
action,
|
|
160
|
+
executed: false,
|
|
161
|
+
message: 'Unknown action type',
|
|
162
|
+
error: `Unknown action type: ${action.type}`
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Execute all init actions from a pack
|
|
168
|
+
*/
|
|
169
|
+
export async function executeActions(actions, context) {
|
|
170
|
+
const results = [];
|
|
171
|
+
for (const action of actions) {
|
|
172
|
+
const result = await executeAction(action, context);
|
|
173
|
+
results.push(result);
|
|
174
|
+
}
|
|
175
|
+
return results;
|
|
176
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
5
|
+
const __dirname = path.dirname(__filename);
|
|
6
|
+
/**
|
|
7
|
+
* Get the packs directory path (repo root / packs)
|
|
8
|
+
*/
|
|
9
|
+
function getPacksDir() {
|
|
10
|
+
// Go up from src/packs/loader.ts to repo root
|
|
11
|
+
return path.resolve(__dirname, '../../packs');
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Get the packs directory path (public API for debugging)
|
|
15
|
+
*/
|
|
16
|
+
export function getPacksDirectory() {
|
|
17
|
+
return getPacksDir();
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Validate a pack manifest
|
|
21
|
+
*/
|
|
22
|
+
function validatePackManifest(manifest, packDir) {
|
|
23
|
+
const errors = [];
|
|
24
|
+
// Check required fields
|
|
25
|
+
if (typeof manifest !== 'object' || manifest === null) {
|
|
26
|
+
errors.push('Manifest must be an object');
|
|
27
|
+
return { valid: false, errors };
|
|
28
|
+
}
|
|
29
|
+
if (manifest.pack_version !== 1) {
|
|
30
|
+
errors.push(`pack_version must be 1, got: ${manifest.pack_version}`);
|
|
31
|
+
}
|
|
32
|
+
if (typeof manifest.name !== 'string' || !manifest.name.match(/^[a-z][a-z0-9-]*$/)) {
|
|
33
|
+
errors.push(`name must be lowercase alphanumeric with hyphens, got: ${manifest.name}`);
|
|
34
|
+
}
|
|
35
|
+
if (typeof manifest.display_name !== 'string' || manifest.display_name.length === 0) {
|
|
36
|
+
errors.push('display_name is required and must be a non-empty string');
|
|
37
|
+
}
|
|
38
|
+
if (typeof manifest.description !== 'string' || manifest.description.length === 0) {
|
|
39
|
+
errors.push('description is required and must be a non-empty string');
|
|
40
|
+
}
|
|
41
|
+
// Validate templates (if provided)
|
|
42
|
+
if (manifest.templates) {
|
|
43
|
+
if (typeof manifest.templates !== 'object') {
|
|
44
|
+
errors.push('templates must be an object');
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
for (const [key, templatePath] of Object.entries(manifest.templates)) {
|
|
48
|
+
if (typeof templatePath !== 'string') {
|
|
49
|
+
errors.push(`Template path for "${key}" must be a string`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const fullPath = path.join(packDir, templatePath);
|
|
53
|
+
if (!fs.existsSync(fullPath)) {
|
|
54
|
+
errors.push(`Template file not found: ${templatePath}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Validate init_actions (if provided)
|
|
60
|
+
if (manifest.init_actions) {
|
|
61
|
+
if (!Array.isArray(manifest.init_actions)) {
|
|
62
|
+
errors.push('init_actions must be an array');
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
for (let i = 0; i < manifest.init_actions.length; i++) {
|
|
66
|
+
const action = manifest.init_actions[i];
|
|
67
|
+
if (!action.type) {
|
|
68
|
+
errors.push(`Action ${i}: missing type field`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (action.type === 'ensure_gitignore_entry') {
|
|
72
|
+
if (!action.path || typeof action.path !== 'string') {
|
|
73
|
+
errors.push(`Action ${i}: ensure_gitignore_entry requires path (string)`);
|
|
74
|
+
}
|
|
75
|
+
if (!action.line || typeof action.line !== 'string') {
|
|
76
|
+
errors.push(`Action ${i}: ensure_gitignore_entry requires line (string)`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else if (action.type === 'create_file_if_missing') {
|
|
80
|
+
if (!action.path || typeof action.path !== 'string') {
|
|
81
|
+
errors.push(`Action ${i}: create_file_if_missing requires path (string)`);
|
|
82
|
+
}
|
|
83
|
+
if (!action.template || typeof action.template !== 'string') {
|
|
84
|
+
errors.push(`Action ${i}: create_file_if_missing requires template (string)`);
|
|
85
|
+
}
|
|
86
|
+
if (action.mode && !action.mode.match(/^0[0-7]{3}$/)) {
|
|
87
|
+
errors.push(`Action ${i}: mode must be octal string (e.g., "0644")`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
errors.push(`Action ${i}: unknown action type "${action.type}"`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
valid: errors.length === 0,
|
|
98
|
+
errors
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Load a single pack from a directory
|
|
103
|
+
*/
|
|
104
|
+
function loadPack(packDir) {
|
|
105
|
+
const manifestPath = path.join(packDir, 'pack.json');
|
|
106
|
+
if (!fs.existsSync(manifestPath)) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const packName = path.basename(packDir);
|
|
110
|
+
try {
|
|
111
|
+
const manifestContent = fs.readFileSync(manifestPath, 'utf-8');
|
|
112
|
+
const manifest = JSON.parse(manifestContent);
|
|
113
|
+
const validation = validatePackManifest(manifest, packDir);
|
|
114
|
+
return {
|
|
115
|
+
name: packName,
|
|
116
|
+
packDir,
|
|
117
|
+
manifest,
|
|
118
|
+
validation
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
return {
|
|
123
|
+
name: packName,
|
|
124
|
+
packDir,
|
|
125
|
+
manifest: {},
|
|
126
|
+
validation: {
|
|
127
|
+
valid: false,
|
|
128
|
+
errors: [`Failed to parse pack.json: ${error instanceof Error ? error.message : String(error)}`]
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Load all available packs
|
|
135
|
+
*/
|
|
136
|
+
export function loadAllPacks() {
|
|
137
|
+
const packsDir = getPacksDir();
|
|
138
|
+
if (!fs.existsSync(packsDir)) {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
const entries = fs.readdirSync(packsDir, { withFileTypes: true });
|
|
142
|
+
const packs = [];
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
// Skip non-directories and special directories
|
|
145
|
+
if (!entry.isDirectory() || entry.name.startsWith('_')) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const packDir = path.join(packsDir, entry.name);
|
|
149
|
+
const pack = loadPack(packDir);
|
|
150
|
+
if (pack) {
|
|
151
|
+
packs.push(pack);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return packs;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Sanitize pack name to prevent directory traversal
|
|
158
|
+
*/
|
|
159
|
+
function sanitizePackName(name) {
|
|
160
|
+
// Only allow lowercase letters, numbers, and hyphens
|
|
161
|
+
// No dots, slashes, or other special characters
|
|
162
|
+
if (!name.match(/^[a-z][a-z0-9-]*$/)) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
// Additional check: ensure the sanitized name doesn't try to escape
|
|
166
|
+
const normalized = path.normalize(name);
|
|
167
|
+
if (normalized !== name || normalized.includes('..') || normalized.includes('/')) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
return name;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Load a specific pack by name
|
|
174
|
+
*/
|
|
175
|
+
export function loadPackByName(name) {
|
|
176
|
+
// Sanitize pack name to prevent directory traversal
|
|
177
|
+
const sanitizedName = sanitizePackName(name);
|
|
178
|
+
if (!sanitizedName) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
const packsDir = getPacksDir();
|
|
182
|
+
const packDir = path.join(packsDir, sanitizedName);
|
|
183
|
+
// Verify the resolved path is actually within packsDir (defense in depth)
|
|
184
|
+
const resolvedPackDir = path.resolve(packDir);
|
|
185
|
+
const resolvedPacksDir = path.resolve(packsDir);
|
|
186
|
+
if (!resolvedPackDir.startsWith(resolvedPacksDir + path.sep)) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
if (!fs.existsSync(packDir)) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
return loadPack(packDir);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Get a list of valid pack names
|
|
196
|
+
*/
|
|
197
|
+
export function getValidPackNames() {
|
|
198
|
+
const packs = loadAllPacks();
|
|
199
|
+
return packs.filter(p => p.validation.valid).map(p => p.name);
|
|
200
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple template renderer for pack templates.
|
|
3
|
+
* Supports basic variable substitution using {{variable}} syntax.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Render a template string with the provided context.
|
|
7
|
+
* Variables are denoted with {{variable_name}} syntax.
|
|
8
|
+
* Missing variables are replaced with empty string.
|
|
9
|
+
*/
|
|
10
|
+
export function renderTemplate(template, context) {
|
|
11
|
+
return template.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
|
|
12
|
+
const value = context[varName];
|
|
13
|
+
return value !== undefined ? value : '';
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Format verification commands as a bullet list for templates
|
|
18
|
+
*/
|
|
19
|
+
export function formatVerificationCommands(verification) {
|
|
20
|
+
const lines = [];
|
|
21
|
+
if (verification.tier0 && verification.tier0.length > 0) {
|
|
22
|
+
lines.push('**Tier 0 (fast checks)**:');
|
|
23
|
+
for (const cmd of verification.tier0) {
|
|
24
|
+
lines.push(`- \`${cmd}\``);
|
|
25
|
+
}
|
|
26
|
+
lines.push('');
|
|
27
|
+
}
|
|
28
|
+
if (verification.tier1 && verification.tier1.length > 0) {
|
|
29
|
+
lines.push('**Tier 1 (build)**:');
|
|
30
|
+
for (const cmd of verification.tier1) {
|
|
31
|
+
lines.push(`- \`${cmd}\``);
|
|
32
|
+
}
|
|
33
|
+
lines.push('');
|
|
34
|
+
}
|
|
35
|
+
if (verification.tier2 && verification.tier2.length > 0) {
|
|
36
|
+
lines.push('**Tier 2 (tests)**:');
|
|
37
|
+
for (const cmd of verification.tier2) {
|
|
38
|
+
lines.push(`- \`${cmd}\``);
|
|
39
|
+
}
|
|
40
|
+
lines.push('');
|
|
41
|
+
}
|
|
42
|
+
if (lines.length === 0) {
|
|
43
|
+
return 'No verification commands configured yet. Edit `.runr/runr.config.json` to add them.';
|
|
44
|
+
}
|
|
45
|
+
return lines.join('\n').trim();
|
|
46
|
+
}
|