thepopebot 1.2.70 → 1.2.71-beta.1

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/README.md CHANGED
@@ -64,10 +64,10 @@ You interact with your bot via the web chat interface or Telegram (optional). Th
64
64
  | **npm** | Included with Node.js |
65
65
  | **Git** | [git-scm.com](https://git-scm.com) |
66
66
  | **GitHub CLI** | [cli.github.com](https://cli.github.com) |
67
- | **Docker + Docker Compose** | [docker.com](https://docs.docker.com/get-docker/) |
68
- | **ngrok*** | [ngrok.com](https://ngrok.com/download) |
67
+ | **Docker + Docker Compose** | [docker.com](https://docs.docker.com/get-docker/) (installer requires admin password) |
68
+ | **ngrok*** | [ngrok.com](https://ngrok.com/download) (free account + authtoken required) |
69
69
 
70
- *\*ngrok is only required for local installs without port forwarding. VPS/cloud deployments don't need it.*
70
+ *\*ngrok is only required for local installs without port forwarding. VPS/cloud deployments don't need it. [Sign up](https://dashboard.ngrok.com/signup) for a free ngrok account, then run `ngrok config add-authtoken <YOUR_TOKEN>` before starting setup.*
71
71
 
72
72
  ### Three steps
73
73
 
@@ -106,7 +106,16 @@ docker compose up -d
106
106
  - **Webhook**: Send a POST to `/api/create-job` with your API key to create jobs programmatically
107
107
  - **Cron**: Edit `config/CRONS.json` to schedule recurring jobs
108
108
 
109
- > **Local installs**: Your server needs to be reachable from the internet for GitHub webhooks and Telegram. On a VPS/cloud server, your APP_URL is just your domain. For local development, use [ngrok](https://ngrok.com) (`ngrok http 80`) or port forwarding to expose your machine. If your ngrok URL changes, update APP_URL in `.env` and the GitHub repository variable, and re-run `npm run setup-telegram` if Telegram is configured.
109
+ > **Local installs**: Your server needs to be reachable from the internet for GitHub webhooks and Telegram. On a VPS/cloud server, your APP_URL is just your domain. For local development, use [ngrok](https://ngrok.com) (`ngrok http 80`) or port forwarding to expose your machine.
110
+ >
111
+ > **If your ngrok URL changes** (it changes every time you restart ngrok on the free plan), you must update APP_URL everywhere:
112
+ >
113
+ > ```bash
114
+ > # Update .env and GitHub variable in one command:
115
+ > npx thepopebot set-var APP_URL https://your-new-url.ngrok.io
116
+ > # If Telegram is configured, re-register the webhook:
117
+ > npm run setup-telegram
118
+ > ```
110
119
 
111
120
  ---
112
121
 
@@ -272,7 +281,7 @@ See [docs/SECURITY.md](docs/SECURITY.md) for full details on what's exposed, the
272
281
  | [Auto-Merge](docs/AUTO_MERGE.md) | Auto-merge controls, ALLOWED_PATHS configuration |
273
282
  | [Deployment](docs/DEPLOYMENT.md) | VPS setup, Docker Compose, HTTPS with Let's Encrypt |
274
283
  | [How to Use Pi](docs/HOW_TO_USE_PI.md) | Guide to the Pi coding agent |
275
- | [Pre-Release](docs/PRE_RELEASE.md) | Installing beta/alpha builds, going back to stable |
284
+ | [Pre-Release](docs/PRE_RELEASE.md) | Installing beta/alpha builds |
276
285
  | [Security](docs/SECURITY.md) | Security disclaimer, local development risks |
277
286
  | [Upgrading](docs/UPGRADE.md) | Automated upgrades, recovering from failed upgrades |
278
287
 
package/api/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createHash, timingSafeEqual } from 'crypto';
2
2
  import { createJob } from '../lib/tools/create-job.js';
3
3
  import { setWebhook } from '../lib/tools/telegram.js';
4
- import { getJobStatus } from '../lib/tools/github.js';
4
+ import { getJobStatus, fetchJobLog } from '../lib/tools/github.js';
5
5
  import { getTelegramAdapter } from '../lib/channels/index.js';
6
6
  import { chat, summarizeJob } from '../lib/ai/index.js';
7
7
  import { createNotification } from '../lib/db/notifications.js';
@@ -171,13 +171,19 @@ async function handleGithubWebhook(request) {
171
171
  if (!jobId) return Response.json({ ok: true, skipped: true, reason: 'not a job' });
172
172
 
173
173
  try {
174
+ // Fetch log from repo via API (no longer sent in payload)
175
+ let log = payload.log || '';
176
+ if (!log) {
177
+ log = await fetchJobLog(jobId, payload.commit_sha);
178
+ }
179
+
174
180
  const results = {
175
181
  job: payload.job || '',
176
182
  pr_url: payload.pr_url || payload.run_url || '',
177
183
  run_url: payload.run_url || '',
178
184
  status: payload.status || '',
179
185
  merge_result: payload.merge_result || '',
180
- log: payload.log || '',
186
+ log,
181
187
  changed_files: payload.changed_files || [],
182
188
  commit_message: payload.commit_message || '',
183
189
  };
package/lib/actions.js CHANGED
@@ -34,7 +34,10 @@ async function executeAction(action, opts = {}) {
34
34
  }
35
35
 
36
36
  // Default: agent
37
- const result = await createJob(action.job);
37
+ const options = {};
38
+ if (action.llm_provider) options.llmProvider = action.llm_provider;
39
+ if (action.llm_model) options.llmModel = action.llm_model;
40
+ const result = await createJob(action.job, options);
38
41
  return `job ${result.job_id}`;
39
42
  }
40
43
 
@@ -176,7 +176,11 @@ export async function getAppVersion() {
176
176
  export async function triggerUpgrade() {
177
177
  await requireAuth();
178
178
  const { triggerWorkflowDispatch } = await import('../tools/github.js');
179
- await triggerWorkflowDispatch('upgrade-event-handler.yml');
179
+ const { getAvailableVersion } = await import('../db/update-check.js');
180
+ const targetVersion = getAvailableVersion();
181
+ await triggerWorkflowDispatch('upgrade-event-handler.yml', 'main', {
182
+ target_version: targetVersion || '',
183
+ });
180
184
  return { success: true };
181
185
  }
182
186
 
@@ -99,7 +99,11 @@ function CronCard({ cron }) {
99
99
  expanded && /* @__PURE__ */ jsxs("div", { className: "border-t px-4 py-3", children: [
100
100
  type === "agent" && cron.job && /* @__PURE__ */ jsxs("div", { children: [
101
101
  /* @__PURE__ */ jsx("p", { className: "text-xs font-medium text-muted-foreground mb-1.5", children: "Job prompt" }),
102
- /* @__PURE__ */ jsx("pre", { className: "text-xs bg-muted rounded-md p-3 whitespace-pre-wrap break-words font-mono overflow-auto max-h-48", children: cron.job })
102
+ /* @__PURE__ */ jsx("pre", { className: "text-xs bg-muted rounded-md p-3 whitespace-pre-wrap break-words font-mono overflow-auto max-h-48", children: cron.job }),
103
+ (cron.llm_provider || cron.llm_model) && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 mt-2", children: [
104
+ /* @__PURE__ */ jsx("span", { className: "text-xs font-medium text-muted-foreground", children: "LLM:" }),
105
+ /* @__PURE__ */ jsx("span", { className: "inline-flex items-center rounded-full bg-purple-500/10 text-purple-500 px-2 py-0.5 text-[10px] font-medium", children: [cron.llm_provider, cron.llm_model].filter(Boolean).join(" / ") })
106
+ ] })
103
107
  ] }),
104
108
  type === "command" && cron.command && /* @__PURE__ */ jsxs("div", { children: [
105
109
  /* @__PURE__ */ jsx("p", { className: "text-xs font-medium text-muted-foreground mb-1.5", children: "Command" }),
@@ -133,6 +133,14 @@ function CronCard({ cron }) {
133
133
  <pre className="text-xs bg-muted rounded-md p-3 whitespace-pre-wrap break-words font-mono overflow-auto max-h-48">
134
134
  {cron.job}
135
135
  </pre>
136
+ {(cron.llm_provider || cron.llm_model) && (
137
+ <div className="flex items-center gap-2 mt-2">
138
+ <span className="text-xs font-medium text-muted-foreground">LLM:</span>
139
+ <span className="inline-flex items-center rounded-full bg-purple-500/10 text-purple-500 px-2 py-0.5 text-[10px] font-medium">
140
+ {[cron.llm_provider, cron.llm_model].filter(Boolean).join(' / ')}
141
+ </span>
142
+ </div>
143
+ )}
136
144
  </div>
137
145
  )}
138
146
  {type === 'command' && cron.command && (
@@ -38,7 +38,13 @@ function ActionCard({ action, index }) {
38
38
  ] }),
39
39
  /* @__PURE__ */ jsx("span", { className: `inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${typeBadgeStyles[type] || typeBadgeStyles.agent}`, children: type })
40
40
  ] }),
41
- type === "agent" && action.job && /* @__PURE__ */ jsx("pre", { className: "text-xs bg-muted rounded-md p-3 whitespace-pre-wrap break-words font-mono overflow-auto max-h-48", children: action.job }),
41
+ type === "agent" && action.job && /* @__PURE__ */ jsxs("div", { children: [
42
+ /* @__PURE__ */ jsx("pre", { className: "text-xs bg-muted rounded-md p-3 whitespace-pre-wrap break-words font-mono overflow-auto max-h-48", children: action.job }),
43
+ (action.llm_provider || action.llm_model) && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 mt-2", children: [
44
+ /* @__PURE__ */ jsx("span", { className: "text-xs font-medium text-muted-foreground", children: "LLM:" }),
45
+ /* @__PURE__ */ jsx("span", { className: "inline-flex items-center rounded-full bg-purple-500/10 text-purple-500 px-2 py-0.5 text-[10px] font-medium", children: [action.llm_provider, action.llm_model].filter(Boolean).join(" / ") })
46
+ ] })
47
+ ] }),
42
48
  type === "command" && action.command && /* @__PURE__ */ jsx("pre", { className: "text-xs bg-muted rounded-md p-3 whitespace-pre-wrap break-words font-mono overflow-auto max-h-48", children: action.command }),
43
49
  type === "webhook" && /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", children: [
44
50
  /* @__PURE__ */ jsxs("pre", { className: "text-xs bg-muted rounded-md p-3 whitespace-pre-wrap break-words font-mono overflow-auto", children: [
@@ -51,9 +51,19 @@ function ActionCard({ action, index }) {
51
51
  </span>
52
52
  </div>
53
53
  {type === 'agent' && action.job && (
54
- <pre className="text-xs bg-muted rounded-md p-3 whitespace-pre-wrap break-words font-mono overflow-auto max-h-48">
55
- {action.job}
56
- </pre>
54
+ <div>
55
+ <pre className="text-xs bg-muted rounded-md p-3 whitespace-pre-wrap break-words font-mono overflow-auto max-h-48">
56
+ {action.job}
57
+ </pre>
58
+ {(action.llm_provider || action.llm_model) && (
59
+ <div className="flex items-center gap-2 mt-2">
60
+ <span className="text-xs font-medium text-muted-foreground">LLM:</span>
61
+ <span className="inline-flex items-center rounded-full bg-purple-500/10 text-purple-500 px-2 py-0.5 text-[10px] font-medium">
62
+ {[action.llm_provider, action.llm_model].filter(Boolean).join(' / ')}
63
+ </span>
64
+ </div>
65
+ )}
66
+ </div>
57
67
  )}
58
68
  {type === 'command' && action.command && (
59
69
  <pre className="text-xs bg-muted rounded-md p-3 whitespace-pre-wrap break-words font-mono overflow-auto max-h-48">
package/lib/cron.js CHANGED
@@ -49,31 +49,108 @@ function isVersionNewer(candidate, baseline) {
49
49
  return false;
50
50
  }
51
51
 
52
+ /**
53
+ * Check if a version string is a pre-release (contains '-').
54
+ * @param {string} v
55
+ * @returns {boolean}
56
+ */
57
+ function isPrerelease(v) {
58
+ return v.includes('-');
59
+ }
60
+
61
+ /**
62
+ * Compare two semver strings (including pre-release).
63
+ * Returns positive if a > b, negative if a < b, 0 if equal.
64
+ * Ordering: 1.2.71-beta.0 < 1.2.71-beta.1 < 1.2.71 (stable) < 1.2.72-beta.0
65
+ * @param {string} a
66
+ * @param {string} b
67
+ * @returns {number}
68
+ */
69
+ function compareVersions(a, b) {
70
+ const [aCore, aPre] = a.split('-');
71
+ const [bCore, bPre] = b.split('-');
72
+
73
+ const aParts = aCore.split('.').map(Number);
74
+ const bParts = bCore.split('.').map(Number);
75
+
76
+ // Compare major.minor.patch
77
+ for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
78
+ const av = aParts[i] || 0;
79
+ const bv = bParts[i] || 0;
80
+ if (av !== bv) return av - bv;
81
+ }
82
+
83
+ // Same core version: stable beats pre-release
84
+ if (!aPre && bPre) return 1; // a is stable, b is pre-release
85
+ if (aPre && !bPre) return -1; // a is pre-release, b is stable
86
+ if (!aPre && !bPre) return 0; // both stable, same core
87
+
88
+ // Both pre-release with same core: compare pre-release number
89
+ const aNum = parseInt(aPre.split('.').pop(), 10) || 0;
90
+ const bNum = parseInt(bPre.split('.').pop(), 10) || 0;
91
+ return aNum - bNum;
92
+ }
93
+
52
94
  /**
53
95
  * Check npm registry for a newer version of thepopebot.
54
96
  */
55
97
  async function runVersionCheck() {
56
98
  try {
57
- const res = await fetch('https://registry.npmjs.org/thepopebot/latest');
58
- if (!res.ok) {
59
- console.warn(`[version check] npm registry returned ${res.status}`);
60
- return;
61
- }
62
- const data = await res.json();
63
- const latest = data.version;
64
-
65
99
  const installed = getInstalledVersion();
66
- if (isVersionNewer(latest, installed)) {
67
- console.log(`[version check] update available: ${installed} → ${latest}`);
68
- setUpdateAvailable(latest);
69
- // Persist to DB
70
- const { setAvailableVersion } = await import('./db/update-check.js');
71
- setAvailableVersion(latest);
100
+
101
+ if (isPrerelease(installed)) {
102
+ // Beta path: check both stable and beta dist-tags
103
+ const results = await Promise.allSettled([
104
+ fetch('https://registry.npmjs.org/thepopebot/latest'),
105
+ fetch('https://registry.npmjs.org/thepopebot/beta'),
106
+ ]);
107
+
108
+ const candidates = [];
109
+ for (const result of results) {
110
+ if (result.status !== 'fulfilled') continue;
111
+ const res = result.value;
112
+ if (!res.ok) continue;
113
+ const data = await res.json();
114
+ if (data.version && compareVersions(data.version, installed) > 0) {
115
+ candidates.push(data.version);
116
+ }
117
+ }
118
+
119
+ if (candidates.length > 0) {
120
+ // Pick the best candidate (highest version)
121
+ candidates.sort(compareVersions);
122
+ const best = candidates[candidates.length - 1];
123
+ console.log(`[version check] update available: ${installed} → ${best}`);
124
+ setUpdateAvailable(best);
125
+ const { setAvailableVersion } = await import('./db/update-check.js');
126
+ setAvailableVersion(best);
127
+ } else {
128
+ setUpdateAvailable(null);
129
+ const { clearAvailableVersion } = await import('./db/update-check.js');
130
+ clearAvailableVersion();
131
+ }
72
132
  } else {
73
- setUpdateAvailable(null);
74
- // Clear DB
75
- const { clearAvailableVersion } = await import('./db/update-check.js');
76
- clearAvailableVersion();
133
+ // Stable path: existing logic, untouched
134
+ const res = await fetch('https://registry.npmjs.org/thepopebot/latest');
135
+ if (!res.ok) {
136
+ console.warn(`[version check] npm registry returned ${res.status}`);
137
+ return;
138
+ }
139
+ const data = await res.json();
140
+ const latest = data.version;
141
+
142
+ if (isVersionNewer(latest, installed)) {
143
+ console.log(`[version check] update available: ${installed} → ${latest}`);
144
+ setUpdateAvailable(latest);
145
+ // Persist to DB
146
+ const { setAvailableVersion } = await import('./db/update-check.js');
147
+ setAvailableVersion(latest);
148
+ } else {
149
+ setUpdateAvailable(null);
150
+ // Clear DB
151
+ const { clearAvailableVersion } = await import('./db/update-check.js');
152
+ clearAvailableVersion();
153
+ }
77
154
  }
78
155
  } catch (err) {
79
156
  console.warn(`[version check] failed: ${err.message}`);
@@ -144,4 +221,4 @@ function loadCrons() {
144
221
  return tasks;
145
222
  }
146
223
 
147
- export { loadCrons, startBuiltinCrons, getUpdateAvailable, setUpdateAvailable, getInstalledVersion };
224
+ export { loadCrons, startBuiltinCrons, getUpdateAvailable, setUpdateAvailable, getInstalledVersion, isPrerelease };
@@ -4,9 +4,12 @@ import { githubApi } from './github.js';
4
4
  /**
5
5
  * Create a new job branch with updated job.md
6
6
  * @param {string} jobDescription - The job description to write to job.md
7
+ * @param {Object} [options] - Optional overrides
8
+ * @param {string} [options.llmProvider] - LLM provider override (e.g. 'openai', 'anthropic')
9
+ * @param {string} [options.llmModel] - LLM model override (e.g. 'gpt-4o', 'claude-sonnet-4-5-20250929')
7
10
  * @returns {Promise<{job_id: string, branch: string}>} - Job ID and branch name
8
11
  */
9
- async function createJob(jobDescription) {
12
+ async function createJob(jobDescription, options = {}) {
10
13
  const { GH_OWNER, GH_REPO } = process.env;
11
14
  const jobId = uuidv4();
12
15
  const branch = `job/${jobId}`;
@@ -34,6 +37,21 @@ async function createJob(jobDescription) {
34
37
  }),
35
38
  });
36
39
 
40
+ // Write job.config.json if LLM overrides specified
41
+ const config = {};
42
+ if (options.llmProvider) config.llm_provider = options.llmProvider;
43
+ if (options.llmModel) config.llm_model = options.llmModel;
44
+ if (Object.keys(config).length > 0) {
45
+ await githubApi(`/repos/${GH_OWNER}/${GH_REPO}/contents/logs/${jobId}/job.config.json`, {
46
+ method: 'PUT',
47
+ body: JSON.stringify({
48
+ message: `job config: ${jobId}`,
49
+ content: Buffer.from(JSON.stringify(config, null, 2)).toString('base64'),
50
+ branch: branch,
51
+ }),
52
+ });
53
+ }
54
+
37
55
  return { job_id: jobId, branch };
38
56
  }
39
57
 
@@ -178,6 +178,33 @@ async function triggerWorkflowDispatch(workflowId, ref = 'main', inputs = {}) {
178
178
  return { success: true };
179
179
  }
180
180
 
181
+ /**
182
+ * Fetch the session log (.jsonl) for a job from the GitHub repo at a specific commit.
183
+ * @param {string} jobId - The job ID (used to locate logs/{jobId}/)
184
+ * @param {string} commitSha - Git commit SHA to read from
185
+ * @returns {Promise<string>} - Log content or empty string if unavailable
186
+ */
187
+ async function fetchJobLog(jobId, commitSha) {
188
+ if (!commitSha) return '';
189
+ const { GH_OWNER, GH_REPO } = process.env;
190
+ try {
191
+ const files = await githubApi(
192
+ `/repos/${GH_OWNER}/${GH_REPO}/contents/logs/${jobId}?ref=${encodeURIComponent(commitSha)}`
193
+ );
194
+ if (!Array.isArray(files)) return '';
195
+ const logFile = files.find(f => f.name.endsWith('.jsonl'));
196
+ if (!logFile || !logFile.download_url) return '';
197
+ const res = await fetch(logFile.download_url, {
198
+ headers: { 'Authorization': `Bearer ${process.env.GH_TOKEN}` },
199
+ });
200
+ if (!res.ok) return '';
201
+ return await res.text();
202
+ } catch (err) {
203
+ console.error('Failed to fetch job log:', err.message);
204
+ return '';
205
+ }
206
+ }
207
+
181
208
  export {
182
209
  githubApi,
183
210
  getWorkflowRuns,
@@ -185,4 +212,5 @@ export {
185
212
  getJobStatus,
186
213
  getSwarmStatus,
187
214
  triggerWorkflowDispatch,
215
+ fetchJobLog,
188
216
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thepopebot",
3
- "version": "1.2.70",
3
+ "version": "1.2.71-beta.1",
4
4
  "type": "module",
5
5
  "description": "Create autonomous AI agents with a two-layer architecture: Next.js Event Handler + Docker Agent.",
6
6
  "bin": {
@@ -1,6 +1,7 @@
1
1
  import { execSync, exec } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import { randomBytes } from 'crypto';
4
+ import { ghEnv } from './prerequisites.mjs';
4
5
 
5
6
  const execAsync = promisify(exec);
6
7
 
@@ -69,7 +70,7 @@ export async function setSecret(owner, repo, name, value) {
69
70
  // Use stdin to pass the secret value securely
70
71
  const { stdout, stderr } = await execAsync(
71
72
  `echo "${value.replace(/"/g, '\\"')}" | gh secret set ${name} --repo ${owner}/${repo}`,
72
- { encoding: 'utf-8' }
73
+ { encoding: 'utf-8', env: ghEnv() }
73
74
  );
74
75
  return { success: true };
75
76
  } catch (error) {
@@ -95,6 +96,7 @@ export async function listSecrets(owner, repo) {
95
96
  try {
96
97
  const { stdout } = await execAsync(`gh secret list --repo ${owner}/${repo}`, {
97
98
  encoding: 'utf-8',
99
+ env: ghEnv(),
98
100
  });
99
101
  const secrets = stdout
100
102
  .trim()
@@ -114,7 +116,7 @@ export async function setVariable(owner, repo, name, value) {
114
116
  try {
115
117
  const { stdout, stderr } = await execAsync(
116
118
  `echo "${value.replace(/"/g, '\\"')}" | gh variable set ${name} --repo ${owner}/${repo}`,
117
- { encoding: 'utf-8' }
119
+ { encoding: 'utf-8', env: ghEnv() }
118
120
  );
119
121
  return { success: true };
120
122
  } catch (error) {
@@ -3,6 +3,18 @@ import { promisify } from 'util';
3
3
 
4
4
  const execAsync = promisify(exec);
5
5
 
6
+ /**
7
+ * Return process.env without GITHUB_TOKEN and GH_TOKEN.
8
+ * The gh CLI auto-uses these env vars, which can shadow interactive login
9
+ * and cause secret/variable operations to fail with the wrong identity.
10
+ */
11
+ export function ghEnv() {
12
+ const env = { ...process.env };
13
+ delete env.GITHUB_TOKEN;
14
+ delete env.GH_TOKEN;
15
+ return env;
16
+ }
17
+
6
18
  /**
7
19
  * Check if a command exists
8
20
  */
@@ -11,6 +23,14 @@ function commandExists(cmd) {
11
23
  execSync(`which ${cmd}`, { stdio: 'ignore' });
12
24
  return true;
13
25
  } catch {
26
+ if (process.platform === 'win32') {
27
+ try {
28
+ execSync(`where ${cmd}`, { stdio: 'ignore' });
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
14
34
  return false;
15
35
  }
16
36
  }
@@ -32,7 +52,7 @@ function getNodeVersion() {
32
52
  */
33
53
  async function isGhAuthenticated() {
34
54
  try {
35
- await execAsync('gh auth status');
55
+ await execAsync('gh auth status', { env: ghEnv() });
36
56
  return true;
37
57
  } catch {
38
58
  return false;
@@ -136,7 +156,7 @@ export async function installGlobalPackage(packageName) {
136
156
  */
137
157
  export async function runGhAuth() {
138
158
  // This needs to be interactive, so we use execSync
139
- execSync('gh auth login', { stdio: 'inherit' });
159
+ execSync('gh auth login', { stdio: 'inherit', env: ghEnv() });
140
160
  }
141
161
 
142
162
  export { commandExists, getGitRemoteInfo, getPackageManager };
@@ -189,18 +189,18 @@ export async function promptForCustomProvider() {
189
189
  */
190
190
  export async function promptForBraveKey() {
191
191
  const addKey = handleCancel(await clack.confirm({
192
- message: 'Add Brave Search API key? (free tier, greatly improves agent)',
192
+ message: 'Add Brave Search API key? (optional, greatly improves agent)',
193
193
  initialValue: true,
194
194
  }));
195
195
 
196
196
  if (!addKey) return null;
197
197
 
198
198
  clack.log.info(
199
- 'To get a free Brave Search API key:\n' +
199
+ 'To get a Brave Search API key:\n' +
200
200
  ' 1. Go to https://api-dashboard.search.brave.com/app/keys\n' +
201
201
  ' 2. Click "Get Started"\n' +
202
202
  ' 3. Create an account (or sign in)\n' +
203
- ' 4. Subscribe to the "Free" plan (2,000 queries/month)\n' +
203
+ ' 4. Subscribe to a plan (free credits may be available)\n' +
204
204
  ' 5. Copy your API key'
205
205
  );
206
206
 
package/setup/setup.mjs CHANGED
@@ -249,7 +249,11 @@ async function main() {
249
249
  clack.log.success('ngrok installed');
250
250
  } else {
251
251
  clack.log.warn('ngrok not installed (needed to expose local server)');
252
- clack.log.info('Install with: brew install ngrok/ngrok/ngrok');
252
+ clack.log.info(
253
+ 'Install with: brew install ngrok/ngrok/ngrok\n' +
254
+ ' Sign up for a free account at https://dashboard.ngrok.com/signup\n' +
255
+ ' Then run: ngrok config add-authtoken <YOUR_TOKEN>'
256
+ );
253
257
  }
254
258
 
255
259
  // ─── Step 2: GitHub PAT ──────────────────────────────────────────────
@@ -559,25 +563,41 @@ async function main() {
559
563
  // Server not reachable
560
564
  }
561
565
 
562
- if (serverAlreadyRunning) {
563
- clack.log.success('Server is already running');
564
- if (await confirm('Rebuild and restart anyway?', false)) {
565
- clack.log.info('Rebuilding Next.js...');
566
+ // Helper: run build with retry on failure
567
+ async function runBuildWithRetry() {
568
+ for (let attempt = 1; attempt <= 2; attempt++) {
566
569
  try {
567
570
  execSync('npm run build', { stdio: 'inherit' });
568
571
  clack.log.success('Build complete');
572
+ return true;
569
573
  } catch {
570
- clack.log.error('Build failed run npm run build manually');
574
+ if (attempt === 1) {
575
+ clack.log.error('Build failed.');
576
+ const retry = await confirm('Retry build?');
577
+ if (!retry) break;
578
+ } else {
579
+ clack.log.error('Build failed again.');
580
+ }
571
581
  }
572
582
  }
583
+ clack.log.error(
584
+ 'Cannot continue without a successful build.\n' +
585
+ ' Fix the error above, then run:\n\n' +
586
+ ' npm run build\n' +
587
+ ' docker compose up -d'
588
+ );
589
+ process.exit(1);
590
+ }
591
+
592
+ if (serverAlreadyRunning) {
593
+ clack.log.success('Server is already running');
594
+ if (await confirm('Rebuild and restart anyway?', false)) {
595
+ clack.log.info('Rebuilding Next.js...');
596
+ await runBuildWithRetry();
597
+ }
573
598
  } else {
574
599
  clack.log.info('Building Next.js...');
575
- try {
576
- execSync('npm run build', { stdio: 'inherit' });
577
- clack.log.success('Build complete');
578
- } catch {
579
- clack.log.error('Build failed — run npm run build manually');
580
- }
600
+ await runBuildWithRetry();
581
601
 
582
602
  clack.log.info('Start Docker in a new terminal window:\n\n docker compose up -d');
583
603
 
@@ -114,4 +114,4 @@ jobs:
114
114
  env:
115
115
  GH_TOKEN: ${{ github.token }}
116
116
  run: |
117
- gh pr merge ${{ github.event.pull_request.number }} --squash --repo "${{ github.repository }}"
117
+ gh pr merge ${{ github.event.pull_request.number }} --squash --delete-branch --repo "${{ github.repository }}"
@@ -35,9 +35,7 @@ jobs:
35
35
  JOB_CONTENT=$(cat "logs/${JOB_ID}/job.md")
36
36
  fi
37
37
 
38
- # Fetch the GitHub Actions run log (Docker container output)
39
- RUN_LOG=$(gh run view "$RUN_ID" --repo "${{ github.repository }}" --log 2>/dev/null || echo "")
40
-
38
+ COMMIT_SHA=$(git rev-parse HEAD)
41
39
  STATUS="${{ github.event.workflow_run.conclusion }}"
42
40
 
43
41
  jq -n \
@@ -46,7 +44,7 @@ jobs:
46
44
  --arg status "$STATUS" \
47
45
  --arg job "$JOB_CONTENT" \
48
46
  --arg run_url "$RUN_URL" \
49
- --arg log "$RUN_LOG" \
47
+ --arg commit_sha "$COMMIT_SHA" \
50
48
  '{
51
49
  job_id: $job_id,
52
50
  branch: $branch,
@@ -56,7 +54,7 @@ jobs:
56
54
  pr_url: "",
57
55
  changed_files: [],
58
56
  commit_message: "",
59
- log: $log,
57
+ commit_sha: $commit_sha,
60
58
  merge_result: ""
61
59
  }' > /tmp/payload.json
62
60
 
@@ -77,15 +77,8 @@ jobs:
77
77
  # 4. Get PR status
78
78
  PR_STATUS=$(gh pr view "$PR_NUMBER" --json state --jq '.state' --repo "${{ github.repository }}" 2>/dev/null || echo "open")
79
79
 
80
- # 5. Read log file (find .jsonl in logs/{jobId}/)
81
- LOG_CONTENT=""
82
- LOG_DIR="logs/${JOB_ID}"
83
- if [ -d "$LOG_DIR" ]; then
84
- LOG_FILE=$(find "$LOG_DIR" -name "*.jsonl" -type f | head -1)
85
- if [ -n "$LOG_FILE" ]; then
86
- LOG_CONTENT=$(cat "$LOG_FILE")
87
- fi
88
- fi
80
+ # 5. Commit SHA for server-side log fetching
81
+ COMMIT_SHA="${{ github.event.workflow_run.head_sha }}"
89
82
 
90
83
  # Determine status based on merge result and PR state
91
84
  if [ "$MERGE_RESULT" = "merged" ]; then
@@ -104,7 +97,7 @@ jobs:
104
97
  --arg pr_url "${{ steps.pr.outputs.url }}" \
105
98
  --arg commit_message "$COMMIT_MSG" \
106
99
  --arg changed_files "$CHANGED_FILES" \
107
- --arg log "$LOG_CONTENT" \
100
+ --arg commit_sha "$COMMIT_SHA" \
108
101
  --arg merge_result "$MERGE_RESULT" \
109
102
  '{
110
103
  job_id: $job_id,
@@ -115,7 +108,7 @@ jobs:
115
108
  pr_url: $pr_url,
116
109
  changed_files: ($changed_files | split("\n") | map(select(length > 0))),
117
110
  commit_message: $commit_message,
118
- log: $log,
111
+ commit_sha: $commit_sha,
119
112
  merge_result: $merge_result
120
113
  }' > /tmp/payload.json
121
114
 
@@ -40,7 +40,11 @@ jobs:
40
40
 
41
41
  if [ -n "$OLD_TPB" ] && [ "$OLD_TPB" != "$NEW_TPB" ]; then
42
42
  # Version changed — run thepopebot init to scaffold new templates
43
- npx --yes thepopebot@latest init
43
+ if echo "$NEW_TPB" | grep -q '-'; then
44
+ npx --yes "thepopebot@${NEW_TPB}" init
45
+ else
46
+ npx --yes thepopebot@latest init
47
+ fi
44
48
 
45
49
  # Commit any template changes from init
46
50
  git add -A
@@ -16,7 +16,9 @@ jobs:
16
16
  uses: actions/checkout@v4
17
17
  with:
18
18
  ref: ${{ github.ref_name }}
19
- sparse-checkout: package-lock.json
19
+ sparse-checkout: |
20
+ package-lock.json
21
+ logs/*/job.config.json
20
22
  sparse-checkout-cone-mode: false
21
23
 
22
24
  - name: Get thepopebot version
@@ -25,6 +27,17 @@ jobs:
25
27
  VERSION=$(jq -r '.packages["node_modules/thepopebot"].version // "latest"' package-lock.json)
26
28
  echo "tag=$VERSION" >> $GITHUB_OUTPUT
27
29
 
30
+ - name: Read job config overrides
31
+ id: job-config
32
+ run: |
33
+ JOB_ID="${{ github.ref_name }}"
34
+ JOB_ID="${JOB_ID#job/}"
35
+ CONFIG_FILE="logs/${JOB_ID}/job.config.json"
36
+ if [ -f "$CONFIG_FILE" ]; then
37
+ echo "llm_provider=$(jq -r '.llm_provider // empty' "$CONFIG_FILE")" >> $GITHUB_OUTPUT
38
+ echo "llm_model=$(jq -r '.llm_model // empty' "$CONFIG_FILE")" >> $GITHUB_OUTPUT
39
+ fi
40
+
28
41
  - name: Login to GHCR
29
42
  if: startsWith(vars.JOB_IMAGE_URL, 'ghcr.io/')
30
43
  uses: docker/login-action@v3
@@ -38,8 +51,8 @@ jobs:
38
51
  ALL_SECRETS: ${{ toJson(secrets) }}
39
52
  JOB_IMAGE_URL: ${{ vars.JOB_IMAGE_URL }}
40
53
  THEPOPEBOT_VERSION: ${{ steps.version.outputs.tag }}
41
- LLM_MODEL: ${{ vars.LLM_MODEL }}
42
- LLM_PROVIDER: ${{ vars.LLM_PROVIDER }}
54
+ LLM_MODEL: ${{ steps.job-config.outputs.llm_model || vars.LLM_MODEL }}
55
+ LLM_PROVIDER: ${{ steps.job-config.outputs.llm_provider || vars.LLM_PROVIDER }}
43
56
  OPENAI_BASE_URL: ${{ vars.OPENAI_BASE_URL }}
44
57
  run: |
45
58
  if [ -n "$JOB_IMAGE_URL" ]; then
@@ -2,6 +2,11 @@ name: Upgrade Event Handler
2
2
 
3
3
  on:
4
4
  workflow_dispatch:
5
+ inputs:
6
+ target_version:
7
+ description: 'Target version to install (empty for latest stable)'
8
+ required: false
9
+ default: ''
5
10
 
6
11
  concurrency:
7
12
  group: event-handler-deploy
@@ -29,7 +34,16 @@ jobs:
29
34
 
30
35
  npm install --omit=dev
31
36
  OLD_VERSION=$(node -p "require(\"./node_modules/thepopebot/package.json\").version")
32
- npm update thepopebot --prefer-online
37
+
38
+ TARGET="${{ github.event.inputs.target_version }}"
39
+ if [ -n "$TARGET" ] && echo "$TARGET" | grep -q '-'; then
40
+ # Beta: npm update won't upgrade an exact-pinned dependency, must use install
41
+ npm install "thepopebot@${TARGET}" --prefer-online
42
+ else
43
+ # Stable: npm update resolves to latest within semver range
44
+ npm update thepopebot --prefer-online
45
+ fi
46
+
33
47
  NEW_VERSION=$(node -p "require(\"./node_modules/thepopebot/package.json\").version")
34
48
 
35
49
  if [ "$OLD_VERSION" != "$NEW_VERSION" ]; then
@@ -43,5 +43,14 @@
43
43
  "url": "https://example.com/health",
44
44
  "method": "GET",
45
45
  "enabled": false
46
+ },
47
+ {
48
+ "name": "daily-check-openai",
49
+ "schedule": "0 9 * * *",
50
+ "type": "agent",
51
+ "job": "Check for dependency updates in package.json and report any outdated packages.",
52
+ "llm_provider": "openai",
53
+ "llm_model": "gpt-4o",
54
+ "enabled": false
46
55
  }
47
56
  ]
@@ -46,5 +46,13 @@
46
46
  { "type": "webhook", "url": "https://example.com/hook", "method": "POST", "vars": { "source": "webhook" } }
47
47
  ],
48
48
  "enabled": false
49
+ },
50
+ {
51
+ "name": "review-with-openai",
52
+ "watch_path": "/github/webhook",
53
+ "actions": [
54
+ { "type": "agent", "job": "Review the GitHub event and summarize what happened:\n{{body}}", "llm_provider": "openai", "llm_model": "gpt-4o" }
55
+ ],
56
+ "enabled": false
49
57
  }
50
58
  ]