thepopebot 1.2.75-beta.2 → 1.2.75-beta.21

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 (120) hide show
  1. package/README.md +1 -1
  2. package/api/CLAUDE.md +1 -1
  3. package/api/index.js +5 -12
  4. package/bin/CLAUDE.md +1 -1
  5. package/bin/cli.js +329 -14
  6. package/bin/docker-build.js +5 -0
  7. package/bin/managed-paths.js +0 -7
  8. package/bin/sync.js +84 -0
  9. package/config/CLAUDE.md +1 -29
  10. package/config/instrumentation.js +1 -1
  11. package/lib/CLAUDE.md +3 -3
  12. package/lib/ai/CLAUDE.md +24 -3
  13. package/lib/ai/agent.js +8 -5
  14. package/lib/ai/async-channel.js +51 -0
  15. package/lib/ai/headless-stream.js +3 -0
  16. package/lib/ai/index.js +149 -173
  17. package/lib/ai/line-mappers.js +72 -9
  18. package/lib/ai/tools.js +40 -28
  19. package/lib/chat/actions.js +34 -6
  20. package/lib/chat/api.js +17 -1
  21. package/lib/chat/components/chat-header.js +4 -0
  22. package/lib/chat/components/chat-header.jsx +4 -0
  23. package/lib/chat/components/chat-input.js +1 -0
  24. package/lib/chat/components/chat-input.jsx +1 -0
  25. package/lib/chat/components/chat.js +9 -1
  26. package/lib/chat/components/chat.jsx +15 -2
  27. package/lib/chat/components/chats-page.js +3 -3
  28. package/lib/chat/components/chats-page.jsx +4 -6
  29. package/lib/chat/components/crons-page.js +1 -1
  30. package/lib/chat/components/crons-page.jsx +1 -1
  31. package/lib/chat/components/message.js +12 -4
  32. package/lib/chat/components/message.jsx +17 -4
  33. package/lib/chat/components/settings-chat-page.js +2 -1
  34. package/lib/chat/components/settings-chat-page.jsx +4 -1
  35. package/lib/chat/components/settings-coding-agents-page.js +139 -1
  36. package/lib/chat/components/settings-coding-agents-page.jsx +160 -0
  37. package/lib/chat/components/settings-jobs-page.js +13 -2
  38. package/lib/chat/components/settings-jobs-page.jsx +15 -1
  39. package/lib/chat/components/settings-secrets-layout.js +1 -1
  40. package/lib/chat/components/settings-secrets-layout.jsx +1 -1
  41. package/lib/chat/components/sidebar-history-item.js +3 -3
  42. package/lib/chat/components/sidebar-history-item.jsx +4 -6
  43. package/lib/chat/components/triggers-page.js +1 -1
  44. package/lib/chat/components/triggers-page.jsx +1 -1
  45. package/lib/cluster/actions.js +4 -4
  46. package/lib/cluster/execute.js +3 -1
  47. package/lib/code/actions.js +34 -11
  48. package/lib/code/code-page.js +40 -40
  49. package/lib/code/code-page.jsx +36 -36
  50. package/lib/code/port-forwards.js +17 -3
  51. package/lib/code/terminal-view.js +16 -0
  52. package/lib/code/terminal-view.jsx +18 -0
  53. package/lib/config.js +4 -0
  54. package/lib/cron.js +3 -3
  55. package/lib/db/api-keys.js +22 -61
  56. package/lib/db/config.js +23 -0
  57. package/lib/db/index.js +3 -1
  58. package/lib/maintenance.js +34 -11
  59. package/lib/paths.js +1 -38
  60. package/lib/tools/create-agent-job.js +0 -4
  61. package/lib/tools/docker.js +23 -16
  62. package/lib/triggers.js +4 -3
  63. package/lib/utils/render-md.js +3 -1
  64. package/package.json +2 -1
  65. package/setup/setup-ssl.mjs +414 -0
  66. package/templates/.github/workflows/rebuild-event-handler.yml +3 -0
  67. package/templates/.github/workflows/upgrade-event-handler.yml +1 -1
  68. package/templates/.gitignore.template +7 -3
  69. package/templates/.tmp/CLAUDE.md.template +5 -0
  70. package/templates/CLAUDE.md +3 -2
  71. package/templates/CLAUDE.md.template +24 -357
  72. package/templates/agent-job/CLAUDE.md.template +57 -0
  73. package/templates/agent-job/CRONS.json +16 -0
  74. package/templates/{config/agent-job → agent-job}/SOUL.md +3 -3
  75. package/templates/agent-job/SYSTEM.md +60 -0
  76. package/templates/agents/CLAUDE.md.template +54 -0
  77. package/templates/data/CLAUDE.md.template +5 -0
  78. package/templates/docker-compose.custom.yml +41 -62
  79. package/templates/docker-compose.yml +14 -21
  80. package/templates/event-handler/CLAUDE.md.template +0 -0
  81. package/templates/logs/CLAUDE.md.template +5 -0
  82. package/templates/skills/CLAUDE.md.template +57 -32
  83. package/templates/skills/active/.gitkeep +0 -0
  84. package/templates/skills/library/agent-job-secrets/SKILL.md +23 -0
  85. package/templates/skills/library/agent-job-secrets/agent-job-secrets.js +62 -0
  86. package/templates/.pi/extensions/env-sanitizer/index.ts +0 -48
  87. package/templates/.pi/extensions/env-sanitizer/package.json +0 -5
  88. package/templates/README.md +0 -75
  89. package/templates/config/CLAUDE.md.template +0 -40
  90. package/templates/config/CRONS.json +0 -56
  91. package/templates/config/agent-job/AGENT_JOB.md +0 -30
  92. package/templates/cron/CLAUDE.md.template +0 -24
  93. package/templates/docker-compose.litellm.yml +0 -82
  94. package/templates/docs/CLAUDE.md.template +0 -12
  95. package/templates/docs/CLI.md +0 -59
  96. package/templates/docs/CLUSTERS.md +0 -151
  97. package/templates/docs/CONFIGURATION.md +0 -181
  98. package/templates/docs/CRONS_AND_TRIGGERS.md +0 -132
  99. package/templates/docs/GETTING_STARTED.md +0 -64
  100. package/templates/docs/SECURITY.md +0 -61
  101. package/templates/docs/SKILLS.md +0 -113
  102. package/templates/docs/UPGRADING.md +0 -92
  103. package/templates/skills/LICENSE +0 -21
  104. package/templates/skills/README.md +0 -117
  105. package/templates/skills/agent-job-secrets/SKILL.md +0 -25
  106. package/templates/skills/agent-job-secrets/agent-job-secrets.js +0 -66
  107. package/templates/traefik-dynamic.yml.example +0 -7
  108. package/templates/triggers/CLAUDE.md.template +0 -41
  109. /package/templates/{config → agent-job}/HEARTBEAT.md +0 -0
  110. /package/templates/{cron → data}/.gitkeep +0 -0
  111. /package/templates/{logs → data/clusters}/.gitkeep +0 -0
  112. /package/templates/{triggers → data/db}/.gitkeep +0 -0
  113. /package/templates/{config/agent-job → event-handler}/SUMMARY.md +0 -0
  114. /package/templates/{config → event-handler}/TRIGGERS.json +0 -0
  115. /package/templates/{config → event-handler}/agent-chat/SYSTEM.md +0 -0
  116. /package/templates/{config/cluster → event-handler/clusters}/ROLE.md +0 -0
  117. /package/templates/{config/cluster → event-handler/clusters}/SYSTEM.md +0 -0
  118. /package/templates/{config → event-handler}/code-chat/SYSTEM.md +0 -0
  119. /package/templates/{config → event-handler}/litellm/main.yaml +0 -0
  120. /package/templates/skills/{playwright-cli → library/playwright-cli}/SKILL.md +0 -0
