create-urateam 0.1.45 → 0.1.46

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/index.js CHANGED
@@ -1,491 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { mkdirSync, writeFileSync, cpSync, readFileSync, readdirSync, statSync, existsSync, appendFileSync, } from "fs";
3
- import { join, dirname, basename } from "path";
4
- import { fileURLToPath } from "url";
5
- import { randomBytes } from "crypto";
6
- const __filename = fileURLToPath(import.meta.url);
7
- const __dirname = dirname(__filename);
8
- /**
9
- * Tier → implicit feature set, mirroring packages/core/src/license.ts.
10
- * The runtime grants Pro tier ALL Pro features regardless of whether the
11
- * JWT carries an explicit `features` array — so the scaffolder must do
12
- * the same expansion or it'll skip tier-gated prompts for licenses
13
- * issued without `--features`.
14
- */
15
- const PRO_FEATURES = [
16
- "slack-interface",
17
- "conflict-detection",
18
- "deep-review",
19
- "approval-workflows",
20
- "multi-repo",
21
- "stage-models",
22
- "advanced-automerge",
23
- ];
24
- const ENTERPRISE_FEATURES = [
25
- ...PRO_FEATURES,
26
- "sso",
27
- "audit-log",
28
- "spend-caps",
29
- "rbac",
30
- "cost-dashboard",
31
- "cost-roi",
32
- "org-policy",
33
- "pm-agent-governance",
34
- ];
35
- /**
36
- * Decode a urateam license JWT payload WITHOUT verifying the signature.
37
- *
38
- * The scaffolder doesn't ship the public key (would bloat the package and
39
- * couple it to a specific signing-key generation), and a malformed JWT here
40
- * just produces a wrong prompt flow which is recoverable by editing .env
41
- * after the fact. Production verification happens at runtime in the agent
42
- * via packages/core/src/license.ts against the embedded public key.
43
- *
44
- * If the JWT has no explicit `features` array, this expands by tier to
45
- * match runtime semantics. An explicit (possibly trimmed) array in the
46
- * JWT takes precedence — operators can ship Pro licenses with a subset
47
- * of features and the scaffolder honors that.
48
- *
49
- * Returns null on any parse failure.
50
- */
51
- export function decodeLicense(jwt) {
52
- if (!jwt)
53
- return null;
54
- const segments = jwt.split(".");
55
- if (segments.length !== 3)
56
- return null;
57
- try {
58
- const payloadJson = Buffer.from(segments[1].replace(/-/g, "+").replace(/_/g, "/") +
59
- "=".repeat((4 - (segments[1].length % 4)) % 4), "base64").toString("utf-8");
60
- const payload = JSON.parse(payloadJson);
61
- const tier = payload.tier === "pro" || payload.tier === "enterprise" ? payload.tier : "oss";
62
- // Honor an explicit features array (operators can issue restricted licenses).
63
- // Otherwise expand by tier to match the runtime's tier-implicit feature set.
64
- let features;
65
- if (Array.isArray(payload.features) && payload.features.length > 0) {
66
- features = payload.features;
67
- }
68
- else if (tier === "pro") {
69
- features = [...PRO_FEATURES];
70
- }
71
- else if (tier === "enterprise") {
72
- features = [...ENTERPRISE_FEATURES];
73
- }
74
- else {
75
- features = [];
76
- }
77
- return {
78
- tier,
79
- features,
80
- customerId: payload.sub,
81
- expiresAt: payload.exp ? new Date(payload.exp * 1000) : undefined,
82
- };
83
- }
84
- catch {
85
- return null;
86
- }
87
- }
88
- /**
89
- * Build the `.env` content for a scaffolded urateam sidecar.
90
- *
91
- * Pure function: takes already-resolved values, returns the file string.
92
- * Auto-generation of missing secrets is the caller's responsibility (see
93
- * resolveSecrets below) so this stays trivially testable.
94
- */
95
- function buildEnv(options) {
96
- const lines = [];
97
- const push = (line) => lines.push(line);
98
- const blank = () => lines.push("");
99
- push("# === Linear (REQUIRED) ===");
100
- push(`LINEAR_API_KEY=${options.linearApiKey}`);
101
- // Comment out when blank so the runtime's "is required" check fails fast
102
- // with the right message instead of silently treating "" as a valid secret.
103
- if (options.linearWebhookSecret) {
104
- push(`LINEAR_WEBHOOK_SECRET=${options.linearWebhookSecret}`);
105
- }
106
- else {
107
- push("# LINEAR_WEBHOOK_SECRET= # paste from Linear's webhook config UI");
108
- }
109
- push(`LINEAR_TEAM_ID=${options.linearTeamId}`);
110
- blank();
111
- push("# === Repository (REQUIRED) ===");
112
- push(`REPO_URL=${options.repoUrl}`);
113
- push(`REPO_DEFAULT_BRANCH=${options.defaultBranch}`);
114
- // REPO_TEAM_ID is the map key for repoConfigs[teamId] — defaulted to
115
- // LINEAR_TEAM_ID for single-team / single-repo setups (the env-var path).
116
- // Multi-repo Pro deployments use repos.config.ts instead and can ignore
117
- // this var. See packages/cli/src/commands/start.ts:50.
118
- push(`REPO_TEAM_ID=${options.linearTeamId}`);
119
- blank();
120
- push("# === Anthropic auth ===");
121
- if (options.anthropicApiKey) {
122
- push(`ANTHROPIC_API_KEY=${options.anthropicApiKey}`);
123
- }
124
- else {
125
- push("# ANTHROPIC_API_KEY= # blank → run `docker compose exec agent claude login` after deploy");
126
- }
127
- blank();
128
- push("# === Pro license (blank = OSS tier) ===");
129
- push(`URATEAM_LICENSE_KEY=${options.licenseKey}`);
130
- blank();
131
- push("# === GitHub auth (REQUIRED for PR creation) ===");
132
- push("# Either run `docker compose exec agent gh auth login` after deploy,");
133
- push("# or set the GitHub App trio below:");
134
- push("# GITHUB_APP_ID=");
135
- push("# GITHUB_PRIVATE_KEY_PATH=/run/gh-app.pem");
136
- push("# GITHUB_INSTALLATION_ID=");
137
- // GITHUB_WEBHOOK_SECRET is shared with GitHub's webhook config. Auto-genned
138
- // by default (operator pastes the value INTO GitHub when creating the
139
- // webhook). Blank means PR-comment re-trigger feature stays disabled.
140
- if (options.githubWebhookSecret) {
141
- push(`GITHUB_WEBHOOK_SECRET=${options.githubWebhookSecret}`);
142
- }
143
- else {
144
- push("# GITHUB_WEBHOOK_SECRET= # paste here AND into GitHub webhook config to enable PR-comment re-runs");
145
- }
146
- blank();
147
- push("# === Database ===");
148
- push(`POSTGRES_PASSWORD=${options.postgresPassword}`);
149
- blank();
150
- if (options.deployMode === "production") {
151
- push("# === Domain (production deploy) ===");
152
- push(`DOMAIN=${options.domain}`);
153
- push(`CADDY_EMAIL=${options.caddyEmail}`);
154
- blank();
155
- }
156
- push("# === Dashboard auth ===");
157
- push(`DASHBOARD_USER=${options.dashboardUser}`);
158
- push(`DASHBOARD_PASSWORD=${options.dashboardPassword}`);
159
- if (options.dashboardBasePath) {
160
- push(`DASHBOARD_BASE_PATH=${options.dashboardBasePath}`);
161
- }
162
- else {
163
- push("# DASHBOARD_BASE_PATH= # set with leading slash, no trailing, when behind a path prefix");
164
- }
165
- blank();
166
- push("# === Concurrency ===");
167
- push(`MAX_CONCURRENT_RUNS=${options.maxConcurrentRuns}`);
168
- blank();
169
- // AGENT_BYPASS_PERMISSIONS — Claude Code permission-mode override. Runtime
170
- // logic at packages/core/src/executor/permissions.ts:
171
- // - root user (UID 0): all permission flags ignored (Claude Code refuses)
172
- // - else, env var unset: implement/reproduce=acceptEdits, test/review=default
173
- // - else, env var =true: bypassPermissions for all stages
174
- // The hardened compose template runs the container as root, so this var is
175
- // a no-op for production VPS deploys. For local `pnpm dev` (non-root), the
176
- // test/review stages would hang on interactive prompts without this — local
177
- // mode therefore defaults to true.
178
- push("# === Agent permissions (Claude Code) ===");
179
- if (options.deployMode === "local") {
180
- push("AGENT_BYPASS_PERMISSIONS=true");
181
- }
182
- else {
183
- push("# AGENT_BYPASS_PERMISSIONS=true # no-op in root containers; uncomment if running container as a non-root user");
184
- }
185
- blank();
186
- if (options.pmAgent) {
187
- push("# === PM Agent (Pro: slack-interface) ===");
188
- push("PM_AGENT_ENABLED=true");
189
- push(`PM_AGENT_TEAM_IDS=${options.pmAgent.teamIds}`);
190
- push(`PM_AGENT_SLACK_CHANNEL_ID=${options.pmAgent.slackChannelId}`);
191
- push(`PM_AGENT_DAILY_TOKEN_BUDGET=${options.pmAgent.dailyTokenBudget ?? 5_000_000}`);
192
- push(`PM_AGENT_MAX_IN_FLIGHT=3`);
193
- push(`SLACK_BOT_TOKEN=${options.pmAgent.slackBotToken}`);
194
- push(`SLACK_SIGNING_SECRET=${options.pmAgent.slackSigningSecret}`);
195
- blank();
196
- }
197
- else {
198
- push("# === PM Agent (Pro: slack-interface) — fill in to enable ===");
199
- push("# PM_AGENT_ENABLED=true");
200
- push("# PM_AGENT_TEAM_IDS=");
201
- push("# PM_AGENT_SLACK_CHANNEL_ID=");
202
- push("# PM_AGENT_DAILY_TOKEN_BUDGET=5000000");
203
- push("# PM_AGENT_MAX_IN_FLIGHT=3");
204
- push("# SLACK_BOT_TOKEN=");
205
- push("# SLACK_SIGNING_SECRET=");
206
- blank();
207
- }
208
- push("# === GitHub PR-comment re-trigger (optional, gated by GITHUB_WEBHOOK_SECRET above) ===");
209
- if (options.githubFeedback) {
210
- if (options.githubFeedback.autoTrigger === false) {
211
- push("GITHUB_FEEDBACK_AUTO_TRIGGER=false");
212
- }
213
- else {
214
- push("# GITHUB_FEEDBACK_AUTO_TRIGGER=true # default — fire on any qualifying review/comment");
215
- }
216
- if (options.githubFeedback.triggerKeyword) {
217
- push(`GITHUB_FEEDBACK_TRIGGER_KEYWORD=${options.githubFeedback.triggerKeyword}`);
218
- }
219
- else {
220
- push("# GITHUB_FEEDBACK_TRIGGER_KEYWORD= # require this keyword in the comment to fire");
221
- }
222
- if (options.githubFeedback.allowedReviewers) {
223
- push(`GITHUB_FEEDBACK_ALLOWED_REVIEWERS=${options.githubFeedback.allowedReviewers}`);
224
- }
225
- else {
226
- push("# GITHUB_FEEDBACK_ALLOWED_REVIEWERS= # comma-separated GitHub usernames");
227
- }
228
- if (options.githubFeedback.botLogins) {
229
- push(`GITHUB_FEEDBACK_BOT_LOGINS=${options.githubFeedback.botLogins}`);
230
- }
231
- else {
232
- push("# GITHUB_FEEDBACK_BOT_LOGINS= # comma-separated bot logins, e.g. github-actions[bot]");
233
- }
234
- }
235
- else {
236
- push("# GITHUB_FEEDBACK_AUTO_TRIGGER=true # default — fire on any qualifying review/comment");
237
- push("# GITHUB_FEEDBACK_TRIGGER_KEYWORD= # require this keyword in the comment to fire");
238
- push("# GITHUB_FEEDBACK_ALLOWED_REVIEWERS= # comma-separated GitHub usernames");
239
- push("# GITHUB_FEEDBACK_BOT_LOGINS= # comma-separated bot logins, e.g. github-actions[bot]");
240
- }
241
- blank();
242
- push("# === Pipeline notifications (no Pro license needed) ===");
243
- if (options.slackWebhookUrl) {
244
- push(`SLACK_WEBHOOK_URL=${options.slackWebhookUrl}`);
245
- }
246
- else {
247
- push("# SLACK_WEBHOOK_URL= # Slack incoming-webhook for pipeline event posts");
248
- }
249
- if (options.discordWebhookUrl) {
250
- push(`DISCORD_WEBHOOK_URL=${options.discordWebhookUrl}`);
251
- }
252
- else {
253
- push("# DISCORD_WEBHOOK_URL= # Discord webhook for pipeline event posts");
254
- }
255
- blank();
256
- push("# === Per-stage agent budget overrides (urateam#38) ===");
257
- if (options.agentProfiles && Object.keys(options.agentProfiles).length > 0) {
258
- // Bare (unquoted) JSON. Surrounding single-quotes break Docker Compose's
259
- // env_file parser — same gotcha as the env_file no-interpolation issue.
260
- // Both Compose and Node 22 process.loadEnvFile read everything after `=`
261
- // to EOL and JSON has no whitespace / `=` outside string literals.
262
- push(`URATEAM_AGENT_PROFILES=${JSON.stringify(options.agentProfiles)}`);
263
- }
264
- else {
265
- push('# URATEAM_AGENT_PROFILES={"test":{"maxTurns":50,"maxInputTokens":80000}}');
266
- }
267
- blank();
268
- if (options.openrouterApiKey && options.reviewModels && options.reviewModels.length > 0) {
269
- push("");
270
- push("# OpenRouter multi-model review fanout (BEC-134)");
271
- push(`OPENROUTER_API_KEY=${options.openrouterApiKey}`);
272
- push(`REVIEW_MODELS=${options.reviewModels.join(",")}`);
273
- }
274
- push("# === Optional ===");
275
- push("# LOG_LEVEL=info");
276
- push("");
277
- push("# Additional tunables (worktree TTL, repo clone dir, agent run dir, etc.)");
278
- push("# documented in .env.example next to this file. Keep that file as the");
279
- push("# canonical reference; this .env is generated from prompts.");
280
- return lines.join("\n") + "\n";
281
- }
282
- function resolveSecrets(options) {
283
- const autoGen = options.autoGenSecrets ?? true;
284
- const generated = {};
285
- let dashboardPassword = options.dashboardPassword ?? "";
286
- let postgresPassword = options.postgresPassword ?? "";
287
- let githubWebhookSecret = options.githubWebhookSecret ?? "";
288
- if (autoGen) {
289
- // base64url avoids `+`, `/`, `=` which can trip strict env-file parsers
290
- // and a few HMAC validators in the wild.
291
- if (!dashboardPassword) {
292
- dashboardPassword = randomBytes(18).toString("base64url");
293
- generated.dashboardPassword = dashboardPassword;
294
- }
295
- if (!postgresPassword) {
296
- postgresPassword = randomBytes(24).toString("base64url");
297
- generated.postgresPassword = postgresPassword;
298
- }
299
- if (!githubWebhookSecret) {
300
- // hex (not base64url) for compatibility with the broadest set of HMAC
301
- // validators that operators paste this into. Operator pastes the same
302
- // value into GitHub's webhook config so signatures match.
303
- githubWebhookSecret = randomBytes(32).toString("hex");
304
- generated.githubWebhookSecret = githubWebhookSecret;
305
- }
306
- }
307
- return { dashboardPassword, postgresPassword, githubWebhookSecret, generated };
308
- }
309
- /**
310
- * Scaffold a urateam sidecar into a project directory.
311
- *
312
- * Creates:
313
- * - <projectDir>/.urateam/ — isolated urateam config + deps
314
- * - package.json — depends on @urateam/cli
315
- * - .env — Linear keys, webhook secret, etc.
316
- * - .env.example
317
- * - Dockerfile
318
- * - docker-compose.yml
319
- * - Caddyfile — reverse proxy + auto-HTTPS
320
- * - README.md — how to run the sidecar
321
- * - <projectDir>/CLAUDE.md — project conventions (only if absent)
322
- * - <projectDir>/README.md — project readme (only if absent)
323
- * - <projectDir>/.gitignore — ensures .urateam/.env is ignored
324
- *
325
- * The project root `package.json` is NOT touched. Existing `.env` and
326
- * `package.json` inside `.urateam/` are preserved on re-run.
327
- */
328
- export function scaffold(options) {
329
- const { projectDir, projectName, linearApiKey, linearTeamId, repoUrl, defaultBranch } = options;
330
- mkdirSync(projectDir, { recursive: true });
331
- let templateDir = join(__dirname, "..", "template");
332
- if (!statSync(templateDir, { throwIfNoEntry: false })?.isDirectory()) {
333
- templateDir = join(__dirname, "..", "..", "template");
334
- }
335
- const urateamDir = join(projectDir, ".urateam");
336
- mkdirSync(urateamDir, { recursive: true });
337
- const urateamTemplateDir = join(templateDir, ".urateam");
338
- for (const entry of readdirSync(urateamTemplateDir)) {
339
- if (entry === ".env")
340
- continue;
341
- const src = join(urateamTemplateDir, entry);
342
- const dest = join(urateamDir, entry);
343
- cpSync(src, dest, { recursive: true, force: true });
344
- }
345
- const pkgPath = join(urateamDir, "package.json");
346
- if (!existsSync(pkgPath)) {
347
- const pkg = {
348
- name: `${projectName}-urateam`,
349
- private: true,
350
- type: "module",
351
- scripts: {
352
- dev: "ura dev",
353
- start: "ura start",
354
- },
355
- dependencies: {
356
- "@urateam/cli": "^0.1.4",
357
- },
358
- };
359
- writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
360
- }
361
- const license = decodeLicense(options.licenseKey);
362
- const todos = [];
363
- const { dashboardPassword, postgresPassword, githubWebhookSecret, generated } = resolveSecrets(options);
364
- // --- Compose .env from inputs + resolved secrets ---
365
- const envPath = join(urateamDir, ".env");
366
- if (!existsSync(envPath)) {
367
- const linearWebhookSecret = options.linearWebhookSecret ?? "";
368
- if (!linearWebhookSecret) {
369
- todos.push("LINEAR_WEBHOOK_SECRET — paste from Linear's webhook config UI " +
370
- "(Workspace settings → API → Webhooks).");
371
- }
372
- if (!options.licenseKey) {
373
- todos.push("URATEAM_LICENSE_KEY — Pro features (PM agent, Slack interface, multi-repo, " +
374
- "deep-review, etc.) stay disabled until you set this.");
375
- }
376
- if (license?.tier &&
377
- license.tier !== "oss" &&
378
- license.features.includes("slack-interface") &&
379
- !options.pmAgent) {
380
- todos.push("PM_AGENT_* — your license includes `slack-interface`. Fill in the " +
381
- "PM_AGENT_* + SLACK_* lines in .env to enable it.");
382
- }
383
- if (!options.anthropicApiKey) {
384
- todos.push("Anthropic auth — run `docker compose exec agent claude login` after the stack is up " +
385
- "(or set ANTHROPIC_API_KEY in .env for headless API auth).");
386
- }
387
- todos.push("GitHub auth — run `docker compose exec agent gh auth login` after the stack is up " +
388
- "(or set the GITHUB_APP_* trio in .env for app-based auth).");
389
- if (options.deployMode === "production" && !options.domain) {
390
- todos.push("DOMAIN — set in .env before running `docker compose up`.");
391
- }
392
- if (options.deployMode === "production" && !options.caddyEmail) {
393
- todos.push("CADDY_EMAIL — recommended for Let's Encrypt expiry warnings.");
394
- }
395
- if (!options.autoGenSecrets) {
396
- if (!options.dashboardPassword)
397
- todos.push("DASHBOARD_PASSWORD — fill in .env.");
398
- if (!options.postgresPassword)
399
- todos.push("POSTGRES_PASSWORD — fill in .env.");
400
- }
401
- // GITHUB_WEBHOOK_SECRET is optional (only needed for PR-comment re-runs).
402
- // No TODO when blank — operator who didn't paste it in Stage 6 doesn't want
403
- // the feature. But if they DID set GITHUB_FEEDBACK_*, the missing secret
404
- // makes those values dead — flag that.
405
- if (options.githubFeedback && !githubWebhookSecret) {
406
- todos.push("GITHUB_WEBHOOK_SECRET — required for GITHUB_FEEDBACK_* to take effect; " +
407
- "feedback values without the secret are silently ignored at runtime.");
408
- }
409
- const envContent = buildEnv({
410
- linearApiKey,
411
- linearTeamId,
412
- repoUrl,
413
- defaultBranch,
414
- deployMode: options.deployMode ?? "local",
415
- linearWebhookSecret,
416
- domain: options.domain ?? "",
417
- caddyEmail: options.caddyEmail ?? "",
418
- anthropicApiKey: options.anthropicApiKey ?? "",
419
- licenseKey: options.licenseKey ?? "",
420
- dashboardUser: options.dashboardUser ?? "admin",
421
- dashboardPassword,
422
- dashboardBasePath: options.dashboardBasePath ?? "",
423
- postgresPassword,
424
- githubWebhookSecret,
425
- maxConcurrentRuns: options.maxConcurrentRuns ?? 3,
426
- pmAgent: options.pmAgent,
427
- githubFeedback: options.githubFeedback,
428
- slackWebhookUrl: options.slackWebhookUrl ?? "",
429
- discordWebhookUrl: options.discordWebhookUrl ?? "",
430
- agentProfiles: options.agentProfiles,
431
- openrouterApiKey: options.openrouterApiKey ?? "",
432
- reviewModels: options.reviewModels ?? [],
433
- });
434
- writeFileSync(envPath, envContent);
435
- }
436
- // --- Project root files: copy only if absent ---
437
- const rootFilesWithPlaceholder = ["CLAUDE.md", "README.md"];
438
- for (const file of rootFilesWithPlaceholder) {
439
- const dest = join(projectDir, file);
440
- if (existsSync(dest))
441
- continue;
442
- const src = join(templateDir, file);
443
- if (!existsSync(src))
444
- continue;
445
- const content = readFileSync(src, "utf-8");
446
- writeFileSync(dest, content.replace(/\{\{PROJECT_NAME\}\}/g, projectName));
447
- }
448
- const gitignorePath = join(projectDir, ".gitignore");
449
- if (!existsSync(gitignorePath)) {
450
- writeFileSync(gitignorePath, URATEAM_GITIGNORE);
451
- }
452
- else {
453
- const existing = readFileSync(gitignorePath, "utf-8");
454
- const hasBareEntry = existing
455
- .split(/\r?\n/)
456
- .some((line) => line.trim() === ".urateam/.env");
457
- if (!hasBareEntry) {
458
- const separator = existing.endsWith("\n") ? "\n" : "\n\n";
459
- appendFileSync(gitignorePath, separator + URATEAM_GITIGNORE);
460
- }
461
- }
462
- return { urateamDir, license, generatedSecrets: generated, todos };
463
- }
464
- /**
465
- * Normalize a user-typed dashboard base path: strip trailing slashes, ensure
466
- * leading slash, treat blank as undefined. Eliminates the "I typed /ateam/
467
- * and now the dashboard 404s" footgun.
468
- */
469
- export function normalizeBasePath(input) {
470
- if (!input)
471
- return undefined;
472
- const trimmed = input.trim();
473
- if (!trimmed)
474
- return undefined;
475
- const noTrailingSlash = trimmed.replace(/\/+$/, "");
476
- if (!noTrailingSlash)
477
- return undefined; // operator typed only `/` or `///`
478
- return noTrailingSlash.startsWith("/") ? noTrailingSlash : `/${noTrailingSlash}`;
479
- }
480
- const URATEAM_GITIGNORE = `# urateam sidecar
481
- .urateam/.env
482
- .urateam/.env.*
483
- !.urateam/.env.example
484
- .urateam/node_modules/
485
- .urateam/dist/
486
- .urateam/pnpm-lock.yaml
487
- `;
488
- // CLI entrypoint — only runs when executed directly (not when imported for testing)
2
+ // Re-export all public types and functions from scaffold.ts so that
3
+ // existing code importing from @create-urateam (or ../index.js in tests)
4
+ // continues to work without changes.
5
+ import { scaffold, normalizeBasePath, decodeLicense, buildEnv, resolveSecrets, } from "./scaffold.js";
6
+ import { runWizard } from "./wizard.js";
7
+ export { scaffold, normalizeBasePath, decodeLicense, buildEnv, resolveSecrets };
489
8
  async function main() {
490
9
  const arg = process.argv[2] ?? ".";
491
10
  if (arg === "--help" || arg === "-h") {
@@ -495,327 +14,13 @@ async function main() {
495
14
  console.log(" create-urateam my-project # creates new directory and adds .urateam/ inside");
496
15
  process.exit(0);
497
16
  }
498
- const prompts = (await import("prompts")).default;
499
- // Detect existing .env early so we can short-circuit before walking the
500
- // operator through 8 stages of prompts that won't be applied. The
501
- // scaffolder already preserves .env on re-run; without this, the prompts
502
- // are pure UX waste.
503
- const projectDirEarly = arg === "." ? process.cwd() : join(process.cwd(), arg);
504
- const existingEnv = join(projectDirEarly, ".urateam", ".env");
505
- if (existsSync(existingEnv)) {
506
- console.log(`\n Existing .env detected at ${existingEnv}.\n` +
507
- " Re-running create-urateam will refresh template files (Dockerfile, docker-compose.yml,\n" +
508
- " Caddyfile, etc.) but will NOT touch your .env. To re-prompt for secrets, delete .env\n" +
509
- " first and re-run. Continuing with template-refresh only…\n");
510
- // Use minimal stub options — scaffold() needs them but won't write .env.
511
- scaffold({
512
- projectDir: projectDirEarly,
513
- projectName: arg === "." ? basename(projectDirEarly) || "my-project" : arg,
514
- linearApiKey: "",
515
- linearTeamId: "",
516
- repoUrl: "",
517
- defaultBranch: "main",
518
- });
519
- console.log(` ✓ Template files refreshed in ${projectDirEarly}/.urateam\n`);
17
+ const wizardResult = await runWizard(arg);
18
+ if (wizardResult === null) {
19
+ // Early-exit: existing .env detected; template files were refreshed internally.
520
20
  return;
521
21
  }
522
- // --- Stage 1: Linear / repo basics ---
523
- const stage1 = await prompts([
524
- { type: "text", name: "linearApiKey", message: "Linear API key:" },
525
- { type: "text", name: "linearTeamId", message: "Linear team ID:" },
526
- { type: "text", name: "repoUrl", message: "Repo URL (GitHub/GitLab):" },
527
- { type: "text", name: "defaultBranch", message: "Default branch:", initial: "main" },
528
- ]);
529
- if (!stage1.linearApiKey || !stage1.repoUrl) {
530
- console.error("Cancelled.");
531
- process.exit(1);
532
- }
533
- // --- Stage 2: deploy mode + Linear webhook secret (hidden input) ---
534
- const stage2 = await prompts([
535
- {
536
- type: "select",
537
- name: "deployMode",
538
- message: "Deploy target:",
539
- choices: [
540
- { title: "local (laptop / dev — pnpm dev)", value: "local" },
541
- { title: "production (VPS / docker compose)", value: "production" },
542
- ],
543
- },
544
- {
545
- // password type masks input so the secret doesn't land in scrollback / shell history
546
- type: "password",
547
- name: "linearWebhookSecret",
548
- message: "LINEAR_WEBHOOK_SECRET (paste from Linear webhook config; leave blank to fill in later):",
549
- },
550
- ]);
551
- // --- Stage 3: production-only details (domain / caddy email / dashboard base path) ---
552
- const stage3 = stage2.deployMode === "production"
553
- ? await prompts([
554
- { type: "text", name: "domain", message: "Public domain (e.g. urateam.example.com):" },
555
- { type: "text", name: "caddyEmail", message: "Email for Let's Encrypt:" },
556
- {
557
- type: "text",
558
- name: "dashboardBasePath",
559
- message: "DASHBOARD_BASE_PATH (leading slash, no trailing — leave blank if dashboard is at root):",
560
- },
561
- ])
562
- : { domain: undefined, caddyEmail: undefined, dashboardBasePath: undefined };
563
- // --- Stage 4: Anthropic auth choice ---
564
- const stage4 = await prompts([
565
- {
566
- type: "select",
567
- name: "anthropicAuth",
568
- message: "Anthropic auth method:",
569
- choices: [
570
- { title: "Claude Code CLI (`claude login` after deploy)", value: "cli" },
571
- { title: "API key (set ANTHROPIC_API_KEY now)", value: "apiKey" },
572
- ],
573
- },
574
- {
575
- type: (prev) => (prev === "apiKey" ? "password" : null),
576
- name: "anthropicApiKey",
577
- message: "ANTHROPIC_API_KEY:",
578
- },
579
- ]);
580
- // --- Stage 5: license + tier-gated PM agent setup ---
581
- const stage5 = await prompts([
582
- {
583
- // password type masks the JWT so it doesn't echo to scrollback / shell history.
584
- // Decoded license summary printed below intentionally only shows tier + features,
585
- // not the full JWT, so paste-into-terminal stays opaque.
586
- type: "password",
587
- name: "licenseKey",
588
- message: "URATEAM_LICENSE_KEY (leave blank for OSS):",
589
- },
590
- ]);
591
- const license = decodeLicense(stage5.licenseKey);
592
- if (license) {
593
- console.log(`\n License decoded: tier=${license.tier}, features=[${license.features.join(", ")}]`);
594
- if (license.expiresAt && license.expiresAt.getTime() < Date.now()) {
595
- console.warn(` ⚠ License expired at ${license.expiresAt.toISOString()} — runtime will reject ` +
596
- "it and fall back to OSS tier. Renew before deploying.");
597
- }
598
- console.log("");
599
- }
600
- let pmAgent;
601
- if (license &&
602
- license.tier !== "oss" &&
603
- license.features.includes("slack-interface")) {
604
- console.log("\n Your license includes the `slack-interface` feature (PM agent + Slack /pm slash commands).\n" +
605
- " You can configure it now if you have your Slack app credentials handy, or skip and add\n" +
606
- " the PM_AGENT_* + SLACK_* lines to .env later. Setup walkthrough:\n" +
607
- " https://github.com/JonB32/urateam/blob/main/docs/slack-setup.md\n");
608
- const setupNow = await prompts({
609
- type: "confirm",
610
- name: "setup",
611
- message: "Set up PM agent + Slack interface now?",
612
- initial: false,
613
- });
614
- if (setupNow.setup) {
615
- const pmPrompts = await prompts([
616
- { type: "password", name: "slackBotToken", message: "SLACK_BOT_TOKEN (xoxb-...):" },
617
- { type: "password", name: "slackSigningSecret", message: "SLACK_SIGNING_SECRET:" },
618
- { type: "text", name: "slackChannelId", message: "PM_AGENT_SLACK_CHANNEL_ID (Cxxxxx):" },
619
- {
620
- type: "text",
621
- name: "teamIds",
622
- message: "PM_AGENT_TEAM_IDS (comma-separated):",
623
- initial: stage1.linearTeamId,
624
- },
625
- {
626
- type: "number",
627
- name: "dailyTokenBudget",
628
- message: "PM_AGENT_DAILY_TOKEN_BUDGET:",
629
- initial: 5_000_000,
630
- },
631
- ]);
632
- if (pmPrompts.slackBotToken && pmPrompts.slackSigningSecret && pmPrompts.slackChannelId) {
633
- pmAgent = {
634
- slackBotToken: pmPrompts.slackBotToken,
635
- slackSigningSecret: pmPrompts.slackSigningSecret,
636
- slackChannelId: pmPrompts.slackChannelId,
637
- teamIds: pmPrompts.teamIds || stage1.linearTeamId,
638
- dailyTokenBudget: pmPrompts.dailyTokenBudget ?? 5_000_000,
639
- };
640
- }
641
- else if (pmPrompts.slackBotToken || pmPrompts.slackSigningSecret || pmPrompts.slackChannelId) {
642
- // Partial input — warn loudly so the operator doesn't think they configured PM.
643
- console.warn("\n ⚠ PM agent setup incomplete — at least one of SLACK_BOT_TOKEN, " +
644
- "SLACK_SIGNING_SECRET, PM_AGENT_SLACK_CHANNEL_ID was blank. The PM_AGENT_* " +
645
- "block in .env has been left commented out; fill it in by hand to enable.\n");
646
- }
647
- }
648
- }
649
- // --- Stage 6: optional GitHub webhook secret + notification webhooks ---
650
- const stage6 = await prompts([
651
- {
652
- // Hidden input — secret is shared with GitHub's webhook config. If the
653
- // operator already has a secret in GitHub, paste it here. Otherwise leave
654
- // blank and the auto-gen step at Stage 8 will mint one for both sides.
655
- type: "password",
656
- name: "githubWebhookSecret",
657
- message: "GITHUB_WEBHOOK_SECRET (paste from GitHub if you already set one, or leave blank to auto-generate):",
658
- },
659
- {
660
- type: "text",
661
- name: "slackWebhookUrl",
662
- message: "SLACK_WEBHOOK_URL (incoming-webhook for pipeline events; leave blank to skip):",
663
- },
664
- {
665
- type: "text",
666
- name: "discordWebhookUrl",
667
- message: "DISCORD_WEBHOOK_URL (leave blank to skip):",
668
- },
669
- ]);
670
- // --- Stage 7: per-stage agent profile overrides (wizard) ---
671
- const stage7Customize = await prompts({
672
- type: "confirm",
673
- name: "customize",
674
- message: "Customize per-stage agent budgets (URATEAM_AGENT_PROFILES)? Most operators skip this.",
675
- initial: false,
676
- });
677
- let agentProfiles;
678
- if (stage7Customize.customize) {
679
- agentProfiles = {};
680
- const stages = ["implement", "test", "review"];
681
- for (const stage of stages) {
682
- const wantStage = await prompts({
683
- type: "confirm",
684
- name: "yes",
685
- message: `Override budget for the \`${stage}\` stage?`,
686
- initial: false,
687
- });
688
- if (!wantStage.yes)
689
- continue;
690
- // Min/max mirror the runtime ceilings in packages/core/src/executor/profiles.ts:96
691
- // (MAX_TURNS_CEILING=500, MAX_INPUT_TOKENS_CEILING=500_000) so the wizard
692
- // rejects out-of-range values at input time instead of letting the runtime
693
- // silently drop them with a warn.
694
- const profile = await prompts([
695
- {
696
- type: "number",
697
- name: "maxTurns",
698
- message: ` ${stage}.maxTurns (1–500, blank to keep default):`,
699
- min: 1,
700
- max: 500,
701
- },
702
- {
703
- type: "number",
704
- name: "maxInputTokens",
705
- message: ` ${stage}.maxInputTokens (1–500000, blank to keep default):`,
706
- min: 1,
707
- max: 500_000,
708
- },
709
- { type: "text", name: "model", message: ` ${stage}.model (blank to keep default):` },
710
- ]);
711
- const entry = {};
712
- if (typeof profile.maxTurns === "number")
713
- entry.maxTurns = profile.maxTurns;
714
- if (typeof profile.maxInputTokens === "number")
715
- entry.maxInputTokens = profile.maxInputTokens;
716
- if (profile.model)
717
- entry.model = profile.model;
718
- if (Object.keys(entry).length > 0)
719
- agentProfiles[stage] = entry;
720
- }
721
- if (Object.keys(agentProfiles).length === 0) {
722
- agentProfiles = undefined; // all stages skipped — don't write a `{}` JSON
723
- }
724
- else {
725
- // Round-trip-validate the JSON we'd write so a typo doesn't get persisted as broken
726
- // syntax that the agent would crash on at runtime.
727
- try {
728
- JSON.parse(JSON.stringify(agentProfiles));
729
- }
730
- catch (e) {
731
- console.error("Internal: agent profiles JSON failed to round-trip — skipping.", e);
732
- agentProfiles = undefined;
733
- }
734
- }
735
- }
736
- // --- Stage 8: secret generation strategy ---
737
- // GITHUB_WEBHOOK_SECRET is in this set with one twist: if the operator
738
- // explicitly pasted a value in Stage 6, it wins; otherwise it's auto-genned
739
- // here. Either way the operator gets a value they can paste into GitHub's
740
- // webhook config so HMAC signatures match.
741
- const stage8 = await prompts({
742
- type: "confirm",
743
- name: "autoGen",
744
- message: "Auto-generate POSTGRES_PASSWORD, DASHBOARD_PASSWORD, GITHUB_WEBHOOK_SECRET? (No → leave blank in .env for any not provided above)",
745
- initial: true,
746
- });
747
- // --- Stage 9: GitHub PR-comment re-trigger config (gated) ---
748
- // Only prompt when both signals are present:
749
- // - URATEAM_LICENSE_KEY pasted (operator is engaged with the product enough
750
- // to want advanced workflows)
751
- // - A GITHUB_WEBHOOK_SECRET will exist in .env (either pasted in Stage 6
752
- // or auto-genned in Stage 8) — without it the runtime won't even mount
753
- // the feedback handler.
754
- let githubFeedback;
755
- const willHaveGhSecret = !!stage6.githubWebhookSecret || stage8.autoGen;
756
- if (stage5.licenseKey && willHaveGhSecret) {
757
- const setupFeedback = await prompts({
758
- type: "confirm",
759
- name: "setup",
760
- message: "Configure GitHub PR-comment re-triggers (GITHUB_FEEDBACK_*) now? You can skip and add later.",
761
- initial: false,
762
- });
763
- if (setupFeedback.setup) {
764
- const fb = await prompts([
765
- {
766
- type: "text",
767
- name: "triggerKeyword",
768
- message: " GITHUB_FEEDBACK_TRIGGER_KEYWORD (require this string in PR comment to fire; blank = any review/comment):",
769
- },
770
- {
771
- type: "text",
772
- name: "allowedReviewers",
773
- message: " GITHUB_FEEDBACK_ALLOWED_REVIEWERS (csv of GitHub usernames whose comments fire it; blank = all):",
774
- },
775
- {
776
- type: "text",
777
- name: "botLogins",
778
- message: " GITHUB_FEEDBACK_BOT_LOGINS (csv of bot logins like github-actions[bot]; blank = none):",
779
- },
780
- {
781
- type: "confirm",
782
- name: "autoTrigger",
783
- message: " GITHUB_FEEDBACK_AUTO_TRIGGER — fire automatically on qualifying comments?",
784
- initial: true,
785
- },
786
- ]);
787
- githubFeedback = {
788
- triggerKeyword: fb.triggerKeyword || undefined,
789
- allowedReviewers: fb.allowedReviewers || undefined,
790
- botLogins: fb.botLogins || undefined,
791
- autoTrigger: fb.autoTrigger,
792
- };
793
- }
794
- }
795
- const projectDir = arg === "." ? process.cwd() : join(process.cwd(), arg);
796
- const projectName = arg === "." ? basename(projectDir) || "my-project" : arg;
797
- const result = scaffold({
798
- projectDir,
799
- projectName,
800
- linearApiKey: stage1.linearApiKey,
801
- linearTeamId: stage1.linearTeamId,
802
- repoUrl: stage1.repoUrl,
803
- defaultBranch: stage1.defaultBranch || "main",
804
- deployMode: stage2.deployMode,
805
- linearWebhookSecret: stage2.linearWebhookSecret,
806
- domain: stage3.domain,
807
- caddyEmail: stage3.caddyEmail,
808
- dashboardBasePath: normalizeBasePath(stage3.dashboardBasePath),
809
- anthropicApiKey: stage4.anthropicApiKey,
810
- licenseKey: stage5.licenseKey,
811
- pmAgent,
812
- githubWebhookSecret: stage6.githubWebhookSecret || undefined,
813
- githubFeedback,
814
- slackWebhookUrl: stage6.slackWebhookUrl || undefined,
815
- discordWebhookUrl: stage6.discordWebhookUrl || undefined,
816
- agentProfiles,
817
- autoGenSecrets: stage8.autoGen,
818
- });
22
+ const { scaffoldOptions, deployMode, domain, dashboardBasePath, anthropicAuth } = wizardResult;
23
+ const result = scaffold(scaffoldOptions);
819
24
  // --- Next steps printout ---
820
25
  console.log(`\n ✓ urateam sidecar installed in ${result.urateamDir}\n`);
821
26
  if (Object.keys(result.generatedSecrets).length > 0) {
@@ -841,14 +46,6 @@ async function main() {
841
46
  }
842
47
  console.log("");
843
48
  }
844
- if (result.todos.length > 0) {
845
- console.log(" Still to do (edit .urateam/.env or run the noted commands):");
846
- for (const todo of result.todos) {
847
- console.log(` • ${todo}`);
848
- }
849
- console.log("");
850
- }
851
- // Detail any .env updates the operator must do before bringing the stack up.
852
49
  if (result.todos.length > 0) {
853
50
  console.log(" Before starting the stack, edit .urateam/.env to fill in:");
854
51
  for (const todo of result.todos) {
@@ -863,25 +60,25 @@ async function main() {
863
60
  if (result.todos.length > 0) {
864
61
  console.log(" # ↑ open .env in your editor and fill in the TODOs above first");
865
62
  }
866
- if (stage2.deployMode === "production") {
63
+ if (deployMode === "production") {
867
64
  console.log(" docker compose up -d --build");
868
- if (stage4.anthropicAuth === "cli") {
65
+ if (anthropicAuth === "cli") {
869
66
  console.log(" docker compose exec agent claude login # device-flow auth");
870
67
  }
871
68
  console.log(" docker compose exec agent gh auth login # device-flow auth");
872
69
  console.log("");
873
70
  console.log(" After the stack is up:");
874
71
  console.log(` 1. Add a webhook in Linear → Settings → API → Webhooks`);
875
- console.log(` URL: https://${stage3.domain || "<your-domain>"}/webhooks/linear`);
876
- if (stage3.dashboardBasePath) {
877
- console.log(` ⚠ NOT https://${stage3.domain || "<your-domain>"}${stage3.dashboardBasePath}/webhooks/linear`);
72
+ console.log(` URL: https://${domain || "<your-domain>"}/webhooks/linear`);
73
+ if (dashboardBasePath) {
74
+ console.log(` ⚠ NOT https://${domain || "<your-domain>"}${dashboardBasePath}/webhooks/linear`);
878
75
  console.log(` (webhook routes are server-level, not under DASHBOARD_BASE_PATH)`);
879
76
  }
880
77
  console.log(` Subscribe to: Issue state changes`);
881
78
  console.log(` Copy the secret → paste into .env as LINEAR_WEBHOOK_SECRET → restart the stack`);
882
- const dashboardUrl = stage3.dashboardBasePath
883
- ? `https://${stage3.domain || "<your-domain>"}${stage3.dashboardBasePath}`
884
- : `https://${stage3.domain || "<your-domain>"}`;
79
+ const dashboardUrl = dashboardBasePath
80
+ ? `https://${domain || "<your-domain>"}${dashboardBasePath}`
81
+ : `https://${domain || "<your-domain>"}`;
885
82
  console.log(` 2. Open the dashboard at ${dashboardUrl} (admin / your-generated-password)`);
886
83
  console.log(` 3. Move a Linear issue to Todo with a pipeline label to trigger your first run`);
887
84
  }