canary-lab 0.1.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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +282 -0
  3. package/dist/feature-support/load-env.d.ts +1 -0
  4. package/dist/feature-support/load-env.js +5 -0
  5. package/dist/feature-support/log-marker-fixture.d.ts +1 -0
  6. package/dist/feature-support/log-marker-fixture.js +6 -0
  7. package/dist/feature-support/playwright-base.d.ts +1 -0
  8. package/dist/feature-support/playwright-base.js +5 -0
  9. package/dist/feature-support/types.d.ts +1 -0
  10. package/dist/feature-support/types.js +2 -0
  11. package/dist/scripts/cli.d.ts +2 -0
  12. package/dist/scripts/cli.js +47 -0
  13. package/dist/scripts/init-project.d.ts +1 -0
  14. package/dist/scripts/init-project.js +110 -0
  15. package/dist/scripts/new-feature.d.ts +1 -0
  16. package/dist/scripts/new-feature.js +135 -0
  17. package/dist/shared/configs/loadEnv.d.ts +5 -0
  18. package/dist/shared/configs/loadEnv.js +19 -0
  19. package/dist/shared/configs/playwright.base.d.ts +12 -0
  20. package/dist/shared/configs/playwright.base.js +15 -0
  21. package/dist/shared/e2e-runner/log-marker-fixture.d.ts +13 -0
  22. package/dist/shared/e2e-runner/log-marker-fixture.js +45 -0
  23. package/dist/shared/e2e-runner/paths.d.ts +8 -0
  24. package/dist/shared/e2e-runner/paths.js +16 -0
  25. package/dist/shared/e2e-runner/runner.d.ts +1 -0
  26. package/dist/shared/e2e-runner/runner.js +567 -0
  27. package/dist/shared/e2e-runner/summary-reporter.d.ts +7 -0
  28. package/dist/shared/e2e-runner/summary-reporter.js +33 -0
  29. package/dist/shared/env-switcher/root-cli.d.ts +1 -0
  30. package/dist/shared/env-switcher/root-cli.js +84 -0
  31. package/dist/shared/env-switcher/switch.d.ts +1 -0
  32. package/dist/shared/env-switcher/switch.js +249 -0
  33. package/dist/shared/env-switcher/types.d.ts +18 -0
  34. package/dist/shared/env-switcher/types.js +2 -0
  35. package/dist/shared/launcher/iterm.d.ts +2 -0
  36. package/dist/shared/launcher/iterm.js +28 -0
  37. package/dist/shared/launcher/startup.d.ts +9 -0
  38. package/dist/shared/launcher/startup.js +40 -0
  39. package/dist/shared/launcher/terminal.d.ts +2 -0
  40. package/dist/shared/launcher/terminal.js +25 -0
  41. package/dist/shared/launcher/types.d.ts +28 -0
  42. package/dist/shared/launcher/types.js +2 -0
  43. package/dist/shared/runtime/project-root.d.ts +2 -0
  44. package/dist/shared/runtime/project-root.js +32 -0
  45. package/dist/templates/project/.claude/skills/self-fixing-loop.md +53 -0
  46. package/dist/templates/project/.codex/self-fixing-loop.md +49 -0
  47. package/dist/templates/project/AGENTS.md +27 -0
  48. package/dist/templates/project/CLAUDE.md +31 -0
  49. package/dist/templates/project/features/broken_todo_api/.env.example +1 -0
  50. package/dist/templates/project/features/broken_todo_api/e2e/broken-todo-api.spec.js +34 -0
  51. package/dist/templates/project/features/broken_todo_api/e2e/helpers/api.js +48 -0
  52. package/dist/templates/project/features/broken_todo_api/envsets/envsets.config.json +14 -0
  53. package/dist/templates/project/features/broken_todo_api/envsets/local/broken_todo_api.env +1 -0
  54. package/dist/templates/project/features/broken_todo_api/feature.config.cjs +24 -0
  55. package/dist/templates/project/features/broken_todo_api/playwright.config.js +6 -0
  56. package/dist/templates/project/features/broken_todo_api/scripts/server.js +76 -0
  57. package/dist/templates/project/features/broken_todo_api/src/config.js +9 -0
  58. package/dist/templates/project/features/example_todo_api/.env.example +1 -0
  59. package/dist/templates/project/features/example_todo_api/e2e/helpers/api.js +36 -0
  60. package/dist/templates/project/features/example_todo_api/e2e/todo-api.spec.js +25 -0
  61. package/dist/templates/project/features/example_todo_api/envsets/envsets.config.json +14 -0
  62. package/dist/templates/project/features/example_todo_api/envsets/local/example_todo_api.env +1 -0
  63. package/dist/templates/project/features/example_todo_api/feature.config.cjs +24 -0
  64. package/dist/templates/project/features/example_todo_api/playwright.config.js +6 -0
  65. package/dist/templates/project/features/example_todo_api/scripts/server.js +60 -0
  66. package/dist/templates/project/features/example_todo_api/src/config.js +9 -0
  67. package/package.json +71 -0
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_1 = __importDefault(require("fs"));
7
+ const path_1 = __importDefault(require("path"));
8
+ const paths_1 = require("./paths");
9
+ function slugify(title) {
10
+ return title
11
+ .toLowerCase()
12
+ .replace(/[^a-z0-9]+/g, '-')
13
+ .replace(/^-|-$/g, '');
14
+ }
15
+ class SummaryReporter {
16
+ results = [];
17
+ onTestEnd(test, result) {
18
+ this.results.push({
19
+ name: `test-case-${slugify(test.title)}`,
20
+ passed: result.status === 'passed',
21
+ });
22
+ }
23
+ onEnd(_result) {
24
+ const summary = {
25
+ total: this.results.length,
26
+ passed: this.results.filter((r) => r.passed).length,
27
+ failed: this.results.filter((r) => !r.passed).map((r) => r.name),
28
+ };
29
+ fs_1.default.mkdirSync(paths_1.LOGS_DIR, { recursive: true });
30
+ fs_1.default.writeFileSync(path_1.default.join(paths_1.LOGS_DIR, 'e2e-summary.json'), JSON.stringify(summary, null, 2) + '\n');
31
+ }
32
+ }
33
+ exports.default = SummaryReporter;
@@ -0,0 +1 @@
1
+ export declare function main(args?: string[]): Promise<void>;
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.main = main;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const readline_1 = __importDefault(require("readline"));
10
+ const child_process_1 = require("child_process");
11
+ const project_root_1 = require("../runtime/project-root");
12
+ const FEATURES_DIR = (0, project_root_1.getFeaturesDir)();
13
+ const SWITCH_SCRIPT = path_1.default.join(__dirname, 'switch.js');
14
+ function prompt(rl, question) {
15
+ return new Promise((resolve) => rl.question(question, resolve));
16
+ }
17
+ async function selectOption(rl, label, options) {
18
+ console.log(`\n${label}`);
19
+ options.forEach((opt, i) => console.log(` ${i + 1}) ${opt}`));
20
+ while (true) {
21
+ const answer = await prompt(rl, `Select [1-${options.length}]: `);
22
+ const idx = parseInt(answer.trim(), 10) - 1;
23
+ if (idx >= 0 && idx < options.length)
24
+ return options[idx];
25
+ console.log(` Please enter a number between 1 and ${options.length}`);
26
+ }
27
+ }
28
+ function discoverFeaturesWithEnvSets() {
29
+ return fs_1.default.readdirSync(FEATURES_DIR, { withFileTypes: true })
30
+ .filter(d => d.isDirectory())
31
+ .map(d => d.name)
32
+ .filter(name => fs_1.default.existsSync(path_1.default.join(FEATURES_DIR, name, 'envsets', 'envsets.config.json')))
33
+ .sort();
34
+ }
35
+ function listEnvSets(featureName) {
36
+ const envSetsDir = path_1.default.join(FEATURES_DIR, featureName, 'envsets');
37
+ return fs_1.default.readdirSync(envSetsDir, { withFileTypes: true })
38
+ .filter(d => d.isDirectory())
39
+ .map(d => d.name)
40
+ .sort();
41
+ }
42
+ async function main(args = process.argv.slice(2)) {
43
+ let mode = args[0];
44
+ const rl = readline_1.default.createInterface({ input: process.stdin, output: process.stdout });
45
+ try {
46
+ if (mode !== '--apply' && mode !== '--revert') {
47
+ mode = await selectOption(rl, 'What do you want to do?', [
48
+ 'Apply env set',
49
+ 'Revert env files',
50
+ ]);
51
+ mode = mode.startsWith('Revert') ? '--revert' : '--apply';
52
+ }
53
+ const features = discoverFeaturesWithEnvSets();
54
+ if (features.length === 0) {
55
+ console.error('No features with env sets found.');
56
+ process.exit(1);
57
+ }
58
+ const featureName = await selectOption(rl, 'Which feature?', features);
59
+ if (mode === '--revert') {
60
+ console.log(`\nReverting env for ${featureName}...`);
61
+ (0, child_process_1.execFileSync)(process.execPath, [SWITCH_SCRIPT, featureName, '--revert'], { stdio: 'inherit' });
62
+ return;
63
+ }
64
+ const envSets = listEnvSets(featureName);
65
+ let chosenSet;
66
+ if (envSets.length === 1) {
67
+ chosenSet = envSets[0];
68
+ console.log(`\n Using env set: ${chosenSet}`);
69
+ }
70
+ else {
71
+ chosenSet = await selectOption(rl, `Which env set for ${featureName}?`, envSets);
72
+ }
73
+ (0, child_process_1.execFileSync)(process.execPath, [SWITCH_SCRIPT, featureName, '--apply', chosenSet], { stdio: 'inherit' });
74
+ }
75
+ finally {
76
+ rl.close();
77
+ }
78
+ }
79
+ if (require.main === module) {
80
+ main().catch(err => {
81
+ console.error(err);
82
+ process.exit(1);
83
+ });
84
+ }
@@ -0,0 +1 @@
1
+ export declare function main(args?: string[]): Promise<void>;
@@ -0,0 +1,249 @@
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.main = main;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const readline = __importStar(require("readline"));
40
+ const child_process_1 = require("child_process");
41
+ const project_root_1 = require("../runtime/project-root");
42
+ function resolveVars(str, appRoots) {
43
+ return str.replace(/\$([A-Z_]+)/g, (_, key) => appRoots[key] ?? `$${key}`);
44
+ }
45
+ function getEnvSetsDir(featureName) {
46
+ if (path.isAbsolute(featureName)) {
47
+ return path.join(featureName, 'envsets');
48
+ }
49
+ return path.join((0, project_root_1.getFeaturesDir)(), featureName, 'envsets');
50
+ }
51
+ function loadConfig(featureName) {
52
+ const envSetsDir = getEnvSetsDir(featureName);
53
+ const configPath = path.join(envSetsDir, 'envsets.config.json');
54
+ if (!fs.existsSync(configPath)) {
55
+ throw new Error(`Missing envsets config for "${featureName}" at ${configPath}`);
56
+ }
57
+ const raw = fs.readFileSync(configPath, 'utf-8');
58
+ const config = JSON.parse(raw);
59
+ config.appRoots = {
60
+ CANARY_LAB_PROJECT_ROOT: (0, project_root_1.getProjectRoot)(),
61
+ ...config.appRoots,
62
+ };
63
+ return config;
64
+ }
65
+ function listEnvSets(envSetsDir) {
66
+ return fs
67
+ .readdirSync(envSetsDir, { withFileTypes: true })
68
+ .filter((d) => d.isDirectory())
69
+ .map((d) => d.name)
70
+ .sort();
71
+ }
72
+ function getSlotFilesInSet(envSetsDir, setName, slots) {
73
+ const setDir = path.join(envSetsDir, setName);
74
+ return slots.filter((slot) => fs.existsSync(path.join(setDir, slot)));
75
+ }
76
+ function backup(targets, timestamp) {
77
+ const records = [];
78
+ for (const { targetPath } of targets) {
79
+ if (fs.existsSync(targetPath)) {
80
+ const backupPath = `${targetPath}.bak.${timestamp}`;
81
+ fs.copyFileSync(targetPath, backupPath);
82
+ records.push({ originalPath: targetPath, backupPath });
83
+ }
84
+ }
85
+ return records;
86
+ }
87
+ function applySet(envSetsDir, setName, targets) {
88
+ const setDir = path.join(envSetsDir, setName);
89
+ for (const { slot, targetPath } of targets) {
90
+ const sourcePath = path.join(setDir, slot);
91
+ if (fs.existsSync(sourcePath)) {
92
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
93
+ fs.copyFileSync(sourcePath, targetPath);
94
+ }
95
+ }
96
+ }
97
+ function restore(records) {
98
+ for (const { originalPath, backupPath } of records) {
99
+ if (fs.existsSync(backupPath)) {
100
+ fs.copyFileSync(backupPath, originalPath);
101
+ fs.unlinkSync(backupPath);
102
+ }
103
+ }
104
+ }
105
+ function revert(targets) {
106
+ let found = 0;
107
+ for (const { targetPath } of targets) {
108
+ const dir = path.dirname(targetPath);
109
+ const base = path.basename(targetPath);
110
+ if (!fs.existsSync(dir))
111
+ continue;
112
+ const backups = fs
113
+ .readdirSync(dir)
114
+ .filter((f) => f.startsWith(`${base}.bak.`))
115
+ .sort()
116
+ .reverse(); // most recent first
117
+ if (backups.length > 0) {
118
+ const latest = path.join(dir, backups[0]);
119
+ fs.copyFileSync(latest, targetPath);
120
+ for (const b of backups)
121
+ fs.unlinkSync(path.join(dir, b));
122
+ found++;
123
+ console.log(` Restored: ${targetPath}`);
124
+ }
125
+ }
126
+ if (found === 0) {
127
+ console.log(' No backup files found.');
128
+ }
129
+ else {
130
+ console.log(`\nRestored ${found} file(s).`);
131
+ }
132
+ }
133
+ async function prompt(question) {
134
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
135
+ return new Promise((resolve) => {
136
+ rl.question(question, (answer) => {
137
+ rl.close();
138
+ resolve(answer.trim());
139
+ });
140
+ });
141
+ }
142
+ async function main(args = process.argv.slice(2)) {
143
+ const featureName = args[0];
144
+ if (!featureName) {
145
+ console.error('Usage: switch.js <feature-name> [--apply <set>|--revert]');
146
+ process.exit(1);
147
+ }
148
+ const envSetsDir = getEnvSetsDir(featureName);
149
+ const config = loadConfig(featureName);
150
+ const feature = config.feature;
151
+ const targets = feature.slots.map((slot) => ({
152
+ slot,
153
+ targetPath: resolveVars(config.slots[slot].target, config.appRoots),
154
+ }));
155
+ // --- REVERT MODE ---
156
+ if (args[1] === '--revert') {
157
+ console.log(`\nReverting env files for "${featureName}"...\n`);
158
+ revert(targets);
159
+ return;
160
+ }
161
+ // --- APPLY MODE (no tests) ---
162
+ if (args[1] === '--apply') {
163
+ const setName = args[2];
164
+ if (!setName) {
165
+ console.error('Usage: switch.js <feature> --apply <set-name>');
166
+ process.exit(1);
167
+ }
168
+ const envSets = listEnvSets(envSetsDir);
169
+ if (!envSets.includes(setName)) {
170
+ console.error(`Unknown set "${setName}". Available: ${envSets.join(', ')}`);
171
+ process.exit(1);
172
+ }
173
+ const timestamp = Date.now();
174
+ console.log(`\nBacking up ${targets.length} file(s)...`);
175
+ backup(targets, timestamp);
176
+ console.log(`Applying "${setName}" env set...`);
177
+ applySet(envSetsDir, setName, targets);
178
+ console.log('Done. Run "npx canary-lab env" to revert originals when needed.\n');
179
+ return;
180
+ }
181
+ // --- INTERACTIVE MODE (prompt + run tests) ---
182
+ const envSets = listEnvSets(envSetsDir);
183
+ if (envSets.length === 0) {
184
+ console.error(`No env sets found in ${envSetsDir}`);
185
+ process.exit(1);
186
+ }
187
+ console.log(`\nWhich env set for ${featureName}?\n`);
188
+ envSets.forEach((setName, i) => {
189
+ const presentFiles = getSlotFilesInSet(envSetsDir, setName, feature.slots);
190
+ console.log(` ${i + 1}) ${setName.padEnd(12)} — ${presentFiles.length} file(s) (${presentFiles.join(', ')})`);
191
+ });
192
+ console.log('');
193
+ let chosenSet;
194
+ while (!chosenSet) {
195
+ const answer = await prompt(`Enter number or name [1]: `);
196
+ const trimmed = answer === '' ? '1' : answer;
197
+ const asNumber = parseInt(trimmed, 10);
198
+ if (!isNaN(asNumber) && asNumber >= 1 && asNumber <= envSets.length) {
199
+ chosenSet = envSets[asNumber - 1];
200
+ }
201
+ else if (envSets.includes(trimmed)) {
202
+ chosenSet = trimmed;
203
+ }
204
+ else {
205
+ console.log(` Invalid choice. Enter a number 1-${envSets.length} or a set name.`);
206
+ }
207
+ }
208
+ const timestamp = Date.now();
209
+ console.log(`\nBacking up ${targets.length} file(s)... `);
210
+ const backups = backup(targets, timestamp);
211
+ console.log('done');
212
+ console.log(`Applying "${chosenSet}" env set... `);
213
+ applySet(envSetsDir, chosenSet, targets);
214
+ console.log('done\n');
215
+ let cleanupDone = false;
216
+ function cleanup() {
217
+ if (cleanupDone)
218
+ return;
219
+ cleanupDone = true;
220
+ process.stdout.write('\nRestoring original files... ');
221
+ restore(backups);
222
+ console.log('done\n');
223
+ }
224
+ process.on('SIGINT', () => {
225
+ cleanup();
226
+ process.exit(130);
227
+ });
228
+ const testCwd = resolveVars(feature.testCwd, config.appRoots);
229
+ const [cmd, ...cmdArgs] = feature.testCommand.split(' ');
230
+ console.log(`Running: ${feature.testCommand}`);
231
+ console.log('─'.repeat(45));
232
+ const child = (0, child_process_1.spawn)(cmd, cmdArgs, { cwd: testCwd, stdio: 'inherit', shell: true });
233
+ child.on('close', (code) => {
234
+ console.log('─'.repeat(45));
235
+ cleanup();
236
+ process.exit(code ?? 0);
237
+ });
238
+ child.on('error', (err) => {
239
+ console.error(`Failed to run test command: ${err.message}`);
240
+ cleanup();
241
+ process.exit(1);
242
+ });
243
+ }
244
+ if (require.main === module) {
245
+ main().catch((err) => {
246
+ console.error(err);
247
+ process.exit(1);
248
+ });
249
+ }
@@ -0,0 +1,18 @@
1
+ export type SlotDefinition = {
2
+ description: string;
3
+ target: string;
4
+ };
5
+ export type FeatureDefinition = {
6
+ slots: string[];
7
+ testCommand: string;
8
+ testCwd: string;
9
+ };
10
+ export type EnvSetsConfig = {
11
+ appRoots: Record<string, string>;
12
+ slots: Record<string, SlotDefinition>;
13
+ feature: FeatureDefinition;
14
+ };
15
+ export type BackupRecord = {
16
+ originalPath: string;
17
+ backupPath: string;
18
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,2 @@
1
+ import type { StartTab } from './startup';
2
+ export declare function openItermTabs(tabs: StartTab[], label: string): void;
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.openItermTabs = openItermTabs;
4
+ const child_process_1 = require("child_process");
5
+ function openItermTabs(tabs, label) {
6
+ const sessionDecls = tabs
7
+ .map((_, i) => i === 0
8
+ ? `set s1 to current session of current tab`
9
+ : `set t${i + 1} to create tab with default profile\n set s${i + 1} to current session of t${i + 1}`)
10
+ .join('\n\n ');
11
+ const sessionWrites = tabs
12
+ .map(({ dir, command }, i) => `tell s${i + 1}\n delay 0.3\n write text "cd ${dir} && ${command}"\n end tell`)
13
+ .join('\n ');
14
+ const script = `
15
+ tell application "iTerm"
16
+ set w to create window with default profile
17
+ tell w
18
+ ${sessionDecls}
19
+ end tell
20
+
21
+ ${sessionWrites}
22
+
23
+ activate
24
+ end tell
25
+ `;
26
+ console.log(label);
27
+ (0, child_process_1.execFileSync)('osascript', ['-e', script], { stdio: 'inherit' });
28
+ }
@@ -0,0 +1,9 @@
1
+ import type { StartCommand } from './types';
2
+ export interface StartTab {
3
+ dir: string;
4
+ command: string;
5
+ name: string;
6
+ }
7
+ export declare function resolvePath(p: string): string;
8
+ export declare function normalizeStartCommand(command: string | StartCommand, fallbackName: string): StartCommand;
9
+ export declare function isHealthy(url: string, timeoutMs?: number): Promise<boolean>;
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.resolvePath = resolvePath;
7
+ exports.normalizeStartCommand = normalizeStartCommand;
8
+ exports.isHealthy = isHealthy;
9
+ const http_1 = __importDefault(require("http"));
10
+ const https_1 = __importDefault(require("https"));
11
+ const os_1 = __importDefault(require("os"));
12
+ function resolvePath(p) {
13
+ return p.startsWith('~/') ? p.replace('~', os_1.default.homedir()) : p;
14
+ }
15
+ function normalizeStartCommand(command, fallbackName) {
16
+ if (typeof command === 'string') {
17
+ return {
18
+ command,
19
+ name: fallbackName,
20
+ };
21
+ }
22
+ return {
23
+ ...command,
24
+ name: command.name ?? fallbackName,
25
+ };
26
+ }
27
+ async function isHealthy(url, timeoutMs = 1500) {
28
+ const client = url.startsWith('https://') ? https_1.default : http_1.default;
29
+ return await new Promise((resolve) => {
30
+ const req = client.get(url, { timeout: timeoutMs }, (res) => {
31
+ res.resume();
32
+ resolve((res.statusCode ?? 0) < 500);
33
+ });
34
+ req.on('timeout', () => {
35
+ req.destroy();
36
+ resolve(false);
37
+ });
38
+ req.on('error', () => resolve(false));
39
+ });
40
+ }
@@ -0,0 +1,2 @@
1
+ import type { StartTab } from './startup';
2
+ export declare function openTerminalTabs(tabs: StartTab[], label: string): void;
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.openTerminalTabs = openTerminalTabs;
4
+ const child_process_1 = require("child_process");
5
+ function openTerminalTabs(tabs, label) {
6
+ // First tab: open a new window. Subsequent tabs: open tabs in the same window.
7
+ const commands = tabs.map(({ dir, command }, i) => {
8
+ if (i === 0) {
9
+ return `do script "cd ${dir} && ${command}"`;
10
+ }
11
+ return [
12
+ `tell application "System Events" to keystroke "t" using command down`,
13
+ `delay 0.5`,
14
+ `do script "cd ${dir} && ${command}" in front window`,
15
+ ].join('\n ');
16
+ });
17
+ const script = `
18
+ tell application "Terminal"
19
+ activate
20
+ ${commands.join('\n ')}
21
+ end tell
22
+ `;
23
+ console.log(label);
24
+ (0, child_process_1.execFileSync)('osascript', ['-e', script], { stdio: 'inherit' });
25
+ }
@@ -0,0 +1,28 @@
1
+ export interface RepoPrerequisite {
2
+ name: string;
3
+ localPath: string;
4
+ cloneUrl?: string;
5
+ startCommands?: Array<string | StartCommand>;
6
+ }
7
+ export interface HealthCheck {
8
+ url: string;
9
+ timeoutMs?: number;
10
+ }
11
+ export interface StartCommand {
12
+ command: string;
13
+ name?: string;
14
+ healthCheck?: HealthCheck;
15
+ }
16
+ export interface NgrokTunnel {
17
+ port: number;
18
+ subdomain: string;
19
+ }
20
+ export interface FeatureConfig {
21
+ name: string;
22
+ description: string;
23
+ envs: string[];
24
+ repos?: RepoPrerequisite[];
25
+ tunnels?: NgrokTunnel[];
26
+ startScript?: Record<string, string>;
27
+ featureDir: string;
28
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,2 @@
1
+ export declare function getProjectRoot(): string;
2
+ export declare function getFeaturesDir(): string;
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getProjectRoot = getProjectRoot;
7
+ exports.getFeaturesDir = getFeaturesDir;
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const path_1 = __importDefault(require("path"));
10
+ function looksLikeProjectRoot(candidate) {
11
+ return fs_1.default.existsSync(path_1.default.join(candidate, 'features'));
12
+ }
13
+ function getProjectRoot() {
14
+ const explicitRoot = process.env.CANARY_LAB_PROJECT_ROOT;
15
+ if (explicitRoot) {
16
+ return path_1.default.resolve(explicitRoot);
17
+ }
18
+ let current = path_1.default.resolve(process.cwd());
19
+ while (true) {
20
+ if (looksLikeProjectRoot(current)) {
21
+ return current;
22
+ }
23
+ const parent = path_1.default.dirname(current);
24
+ if (parent === current) {
25
+ return path_1.default.resolve(process.cwd());
26
+ }
27
+ current = parent;
28
+ }
29
+ }
30
+ function getFeaturesDir() {
31
+ return path_1.default.join(getProjectRoot(), 'features');
32
+ }
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: Canary Lab Self-Fixing Loop
3
+ description: Canonical Claude workflow for the broken Canary Lab demo. Triggered when the user says "self heal".
4
+ type: skill
5
+ ---
6
+
7
+ # Self-Fixing Loop
8
+
9
+ ## Trigger Phrase
10
+
11
+ If the user types:
12
+
13
+ ```text
14
+ self heal
15
+ ```
16
+
17
+ follow this workflow.
18
+
19
+ ## Start State
20
+
21
+ - The user should already have run `npx canary-lab run`
22
+ - The selected feature should be `broken_todo_api`
23
+ - The runner should still be open in watch mode
24
+
25
+ ## Read First
26
+
27
+ - `logs/e2e-summary.json`
28
+ - `logs/svc-*.log`
29
+ - `features/broken_todo_api/scripts/server.js`
30
+
31
+ ## Rules
32
+
33
+ - Fix the implementation, not the test
34
+ - Do not “solve” the demo by changing the failing assertion
35
+ - If running service code changed, run:
36
+
37
+ ```bash
38
+ touch logs/.restart
39
+ ```
40
+
41
+ - If no restart is needed, run:
42
+
43
+ ```bash
44
+ touch logs/.rerun
45
+ ```
46
+
47
+ ## Copy-Paste Prompt
48
+
49
+ ```text
50
+ Please read CLAUDE.md and .claude/skills/self-fixing-loop.md first.
51
+ The Canary Lab runner is already in watch mode.
52
+ Inspect logs/e2e-summary.json and the service logs, diagnose the failing broken_todo_api test, fix the implementation without changing the test, and then trigger the correct rerun signal.
53
+ ```
@@ -0,0 +1,49 @@
1
+ # Codex Self-Fixing Loop
2
+
3
+ This is the canonical Codex workflow doc for the Canary Lab self-fixing flow.
4
+
5
+ ## Trigger Phrase
6
+
7
+ If the user types:
8
+
9
+ ```text
10
+ self heal
11
+ ```
12
+
13
+ Codex should run this workflow.
14
+
15
+ ## Start State
16
+
17
+ - The user should already have run `npx canary-lab run`
18
+ - The selected feature should be `broken_todo_api`
19
+ - The runner should still be open in watch mode
20
+
21
+ ## Read First
22
+
23
+ - `logs/e2e-summary.json`
24
+ - `logs/svc-*.log`
25
+ - `features/broken_todo_api/scripts/server.js`
26
+
27
+ ## Rules
28
+
29
+ - Fix the implementation, not the test
30
+ - Do not “solve” the demo by changing the failing assertion
31
+ - If running service code changed, run:
32
+
33
+ ```bash
34
+ touch logs/.restart
35
+ ```
36
+
37
+ - If no restart is needed, run:
38
+
39
+ ```bash
40
+ touch logs/.rerun
41
+ ```
42
+
43
+ ## Copy-Paste Prompt
44
+
45
+ ```text
46
+ Please read AGENTS.md and .codex/self-fixing-loop.md first.
47
+ The Canary Lab runner is already in watch mode.
48
+ Inspect logs/e2e-summary.json and the service logs, diagnose the failing broken_todo_api test, fix the implementation without changing the test, and then trigger the correct rerun signal.
49
+ ```