package/lib/paths.js CHANGED
@@ -1,43 +1,6 @@
1
- import path from 'path';
2
-
3
1
  /**
4
2
  * Central path resolver for thepopebot.
5
3
  * All paths resolve from process.cwd() (the user's project root).
6
4
  */
7
5
 
8
- const PROJECT_ROOT = process.cwd();
9
-
10
- export {
11
- PROJECT_ROOT,
12
- };
13
-
14
- // config/ files
15
- export const configDir = path.join(PROJECT_ROOT, 'config');
16
- export const cronsFile = path.join(PROJECT_ROOT, 'config', 'CRONS.json');
17
- export const triggersFile = path.join(PROJECT_ROOT, 'config', 'TRIGGERS.json');
18
- export const agentJobPlanningMd = path.join(PROJECT_ROOT, 'config', 'agent-chat', 'SYSTEM.md');
19
- export const codePlanningMd = path.join(PROJECT_ROOT, 'config', 'code-chat', 'SYSTEM.md');
20
- export const agentJobSummaryMd = path.join(PROJECT_ROOT, 'config', 'agent-job', 'SUMMARY.md');
21
- export const claudeMd = path.join(PROJECT_ROOT, 'CLAUDE.md');
22
-
23
- // Skills directory
24
- export const skillsDir = path.join(PROJECT_ROOT, 'skills');
25
-
26
- // Working directories for command-type actions
27
- export const cronDir = path.join(PROJECT_ROOT, 'cron');
28
- export const triggersDir = path.join(PROJECT_ROOT, 'triggers');
29
-
30
- // Logs
31
- export const logsDir = path.join(PROJECT_ROOT, 'logs');
32
-
33
- // Database
34
- export const thepopebotDb = process.env.DATABASE_PATH || path.join(PROJECT_ROOT, 'data', 'db', 'thepopebot.sqlite');
35
-
36
- // Cluster data (bind-mount root for cluster containers)
37
- export const clusterDataDir = process.env.CLUSTER_DATA_PATH || path.join(PROJECT_ROOT, 'data', 'clusters');
38
-
39
- // Code workspace data (bind-mount root for workspace containers)
40
- export const workspacesDir = path.join(PROJECT_ROOT, 'data', 'workspaces');
41
-
42
- // .env
43
- export const envFile = path.join(PROJECT_ROOT, '.env');
6
+ export const PROJECT_ROOT = process.cwd();
@@ -3,8 +3,6 @@ import { z } from 'zod';
3
3
  import { githubApi } from './github.js';
