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.
- package/LICENSE +21 -0
- package/README.md +282 -0
- package/dist/feature-support/load-env.d.ts +1 -0
- package/dist/feature-support/load-env.js +5 -0
- package/dist/feature-support/log-marker-fixture.d.ts +1 -0
- package/dist/feature-support/log-marker-fixture.js +6 -0
- package/dist/feature-support/playwright-base.d.ts +1 -0
- package/dist/feature-support/playwright-base.js +5 -0
- package/dist/feature-support/types.d.ts +1 -0
- package/dist/feature-support/types.js +2 -0
- package/dist/scripts/cli.d.ts +2 -0
- package/dist/scripts/cli.js +47 -0
- package/dist/scripts/init-project.d.ts +1 -0
- package/dist/scripts/init-project.js +110 -0
- package/dist/scripts/new-feature.d.ts +1 -0
- package/dist/scripts/new-feature.js +135 -0
- package/dist/shared/configs/loadEnv.d.ts +5 -0
- package/dist/shared/configs/loadEnv.js +19 -0
- package/dist/shared/configs/playwright.base.d.ts +12 -0
- package/dist/shared/configs/playwright.base.js +15 -0
- package/dist/shared/e2e-runner/log-marker-fixture.d.ts +13 -0
- package/dist/shared/e2e-runner/log-marker-fixture.js +45 -0
- package/dist/shared/e2e-runner/paths.d.ts +8 -0
- package/dist/shared/e2e-runner/paths.js +16 -0
- package/dist/shared/e2e-runner/runner.d.ts +1 -0
- package/dist/shared/e2e-runner/runner.js +567 -0
- package/dist/shared/e2e-runner/summary-reporter.d.ts +7 -0
- package/dist/shared/e2e-runner/summary-reporter.js +33 -0
- package/dist/shared/env-switcher/root-cli.d.ts +1 -0
- package/dist/shared/env-switcher/root-cli.js +84 -0
- package/dist/shared/env-switcher/switch.d.ts +1 -0
- package/dist/shared/env-switcher/switch.js +249 -0
- package/dist/shared/env-switcher/types.d.ts +18 -0
- package/dist/shared/env-switcher/types.js +2 -0
- package/dist/shared/launcher/iterm.d.ts +2 -0
- package/dist/shared/launcher/iterm.js +28 -0
- package/dist/shared/launcher/startup.d.ts +9 -0
- package/dist/shared/launcher/startup.js +40 -0
- package/dist/shared/launcher/terminal.d.ts +2 -0
- package/dist/shared/launcher/terminal.js +25 -0
- package/dist/shared/launcher/types.d.ts +28 -0
- package/dist/shared/launcher/types.js +2 -0
- package/dist/shared/runtime/project-root.d.ts +2 -0
- package/dist/shared/runtime/project-root.js +32 -0
- package/dist/templates/project/.claude/skills/self-fixing-loop.md +53 -0
- package/dist/templates/project/.codex/self-fixing-loop.md +49 -0
- package/dist/templates/project/AGENTS.md +27 -0
- package/dist/templates/project/CLAUDE.md +31 -0
- package/dist/templates/project/features/broken_todo_api/.env.example +1 -0
- package/dist/templates/project/features/broken_todo_api/e2e/broken-todo-api.spec.js +34 -0
- package/dist/templates/project/features/broken_todo_api/e2e/helpers/api.js +48 -0
- package/dist/templates/project/features/broken_todo_api/envsets/envsets.config.json +14 -0
- package/dist/templates/project/features/broken_todo_api/envsets/local/broken_todo_api.env +1 -0
- package/dist/templates/project/features/broken_todo_api/feature.config.cjs +24 -0
- package/dist/templates/project/features/broken_todo_api/playwright.config.js +6 -0
- package/dist/templates/project/features/broken_todo_api/scripts/server.js +76 -0
- package/dist/templates/project/features/broken_todo_api/src/config.js +9 -0
- package/dist/templates/project/features/example_todo_api/.env.example +1 -0
- package/dist/templates/project/features/example_todo_api/e2e/helpers/api.js +36 -0
- package/dist/templates/project/features/example_todo_api/e2e/todo-api.spec.js +25 -0
- package/dist/templates/project/features/example_todo_api/envsets/envsets.config.json +14 -0
- package/dist/templates/project/features/example_todo_api/envsets/local/example_todo_api.env +1 -0
- package/dist/templates/project/features/example_todo_api/feature.config.cjs +24 -0
- package/dist/templates/project/features/example_todo_api/playwright.config.js +6 -0
- package/dist/templates/project/features/example_todo_api/scripts/server.js +60 -0
- package/dist/templates/project/features/example_todo_api/src/config.js +9 -0
- 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,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,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,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
|
+
```
|