create-urateam 0.1.9 → 0.1.15
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/__tests__/scaffold.test.js +289 -2
- package/dist/__tests__/scaffold.test.js.map +1 -1
- package/dist/index.d.ts +106 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +621 -61
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/template/.urateam/.env.example +4 -0
- package/template/.urateam/Dockerfile +10 -1
- package/template/.urateam/README.md +7 -2
- package/template/.urateam/docker-compose.yml +6 -0
package/dist/index.js
CHANGED
|
@@ -5,6 +5,226 @@ import { fileURLToPath } from "url";
|
|
|
5
5
|
import { randomBytes } from "crypto";
|
|
6
6
|
const __filename = fileURLToPath(import.meta.url);
|
|
7
7
|
const __dirname = dirname(__filename);
|
|
8
|
+
/**
|
|
9
|
+
* Decode a urateam license JWT payload WITHOUT verifying the signature.
|
|
10
|
+
*
|
|
11
|
+
* The scaffolder doesn't ship the public key (would bloat the package and
|
|
12
|
+
* couple it to a specific signing-key generation), and a malformed JWT here
|
|
13
|
+
* just produces a wrong prompt flow which is recoverable by editing .env
|
|
14
|
+
* after the fact. Production verification happens at runtime in the agent
|
|
15
|
+
* via packages/core/src/license.ts against the embedded public key.
|
|
16
|
+
*
|
|
17
|
+
* Returns null on any parse failure.
|
|
18
|
+
*/
|
|
19
|
+
export function decodeLicense(jwt) {
|
|
20
|
+
if (!jwt)
|
|
21
|
+
return null;
|
|
22
|
+
const segments = jwt.split(".");
|
|
23
|
+
if (segments.length !== 3)
|
|
24
|
+
return null;
|
|
25
|
+
try {
|
|
26
|
+
const payloadJson = Buffer.from(segments[1].replace(/-/g, "+").replace(/_/g, "/") +
|
|
27
|
+
"=".repeat((4 - (segments[1].length % 4)) % 4), "base64").toString("utf-8");
|
|
28
|
+
const payload = JSON.parse(payloadJson);
|
|
29
|
+
const tier = payload.tier === "pro" || payload.tier === "enterprise" ? payload.tier : "oss";
|
|
30
|
+
return {
|
|
31
|
+
tier,
|
|
32
|
+
features: payload.features ?? [],
|
|
33
|
+
customerId: payload.sub,
|
|
34
|
+
expiresAt: payload.exp ? new Date(payload.exp * 1000) : undefined,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Build the `.env` content for a scaffolded urateam sidecar.
|
|
43
|
+
*
|
|
44
|
+
* Pure function: takes already-resolved values, returns the file string.
|
|
45
|
+
* Auto-generation of missing secrets is the caller's responsibility (see
|
|
46
|
+
* resolveSecrets below) so this stays trivially testable.
|
|
47
|
+
*/
|
|
48
|
+
function buildEnv(options) {
|
|
49
|
+
const lines = [];
|
|
50
|
+
const push = (line) => lines.push(line);
|
|
51
|
+
const blank = () => lines.push("");
|
|
52
|
+
push("# === Linear (REQUIRED) ===");
|
|
53
|
+
push(`LINEAR_API_KEY=${options.linearApiKey}`);
|
|
54
|
+
// Comment out when blank so the runtime's "is required" check fails fast
|
|
55
|
+
// with the right message instead of silently treating "" as a valid secret.
|
|
56
|
+
if (options.linearWebhookSecret) {
|
|
57
|
+
push(`LINEAR_WEBHOOK_SECRET=${options.linearWebhookSecret}`);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
push("# LINEAR_WEBHOOK_SECRET= # paste from Linear's webhook config UI");
|
|
61
|
+
}
|
|
62
|
+
push(`LINEAR_TEAM_ID=${options.linearTeamId}`);
|
|
63
|
+
blank();
|
|
64
|
+
push("# === Repository (REQUIRED) ===");
|
|
65
|
+
push(`REPO_URL=${options.repoUrl}`);
|
|
66
|
+
push(`REPO_DEFAULT_BRANCH=${options.defaultBranch}`);
|
|
67
|
+
// REPO_TEAM_ID is the map key for repoConfigs[teamId] — defaulted to
|
|
68
|
+
// LINEAR_TEAM_ID for single-team / single-repo setups (the env-var path).
|
|
69
|
+
// Multi-repo Pro deployments use repos.config.ts instead and can ignore
|
|
70
|
+
// this var. See packages/cli/src/commands/start.ts:50.
|
|
71
|
+
push(`REPO_TEAM_ID=${options.linearTeamId}`);
|
|
72
|
+
blank();
|
|
73
|
+
push("# === Anthropic auth ===");
|
|
74
|
+
if (options.anthropicApiKey) {
|
|
75
|
+
push(`ANTHROPIC_API_KEY=${options.anthropicApiKey}`);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
push("# ANTHROPIC_API_KEY= # blank → run `docker compose exec agent claude login` after deploy");
|
|
79
|
+
}
|
|
80
|
+
blank();
|
|
81
|
+
push("# === Pro license (blank = OSS tier) ===");
|
|
82
|
+
push(`URATEAM_LICENSE_KEY=${options.licenseKey}`);
|
|
83
|
+
blank();
|
|
84
|
+
push("# === GitHub auth (REQUIRED for PR creation) ===");
|
|
85
|
+
push("# Either run `docker compose exec agent gh auth login` after deploy,");
|
|
86
|
+
push("# or set the GitHub App trio below:");
|
|
87
|
+
push("# GITHUB_APP_ID=");
|
|
88
|
+
push("# GITHUB_PRIVATE_KEY_PATH=/run/gh-app.pem");
|
|
89
|
+
push("# GITHUB_INSTALLATION_ID=");
|
|
90
|
+
push(`GITHUB_WEBHOOK_SECRET=${options.githubWebhookSecret}`);
|
|
91
|
+
blank();
|
|
92
|
+
push("# === Database ===");
|
|
93
|
+
push(`POSTGRES_PASSWORD=${options.postgresPassword}`);
|
|
94
|
+
blank();
|
|
95
|
+
if (options.deployMode === "production") {
|
|
96
|
+
push("# === Domain (production deploy) ===");
|
|
97
|
+
push(`DOMAIN=${options.domain}`);
|
|
98
|
+
push(`CADDY_EMAIL=${options.caddyEmail}`);
|
|
99
|
+
blank();
|
|
100
|
+
}
|
|
101
|
+
push("# === Dashboard auth ===");
|
|
102
|
+
push(`DASHBOARD_USER=${options.dashboardUser}`);
|
|
103
|
+
push(`DASHBOARD_PASSWORD=${options.dashboardPassword}`);
|
|
104
|
+
if (options.dashboardBasePath) {
|
|
105
|
+
push(`DASHBOARD_BASE_PATH=${options.dashboardBasePath}`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
push("# DASHBOARD_BASE_PATH= # set with leading slash, no trailing, when behind a path prefix");
|
|
109
|
+
}
|
|
110
|
+
blank();
|
|
111
|
+
push("# === Concurrency ===");
|
|
112
|
+
push(`MAX_CONCURRENT_RUNS=${options.maxConcurrentRuns}`);
|
|
113
|
+
blank();
|
|
114
|
+
if (options.pmAgent) {
|
|
115
|
+
push("# === PM Agent (Pro: slack-interface) ===");
|
|
116
|
+
push("PM_AGENT_ENABLED=true");
|
|
117
|
+
push(`PM_AGENT_TEAM_IDS=${options.pmAgent.teamIds}`);
|
|
118
|
+
push(`PM_AGENT_SLACK_CHANNEL_ID=${options.pmAgent.slackChannelId}`);
|
|
119
|
+
push(`PM_AGENT_DAILY_TOKEN_BUDGET=${options.pmAgent.dailyTokenBudget ?? 5_000_000}`);
|
|
120
|
+
push(`PM_AGENT_MAX_IN_FLIGHT=3`);
|
|
121
|
+
push(`SLACK_BOT_TOKEN=${options.pmAgent.slackBotToken}`);
|
|
122
|
+
push(`SLACK_SIGNING_SECRET=${options.pmAgent.slackSigningSecret}`);
|
|
123
|
+
blank();
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
push("# === PM Agent (Pro: slack-interface) — fill in to enable ===");
|
|
127
|
+
push("# PM_AGENT_ENABLED=true");
|
|
128
|
+
push("# PM_AGENT_TEAM_IDS=");
|
|
129
|
+
push("# PM_AGENT_SLACK_CHANNEL_ID=");
|
|
130
|
+
push("# PM_AGENT_DAILY_TOKEN_BUDGET=5000000");
|
|
131
|
+
push("# PM_AGENT_MAX_IN_FLIGHT=3");
|
|
132
|
+
push("# SLACK_BOT_TOKEN=");
|
|
133
|
+
push("# SLACK_SIGNING_SECRET=");
|
|
134
|
+
blank();
|
|
135
|
+
}
|
|
136
|
+
push("# === GitHub PR-comment re-trigger (optional, gated by GITHUB_WEBHOOK_SECRET above) ===");
|
|
137
|
+
if (options.githubFeedback) {
|
|
138
|
+
if (options.githubFeedback.autoTrigger === false) {
|
|
139
|
+
push("GITHUB_FEEDBACK_AUTO_TRIGGER=false");
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
push("# GITHUB_FEEDBACK_AUTO_TRIGGER=true # default — fire on any qualifying review/comment");
|
|
143
|
+
}
|
|
144
|
+
if (options.githubFeedback.triggerKeyword) {
|
|
145
|
+
push(`GITHUB_FEEDBACK_TRIGGER_KEYWORD=${options.githubFeedback.triggerKeyword}`);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
push("# GITHUB_FEEDBACK_TRIGGER_KEYWORD= # require this keyword in the comment to fire");
|
|
149
|
+
}
|
|
150
|
+
if (options.githubFeedback.allowedReviewers) {
|
|
151
|
+
push(`GITHUB_FEEDBACK_ALLOWED_REVIEWERS=${options.githubFeedback.allowedReviewers}`);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
push("# GITHUB_FEEDBACK_ALLOWED_REVIEWERS= # comma-separated GitHub usernames");
|
|
155
|
+
}
|
|
156
|
+
if (options.githubFeedback.botLogins) {
|
|
157
|
+
push(`GITHUB_FEEDBACK_BOT_LOGINS=${options.githubFeedback.botLogins}`);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
push("# GITHUB_FEEDBACK_BOT_LOGINS= # comma-separated bot logins, e.g. github-actions[bot]");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
push("# GITHUB_FEEDBACK_AUTO_TRIGGER=true # default — fire on any qualifying review/comment");
|
|
165
|
+
push("# GITHUB_FEEDBACK_TRIGGER_KEYWORD= # require this keyword in the comment to fire");
|
|
166
|
+
push("# GITHUB_FEEDBACK_ALLOWED_REVIEWERS= # comma-separated GitHub usernames");
|
|
167
|
+
push("# GITHUB_FEEDBACK_BOT_LOGINS= # comma-separated bot logins, e.g. github-actions[bot]");
|
|
168
|
+
}
|
|
169
|
+
blank();
|
|
170
|
+
push("# === Pipeline notifications (no Pro license needed) ===");
|
|
171
|
+
if (options.slackWebhookUrl) {
|
|
172
|
+
push(`SLACK_WEBHOOK_URL=${options.slackWebhookUrl}`);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
push("# SLACK_WEBHOOK_URL= # Slack incoming-webhook for pipeline event posts");
|
|
176
|
+
}
|
|
177
|
+
if (options.discordWebhookUrl) {
|
|
178
|
+
push(`DISCORD_WEBHOOK_URL=${options.discordWebhookUrl}`);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
push("# DISCORD_WEBHOOK_URL= # Discord webhook for pipeline event posts");
|
|
182
|
+
}
|
|
183
|
+
blank();
|
|
184
|
+
push("# === Per-stage agent budget overrides (urateam#38) ===");
|
|
185
|
+
if (options.agentProfiles && Object.keys(options.agentProfiles).length > 0) {
|
|
186
|
+
// Bare (unquoted) JSON. Surrounding single-quotes break Docker Compose's
|
|
187
|
+
// env_file parser — same gotcha as the env_file no-interpolation issue.
|
|
188
|
+
// Both Compose and Node 22 process.loadEnvFile read everything after `=`
|
|
189
|
+
// to EOL and JSON has no whitespace / `=` outside string literals.
|
|
190
|
+
push(`URATEAM_AGENT_PROFILES=${JSON.stringify(options.agentProfiles)}`);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
push('# URATEAM_AGENT_PROFILES={"test":{"maxTurns":50,"maxInputTokens":80000}}');
|
|
194
|
+
}
|
|
195
|
+
blank();
|
|
196
|
+
push("# === Optional ===");
|
|
197
|
+
push("# LOG_LEVEL=info");
|
|
198
|
+
push("");
|
|
199
|
+
push("# Additional tunables (worktree TTL, repo clone dir, agent run dir, etc.)");
|
|
200
|
+
push("# documented in .env.example next to this file. Keep that file as the");
|
|
201
|
+
push("# canonical reference; this .env is generated from prompts.");
|
|
202
|
+
return lines.join("\n") + "\n";
|
|
203
|
+
}
|
|
204
|
+
function resolveSecrets(options) {
|
|
205
|
+
const autoGen = options.autoGenSecrets ?? true;
|
|
206
|
+
const generated = {};
|
|
207
|
+
let dashboardPassword = options.dashboardPassword ?? "";
|
|
208
|
+
let postgresPassword = options.postgresPassword ?? "";
|
|
209
|
+
let githubWebhookSecret = options.githubWebhookSecret ?? "";
|
|
210
|
+
if (autoGen) {
|
|
211
|
+
// base64url avoids `+`, `/`, `=` which can trip strict env-file parsers
|
|
212
|
+
// and a few HMAC validators in the wild.
|
|
213
|
+
if (!dashboardPassword) {
|
|
214
|
+
dashboardPassword = randomBytes(18).toString("base64url");
|
|
215
|
+
generated.dashboardPassword = dashboardPassword;
|
|
216
|
+
}
|
|
217
|
+
if (!postgresPassword) {
|
|
218
|
+
postgresPassword = randomBytes(24).toString("base64url");
|
|
219
|
+
generated.postgresPassword = postgresPassword;
|
|
220
|
+
}
|
|
221
|
+
if (!githubWebhookSecret) {
|
|
222
|
+
githubWebhookSecret = randomBytes(32).toString("hex");
|
|
223
|
+
generated.githubWebhookSecret = githubWebhookSecret;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { dashboardPassword, postgresPassword, githubWebhookSecret, generated };
|
|
227
|
+
}
|
|
8
228
|
/**
|
|
9
229
|
* Scaffold a urateam sidecar into a project directory.
|
|
10
230
|
*
|
|
@@ -15,41 +235,32 @@ const __dirname = dirname(__filename);
|
|
|
15
235
|
* - .env.example
|
|
16
236
|
* - Dockerfile
|
|
17
237
|
* - docker-compose.yml
|
|
238
|
+
* - Caddyfile — reverse proxy + auto-HTTPS
|
|
18
239
|
* - README.md — how to run the sidecar
|
|
19
240
|
* - <projectDir>/CLAUDE.md — project conventions (only if absent)
|
|
20
241
|
* - <projectDir>/README.md — project readme (only if absent)
|
|
21
242
|
* - <projectDir>/.gitignore — ensures .urateam/.env is ignored
|
|
22
243
|
*
|
|
23
|
-
* The project root `package.json` is NOT touched
|
|
24
|
-
*
|
|
25
|
-
* preserved (not overwritten).
|
|
244
|
+
* The project root `package.json` is NOT touched. Existing `.env` and
|
|
245
|
+
* `package.json` inside `.urateam/` are preserved on re-run.
|
|
26
246
|
*/
|
|
27
247
|
export function scaffold(options) {
|
|
28
248
|
const { projectDir, projectName, linearApiKey, linearTeamId, repoUrl, defaultBranch } = options;
|
|
29
249
|
mkdirSync(projectDir, { recursive: true });
|
|
30
|
-
// Locate template directory (supports running from dist/ or src/ during tests)
|
|
31
250
|
let templateDir = join(__dirname, "..", "template");
|
|
32
251
|
if (!statSync(templateDir, { throwIfNoEntry: false })?.isDirectory()) {
|
|
33
252
|
templateDir = join(__dirname, "..", "..", "template");
|
|
34
253
|
}
|
|
35
|
-
// --- Sidecar files: refresh template files but preserve .env and package.json ---
|
|
36
|
-
// Template files (Dockerfile, docker-compose.yml, .env.example, README.md)
|
|
37
|
-
// are always overwritten with the latest version from the package.
|
|
38
|
-
// User-editable files (.env, package.json) are preserved if they exist
|
|
39
|
-
// so re-running create-urateam is safe and non-destructive to credentials.
|
|
40
254
|
const urateamDir = join(projectDir, ".urateam");
|
|
41
255
|
mkdirSync(urateamDir, { recursive: true });
|
|
42
|
-
// Copy template files from template/.urateam/, skipping .env (generated below)
|
|
43
256
|
const urateamTemplateDir = join(templateDir, ".urateam");
|
|
44
257
|
for (const entry of readdirSync(urateamTemplateDir)) {
|
|
45
|
-
// .env is never in the template — it's generated below — but guard anyway
|
|
46
258
|
if (entry === ".env")
|
|
47
259
|
continue;
|
|
48
260
|
const src = join(urateamTemplateDir, entry);
|
|
49
261
|
const dest = join(urateamDir, entry);
|
|
50
262
|
cpSync(src, dest, { recursive: true, force: true });
|
|
51
263
|
}
|
|
52
|
-
// Write .urateam/package.json only if absent (user may have customized deps)
|
|
53
264
|
const pkgPath = join(urateamDir, "package.json");
|
|
54
265
|
if (!existsSync(pkgPath)) {
|
|
55
266
|
const pkg = {
|
|
@@ -66,27 +277,80 @@ export function scaffold(options) {
|
|
|
66
277
|
};
|
|
67
278
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
68
279
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
//
|
|
280
|
+
const license = decodeLicense(options.licenseKey);
|
|
281
|
+
const todos = [];
|
|
282
|
+
const { dashboardPassword, postgresPassword, githubWebhookSecret, generated } = resolveSecrets(options);
|
|
283
|
+
// --- Compose .env from inputs + resolved secrets ---
|
|
73
284
|
const envPath = join(urateamDir, ".env");
|
|
74
285
|
if (!existsSync(envPath)) {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
286
|
+
const linearWebhookSecret = options.linearWebhookSecret ?? "";
|
|
287
|
+
if (!linearWebhookSecret) {
|
|
288
|
+
todos.push("LINEAR_WEBHOOK_SECRET — paste from Linear's webhook config UI " +
|
|
289
|
+
"(Workspace settings → API → Webhooks).");
|
|
290
|
+
}
|
|
291
|
+
if (!options.licenseKey) {
|
|
292
|
+
todos.push("URATEAM_LICENSE_KEY — Pro features (PM agent, Slack interface, multi-repo, " +
|
|
293
|
+
"deep-review, etc.) stay disabled until you set this.");
|
|
294
|
+
}
|
|
295
|
+
if (license?.tier &&
|
|
296
|
+
license.tier !== "oss" &&
|
|
297
|
+
license.features.includes("slack-interface") &&
|
|
298
|
+
!options.pmAgent) {
|
|
299
|
+
todos.push("PM_AGENT_* — your license includes `slack-interface`. Fill in the " +
|
|
300
|
+
"PM_AGENT_* + SLACK_* lines in .env to enable it.");
|
|
301
|
+
}
|
|
302
|
+
if (!options.anthropicApiKey) {
|
|
303
|
+
todos.push("Anthropic auth — run `docker compose exec agent claude login` after the stack is up " +
|
|
304
|
+
"(or set ANTHROPIC_API_KEY in .env for headless API auth).");
|
|
305
|
+
}
|
|
306
|
+
todos.push("GitHub auth — run `docker compose exec agent gh auth login` after the stack is up " +
|
|
307
|
+
"(or set the GITHUB_APP_* trio in .env for app-based auth).");
|
|
308
|
+
if (options.deployMode === "production" && !options.domain) {
|
|
309
|
+
todos.push("DOMAIN — set in .env before running `docker compose up`.");
|
|
310
|
+
}
|
|
311
|
+
if (options.deployMode === "production" && !options.caddyEmail) {
|
|
312
|
+
todos.push("CADDY_EMAIL — recommended for Let's Encrypt expiry warnings.");
|
|
313
|
+
}
|
|
314
|
+
if (!options.autoGenSecrets) {
|
|
315
|
+
if (!options.dashboardPassword)
|
|
316
|
+
todos.push("DASHBOARD_PASSWORD — fill in .env.");
|
|
317
|
+
if (!options.postgresPassword)
|
|
318
|
+
todos.push("POSTGRES_PASSWORD — fill in .env.");
|
|
319
|
+
if (!options.githubWebhookSecret)
|
|
320
|
+
todos.push("GITHUB_WEBHOOK_SECRET — fill in .env.");
|
|
321
|
+
}
|
|
322
|
+
// GITHUB_FEEDBACK_* are gated by GITHUB_WEBHOOK_SECRET at runtime — surface
|
|
323
|
+
// a TODO if feedback config is set but the secret is empty.
|
|
324
|
+
if (options.githubFeedback && !githubWebhookSecret) {
|
|
325
|
+
todos.push("GITHUB_WEBHOOK_SECRET — required for GITHUB_FEEDBACK_* to take effect; " +
|
|
326
|
+
"feedback values without the secret are silently ignored at runtime.");
|
|
327
|
+
}
|
|
328
|
+
const envContent = buildEnv({
|
|
329
|
+
linearApiKey,
|
|
330
|
+
linearTeamId,
|
|
331
|
+
repoUrl,
|
|
332
|
+
defaultBranch,
|
|
333
|
+
deployMode: options.deployMode ?? "local",
|
|
334
|
+
linearWebhookSecret,
|
|
335
|
+
domain: options.domain ?? "",
|
|
336
|
+
caddyEmail: options.caddyEmail ?? "",
|
|
337
|
+
anthropicApiKey: options.anthropicApiKey ?? "",
|
|
338
|
+
licenseKey: options.licenseKey ?? "",
|
|
339
|
+
dashboardUser: options.dashboardUser ?? "admin",
|
|
340
|
+
dashboardPassword,
|
|
341
|
+
dashboardBasePath: options.dashboardBasePath ?? "",
|
|
342
|
+
postgresPassword,
|
|
343
|
+
githubWebhookSecret,
|
|
344
|
+
maxConcurrentRuns: options.maxConcurrentRuns ?? 3,
|
|
345
|
+
pmAgent: options.pmAgent,
|
|
346
|
+
githubFeedback: options.githubFeedback,
|
|
347
|
+
slackWebhookUrl: options.slackWebhookUrl ?? "",
|
|
348
|
+
discordWebhookUrl: options.discordWebhookUrl ?? "",
|
|
349
|
+
agentProfiles: options.agentProfiles,
|
|
350
|
+
});
|
|
86
351
|
writeFileSync(envPath, envContent);
|
|
87
352
|
}
|
|
88
|
-
// --- Project root files: copy only if absent
|
|
89
|
-
// Files with {{PROJECT_NAME}} placeholder get the substitution applied.
|
|
353
|
+
// --- Project root files: copy only if absent ---
|
|
90
354
|
const rootFilesWithPlaceholder = ["CLAUDE.md", "README.md"];
|
|
91
355
|
for (const file of rootFilesWithPlaceholder) {
|
|
92
356
|
const dest = join(projectDir, file);
|
|
@@ -98,18 +362,12 @@ export function scaffold(options) {
|
|
|
98
362
|
const content = readFileSync(src, "utf-8");
|
|
99
363
|
writeFileSync(dest, content.replace(/\{\{PROJECT_NAME\}\}/g, projectName));
|
|
100
364
|
}
|
|
101
|
-
// Ensure .gitignore at project root has the urateam entries.
|
|
102
|
-
// Content is inlined here (not loaded from template/) because npm publish
|
|
103
|
-
// excludes files named `.gitignore` from published packages automatically,
|
|
104
|
-
// which would cause an ENOENT at runtime in installed create-urateam.
|
|
105
365
|
const gitignorePath = join(projectDir, ".gitignore");
|
|
106
366
|
if (!existsSync(gitignorePath)) {
|
|
107
367
|
writeFileSync(gitignorePath, URATEAM_GITIGNORE);
|
|
108
368
|
}
|
|
109
369
|
else {
|
|
110
370
|
const existing = readFileSync(gitignorePath, "utf-8");
|
|
111
|
-
// Check for the bare `.urateam/.env` entry as a standalone line.
|
|
112
|
-
// Loose substring match would false-positive on `!.urateam/.env.example`.
|
|
113
371
|
const hasBareEntry = existing
|
|
114
372
|
.split(/\r?\n/)
|
|
115
373
|
.some((line) => line.trim() === ".urateam/.env");
|
|
@@ -118,16 +376,24 @@ export function scaffold(options) {
|
|
|
118
376
|
appendFileSync(gitignorePath, separator + URATEAM_GITIGNORE);
|
|
119
377
|
}
|
|
120
378
|
}
|
|
379
|
+
return { urateamDir, license, generatedSecrets: generated, todos };
|
|
121
380
|
}
|
|
122
381
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* When this package is published to npm, files named `.gitignore` are
|
|
127
|
-
* automatically excluded from the tarball by npm's default rules, so the
|
|
128
|
-
* file wouldn't exist at runtime in an installed copy. Inlining avoids
|
|
129
|
-
* the packaging pitfall entirely.
|
|
382
|
+
* Normalize a user-typed dashboard base path: strip trailing slashes, ensure
|
|
383
|
+
* leading slash, treat blank as undefined. Eliminates the "I typed /ateam/
|
|
384
|
+
* and now the dashboard 404s" footgun.
|
|
130
385
|
*/
|
|
386
|
+
export function normalizeBasePath(input) {
|
|
387
|
+
if (!input)
|
|
388
|
+
return undefined;
|
|
389
|
+
const trimmed = input.trim();
|
|
390
|
+
if (!trimmed)
|
|
391
|
+
return undefined;
|
|
392
|
+
const noTrailingSlash = trimmed.replace(/\/+$/, "");
|
|
393
|
+
if (!noTrailingSlash)
|
|
394
|
+
return undefined; // operator typed only `/` or `///`
|
|
395
|
+
return noTrailingSlash.startsWith("/") ? noTrailingSlash : `/${noTrailingSlash}`;
|
|
396
|
+
}
|
|
131
397
|
const URATEAM_GITIGNORE = `# urateam sidecar
|
|
132
398
|
.urateam/.env
|
|
133
399
|
.urateam/.env.*
|
|
@@ -138,42 +404,336 @@ const URATEAM_GITIGNORE = `# urateam sidecar
|
|
|
138
404
|
`;
|
|
139
405
|
// CLI entrypoint — only runs when executed directly (not when imported for testing)
|
|
140
406
|
async function main() {
|
|
141
|
-
const arg = process.argv[2];
|
|
142
|
-
if (
|
|
143
|
-
console.
|
|
144
|
-
console.
|
|
145
|
-
console.
|
|
146
|
-
|
|
407
|
+
const arg = process.argv[2] ?? ".";
|
|
408
|
+
if (arg === "--help" || arg === "-h") {
|
|
409
|
+
console.log("Usage: create-urateam [project-name]");
|
|
410
|
+
console.log(" create-urateam # adds .urateam/ to current directory (default)");
|
|
411
|
+
console.log(" create-urateam . # same as above");
|
|
412
|
+
console.log(" create-urateam my-project # creates new directory and adds .urateam/ inside");
|
|
413
|
+
process.exit(0);
|
|
147
414
|
}
|
|
148
415
|
const prompts = (await import("prompts")).default;
|
|
149
|
-
|
|
416
|
+
// Detect existing .env early so we can short-circuit before walking the
|
|
417
|
+
// operator through 8 stages of prompts that won't be applied. The
|
|
418
|
+
// scaffolder already preserves .env on re-run; without this, the prompts
|
|
419
|
+
// are pure UX waste.
|
|
420
|
+
const projectDirEarly = arg === "." ? process.cwd() : join(process.cwd(), arg);
|
|
421
|
+
const existingEnv = join(projectDirEarly, ".urateam", ".env");
|
|
422
|
+
if (existsSync(existingEnv)) {
|
|
423
|
+
console.log(`\n Existing .env detected at ${existingEnv}.\n` +
|
|
424
|
+
" Re-running create-urateam will refresh template files (Dockerfile, docker-compose.yml,\n" +
|
|
425
|
+
" Caddyfile, etc.) but will NOT touch your .env. To re-prompt for secrets, delete .env\n" +
|
|
426
|
+
" first and re-run. Continuing with template-refresh only…\n");
|
|
427
|
+
// Use minimal stub options — scaffold() needs them but won't write .env.
|
|
428
|
+
scaffold({
|
|
429
|
+
projectDir: projectDirEarly,
|
|
430
|
+
projectName: arg === "." ? basename(projectDirEarly) || "my-project" : arg,
|
|
431
|
+
linearApiKey: "",
|
|
432
|
+
linearTeamId: "",
|
|
433
|
+
repoUrl: "",
|
|
434
|
+
defaultBranch: "main",
|
|
435
|
+
});
|
|
436
|
+
console.log(` ✓ Template files refreshed in ${projectDirEarly}/.urateam\n`);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
// --- Stage 1: Linear / repo basics ---
|
|
440
|
+
const stage1 = await prompts([
|
|
150
441
|
{ type: "text", name: "linearApiKey", message: "Linear API key:" },
|
|
151
442
|
{ type: "text", name: "linearTeamId", message: "Linear team ID:" },
|
|
152
443
|
{ type: "text", name: "repoUrl", message: "Repo URL (GitHub/GitLab):" },
|
|
153
444
|
{ type: "text", name: "defaultBranch", message: "Default branch:", initial: "main" },
|
|
154
445
|
]);
|
|
155
|
-
if (!
|
|
446
|
+
if (!stage1.linearApiKey || !stage1.repoUrl) {
|
|
156
447
|
console.error("Cancelled.");
|
|
157
448
|
process.exit(1);
|
|
158
449
|
}
|
|
450
|
+
// --- Stage 2: deploy mode + Linear webhook secret (hidden input) ---
|
|
451
|
+
const stage2 = await prompts([
|
|
452
|
+
{
|
|
453
|
+
type: "select",
|
|
454
|
+
name: "deployMode",
|
|
455
|
+
message: "Deploy target:",
|
|
456
|
+
choices: [
|
|
457
|
+
{ title: "local (laptop / dev — pnpm dev)", value: "local" },
|
|
458
|
+
{ title: "production (VPS / docker compose)", value: "production" },
|
|
459
|
+
],
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
// password type masks input so the secret doesn't land in scrollback / shell history
|
|
463
|
+
type: "password",
|
|
464
|
+
name: "linearWebhookSecret",
|
|
465
|
+
message: "LINEAR_WEBHOOK_SECRET (paste from Linear webhook config; leave blank to fill in later):",
|
|
466
|
+
},
|
|
467
|
+
]);
|
|
468
|
+
// --- Stage 3: production-only details (domain / caddy email / dashboard base path) ---
|
|
469
|
+
const stage3 = stage2.deployMode === "production"
|
|
470
|
+
? await prompts([
|
|
471
|
+
{ type: "text", name: "domain", message: "Public domain (e.g. urateam.example.com):" },
|
|
472
|
+
{ type: "text", name: "caddyEmail", message: "Email for Let's Encrypt:" },
|
|
473
|
+
{
|
|
474
|
+
type: "text",
|
|
475
|
+
name: "dashboardBasePath",
|
|
476
|
+
message: "DASHBOARD_BASE_PATH (leading slash, no trailing — leave blank if dashboard is at root):",
|
|
477
|
+
},
|
|
478
|
+
])
|
|
479
|
+
: { domain: undefined, caddyEmail: undefined, dashboardBasePath: undefined };
|
|
480
|
+
// --- Stage 4: Anthropic auth choice ---
|
|
481
|
+
const stage4 = await prompts([
|
|
482
|
+
{
|
|
483
|
+
type: "select",
|
|
484
|
+
name: "anthropicAuth",
|
|
485
|
+
message: "Anthropic auth method:",
|
|
486
|
+
choices: [
|
|
487
|
+
{ title: "Claude Code CLI (`claude login` after deploy)", value: "cli" },
|
|
488
|
+
{ title: "API key (set ANTHROPIC_API_KEY now)", value: "apiKey" },
|
|
489
|
+
],
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
type: (prev) => (prev === "apiKey" ? "password" : null),
|
|
493
|
+
name: "anthropicApiKey",
|
|
494
|
+
message: "ANTHROPIC_API_KEY:",
|
|
495
|
+
},
|
|
496
|
+
]);
|
|
497
|
+
// --- Stage 5: license + tier-gated PM agent setup ---
|
|
498
|
+
const stage5 = await prompts([
|
|
499
|
+
{
|
|
500
|
+
type: "text",
|
|
501
|
+
name: "licenseKey",
|
|
502
|
+
message: "URATEAM_LICENSE_KEY (leave blank for OSS):",
|
|
503
|
+
},
|
|
504
|
+
]);
|
|
505
|
+
const license = decodeLicense(stage5.licenseKey);
|
|
506
|
+
if (license) {
|
|
507
|
+
console.log(`\n License decoded: tier=${license.tier}, features=[${license.features.join(", ")}]`);
|
|
508
|
+
if (license.expiresAt && license.expiresAt.getTime() < Date.now()) {
|
|
509
|
+
console.warn(` ⚠ License expired at ${license.expiresAt.toISOString()} — runtime will reject ` +
|
|
510
|
+
"it and fall back to OSS tier. Renew before deploying.");
|
|
511
|
+
}
|
|
512
|
+
console.log("");
|
|
513
|
+
}
|
|
514
|
+
let pmAgent;
|
|
515
|
+
if (license &&
|
|
516
|
+
license.tier !== "oss" &&
|
|
517
|
+
license.features.includes("slack-interface")) {
|
|
518
|
+
console.log("\n Your license includes the `slack-interface` feature (PM agent + Slack /pm slash commands).\n" +
|
|
519
|
+
" You can configure it now if you have your Slack app credentials handy, or skip and add\n" +
|
|
520
|
+
" the PM_AGENT_* + SLACK_* lines to .env later. Setup walkthrough:\n" +
|
|
521
|
+
" https://github.com/JonB32/urateam/blob/main/docs/slack-setup.md\n");
|
|
522
|
+
const setupNow = await prompts({
|
|
523
|
+
type: "confirm",
|
|
524
|
+
name: "setup",
|
|
525
|
+
message: "Set up PM agent + Slack interface now?",
|
|
526
|
+
initial: false,
|
|
527
|
+
});
|
|
528
|
+
if (setupNow.setup) {
|
|
529
|
+
const pmPrompts = await prompts([
|
|
530
|
+
{ type: "password", name: "slackBotToken", message: "SLACK_BOT_TOKEN (xoxb-...):" },
|
|
531
|
+
{ type: "password", name: "slackSigningSecret", message: "SLACK_SIGNING_SECRET:" },
|
|
532
|
+
{ type: "text", name: "slackChannelId", message: "PM_AGENT_SLACK_CHANNEL_ID (Cxxxxx):" },
|
|
533
|
+
{
|
|
534
|
+
type: "text",
|
|
535
|
+
name: "teamIds",
|
|
536
|
+
message: "PM_AGENT_TEAM_IDS (comma-separated):",
|
|
537
|
+
initial: stage1.linearTeamId,
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
type: "number",
|
|
541
|
+
name: "dailyTokenBudget",
|
|
542
|
+
message: "PM_AGENT_DAILY_TOKEN_BUDGET:",
|
|
543
|
+
initial: 5_000_000,
|
|
544
|
+
},
|
|
545
|
+
]);
|
|
546
|
+
if (pmPrompts.slackBotToken && pmPrompts.slackSigningSecret && pmPrompts.slackChannelId) {
|
|
547
|
+
pmAgent = {
|
|
548
|
+
slackBotToken: pmPrompts.slackBotToken,
|
|
549
|
+
slackSigningSecret: pmPrompts.slackSigningSecret,
|
|
550
|
+
slackChannelId: pmPrompts.slackChannelId,
|
|
551
|
+
teamIds: pmPrompts.teamIds || stage1.linearTeamId,
|
|
552
|
+
dailyTokenBudget: pmPrompts.dailyTokenBudget ?? 5_000_000,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
else if (pmPrompts.slackBotToken || pmPrompts.slackSigningSecret || pmPrompts.slackChannelId) {
|
|
556
|
+
// Partial input — warn loudly so the operator doesn't think they configured PM.
|
|
557
|
+
console.warn("\n ⚠ PM agent setup incomplete — at least one of SLACK_BOT_TOKEN, " +
|
|
558
|
+
"SLACK_SIGNING_SECRET, PM_AGENT_SLACK_CHANNEL_ID was blank. The PM_AGENT_* " +
|
|
559
|
+
"block in .env has been left commented out; fill it in by hand to enable.\n");
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// --- Stage 6: notification webhooks (Slack / Discord — both optional) ---
|
|
564
|
+
const stage6 = await prompts([
|
|
565
|
+
{
|
|
566
|
+
type: "text",
|
|
567
|
+
name: "slackWebhookUrl",
|
|
568
|
+
message: "SLACK_WEBHOOK_URL (incoming-webhook for pipeline events; leave blank to skip):",
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
type: "text",
|
|
572
|
+
name: "discordWebhookUrl",
|
|
573
|
+
message: "DISCORD_WEBHOOK_URL (leave blank to skip):",
|
|
574
|
+
},
|
|
575
|
+
]);
|
|
576
|
+
// --- Stage 7: per-stage agent profile overrides (wizard) ---
|
|
577
|
+
const stage7Customize = await prompts({
|
|
578
|
+
type: "confirm",
|
|
579
|
+
name: "customize",
|
|
580
|
+
message: "Customize per-stage agent budgets (URATEAM_AGENT_PROFILES)? Most operators skip this.",
|
|
581
|
+
initial: false,
|
|
582
|
+
});
|
|
583
|
+
let agentProfiles;
|
|
584
|
+
if (stage7Customize.customize) {
|
|
585
|
+
agentProfiles = {};
|
|
586
|
+
const stages = ["implement", "test", "review"];
|
|
587
|
+
for (const stage of stages) {
|
|
588
|
+
const wantStage = await prompts({
|
|
589
|
+
type: "confirm",
|
|
590
|
+
name: "yes",
|
|
591
|
+
message: `Override budget for the \`${stage}\` stage?`,
|
|
592
|
+
initial: false,
|
|
593
|
+
});
|
|
594
|
+
if (!wantStage.yes)
|
|
595
|
+
continue;
|
|
596
|
+
// Min/max mirror the runtime ceilings in packages/core/src/executor/profiles.ts:96
|
|
597
|
+
// (MAX_TURNS_CEILING=500, MAX_INPUT_TOKENS_CEILING=500_000) so the wizard
|
|
598
|
+
// rejects out-of-range values at input time instead of letting the runtime
|
|
599
|
+
// silently drop them with a warn.
|
|
600
|
+
const profile = await prompts([
|
|
601
|
+
{
|
|
602
|
+
type: "number",
|
|
603
|
+
name: "maxTurns",
|
|
604
|
+
message: ` ${stage}.maxTurns (1–500, blank to keep default):`,
|
|
605
|
+
min: 1,
|
|
606
|
+
max: 500,
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
type: "number",
|
|
610
|
+
name: "maxInputTokens",
|
|
611
|
+
message: ` ${stage}.maxInputTokens (1–500000, blank to keep default):`,
|
|
612
|
+
min: 1,
|
|
613
|
+
max: 500_000,
|
|
614
|
+
},
|
|
615
|
+
{ type: "text", name: "model", message: ` ${stage}.model (blank to keep default):` },
|
|
616
|
+
]);
|
|
617
|
+
const entry = {};
|
|
618
|
+
if (typeof profile.maxTurns === "number")
|
|
619
|
+
entry.maxTurns = profile.maxTurns;
|
|
620
|
+
if (typeof profile.maxInputTokens === "number")
|
|
621
|
+
entry.maxInputTokens = profile.maxInputTokens;
|
|
622
|
+
if (profile.model)
|
|
623
|
+
entry.model = profile.model;
|
|
624
|
+
if (Object.keys(entry).length > 0)
|
|
625
|
+
agentProfiles[stage] = entry;
|
|
626
|
+
}
|
|
627
|
+
if (Object.keys(agentProfiles).length === 0) {
|
|
628
|
+
agentProfiles = undefined; // all stages skipped — don't write a `{}` JSON
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
// Round-trip-validate the JSON we'd write so a typo doesn't get persisted as broken
|
|
632
|
+
// syntax that the agent would crash on at runtime.
|
|
633
|
+
try {
|
|
634
|
+
JSON.parse(JSON.stringify(agentProfiles));
|
|
635
|
+
}
|
|
636
|
+
catch (e) {
|
|
637
|
+
console.error("Internal: agent profiles JSON failed to round-trip — skipping.", e);
|
|
638
|
+
agentProfiles = undefined;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
// --- Stage 8: secret generation strategy ---
|
|
643
|
+
const stage8 = await prompts({
|
|
644
|
+
type: "confirm",
|
|
645
|
+
name: "autoGen",
|
|
646
|
+
message: "Auto-generate POSTGRES_PASSWORD, DASHBOARD_PASSWORD, GITHUB_WEBHOOK_SECRET? (No → leave blank in .env)",
|
|
647
|
+
initial: true,
|
|
648
|
+
});
|
|
159
649
|
const projectDir = arg === "." ? process.cwd() : join(process.cwd(), arg);
|
|
160
650
|
const projectName = arg === "." ? basename(projectDir) || "my-project" : arg;
|
|
161
|
-
scaffold({
|
|
651
|
+
const result = scaffold({
|
|
162
652
|
projectDir,
|
|
163
653
|
projectName,
|
|
164
|
-
linearApiKey:
|
|
165
|
-
linearTeamId:
|
|
166
|
-
repoUrl:
|
|
167
|
-
defaultBranch:
|
|
654
|
+
linearApiKey: stage1.linearApiKey,
|
|
655
|
+
linearTeamId: stage1.linearTeamId,
|
|
656
|
+
repoUrl: stage1.repoUrl,
|
|
657
|
+
defaultBranch: stage1.defaultBranch || "main",
|
|
658
|
+
deployMode: stage2.deployMode,
|
|
659
|
+
linearWebhookSecret: stage2.linearWebhookSecret,
|
|
660
|
+
domain: stage3.domain,
|
|
661
|
+
caddyEmail: stage3.caddyEmail,
|
|
662
|
+
dashboardBasePath: normalizeBasePath(stage3.dashboardBasePath),
|
|
663
|
+
anthropicApiKey: stage4.anthropicApiKey,
|
|
664
|
+
licenseKey: stage5.licenseKey,
|
|
665
|
+
pmAgent,
|
|
666
|
+
slackWebhookUrl: stage6.slackWebhookUrl || undefined,
|
|
667
|
+
discordWebhookUrl: stage6.discordWebhookUrl || undefined,
|
|
668
|
+
agentProfiles,
|
|
669
|
+
autoGenSecrets: stage8.autoGen,
|
|
168
670
|
});
|
|
169
|
-
|
|
170
|
-
console.log(
|
|
671
|
+
// --- Next steps printout ---
|
|
672
|
+
console.log(`\n ✓ urateam sidecar installed in ${result.urateamDir}\n`);
|
|
673
|
+
if (Object.keys(result.generatedSecrets).length > 0) {
|
|
674
|
+
console.log(" Auto-generated secrets (saved to .env — record these now):");
|
|
675
|
+
if (result.generatedSecrets.dashboardPassword) {
|
|
676
|
+
console.log(` Dashboard login: admin / ${result.generatedSecrets.dashboardPassword}`);
|
|
677
|
+
}
|
|
678
|
+
if (result.generatedSecrets.postgresPassword) {
|
|
679
|
+
console.log(` POSTGRES_PASSWORD: ${result.generatedSecrets.postgresPassword}`);
|
|
680
|
+
}
|
|
681
|
+
if (result.generatedSecrets.githubWebhookSecret) {
|
|
682
|
+
console.log(` GITHUB_WEBHOOK_SECRET: ${result.generatedSecrets.githubWebhookSecret.slice(0, 12)}…`);
|
|
683
|
+
}
|
|
684
|
+
console.log("");
|
|
685
|
+
}
|
|
686
|
+
if (result.license) {
|
|
687
|
+
console.log(` License: ${result.license.tier}`);
|
|
688
|
+
if (result.license.features.length > 0) {
|
|
689
|
+
console.log(` Features: ${result.license.features.join(", ")}`);
|
|
690
|
+
}
|
|
691
|
+
console.log("");
|
|
692
|
+
}
|
|
693
|
+
if (result.todos.length > 0) {
|
|
694
|
+
console.log(" Still to do (edit .urateam/.env or run the noted commands):");
|
|
695
|
+
for (const todo of result.todos) {
|
|
696
|
+
console.log(` • ${todo}`);
|
|
697
|
+
}
|
|
698
|
+
console.log("");
|
|
699
|
+
}
|
|
700
|
+
// Detail any .env updates the operator must do before bringing the stack up.
|
|
701
|
+
if (result.todos.length > 0) {
|
|
702
|
+
console.log(" Before starting the stack, edit .urateam/.env to fill in:");
|
|
703
|
+
for (const todo of result.todos) {
|
|
704
|
+
console.log(` • ${todo}`);
|
|
705
|
+
}
|
|
706
|
+
console.log("");
|
|
707
|
+
}
|
|
708
|
+
console.log(" Next:");
|
|
171
709
|
if (arg !== ".")
|
|
172
710
|
console.log(` cd ${arg}`);
|
|
173
|
-
console.log(
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
711
|
+
console.log(" cd .urateam");
|
|
712
|
+
if (result.todos.length > 0) {
|
|
713
|
+
console.log(" # ↑ open .env in your editor and fill in the TODOs above first");
|
|
714
|
+
}
|
|
715
|
+
if (stage2.deployMode === "production") {
|
|
716
|
+
console.log(" docker compose up -d --build");
|
|
717
|
+
if (stage4.anthropicAuth === "cli") {
|
|
718
|
+
console.log(" docker compose exec agent claude login # device-flow auth");
|
|
719
|
+
}
|
|
720
|
+
console.log(" docker compose exec agent gh auth login # device-flow auth");
|
|
721
|
+
console.log("");
|
|
722
|
+
console.log(" After the stack is up:");
|
|
723
|
+
console.log(` 1. Add a webhook in Linear → Settings → API → Webhooks`);
|
|
724
|
+
console.log(` URL: https://${stage3.domain || "<your-domain>"}/webhooks/linear`);
|
|
725
|
+
console.log(` Subscribe to: Issue state changes`);
|
|
726
|
+
console.log(` Copy the secret → paste into .env as LINEAR_WEBHOOK_SECRET → restart the stack`);
|
|
727
|
+
console.log(` 2. Open the dashboard at https://${stage3.domain || "<your-domain>"} (admin / your-generated-password)`);
|
|
728
|
+
console.log(` 3. Move a Linear issue to Todo with a pipeline label to trigger your first run`);
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
console.log(" pnpm install");
|
|
732
|
+
console.log(" ura dev");
|
|
733
|
+
}
|
|
734
|
+
console.log("\n Slack / PM agent setup walkthrough:");
|
|
735
|
+
console.log(" https://github.com/JonB32/urateam/blob/main/docs/slack-setup.md");
|
|
736
|
+
console.log("\n See CLAUDE.md in the project root for agent context.\n");
|
|
177
737
|
}
|
|
178
738
|
const isEntrypoint = process.argv[1]?.endsWith("create-urateam") ||
|
|
179
739
|
process.argv[1]?.endsWith("index.js");
|