4
4
  import { createModel } from '../ai/model.js';
5
5
  import { getConfig } from '../config.js';
6
- import { createAgentJobApiKey } from '../db/api-keys.js';
7
-
8
6
  /**
9
7
  * Generate a short descriptive title for an agent job using the LLM.
10
8
  * Uses structured output to avoid thinking-token leaks with extended-thinking models.
@@ -93,7 +91,6 @@ async function createAgentJob(agentJobDescription, options = {}) {
93
91
 
94
92
  // 6. Launch Docker container locally (fire-and-forget with async cleanup)
95
93
  const repoSlug = `${GH_OWNER}/${GH_REPO}`;
96
- const { key: agentJobToken } = createAgentJobApiKey(agentJobId);
97
94
  launchAgentJobContainer({
98
95
  agentJobId,
99
96
  repo: repoSlug,
@@ -102,7 +99,6 @@ async function createAgentJob(agentJobDescription, options = {}) {
102
99
  description: agentJobDescription,
103
100
  codingAgent: options.agentBackend,
104
101
  llmModel: options.llmModel,
105
- agentJobToken,
106
102
  }).catch(err => {
107
103
  console.error(`[agent-job] Failed to launch container for ${agentJobId}:`, err.message);
108
104
  });
@@ -4,7 +4,9 @@ import path from 'path';
4
4
  import { getConfig } from '../config.js';
5
5
  import { getCustomProvider } from '../db/config.js';
6
6
  import { BUILTIN_PROVIDERS } from '../llm-providers.js';
7
- import { workspacesDir } from '../paths.js';
7
+ import { PROJECT_ROOT } from '../paths.js';
8
+
9
+ const workspacesDir = path.join(PROJECT_ROOT, 'data/workspaces');
8
10
 
9
11
  // UID/GID of the coding-agent user inside agent containers (pinned in Dockerfile)
10
12
  const CODING_AGENT_UID = 1001;
@@ -231,9 +233,12 @@ async function runInteractiveContainer({ containerName, repo, branch, codingAgen
231
233
  env.push(`${key}=${value}`);
232
234
  }
233
235
  }
234
- if (jobSecrets.length > 0) {
235
- env.push(`AGENT_JOB_SECRETS=${JSON.stringify(Object.fromEntries(jobSecrets.map(s => [s.key, s.value])))}`);
236
- }
236
+ // Create per-container API key for agent-secrets access
237
+ const { createAgentJobApiKey } = await import('../db/api-keys.js');
238
+ const { key: agentJobToken } = createAgentJobApiKey(containerName);
239
+ env.push(`AGENT_JOB_TOKEN=${agentJobToken}`);
240
+ const appUrl = getConfig('APP_URL');
241
+ if (appUrl) env.push(`APP_URL=${appUrl}`);
237
242
  }
238
243
 
239
244
  const hostConfig = {};
@@ -334,9 +339,9 @@ function buildAgentAuthEnv(agent) {
334
339
  }
335
340
  }
336
341
  }
337
- } else if (agent === 'pi-coding-agent' || agent === 'opencode') {
338
- // Pi and OpenCode share the same multi-provider auth pattern
339
- const configPrefix = agent === 'opencode' ? 'CODING_AGENT_OPENCODE' : 'CODING_AGENT_PI';
342
+ } else if (agent === 'pi-coding-agent' || agent === 'opencode' || agent === 'kimi-cli') {
343
+ // Pi, OpenCode, and Kimi share the same multi-provider auth pattern
344
+ const configPrefix = agent === 'opencode' ? 'CODING_AGENT_OPENCODE' : agent === 'kimi-cli' ? 'CODING_AGENT_KIMI_CLI' : 'CODING_AGENT_PI';
340
345
  const provider = getConfig(`${configPrefix}_PROVIDER`) || 'anthropic';
341
346
  backendApi = provider;
342
347
  const model = getConfig(`${configPrefix}_MODEL`);
@@ -438,9 +443,12 @@ async function runHeadlessContainer({ containerName, repo, branch, featureBranch
438
443
  env.push(`${key}=${value}`);
439
444
  }
440
445
  }
441
- if (jobSecrets.length > 0) {
442
- env.push(`AGENT_JOB_SECRETS=${JSON.stringify(Object.fromEntries(jobSecrets.map(s => [s.key, s.value])))}`);
443
- }
446
+ // Create per-container API key for agent-secrets access
447
+ const { createAgentJobApiKey } = await import('../db/api-keys.js');
448
+ const { key: agentJobToken } = createAgentJobApiKey(containerName);
449
+ env.push(`AGENT_JOB_TOKEN=${agentJobToken}`);
450
+ const appUrl = getConfig('APP_URL');
451
+ if (appUrl) env.push(`APP_URL=${appUrl}`);
444
452
  }
445
453
 
446
454
  const hostConfig = {};
@@ -851,7 +859,7 @@ async function removeVolume(name) {
851
859
  * @param {string} [options.llmModel] - Model override
852
860
  * @returns {Promise<{containerId: string, containerName: string, volumeName: string}>}
853
861
  */
