openthrottle 0.1.4 → 0.1.6

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/dist/init.js ADDED
@@ -0,0 +1,486 @@
1
+ // =============================================================================
2
+ // openthrottle init — Set up Open Throttle in any Node.js project.
3
+ //
4
+ // Detects the project, prompts for config, generates .openthrottle.yml +
5
+ // wake-sandbox.yml, creates a Daytona snapshot, and prints next steps.
6
+ // =============================================================================
7
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
8
+ import { join, relative } from 'node:path';
9
+ import { execFileSync } from 'node:child_process';
10
+ import prompts from 'prompts';
11
+ import { stringify } from 'yaml';
12
+ import { getErrorMessage } from './types.js';
13
+ const cwd = process.cwd();
14
+ // ---------------------------------------------------------------------------
15
+ // 1. Detect project
16
+ // ---------------------------------------------------------------------------
17
+ function detectProject() {
18
+ const pkgPath = join(cwd, 'package.json');
19
+ if (!existsSync(pkgPath)) {
20
+ console.error('No package.json found. openthrottle init currently supports Node.js projects only.');
21
+ process.exit(1);
22
+ }
23
+ let pkg;
24
+ try {
25
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
26
+ }
27
+ catch {
28
+ console.error('Could not parse package.json. Is it valid JSON?');
29
+ process.exit(1);
30
+ }
31
+ const scripts = pkg.scripts || {};
32
+ const rawName = pkg.name?.replace(/^@[^/]+\//, '') || 'project';
33
+ const name = rawName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
34
+ // Detect package manager
35
+ let pm = 'npm';
36
+ if (pkg.packageManager?.startsWith('pnpm'))
37
+ pm = 'pnpm';
38
+ else if (pkg.packageManager?.startsWith('yarn'))
39
+ pm = 'yarn';
40
+ else if (existsSync(join(cwd, 'pnpm-lock.yaml')))
41
+ pm = 'pnpm';
42
+ else if (existsSync(join(cwd, 'yarn.lock')))
43
+ pm = 'yarn';
44
+ else if (existsSync(join(cwd, 'package-lock.json')))
45
+ pm = 'npm';
46
+ // Detect base branch
47
+ let baseBranch = 'main';
48
+ try {
49
+ const head = execFileSync('git', ['remote', 'show', 'origin'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
50
+ const match = head.match(/HEAD branch:\s*(\S+)/);
51
+ if (match?.[1])
52
+ baseBranch = match[1];
53
+ }
54
+ catch {
55
+ // Not a git repo or no remote — default to main
56
+ }
57
+ return {
58
+ name,
59
+ pm,
60
+ baseBranch,
61
+ test: scripts.test ? `${pm} test` : '',
62
+ build: scripts.build ? `${pm} build` : '',
63
+ lint: scripts.lint ? `${pm} lint` : '',
64
+ format: scripts.format ? `${pm} run format` : (pkg.devDependencies?.prettier ? 'npx prettier --write .' : ''),
65
+ dev: scripts.dev ? `${pm} dev --port 8080 --hostname 0.0.0.0` : '',
66
+ };
67
+ }
68
+ // ---------------------------------------------------------------------------
69
+ // 1b. Detect .env files and extract key names
70
+ // ---------------------------------------------------------------------------
71
+ function detectEnvFiles() {
72
+ const envFiles = {};
73
+ const seen = new Set();
74
+ function scan(dir) {
75
+ let entries;
76
+ try {
77
+ entries = readdirSync(dir);
78
+ }
79
+ catch {
80
+ return;
81
+ }
82
+ for (const entry of entries) {
83
+ if (entry === 'node_modules' || entry === '.git' || entry === '.next' || entry === 'dist')
84
+ continue;
85
+ const full = join(dir, entry);
86
+ let stat;
87
+ try {
88
+ stat = statSync(full);
89
+ }
90
+ catch {
91
+ continue;
92
+ }
93
+ if (stat.isDirectory()) {
94
+ scan(full);
95
+ continue;
96
+ }
97
+ if (!entry.startsWith('.env'))
98
+ continue;
99
+ // Skip .env.example, .env.sample, .env.template
100
+ if (/\.(example|sample|template)$/i.test(entry))
101
+ continue;
102
+ const relPath = relative(cwd, full);
103
+ const keys = [];
104
+ try {
105
+ const content = readFileSync(full, 'utf8');
106
+ for (const line of content.split('\n')) {
107
+ const trimmed = line.trim();
108
+ if (!trimmed || trimmed.startsWith('#'))
109
+ continue;
110
+ const match = trimmed.replace(/^export\s+/, '').match(/^([a-zA-Z_][a-zA-Z0-9_]*)=/);
111
+ if (match?.[1])
112
+ keys.push(match[1]);
113
+ }
114
+ }
115
+ catch (err) {
116
+ console.error(`warning: could not read ${relPath}: ${getErrorMessage(err)}`);
117
+ continue;
118
+ }
119
+ if (keys.length > 0) {
120
+ envFiles[relPath] = keys;
121
+ keys.forEach(k => seen.add(k));
122
+ }
123
+ }
124
+ }
125
+ scan(cwd);
126
+ return { envFiles, allKeys: [...seen].sort() };
127
+ }
128
+ // ---------------------------------------------------------------------------
129
+ // 2. Prompt for config
130
+ // ---------------------------------------------------------------------------
131
+ async function promptConfig(detected) {
132
+ console.log(`\n Detected: package.json (${detected.pm})\n`);
133
+ const response = await prompts([
134
+ { type: 'text', name: 'baseBranch', message: 'Base branch', initial: detected.baseBranch },
135
+ { type: 'text', name: 'test', message: 'Test command', initial: detected.test },
136
+ { type: 'text', name: 'build', message: 'Build command', initial: detected.build },
137
+ { type: 'text', name: 'lint', message: 'Lint command', initial: detected.lint },
138
+ { type: 'text', name: 'format', message: 'Format command', initial: detected.format },
139
+ { type: 'text', name: 'dev', message: 'Dev command', initial: detected.dev },
140
+ { type: 'text', name: 'postBootstrap', message: 'Post-bootstrap command', initial: `${detected.pm} install` },
141
+ {
142
+ type: 'select', name: 'agent', message: 'Agent runtime',
143
+ choices: [
144
+ { title: 'Claude', value: 'claude' },
145
+ { title: 'Codex', value: 'codex' },
146
+ { title: 'Aider', value: 'aider' },
147
+ ],
148
+ initial: 0,
149
+ },
150
+ {
151
+ type: 'select', name: 'notifications', message: 'Notifications',
152
+ choices: [
153
+ { title: 'Telegram', value: 'telegram' },
154
+ { title: 'None', value: 'none' },
155
+ ],
156
+ initial: 0,
157
+ },
158
+ { type: 'number', name: 'maxTurns', message: 'Max turns per agent run', initial: 200, min: 1 },
159
+ { type: 'number', name: 'maxBudgetUsd', message: 'Max budget per run in USD (API only)', initial: 5, min: 0 },
160
+ { type: 'confirm', name: 'reviewEnabled', message: 'Enable automated PR review?', initial: true },
161
+ {
162
+ type: (prev) => prev ? 'number' : null,
163
+ name: 'maxRounds', message: 'Max review rounds', initial: 3, min: 1, max: 10,
164
+ },
165
+ { type: 'text', name: 'snapshotName', message: 'Daytona snapshot name', initial: 'openthrottle' },
166
+ ], { onCancel: () => { console.log('\nCancelled.'); process.exit(0); } });
167
+ const { baseBranch, test, build, lint, format, dev, postBootstrap, agent, notifications, maxTurns, maxBudgetUsd, reviewEnabled, maxRounds, snapshotName } = response;
168
+ return {
169
+ ...detected,
170
+ baseBranch: baseBranch || detected.baseBranch,
171
+ test: test ?? detected.test,
172
+ build: build ?? detected.build,
173
+ lint: lint ?? detected.lint,
174
+ format: format ?? detected.format,
175
+ dev: dev ?? detected.dev,
176
+ postBootstrap: postBootstrap,
177
+ agent: agent,
178
+ notifications: notifications,
179
+ maxTurns: maxTurns,
180
+ maxBudgetUsd: maxBudgetUsd,
181
+ reviewEnabled: reviewEnabled,
182
+ maxRounds: maxRounds,
183
+ snapshotName: snapshotName,
184
+ envFiles: {},
185
+ envAllKeys: [],
186
+ };
187
+ }
188
+ // ---------------------------------------------------------------------------
189
+ // 3. Generate .openthrottle.yml
190
+ // ---------------------------------------------------------------------------
191
+ function generateConfig(config) {
192
+ const doc = {
193
+ base_branch: config.baseBranch,
194
+ test: config.test || undefined,
195
+ dev: config.dev || undefined,
196
+ format: config.format || undefined,
197
+ lint: config.lint || undefined,
198
+ build: config.build || undefined,
199
+ notifications: config.notifications === 'none' ? undefined : config.notifications,
200
+ agent: config.agent,
201
+ snapshot: config.snapshotName || 'openthrottle',
202
+ post_bootstrap: [config.postBootstrap],
203
+ mcp_servers: {},
204
+ env_files: config.envFiles && Object.keys(config.envFiles).length > 0
205
+ ? config.envFiles
206
+ : undefined,
207
+ limits: {
208
+ max_turns: config.maxTurns ?? 200,
209
+ max_budget_usd: config.maxBudgetUsd ?? 5,
210
+ },
211
+ review: {
212
+ enabled: config.reviewEnabled,
213
+ max_rounds: config.maxRounds ?? 3,
214
+ },
215
+ };
216
+ // Remove undefined fields
217
+ for (const key of Object.keys(doc)) {
218
+ if (doc[key] === undefined)
219
+ delete doc[key];
220
+ }
221
+ const header = [
222
+ '# openthrottle.yml — project config for Open Throttle (Daytona runtime)',
223
+ '# Generated by openthrottle init. Committed to the repo so the',
224
+ '# sandbox knows how to work with this project.',
225
+ '',
226
+ ].join('\n');
227
+ return header + stringify(doc);
228
+ }
229
+ // ---------------------------------------------------------------------------
230
+ // 4. Copy wake-sandbox.yml
231
+ // ---------------------------------------------------------------------------
232
+ function copyWorkflow(config) {
233
+ const src = new URL('../templates/wake-sandbox.yml', import.meta.url);
234
+ const destDir = join(cwd, '.github', 'workflows');
235
+ const dest = join(destDir, 'wake-sandbox.yml');
236
+ mkdirSync(destDir, { recursive: true });
237
+ let content = readFileSync(src, 'utf8');
238
+ // Inject project-specific secrets into the workflow
239
+ const allKeys = config.envAllKeys || [];
240
+ if (allKeys.length > 0) {
241
+ // Add env: entries for secrets
242
+ const envSecrets = allKeys
243
+ .map(k => ` ${k}: \${{ secrets.${k} }}`)
244
+ .join('\n');
245
+ content = content.replace(/ # @@ENV_SECRETS@@ — scaffolder inserts project-specific secrets here/, envSecrets);
246
+ // Add --env flags for daytona create
247
+ const envFlags = allKeys
248
+ .map(k => ` --env ${k}=\${${k}} \\`)
249
+ .join('\n');
250
+ content = content.replace(/ # @@ENV_FLAGS@@ — scaffolder inserts --env flags for project secrets here/, envFlags);
251
+ }
252
+ else {
253
+ // No project secrets — remove the placeholder comments
254
+ content = content.replace(/ # @@ENV_SECRETS@@ — scaffolder inserts project-specific secrets here\n/, '');
255
+ content = content.replace(/ # @@ENV_FLAGS@@ — scaffolder inserts --env flags for project secrets here\n/, '');
256
+ }
257
+ writeFileSync(dest, content);
258
+ return dest;
259
+ }
260
+ // ---------------------------------------------------------------------------
261
+ // 5. Create Daytona snapshot from pre-built image
262
+ // ---------------------------------------------------------------------------
263
+ function setupDaytona(config) {
264
+ const snapshotName = config.snapshotName || 'openthrottle';
265
+ const image = 'knoxgraeme/openthrottle:v1';
266
+ // Check daytona CLI is available
267
+ try {
268
+ execFileSync('daytona', ['--version'], { stdio: 'pipe' });
269
+ }
270
+ catch {
271
+ console.log(`\n daytona CLI not found. Install it, then run:`);
272
+ console.log(` daytona snapshot create ${snapshotName} --image ${image} --cpu 2 --memory 4 --disk 10\n`);
273
+ return;
274
+ }
275
+ // Create snapshot from pre-built image
276
+ try {
277
+ execFileSync('daytona', [
278
+ 'snapshot', 'create', snapshotName,
279
+ '--image', image,
280
+ '--cpu', '2', '--memory', '4', '--disk', '10',
281
+ ], { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf8' });
282
+ console.log(` Created Daytona snapshot: ${snapshotName}`);
283
+ }
284
+ catch (err) {
285
+ const execErr = err;
286
+ if (execErr.stderr?.toString().includes('already exists')) {
287
+ console.log(` Snapshot already exists: ${snapshotName}`);
288
+ }
289
+ else {
290
+ const detail = getErrorMessage(err);
291
+ console.log(` Snapshot creation failed: ${detail}`);
292
+ console.log(` You can create it manually:`);
293
+ console.log(` daytona snapshot create ${snapshotName} --image ${image} --cpu 2 --memory 4 --disk 10`);
294
+ }
295
+ }
296
+ }
297
+ // ---------------------------------------------------------------------------
298
+ // 6. Push .env secrets to GitHub repo secrets
299
+ // ---------------------------------------------------------------------------
300
+ async function pushSecrets(config) {
301
+ const envFiles = config.envFiles || {};
302
+ const paths = Object.keys(envFiles);
303
+ if (paths.length === 0)
304
+ return;
305
+ // Check gh is available and authenticated
306
+ try {
307
+ execFileSync('gh', ['auth', 'status'], { stdio: 'pipe' });
308
+ }
309
+ catch (err) {
310
+ const detail = getErrorMessage(err);
311
+ console.log(`\n gh CLI check failed: ${detail}`);
312
+ console.log(' Skipping secret push. Run "gh auth login" then set secrets manually.\n');
313
+ return;
314
+ }
315
+ // Detect repo
316
+ let repo;
317
+ try {
318
+ repo = execFileSync('gh', ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], {
319
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
320
+ }).trim();
321
+ }
322
+ catch (err) {
323
+ console.log(`\n Could not detect GitHub repo: ${getErrorMessage(err)}`);
324
+ console.log(' Skipping secret push.\n');
325
+ return;
326
+ }
327
+ // Show what we'd push
328
+ console.log(`\n Push .env secrets to GitHub repo secrets? (${repo})`);
329
+ console.log(' Values are encrypted at rest — not readable after upload.\n');
330
+ for (const [path, keys] of Object.entries(envFiles)) {
331
+ console.log(` ${path} (${keys.length} keys): ${keys.join(', ')}`);
332
+ }
333
+ console.log('');
334
+ const { confirm } = await prompts({
335
+ type: 'confirm', name: 'confirm',
336
+ message: `Push ${config.envAllKeys.length} secret(s) to ${repo}?`, initial: false,
337
+ }, { onCancel: () => { } });
338
+ if (!confirm) {
339
+ console.log(' Skipped secret push.');
340
+ return;
341
+ }
342
+ let pushed = 0;
343
+ let failed = 0;
344
+ for (const [path, keys] of Object.entries(envFiles)) {
345
+ const fullPath = join(cwd, path);
346
+ let content;
347
+ try {
348
+ content = readFileSync(fullPath, 'utf8');
349
+ }
350
+ catch {
351
+ console.log(` Could not read ${path} — skipping`);
352
+ failed += keys.length;
353
+ continue;
354
+ }
355
+ // Parse key=value pairs
356
+ for (const line of content.split('\n')) {
357
+ const trimmed = line.trim();
358
+ if (!trimmed || trimmed.startsWith('#'))
359
+ continue;
360
+ const cleaned = trimmed.replace(/^export\s+/, '');
361
+ const eqIdx = cleaned.indexOf('=');
362
+ if (eqIdx === -1)
363
+ continue;
364
+ const key = cleaned.slice(0, eqIdx);
365
+ let value = cleaned.slice(eqIdx + 1);
366
+ // Strip surrounding quotes
367
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
368
+ value = value.slice(1, -1);
369
+ }
370
+ if (!keys.includes(key))
371
+ continue;
372
+ try {
373
+ execFileSync('gh', ['secret', 'set', key, '--repo', repo], {
374
+ input: value,
375
+ stdio: ['pipe', 'pipe', 'pipe'],
376
+ });
377
+ pushed++;
378
+ }
379
+ catch (err) {
380
+ console.log(` Failed to set ${key}: ${getErrorMessage(err)}`);
381
+ failed++;
382
+ }
383
+ }
384
+ }
385
+ console.log(` Pushed ${pushed} secret(s) to ${repo}${failed > 0 ? ` (${failed} failed)` : ''}`);
386
+ }
387
+ // ---------------------------------------------------------------------------
388
+ // 7. Print next steps
389
+ // ---------------------------------------------------------------------------
390
+ function printNextSteps(config) {
391
+ const agentSecret = config.agent === 'claude'
392
+ ? ' ANTHROPIC_API_KEY <- option a: pay-per-use API key\n CLAUDE_CODE_OAUTH_TOKEN <- option b: subscription token (claude setup-token)'
393
+ : config.agent === 'codex'
394
+ ? ' OPENAI_API_KEY <- required for Codex'
395
+ : ' OPENAI_API_KEY <- or ANTHROPIC_API_KEY (depends on your Aider model)';
396
+ const secrets = [
397
+ ' DAYTONA_API_KEY <- required',
398
+ agentSecret,
399
+ ];
400
+ // Project-specific secrets from env_files
401
+ const projectKeys = config.envAllKeys || [];
402
+ const projectSecrets = projectKeys.length > 0
403
+ ? '\n\n Project secrets (from .env files):\n' +
404
+ projectKeys.map(k => ` ${k}`).join('\n')
405
+ : '';
406
+ console.log(`
407
+ Next steps:
408
+
409
+ 1. Set GitHub repo secrets:
410
+ ${secrets.join('\n')}
411
+ TELEGRAM_BOT_TOKEN <- optional (notifications)
412
+ TELEGRAM_CHAT_ID <- optional (notifications)${projectSecrets}
413
+
414
+ 2. Commit and push:
415
+ git add .openthrottle.yml .github/workflows/wake-sandbox.yml
416
+ git commit -m "feat: add openthrottle config"
417
+ git push
418
+
419
+ 3. Ship your first prompt:
420
+ npx openthrottle ship docs/prds/my-feature.md
421
+ `);
422
+ }
423
+ // ---------------------------------------------------------------------------
424
+ // Main (exported for use by index.ts)
425
+ // ---------------------------------------------------------------------------
426
+ export default async function init() {
427
+ console.log('\n openthrottle init\n');
428
+ // Step 1: Detect
429
+ const detected = detectProject();
430
+ const { envFiles, allKeys: envAllKeys } = detectEnvFiles();
431
+ if (Object.keys(envFiles).length > 0) {
432
+ console.log(` Found ${Object.keys(envFiles).length} .env file(s):`);
433
+ for (const [path, keys] of Object.entries(envFiles)) {
434
+ console.log(` ${path} (${keys.length} keys)`);
435
+ }
436
+ console.log('');
437
+ }
438
+ // Step 2: Prompt
439
+ const config = await promptConfig(detected);
440
+ config.envFiles = envFiles;
441
+ config.envAllKeys = envAllKeys;
442
+ // Step 3: Generate config
443
+ const configPath = join(cwd, '.openthrottle.yml');
444
+ if (existsSync(configPath)) {
445
+ const { overwrite } = await prompts({
446
+ type: 'confirm', name: 'overwrite',
447
+ message: '.openthrottle.yml already exists. Overwrite?', initial: false,
448
+ }, { onCancel: () => { console.log('\nCancelled.'); process.exit(0); } });
449
+ if (!overwrite) {
450
+ console.log(' Skipped .openthrottle.yml');
451
+ }
452
+ else {
453
+ writeFileSync(configPath, generateConfig(config));
454
+ console.log(' Generated .openthrottle.yml');
455
+ }
456
+ }
457
+ else {
458
+ writeFileSync(configPath, generateConfig(config));
459
+ console.log(' Generated .openthrottle.yml');
460
+ }
461
+ // Step 4: Copy workflow
462
+ const workflowPath = join(cwd, '.github', 'workflows', 'wake-sandbox.yml');
463
+ if (existsSync(workflowPath)) {
464
+ const { overwrite } = await prompts({
465
+ type: 'confirm', name: 'overwrite',
466
+ message: 'wake-sandbox.yml already exists. Overwrite?', initial: false,
467
+ }, { onCancel: () => { console.log('\nCancelled.'); process.exit(0); } });
468
+ if (!overwrite) {
469
+ console.log(' Skipped wake-sandbox.yml');
470
+ }
471
+ else {
472
+ copyWorkflow(config);
473
+ console.log(' Copied .github/workflows/wake-sandbox.yml');
474
+ }
475
+ }
476
+ else {
477
+ copyWorkflow(config);
478
+ console.log(' Copied .github/workflows/wake-sandbox.yml');
479
+ }
480
+ // Step 5: Create Daytona snapshot
481
+ setupDaytona(config);
482
+ // Step 6: Push secrets
483
+ await pushSecrets(config);
484
+ // Step 7: Next steps
485
+ printNextSteps(config);
486
+ }
package/dist/types.js ADDED
@@ -0,0 +1,8 @@
1
+ export function getErrorMessage(err) {
2
+ if (err instanceof Error) {
3
+ const execErr = err;
4
+ const stderr = execErr.stderr?.toString().trim();
5
+ return stderr || execErr.message;
6
+ }
7
+ return String(err);
8
+ }
package/package.json CHANGED
@@ -1,16 +1,19 @@
1
1
  {
2
2
  "name": "openthrottle",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "CLI for Open Throttle — ship prompts to Daytona sandboxes.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "openthrottle": "./index.mjs"
7
+ "openthrottle": "./dist/index.js"
8
8
  },
9
9
  "files": [
10
- "index.mjs",
11
- "init.mjs",
10
+ "dist/",
12
11
  "templates/"
13
12
  ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "prepublishOnly": "npm run build"
16
+ },
14
17
  "dependencies": {
15
18
  "prompts": "^2.4.2",
16
19
  "yaml": "^2.4.0"
@@ -28,5 +31,10 @@
28
31
  "daytona",
29
32
  "agent",
30
33
  "cli"
31
- ]
34
+ ],
35
+ "devDependencies": {
36
+ "@types/node": "^25.5.0",
37
+ "@types/prompts": "^2.4.9",
38
+ "typescript": "^5.9.3"
39
+ }
32
40
  }