854
- async function runAgentJobContainer({ agentJobId, repo, branch, title, description, codingAgent, llmModel, agentJobToken }) {
862
+ async function runAgentJobContainer({ agentJobId, repo, branch, title, description, codingAgent, llmModel }) {
855
863
  const agent = codingAgent || getConfig('CODING_AGENT') || 'claude-code';
856
864
  const version = process.env.THEPOPEBOT_VERSION;
857
865
  const image = `stephengpope/thepopebot:coding-agent-${agent}-${version}`;
@@ -889,8 +897,10 @@ async function runAgentJobContainer({ agentJobId, repo, branch, title, descripti
889
897
  const appUrl = getConfig('APP_URL');
890
898
  if (appUrl) env.push(`APP_URL=${appUrl}`);
891
899
 
892
- // Inject per-job API token for agent-secrets skill
893
- if (agentJobToken) env.push(`AGENT_JOB_TOKEN=${agentJobToken}`);
900
+ // Create per-container API key for agent-secrets access
901
+ const { createAgentJobApiKey } = await import('../db/api-keys.js');
902
+ const { key: agentJobToken } = createAgentJobApiKey(containerName);
903
+ env.push(`AGENT_JOB_TOKEN=${agentJobToken}`);
894
904
 
895
905
  // Inject agent job secrets (plain secrets as env vars; oauth types are null — agent must fetch via get)
896
906
  const { getAllAgentJobSecrets } = await import('../db/config.js');
@@ -900,9 +910,6 @@ async function runAgentJobContainer({ agentJobId, repo, branch, title, descripti
900
910
  env.push(`${key}=${value}`);
901
911
  }
902
912
  }
903
- if (jobSecrets.length > 0) {
904
- env.push(`AGENT_JOB_SECRETS=${JSON.stringify(Object.fromEntries(jobSecrets.map(s => [s.key, s.value])))}`);
905
- }
906
913
 
907
914
  console.log(`[agent-job] id=${shortId} agent=${agent} image=${image} backendApi=${backendApi}`);
908
915
 
package/lib/triggers.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs';
2
- import { triggersFile, triggersDir } from './paths.js';
2
+ import path from 'path';
3
+ import { PROJECT_ROOT } from './paths.js';
3
4
  import { executeAction } from './actions.js';
4
5
 
5
6
  /**
@@ -29,7 +30,7 @@ async function executeActions(trigger, context) {
29
30
  const resolved = { ...action };
30
31
  if (resolved.command) resolved.command = resolveTemplate(resolved.command, context);
31
32
  if (resolved.job) resolved.job = resolveTemplate(resolved.job, context);
32
- const result = await executeAction(resolved, { cwd: triggersDir, data: context.body });
33
+ const result = await executeAction(resolved, { cwd: PROJECT_ROOT, data: context.body });
33
34
  console.log(`[TRIGGER] ${trigger.name}: ${result || 'ran'}`);
34
35
  } catch (err) {
35
36
  console.error(`[TRIGGER] ${trigger.name}: error - ${err.message}`);
@@ -42,7 +43,7 @@ async function executeActions(trigger, context) {
42
43
  * @returns {{ triggerMap: Map, fireTriggers: Function }}
43
44
  */
44
45
  function loadTriggers() {
45
- const triggerFile = triggersFile;
46
+ const triggerFile = path.join(PROJECT_ROOT, 'event-handler/TRIGGERS.json');
46
47
  const triggerMap = new Map();
47
48
 
48
49
  console.log('\n--- Triggers ---');
@@ -1,6 +1,8 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { PROJECT_ROOT, skillsDir } from '../paths.js';
3
+ import { PROJECT_ROOT } from '../paths.js';
4
+
5
+ const skillsDir = path.join(PROJECT_ROOT, 'skills');
4
6
 
5
7
  const INCLUDE_PATTERN = /\{\{([^}]+\.md)\}\}/g;
6
8
  const VARIABLE_PATTERN = /\{\{(datetime|skills)\}\}/gi;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thepopebot",
3
- "version": "1.2.75-beta.2",
3
+ "version": "1.2.75-beta.21",
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": {
@@ -15,6 +15,7 @@
15
15
  "./auth/actions": "./lib/auth/actions.js",
16
16
  "./chat/api": "./lib/chat/api.js",
17
17
  "./db": "./lib/db/index.js",
18
+ "./db/chats": "./lib/db/chats.js",
18
19
  "./db/oauth-tokens": "./lib/db/oauth-tokens.js",
19
20
  "./chat": "./lib/chat/components/index.js",
20
21
  "./chat/actions": "./lib/chat/actions.js",
@@ -0,0 +1,414 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Interactive SSL setup — configures Let's Encrypt wildcard cert via Traefik DNS challenge.
5
+ *
6
+ * Prompts for domain, DNS provider, API credentials, and server address.
7
+ * Creates the wildcard DNS record via the provider's API.
8
+ * Writes SSL config to .env, DNS provider credentials to .env.traefik,
9
+ * and switches COMPOSE_FILE to docker-compose.custom.yml.
10
+ */
11
+
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import * as clack from '@clack/prompts';
15
+ import { updateEnvVariable } from './lib/auth.mjs';
16
+ import { loadEnvFile } from './lib/env.mjs';
17
+
18
+ function handleCancel(value) {
19
+ if (clack.isCancel(value)) {
20
+ clack.cancel('Setup cancelled.');
21
+ process.exit(0);
22
+ }
23
+ return value;
24
+ }
25
+
26
+ // DNS providers supported by Traefik's ACME DNS challenge (via lego).
27
+ // Each entry maps to the Traefik provider name and the env vars it expects.
28
+ const DNS_PROVIDERS = {
29
+ cloudflare: {
30
+ label: 'Cloudflare',
31
+ traefikName: 'cloudflare',
32
+ envVars: [
33
+ { key: 'CF_DNS_API_TOKEN', label: 'Cloudflare API Token', secret: true },
34
+ ],
35
+ apiCreate: createCloudflareRecord,
36
+ helpUrl: 'https://dash.cloudflare.com/profile/api-tokens',
37
+ helpSteps: [
38
+ 'Go to your Cloudflare dashboard → Profile → API Tokens',
39
+ 'Click "Create Token"',
40
+ 'Use the "Edit zone DNS" template',
41
+ 'Select the zone (domain) you want to use',
42
+ 'Copy the token',
43
+ ],
44
+ },
45
+ route53: {
46
+ label: 'AWS Route 53',
47
+ traefikName: 'route53',
48
+ envVars: [
49
+ { key: 'AWS_ACCESS_KEY_ID', label: 'AWS Access Key ID', secret: false },
50
+ { key: 'AWS_SECRET_ACCESS_KEY', label: 'AWS Secret Access Key', secret: true },
51
+ { key: 'AWS_REGION', label: 'AWS Region', secret: false, default: 'us-east-1' },
52
+ ],
53
+ apiCreate: null, // TODO: implement
54
+ helpUrl: 'https://console.aws.amazon.com/iam/',
55
+ helpSteps: [
56
+ 'Create an IAM user with Route53 permissions',
57
+ 'Generate access keys for the user',
58
+ ],
59
+ },
60
+ digitalocean: {
61
+ label: 'DigitalOcean',
62
+ traefikName: 'digitalocean',
63
+ envVars: [
64
+ { key: 'DO_AUTH_TOKEN', label: 'DigitalOcean API Token', secret: true },
65
+ ],
66
+ apiCreate: null, // TODO: implement
67
+ helpUrl: 'https://cloud.digitalocean.com/account/api/tokens',
68
+ helpSteps: [
69
+ 'Go to DigitalOcean → API → Tokens',
70
+ 'Generate a new personal access token with read/write scope',
71
+ ],
72
+ },
73
+ namecheap: {
74
+ label: 'Namecheap',
75
+ traefikName: 'namecheap',
76
+ envVars: [
77
+ { key: 'NAMECHEAP_API_USER', label: 'Namecheap API User', secret: false },
78
+ { key: 'NAMECHEAP_API_KEY', label: 'Namecheap API Key', secret: true },
79
+ ],
80
+ apiCreate: null, // TODO: implement
81
+ helpUrl: 'https://www.namecheap.com/support/api/intro/',
82
+ helpSteps: [
83
+ 'Enable API access in your Namecheap account',
84
+ 'Whitelist your server IP',
85
+ 'Copy your API key',
86
+ ],
87
+ },
88
+ godaddy: {
89
+ label: 'GoDaddy',
90
+ traefikName: 'godaddy',
91
+ envVars: [
92
+ { key: 'GODADDY_API_KEY', label: 'GoDaddy API Key', secret: true },
93
+ { key: 'GODADDY_API_SECRET', label: 'GoDaddy API Secret', secret: true },
94
+ ],
95
+ apiCreate: null, // TODO: implement
96
+ helpUrl: 'https://developer.godaddy.com/keys',
97
+ helpSteps: [
98
+ 'Go to GoDaddy Developer Portal → API Keys',
99
+ 'Create a Production key',
100
+ ],
101
+ },
102
+ porkbun: {
103
+ label: 'Porkbun',
104
+ traefikName: 'porkbun',
105
+ envVars: [
106
+ { key: 'PORKBUN_API_KEY', label: 'Porkbun API Key', secret: true },
107
+ { key: 'PORKBUN_SECRET_API_KEY', label: 'Porkbun Secret Key', secret: true },
108
+ ],
109
+ apiCreate: null, // TODO: implement
110
+ helpUrl: 'https://porkbun.com/account/api',
111
+ helpSteps: [
112
+ 'Go to Porkbun → Account → API Access',
113
+ 'Create an API key pair',
114
+ ],
115
+ },
116
+ };
117
+
118
+ async function main() {
119
+ clack.intro('SSL Setup — Let\'s Encrypt wildcard cert');
120
+
121
+ const env = loadEnvFile() || {};
122
+
123
+ // ── Domain ──────────────────────────────────────────────────────────
124
+ const existingDomain = env.SSL_DOMAIN;
125
+ let domain;
126
+
127
+ if (existingDomain) {
128
+ clack.log.info(`Current domain: ${existingDomain}`);
129
+ const reconfig = handleCancel(await clack.confirm({
130
+ message: 'Change domain?',
131
+ initialValue: false,
132
+ }));
133
+ domain = reconfig ? null : existingDomain;
134
+ }
135
+
136
+ if (!domain) {
137
+ domain = handleCancel(await clack.text({
138
+ message: 'Enter your domain (e.g., bot.example.com):',
139
+ placeholder: 'bot.example.com',
140
+ validate: (input) => {
141
+ if (!input) return 'Domain is required';
142
+ if (!input.includes('.')) return 'Enter a valid domain';
143
+ if (input.startsWith('*.')) return 'Enter the base domain without the wildcard (e.g., bot.example.com)';
144
+ },
145
+ }));
146
+ }
147
+
148
+ // ── Email ───────────────────────────────────────────────────────────
149
+ const existingEmail = env.SSL_EMAIL;
150
+ let email;
151
+
152
+ if (existingEmail) {
153
+ clack.log.info(`Current email: ${existingEmail}`);
154
+ const reconfig = handleCancel(await clack.confirm({
155
+ message: 'Change email?',
156
+ initialValue: false,
157
+ }));
158
+ email = reconfig ? null : existingEmail;
159
+ }
160
+
161
+ if (!email) {
162
+ email = handleCancel(await clack.text({
163
+ message: 'Email for Let\'s Encrypt notifications:',
164
+ validate: (input) => {
165
+ if (!input) return 'Email is required';
166
+ if (!input.includes('@')) return 'Enter a valid email';
167
+ },
168
+ }));
169
+ }
170
+
171
+ // ── DNS Provider ────────────────────────────────────────────────────
172
+ const providerKey = handleCancel(await clack.select({
173
+ message: 'Who manages your DNS?',
174
+ options: Object.entries(DNS_PROVIDERS).map(([key, p]) => ({
175
+ label: p.label,
176
+ value: key,
177
+ })),
178
+ }));
179
+
180
+ const provider = DNS_PROVIDERS[providerKey];
181
+
182
+ // Show help steps for getting the API credentials
183
+ clack.log.info(
184
+ `To get your ${provider.label} API credentials:\n` +
185
+ provider.helpSteps.map((s, i) => ` ${i + 1}. ${s}`).join('\n') +
186
+ `\n\n ${provider.helpUrl}`
187
+ );
188
+
189
+ // ── API Credentials ─────────────────────────────────────────────────
190
+ const credentials = {};
191
+ for (const envVar of provider.envVars) {
192
+ const existing = env[envVar.key];
193
+ if (existing) {
194
+ const masked = '****' + existing.slice(-4);
195
+ clack.log.info(`${envVar.label}: ${masked}`);
196
+ const reconfig = handleCancel(await clack.confirm({
197
+ message: `Change ${envVar.label}?`,
198
+ initialValue: false,
199
+ }));
200
+ if (!reconfig) {
201
+ credentials[envVar.key] = existing;
202
+ continue;
203
+ }
204
+ }
205
+
206
+ if (envVar.secret) {
207
+ credentials[envVar.key] = handleCancel(await clack.password({
208
+ message: `${envVar.label}:`,
209
+ validate: (input) => {
210
+ if (!input && !envVar.default) return `${envVar.label} is required`;
211
+ },
212
+ }));
213
+ } else {
214
+ credentials[envVar.key] = handleCancel(await clack.text({
215
+ message: `${envVar.label}:`,
216
+ defaultValue: envVar.default || '',
217
+ placeholder: envVar.default || '',
218
+ validate: (input) => {
219
+ if (!input && !envVar.default) return `${envVar.label} is required`;
220
+ },
221
+ }));
222
+ }
223
+ credentials[envVar.key] = credentials[envVar.key] || envVar.default;
224
+ }
225
+
226
+ // ── DNS Record Target ──────────────────────────────────────────────
227
+ const targetType = handleCancel(await clack.select({
228
+ message: `How should *.${domain} resolve?`,
229
+ options: [
230
+ { label: 'IP address (VPS, bare metal, cloud VM)', value: 'A' },
231
+ { label: 'CNAME (Tailscale hostname, another domain)', value: 'CNAME' },
232
+ ],
233
+ }));
234
+
235
+ let recordValue;
236
+ if (targetType === 'A') {
237
+ // Try to detect public IP
238
+ let detectedIp;
239
+ try {
240
+ const resp = await fetch('https://api.ipify.org');
241
+ if (resp.ok) detectedIp = (await resp.text()).trim();
242
+ } catch {}
243
+
244
+ recordValue = handleCancel(await clack.text({
245
+ message: 'Server IP address:',
246
+ defaultValue: detectedIp || '',
247
+ placeholder: detectedIp || '203.0.113.10',
248
+ validate: (input) => {
249
+ if (!input) return 'IP address is required';
250
+ if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(input)) return 'Enter a valid IPv4 address';
251
+ },
252
+ }));
253
+ } else {
254
+ recordValue = handleCancel(await clack.text({
255
+ message: 'CNAME target (e.g., mybox.tailnet.ts.net):',
256
+ validate: (input) => {
257
+ if (!input) return 'CNAME target is required';
258
+ if (!input.includes('.')) return 'Enter a valid hostname';
259
+ },
260
+ }));
261
+ }
262
+
263
+ // ── Create DNS Record ──────────────────────────────────────────────
264
+ const s = clack.spinner();
265
+
266
+ if (provider.apiCreate) {
267
+ s.start(`Creating *.${domain} ${targetType} record → ${recordValue}`);
268
+ try {
269
+ await provider.apiCreate(domain, targetType, recordValue, credentials);
270
+ s.stop(`Created *.${domain} → ${recordValue}`);
271
+ } catch (err) {
272
+ s.stop(`Failed to create DNS record: ${err.message}`);
273
+ clack.log.warn(
274
+ `Create this record manually in your ${provider.label} dashboard:\n` +
275
+ ` Type: ${targetType}\n` +
276
+ ` Name: *.${domain}\n` +
277
+ ` Value: ${recordValue}`
278
+ );
279
+ const proceed = handleCancel(await clack.confirm({
280
+ message: 'Continue anyway? (you can create the record manually)',
281
+ initialValue: true,
282
+ }));
283
+ if (!proceed) process.exit(0);
284
+ }
285
+ } else {
286
+ clack.log.warn(
287
+ `Automatic DNS record creation is not yet supported for ${provider.label}.\n` +
288
+ ` Create this record in your ${provider.label} dashboard:\n\n` +
289
+ ` Type: ${targetType}\n` +
290
+ ` Name: *.${domain}\n` +
291
+ ` Value: ${recordValue}\n`
292
+ );
293
+ handleCancel(await clack.text({
294
+ message: 'Press enter once you\'ve created the record',
295
+ defaultValue: '',
296
+ }));
297
+ }
298
+
299
+ // ── Write .env ─────────────────────────────────────────────────────
300
+ s.start('Writing configuration to .env');
301
+
302
+ updateEnvVariable('SSL_DOMAIN', domain);
303
+ updateEnvVariable('SSL_EMAIL', email);
304
+ updateEnvVariable('SSL_DNS_PROVIDER', provider.traefikName);
305
+
306
+ // Set APP_HOSTNAME only if not already set (don't overwrite existing webhook hostname)
307
+ if (!env.APP_HOSTNAME) {
308
+ updateEnvVariable('APP_HOSTNAME', domain);
309
+ }
310
+
311
+ // Write DNS provider credentials to .env.traefik (Traefik-only, not leaked to other services)
312
+ const traefikEnvPath = path.join(process.cwd(), '.env.traefik');
313
+ const traefikLines = provider.envVars.map((v) => `${v.key}=${credentials[v.key]}`);
314
+ fs.writeFileSync(traefikEnvPath, traefikLines.join('\n') + '\n');
315
+
316
+ // Switch to custom compose file
317
+ updateEnvVariable('COMPOSE_FILE', 'docker-compose.custom.yml');
318
+
319
+ s.stop('Configuration saved to .env and .env.traefik');
320
+
321
+ // ── Summary ────────────────────────────────────────────────────────
322
+ clack.log.success(
323
+ `SSL configured!\n\n` +
324
+ ` Domain: ${domain}\n` +
325
+ ` Wildcard: *.${domain}\n` +
326
+ ` Provider: ${provider.label}\n` +
327
+ ` Compose: docker-compose.custom.yml\n\n` +
328
+ ` Traefik will automatically obtain and renew your wildcard cert.\n\n` +
329
+ ` Next: restart your containers with \`docker compose up -d\``
330
+ );
331
+
332
+ clack.outro('Done!');
333
+ }
334
+
335
+ // ── Cloudflare API ─────────────────────────────────────────────────────
336
+
337
+ async function createCloudflareRecord(domain, type, value, credentials) {
338
+ const token = credentials.CF_DNS_API_TOKEN;
339
+
340
+ // Find the zone — walk up the domain to find the registered zone.
341
+ // e.g., for "bot.example.com", the zone is "example.com"
342
+ const parts = domain.split('.');
343
+ let zoneId;
344
+ let zoneName;
345
+
346
+ for (let i = 0; i < parts.length - 1; i++) {
347
+ const candidate = parts.slice(i).join('.');
348
+ const resp = await fetch(`https://api.cloudflare.com/client/v4/zones?name=${candidate}`, {
349
+ headers: { Authorization: `Bearer ${token}` },
350
+ });
351
+ const data = await resp.json();
352
+ if (data.result?.length > 0) {
353
+ zoneId = data.result[0].id;
354
+ zoneName = data.result[0].name;
355
+ break;
356
+ }
357
+ }
358
+
359
+ if (!zoneId) {
360
+ throw new Error(`Could not find a Cloudflare zone for ${domain}. Make sure your domain is added to Cloudflare.`);
361
+ }
362
+
363
+ // Create the wildcard record: *.domain → value
364
+ const recordName = `*.${domain}`;
365
+
366
+ // Check if record already exists
367
+ const existingResp = await fetch(
368
+ `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records?type=${type}&name=${recordName}`,
369
+ { headers: { Authorization: `Bearer ${token}` } }
370
+ );
371
+ const existing = await existingResp.json();
372
+
373
+ if (existing.result?.length > 0) {
374
+ // Update existing record
375
+ const recordId = existing.result[0].id;
376
+ const updateResp = await fetch(
377
+ `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records/${recordId}`,
378
+ {
379
+ method: 'PUT',
380
+ headers: {
381
+ Authorization: `Bearer ${token}`,
382
+ 'Content-Type': 'application/json',
383
+ },
384
+ body: JSON.stringify({ type, name: recordName, content: value, proxied: false }),
385
+ }
386
+ );
387
+ const updateData = await updateResp.json();
388
+ if (!updateData.success) {
389
+ throw new Error(updateData.errors?.[0]?.message || 'Failed to update DNS record');
390
+ }
391
+ } else {
392
+ // Create new record
393
+ const createResp = await fetch(
394
+ `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`,
395
+ {
396
+ method: 'POST',
397
+ headers: {
398
+ Authorization: `Bearer ${token}`,
399
+ 'Content-Type': 'application/json',
400
+ },
401
+ body: JSON.stringify({ type, name: recordName, content: value, proxied: false }),
402
+ }
403
+ );
404
+ const createData = await createResp.json();
405
+ if (!createData.success) {
406
+ throw new Error(createData.errors?.[0]?.message || 'Failed to create DNS record');
407
+ }
408
+ }
409
+ }
410
+
411
+ main().catch((err) => {
412
+ clack.log.error(err.message);
413
+ process.exit(1);
414
+ });
@@ -48,6 +48,9 @@ jobs:
48
48
  cd /project && npx --yes thepopebot@latest init --no-install
49
49
  fi
50
50
 
51
+ # Install the new version into node_modules so local CLI stays current
52
+ cd /project && npm install
53
+
51
54
  # Commit any template changes from init
52
55
  git -C /project add -A
53
56
  if ! git -C /project diff --cached --quiet; then
@@ -53,7 +53,7 @@ jobs:
53
53
  git commit -m "chore: upgrade thepopebot to $NEW_VERSION"
54
54
  git push origin "$BRANCH"
55
55
  gh pr create --title "chore: upgrade thepopebot" --body "Automated upgrade via upgrade-event-handler workflow." --base main --head "$BRANCH"
56
- gh pr merge "$BRANCH" --squash --auto --delete-branch
56
+ gh pr merge "$BRANCH" --squash --delete-branch
57
57
  else
58
58
  echo "No changes — thepopebot is already up to date."
59
59
  fi
@@ -1,20 +1,24 @@
1
1
  # Credentials - NEVER commit these
2
2
  .env
3
3
  .env.local
4
+ .env.traefik
4
5
  *.pem
5
6
  *.key
6
7
 
7
8
  # Claude Code
8
9
  .claude/*
9
10
 
11
+ # Playwright MCP
12
+ .playwright-mcp/
13
+
10
14
  # Pi system prompt (generated at runtime from SOUL.md)
11
15
  .pi/SYSTEM.md
12
16
 
13
- # Skills dependencies (installed at runtime in Docker for correct arch)
14
- skills/*/node_modules/
17
+ # Dynamically activated skills (created at container runtime, not user config)
18
+ skills/active/agent-job-secrets
15
19
 
16
20
  # Database
17
- data/
21
+ /data/
18
22
 
19
23
  # Node
20
24
  node_modules/
@@ -0,0 +1,5 @@
1
+ # .tmp/ — Scratch Directory
2
+
3
+ Temporary working files — downloads, screenshots, snapshots, intermediate data, generated files. This directory is gitignored and nothing here gets committed.
4
+
5
+ Playwright MCP saves screenshots and snapshots here. Other tools and scripts should use this directory for any transient output that doesn't belong in the repo.