hub-launch 1.21.0 → 1.23.0
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/CHANGELOG.md +29 -0
- package/README.md +59 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +66 -23
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/launch.d.ts +104 -1
- package/dist/commands/launch.d.ts.map +1 -1
- package/dist/commands/launch.js +933 -181
- package/dist/commands/launch.js.map +1 -1
- package/dist/commands/schedule.d.ts.map +1 -1
- package/dist/commands/schedule.js +8 -7
- package/dist/commands/schedule.js.map +1 -1
- package/dist/commands/upload.d.ts +9 -2
- package/dist/commands/upload.d.ts.map +1 -1
- package/dist/commands/upload.js +16 -6
- package/dist/commands/upload.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +4 -1
- package/dist/config/index.js.map +1 -1
- package/dist/scripts/launch-run.d.ts +6 -0
- package/dist/scripts/launch-run.d.ts.map +1 -1
- package/dist/scripts/launch-run.js +70 -9
- package/dist/scripts/launch-run.js.map +1 -1
- package/dist/services/api/HulaApiClient.d.ts +53 -0
- package/dist/services/api/HulaApiClient.d.ts.map +1 -1
- package/dist/services/api/HulaApiClient.js +18 -0
- package/dist/services/api/HulaApiClient.js.map +1 -1
- package/dist/services/git/FilePublishService.d.ts +9 -1
- package/dist/services/git/FilePublishService.d.ts.map +1 -1
- package/dist/services/git/FilePublishService.js +13 -3
- package/dist/services/git/FilePublishService.js.map +1 -1
- package/dist/services/git/GitService.d.ts +19 -0
- package/dist/services/git/GitService.d.ts.map +1 -1
- package/dist/services/git/GitService.js +56 -31
- package/dist/services/git/GitService.js.map +1 -1
- package/dist/services/git/WorktreeService.d.ts +9 -0
- package/dist/services/git/WorktreeService.d.ts.map +1 -1
- package/dist/services/git/WorktreeService.js +6 -3
- package/dist/services/git/WorktreeService.js.map +1 -1
- package/dist/services/group/GroupLaunchService.d.ts +230 -0
- package/dist/services/group/GroupLaunchService.d.ts.map +1 -0
- package/dist/services/group/GroupLaunchService.js +282 -0
- package/dist/services/group/GroupLaunchService.js.map +1 -0
- package/dist/templates/skills/hula-help/SKILL.md +1 -1
- package/dist/templates/skills/hula-launch/SKILL.md +38 -1
- package/dist/templates/skills/hula-schedule/SKILL.md +3 -3
- package/dist/types/config.schema.d.ts +34 -3
- package/dist/types/config.schema.d.ts.map +1 -1
- package/dist/types/config.schema.js +35 -1
- package/dist/types/config.schema.js.map +1 -1
- package/dist/utils/config-file-edit.d.ts +8 -4
- package/dist/utils/config-file-edit.d.ts.map +1 -1
- package/dist/utils/config-file-edit.js +13 -9
- package/dist/utils/config-file-edit.js.map +1 -1
- package/dist/utils/config-parser.d.ts.map +1 -1
- package/dist/utils/config-parser.js +4 -0
- package/dist/utils/config-parser.js.map +1 -1
- package/dist/utils/env-vars.d.ts.map +1 -1
- package/dist/utils/env-vars.js +4 -0
- package/dist/utils/env-vars.js.map +1 -1
- package/dist/utils/github-cli.d.ts +1 -1
- package/dist/utils/github-cli.d.ts.map +1 -1
- package/dist/utils/github-cli.js +15 -7
- package/dist/utils/github-cli.js.map +1 -1
- package/dist/utils/project-resolver.d.ts +1 -1
- package/dist/utils/project-resolver.d.ts.map +1 -1
- package/dist/utils/project-resolver.js +2 -2
- package/dist/utils/project-resolver.js.map +1 -1
- package/dist/utils/provider-credentials.d.ts +54 -0
- package/dist/utils/provider-credentials.d.ts.map +1 -0
- package/dist/utils/provider-credentials.js +83 -0
- package/dist/utils/provider-credentials.js.map +1 -0
- package/package.json +2 -2
- package/dist/utils/ephemeral-credentials.d.ts +0 -32
- package/dist/utils/ephemeral-credentials.d.ts.map +0 -1
- package/dist/utils/ephemeral-credentials.js +0 -26
- package/dist/utils/ephemeral-credentials.js.map +0 -1
package/dist/commands/launch.js
CHANGED
|
@@ -4,12 +4,14 @@ import chalk from 'chalk';
|
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
7
|
-
import { join } from 'path';
|
|
7
|
+
import { join, isAbsolute } from 'path';
|
|
8
8
|
import { parse } from 'dotenv';
|
|
9
9
|
import { logger } from '../utils/logger.js';
|
|
10
10
|
import { GitService } from '../services/git/GitService.js';
|
|
11
11
|
import { PlanService } from '../services/plan/PlanService.js';
|
|
12
|
-
import {
|
|
12
|
+
import { ConfigLoader } from '../config/index.js';
|
|
13
|
+
import { HulaApiClient, ApiError, } from '../services/api/HulaApiClient.js';
|
|
14
|
+
import { GroupLaunchService, validateGroupId, } from '../services/group/GroupLaunchService.js';
|
|
13
15
|
import { TempFileService } from '../services/logs/TempFileService.js';
|
|
14
16
|
import { EditorService } from '../services/editor/EditorService.js';
|
|
15
17
|
import { executeUpload } from './upload.js';
|
|
@@ -17,11 +19,11 @@ import { loadGitHubToken } from './login.js';
|
|
|
17
19
|
import { resolveProject } from '../utils/project-resolver.js';
|
|
18
20
|
import { HULA_PROJECT_URL } from '../config/constants.js';
|
|
19
21
|
import { updateUsageTierInConfig } from '../utils/config-writer.js';
|
|
20
|
-
import {
|
|
22
|
+
import { resolveProviderCredentials, validateTokenFormat, } from '../utils/provider-credentials.js';
|
|
21
23
|
import { readClientSessionId } from '../utils/client-session.js';
|
|
22
24
|
import { input, UserCancelledError } from '../utils/prompts.js';
|
|
23
25
|
import { findGitRoot } from '../utils/git-utils.js';
|
|
24
|
-
import {
|
|
26
|
+
import { upsertProviderApiKey } from '../utils/config-file-edit.js';
|
|
25
27
|
import { validateNoReservedVars, readAndFilterEnvVars, RESERVED_ENV_VARS, } from '../utils/env-vars.js';
|
|
26
28
|
const DEFAULT_CONTAINER_RESOURCES = { cpu: 2, memory: 4, disk: 10 };
|
|
27
29
|
/**
|
|
@@ -31,6 +33,20 @@ const DEFAULT_CONTAINER_RESOURCES = { cpu: 2, memory: 4, disk: 10 };
|
|
|
31
33
|
* Usage: hula launch --show <name>
|
|
32
34
|
* Usage: hula launch --logs <name>
|
|
33
35
|
*/
|
|
36
|
+
/**
|
|
37
|
+
* Commander accumulator for the repeatable `--plan <repo>=<path>` option.
|
|
38
|
+
* Each occurrence is parsed as `repoName=relativePath` and merged into the
|
|
39
|
+
* override map. A missing `=` throws a clear error.
|
|
40
|
+
*/
|
|
41
|
+
export function collectPlanOverrides(value, previous) {
|
|
42
|
+
const eq = value.indexOf('=');
|
|
43
|
+
if (eq <= 0) {
|
|
44
|
+
throw new Error(`Invalid --plan override "${value}". Expected <repoName>=<path>.`);
|
|
45
|
+
}
|
|
46
|
+
const repo = value.slice(0, eq);
|
|
47
|
+
const planPath = value.slice(eq + 1);
|
|
48
|
+
return { ...previous, [repo]: planPath };
|
|
49
|
+
}
|
|
34
50
|
export function launchCommand(program, config) {
|
|
35
51
|
program
|
|
36
52
|
.command('launch [issueName] [planPath]')
|
|
@@ -53,7 +69,8 @@ export function launchCommand(program, config) {
|
|
|
53
69
|
// 'Run in test mode (server uses a mock Claude — fast E2E run, still creates a real PR)',
|
|
54
70
|
// )
|
|
55
71
|
.option('--handoff <username>', 'Assign the created issue to this GitHub user')
|
|
56
|
-
.option('--
|
|
72
|
+
.option('--provider <type>', 'LLM provider: claude | openai | openrouter (default: config or claude)')
|
|
73
|
+
.option('--provider-key <key>', 'Provider credential (ephemeral; never stored server-side beyond the run)')
|
|
57
74
|
.option('--container-cpu <n>', 'vCPU cores for the sandbox (1–32)', parseInt)
|
|
58
75
|
.option('--container-memory <n>', 'GiB RAM for the sandbox (1–128)', parseInt)
|
|
59
76
|
.option('--container-disk <n>', 'GiB disk for the sandbox (5–200)', parseInt)
|
|
@@ -64,8 +81,22 @@ export function launchCommand(program, config) {
|
|
|
64
81
|
.option('--lines <n>', 'Number of log lines to show (default: 100)', parseInt)
|
|
65
82
|
.option('--type <type>', 'Log type to fetch: log or output (default: log)')
|
|
66
83
|
.option('--editor', 'Open logs in editor instead of printing to stdout')
|
|
84
|
+
// ── Multi-repo feature groups (client-side fan-out) ────────────────────
|
|
85
|
+
// NOTE: this `--folder` (a PARENT directory that contains sibling repos) is
|
|
86
|
+
// DIFFERENT from /hula-plan's `--folder` (a plans SUBDIRECTORY). See README.
|
|
87
|
+
.option('--folder <path>', 'Launch the plan in every initialized repo under this folder as one feature group')
|
|
88
|
+
.option('--group-id <id>', 'Use this group id instead of generating one (join/retry an existing group)')
|
|
89
|
+
.option('--plan <repo>=<path>', 'Override the auto-resolved plan for one repo in --folder mode (repeatable)', collectPlanOverrides, {})
|
|
90
|
+
.option('--show-group [id]', 'Show combined status for a feature group (id optional inside a member repo)')
|
|
91
|
+
.option('--yes', 'Skip the group-launch confirmation and allow plan auto-resolve without a TTY (used by the /hula-launch skill)')
|
|
67
92
|
.action(async (issueName, planPath, options) => {
|
|
68
93
|
try {
|
|
94
|
+
// Handle --show-group flag (read-only group status). Routed before
|
|
95
|
+
// the other read flags and before any folder/launch work.
|
|
96
|
+
if (options.showGroup !== undefined) {
|
|
97
|
+
await showGroupStatus(config, options.showGroup, options);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
69
100
|
// Handle --show flag
|
|
70
101
|
if (options.show) {
|
|
71
102
|
await showJobStatus(config, options.show, options);
|
|
@@ -83,6 +114,21 @@ export function launchCommand(program, config) {
|
|
|
83
114
|
logger.error(killFlagError);
|
|
84
115
|
process.exit(1);
|
|
85
116
|
}
|
|
117
|
+
// Validate the multi-repo `--folder` / `--group-id` / `--plan` flag
|
|
118
|
+
// combination before any work.
|
|
119
|
+
const folderFlagError = validateFolderFlags(options, Boolean(planPath));
|
|
120
|
+
if (folderFlagError) {
|
|
121
|
+
logger.error(folderFlagError);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
// Validate an explicitly-supplied group id against the server format.
|
|
125
|
+
if (typeof options.groupId === 'string') {
|
|
126
|
+
const groupIdError = validateGroupId(options.groupId);
|
|
127
|
+
if (groupIdError) {
|
|
128
|
+
logger.error(groupIdError);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
86
132
|
// Validate per-step config + --skip-regression against the two
|
|
87
133
|
// server-parity cross-field rules before any credential resolution or
|
|
88
134
|
// network call. On conflict, fail fast with the server's exact wording.
|
|
@@ -91,6 +137,21 @@ export function launchCommand(program, config) {
|
|
|
91
137
|
logger.error(stepsValidation.error);
|
|
92
138
|
process.exit(1);
|
|
93
139
|
}
|
|
140
|
+
// Multi-repo fan-out: --folder launches (or kills) one ordinary
|
|
141
|
+
// single-repo job per discovered repo, correlated by a shared groupId.
|
|
142
|
+
if (options.folder) {
|
|
143
|
+
if (!issueName) {
|
|
144
|
+
logger.error('Usage: hula launch <issueName> --folder <path>');
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
if (options.kill) {
|
|
148
|
+
await executeGroupKill(config, issueName, options);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
await executeGroupLaunch(config, issueName, options);
|
|
152
|
+
}
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
94
155
|
// --kill is a cancel-only path: it needs ONLY the tracking name (no
|
|
95
156
|
// plan path) and routes to the dedicated executeKill function.
|
|
96
157
|
if (options.kill) {
|
|
@@ -158,6 +219,43 @@ export function validateKillFlags(options) {
|
|
|
158
219
|
export function requiresPlanPath(options) {
|
|
159
220
|
return !options.kill;
|
|
160
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Validate the multi-repo `--folder` / `--group-id` / `--plan` flag matrix.
|
|
224
|
+
*
|
|
225
|
+
* - `--folder` resolves plans per repository, so it is incompatible with a
|
|
226
|
+
* positional `<planPath>` and with the read/resume flags `--show`, `--logs`,
|
|
227
|
+
* `--resume`, `--fix`.
|
|
228
|
+
* - `--plan` (per-repo override) only makes sense with `--folder`.
|
|
229
|
+
* - `--group-id` alone (no `--folder`) is allowed — it attaches a single-repo
|
|
230
|
+
* launch to an existing group.
|
|
231
|
+
* - `--kill --folder` is allowed (cancel-only fan-out).
|
|
232
|
+
*
|
|
233
|
+
* Returns an error message when the combination is invalid, or `null` when OK.
|
|
234
|
+
*/
|
|
235
|
+
export function validateFolderFlags(options, hasPlanPathPositional) {
|
|
236
|
+
if (options.folder) {
|
|
237
|
+
if (hasPlanPathPositional) {
|
|
238
|
+
return '--folder resolves plans per repository; use --plan <repo>=<path> to override a specific repo';
|
|
239
|
+
}
|
|
240
|
+
const incompatible = [
|
|
241
|
+
{ key: 'show', flag: '--show' },
|
|
242
|
+
{ key: 'logs', flag: '--logs' },
|
|
243
|
+
{ key: 'resume', flag: '--resume' },
|
|
244
|
+
{ key: 'fix', flag: '--fix' },
|
|
245
|
+
];
|
|
246
|
+
for (const { key, flag } of incompatible) {
|
|
247
|
+
const value = options[key];
|
|
248
|
+
if (value !== undefined && value !== false) {
|
|
249
|
+
return `--folder cannot be combined with ${flag}`;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const hasPlanOverrides = options.plan !== undefined && Object.keys(options.plan).length > 0;
|
|
254
|
+
if (hasPlanOverrides && !options.folder) {
|
|
255
|
+
return '--plan requires --folder (it overrides a per-repo plan in folder mode)';
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
161
259
|
/**
|
|
162
260
|
* Derive the request `conflictMode` from the CLI flags (plus an optional explicit
|
|
163
261
|
* override used when re-invoking after an interactive 409 resolution).
|
|
@@ -221,16 +319,56 @@ export function resolveSteps(configSteps, skipRegressionFlag, legacyRegressionFl
|
|
|
221
319
|
}
|
|
222
320
|
return { steps: Object.keys(steps).length > 0 ? steps : undefined };
|
|
223
321
|
}
|
|
224
|
-
|
|
322
|
+
/**
|
|
323
|
+
* Per-provider live-validation endpoint + copy. Claude accepts both subscription
|
|
324
|
+
* OAuth tokens (Bearer) and API keys (x-api-key); openai/openrouter use Bearer.
|
|
325
|
+
* See the endpoint table in the multi-provider plan (Requirements §4).
|
|
326
|
+
*/
|
|
327
|
+
function buildProviderValidation(providerType, authToken) {
|
|
328
|
+
switch (providerType) {
|
|
329
|
+
case 'claude': {
|
|
330
|
+
const isApiKey = authToken.startsWith('sk-ant-api');
|
|
331
|
+
return {
|
|
332
|
+
url: 'https://api.anthropic.com/v1/models',
|
|
333
|
+
headers: isApiKey
|
|
334
|
+
? { 'x-api-key': authToken, 'anthropic-version': '2023-06-01' }
|
|
335
|
+
: {
|
|
336
|
+
Authorization: `Bearer ${authToken}`,
|
|
337
|
+
'anthropic-version': '2023-06-01',
|
|
338
|
+
},
|
|
339
|
+
readyLabel: 'Claude Code is ready to go 🤖',
|
|
340
|
+
providerName: 'Anthropic',
|
|
341
|
+
invalidHint: 'Generate a new credential — a subscription OAuth token at https://claude.ai/settings (Pro/Max) or an API key at https://platform.claude.com.',
|
|
342
|
+
quotaHint: 'Your Anthropic account is out of quota — check your plan and usage.',
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
case 'openai':
|
|
346
|
+
return {
|
|
347
|
+
url: 'https://api.openai.com/v1/models',
|
|
348
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
349
|
+
readyLabel: 'Codex (OpenAI) is ready to go 🤖',
|
|
350
|
+
providerName: 'OpenAI',
|
|
351
|
+
invalidHint: 'Create a new API key at https://platform.openai.com/api-keys.',
|
|
352
|
+
quotaHint: 'Your OpenAI account is out of quota — check billing at https://platform.openai.com/account/billing.',
|
|
353
|
+
};
|
|
354
|
+
case 'openrouter':
|
|
355
|
+
return {
|
|
356
|
+
url: 'https://openrouter.ai/api/v1/key',
|
|
357
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
358
|
+
readyLabel: 'Codex (OpenRouter) is ready to go 🤖',
|
|
359
|
+
providerName: 'OpenRouter',
|
|
360
|
+
invalidHint: 'Create a new key at https://openrouter.ai/settings/keys.',
|
|
361
|
+
quotaHint: 'Your OpenRouter account is out of credits — top up at https://openrouter.ai/settings/credits.',
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
async function validateProviderCredentials(providerType, authToken, githubToken) {
|
|
225
366
|
console.log('Checking your credentials before launch...\n');
|
|
226
|
-
// ---
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const anthropicPromise = axios
|
|
232
|
-
.get('https://api.anthropic.com/v1/models', {
|
|
233
|
-
headers: anthropicAuthHeaders,
|
|
367
|
+
// --- LLM provider ---
|
|
368
|
+
const providerCfg = buildProviderValidation(providerType, authToken);
|
|
369
|
+
const providerPromise = axios
|
|
370
|
+
.get(providerCfg.url, {
|
|
371
|
+
headers: providerCfg.headers,
|
|
234
372
|
timeout: 8000,
|
|
235
373
|
})
|
|
236
374
|
.then(() => ({ ok: true }))
|
|
@@ -238,20 +376,37 @@ async function validateLaunchCredentials(anthropicApiKey, githubToken) {
|
|
|
238
376
|
if (axios.isAxiosError(err) &&
|
|
239
377
|
err.response &&
|
|
240
378
|
err.response.status < 500) {
|
|
241
|
-
|
|
379
|
+
const status = err.response.status;
|
|
380
|
+
if (status === 429) {
|
|
381
|
+
return {
|
|
382
|
+
ok: false,
|
|
383
|
+
hard: true,
|
|
384
|
+
message: `${providerCfg.providerName} rejected the request — rate limit or quota exceeded.`,
|
|
385
|
+
hint: providerCfg.quotaHint,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
if (status === 403) {
|
|
389
|
+
return {
|
|
390
|
+
ok: false,
|
|
391
|
+
hard: true,
|
|
392
|
+
message: `${providerCfg.providerName} credential is forbidden (account suspended or payment required).`,
|
|
393
|
+
hint: providerCfg.invalidHint,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
// 401 and any other 4xx → credential is rejected (invalid or expired)
|
|
242
397
|
return {
|
|
243
398
|
ok: false,
|
|
244
399
|
hard: true,
|
|
245
|
-
message:
|
|
246
|
-
hint:
|
|
400
|
+
message: `${providerCfg.providerName} credential is invalid or expired.`,
|
|
401
|
+
hint: providerCfg.invalidHint,
|
|
247
402
|
};
|
|
248
403
|
}
|
|
249
404
|
// Network error / timeout / 5xx — non-blocking warning
|
|
250
405
|
return {
|
|
251
406
|
ok: false,
|
|
252
407
|
hard: false,
|
|
253
|
-
message:
|
|
254
|
-
hint: 'Proceeding anyway — the server will validate the
|
|
408
|
+
message: `Could not reach the ${providerCfg.providerName} API to verify your credential (network issue).`,
|
|
409
|
+
hint: 'Proceeding anyway — the server will validate the credential on startup.',
|
|
255
410
|
};
|
|
256
411
|
});
|
|
257
412
|
// --- GitHub ---
|
|
@@ -285,22 +440,22 @@ async function validateLaunchCredentials(anthropicApiKey, githubToken) {
|
|
|
285
440
|
message: 'No GitHub token found.',
|
|
286
441
|
hint: "Run 'hula login' to authenticate with GitHub first.",
|
|
287
442
|
});
|
|
288
|
-
const [
|
|
289
|
-
|
|
443
|
+
const [providerResult, githubResult] = await Promise.allSettled([
|
|
444
|
+
providerPromise,
|
|
290
445
|
githubPromise,
|
|
291
446
|
]);
|
|
292
447
|
let hasHardFailure = false;
|
|
293
|
-
// Print
|
|
294
|
-
const result =
|
|
295
|
-
?
|
|
448
|
+
// Print LLM provider result
|
|
449
|
+
const result = providerResult.status === 'fulfilled'
|
|
450
|
+
? providerResult.value
|
|
296
451
|
: {
|
|
297
452
|
ok: false,
|
|
298
453
|
hard: false,
|
|
299
|
-
message:
|
|
454
|
+
message: `Unexpected error during ${providerCfg.providerName} validation.`,
|
|
300
455
|
hint: 'Proceeding anyway.',
|
|
301
456
|
};
|
|
302
457
|
if (result.ok) {
|
|
303
|
-
console.log(` ${chalk.green('✓')}
|
|
458
|
+
console.log(` ${chalk.green('✓')} ${providerCfg.providerName} credential is valid — ${providerCfg.readyLabel}`);
|
|
304
459
|
}
|
|
305
460
|
else if (result.hard) {
|
|
306
461
|
console.error(` ${chalk.red('✗')} ${result.message}`);
|
|
@@ -344,7 +499,7 @@ function printFreeTierError() {
|
|
|
344
499
|
logger.info(' • Run /hula-create, which will assign the issue to Copilot on GitHub.');
|
|
345
500
|
logger.info(' • Go to https://www.hublaunch.site and upgrade your subscription to Pro.');
|
|
346
501
|
logger.blank();
|
|
347
|
-
logger.info('The Pro tier allows launching as many plans as you like, subject to the constraints of your own
|
|
502
|
+
logger.info('The Pro tier allows launching as many plans as you like, subject to the constraints of your own LLM provider (Claude, OpenAI, or OpenRouter) subscription.');
|
|
348
503
|
}
|
|
349
504
|
/**
|
|
350
505
|
* Checks whether the user is on the Pro tier.
|
|
@@ -352,7 +507,11 @@ function printFreeTierError() {
|
|
|
352
507
|
* If the config records 'free', calls the API and updates the config on success.
|
|
353
508
|
* Returns false (and prints the error) when the user is not on Pro.
|
|
354
509
|
*/
|
|
355
|
-
async function verifyProTier(config, serverUrl, apiKey
|
|
510
|
+
async function verifyProTier(config, serverUrl, apiKey,
|
|
511
|
+
// Repo root whose config file records the verified tier. Group mode passes
|
|
512
|
+
// the member repo's path — process.cwd() there is the parent folder, which
|
|
513
|
+
// has no .hublaunch and would silently drop the persistence every run.
|
|
514
|
+
repoRoot = process.cwd()) {
|
|
356
515
|
if (config.usageTier === 'pro') {
|
|
357
516
|
return true;
|
|
358
517
|
}
|
|
@@ -363,7 +522,7 @@ async function verifyProTier(config, serverUrl, apiKey) {
|
|
|
363
522
|
});
|
|
364
523
|
const plan = response.data?.plan;
|
|
365
524
|
if (plan === 'pro') {
|
|
366
|
-
const saved = updateUsageTierInConfig(
|
|
525
|
+
const saved = updateUsageTierInConfig(repoRoot, 'pro');
|
|
367
526
|
if (saved) {
|
|
368
527
|
logger.success('Subscription verified as Pro — config updated for future launches.');
|
|
369
528
|
}
|
|
@@ -390,73 +549,210 @@ async function verifyProTier(config, serverUrl, apiKey) {
|
|
|
390
549
|
return true;
|
|
391
550
|
}
|
|
392
551
|
}
|
|
552
|
+
/**
|
|
553
|
+
* Assemble the `POST /api/v1/ralph-run` request body from already-resolved
|
|
554
|
+
* inputs. Fields are inserted in a fixed order and only when present, so a
|
|
555
|
+
* launch with no group flag produces a body byte-identical to the pre-fan-out
|
|
556
|
+
* CLI (verified by unit test — AC5).
|
|
557
|
+
*/
|
|
558
|
+
export function buildRalphRunRequestBody(input) {
|
|
559
|
+
const body = {
|
|
560
|
+
issueName: input.issueName,
|
|
561
|
+
planPath: input.planPath,
|
|
562
|
+
repositoryId: input.repositoryId,
|
|
563
|
+
};
|
|
564
|
+
if (input.duration !== undefined) {
|
|
565
|
+
body.duration = input.duration;
|
|
566
|
+
}
|
|
567
|
+
// Keep 'ralphPath' property name for server API compatibility
|
|
568
|
+
// even though the CLI option is now '--launch'
|
|
569
|
+
if (input.launchPath) {
|
|
570
|
+
body.ralphPath = input.launchPath;
|
|
571
|
+
}
|
|
572
|
+
// Handle worktree: commander sets it to false for --no-worktree
|
|
573
|
+
if (input.worktree === false) {
|
|
574
|
+
body.worktree = false;
|
|
575
|
+
}
|
|
576
|
+
else if (typeof input.worktree === 'string') {
|
|
577
|
+
body.worktree = input.worktree;
|
|
578
|
+
}
|
|
579
|
+
if (input.resume !== undefined) {
|
|
580
|
+
body.resume = input.resume;
|
|
581
|
+
}
|
|
582
|
+
if (input.fix) {
|
|
583
|
+
body.fix = input.fix;
|
|
584
|
+
}
|
|
585
|
+
if (input.regression) {
|
|
586
|
+
body.regression = true;
|
|
587
|
+
}
|
|
588
|
+
// Maps to RalphRunRequest.steps (hula-server PR #442).
|
|
589
|
+
if (input.steps) {
|
|
590
|
+
body.steps = input.steps;
|
|
591
|
+
}
|
|
592
|
+
// Maps to RalphRunRequest.test (hula-server PR #367).
|
|
593
|
+
if (input.test) {
|
|
594
|
+
body.test = true;
|
|
595
|
+
}
|
|
596
|
+
// Maps to RalphRunRequest.conflictMode (hula-server PR #391). Only added for
|
|
597
|
+
// 'killAndRelaunch' — the server default 'reject' is expressed by omitting.
|
|
598
|
+
if (input.conflictMode) {
|
|
599
|
+
body.conflictMode = input.conflictMode;
|
|
600
|
+
}
|
|
601
|
+
// Maps to RalphRunRequest.groupId (hula-server PR #505): the shared feature-
|
|
602
|
+
// group label correlating the N single-repo launches of a `--folder` fan-out.
|
|
603
|
+
// Omitted entirely for single-repo launches — the server default (ungrouped)
|
|
604
|
+
// is expressed by omitting the key, keeping non-group bodies byte-identical
|
|
605
|
+
// (same omit-when-absent pattern as `test`/`conflictMode` above).
|
|
606
|
+
if (input.groupId) {
|
|
607
|
+
body.groupId = input.groupId;
|
|
608
|
+
}
|
|
609
|
+
if (input.verbose) {
|
|
610
|
+
body.verbose = true;
|
|
611
|
+
}
|
|
612
|
+
if (input.handoff) {
|
|
613
|
+
body.handoff = input.handoff;
|
|
614
|
+
}
|
|
615
|
+
// Maps to RalphRunRequest.clientSessionId (hula-server PR #419).
|
|
616
|
+
if (input.clientSessionId) {
|
|
617
|
+
body.clientSessionId = input.clientSessionId;
|
|
618
|
+
}
|
|
619
|
+
body.provider = {
|
|
620
|
+
type: input.provider.type,
|
|
621
|
+
authToken: input.provider.authToken,
|
|
622
|
+
};
|
|
623
|
+
if (input.containerResources) {
|
|
624
|
+
body.containerResources = input.containerResources;
|
|
625
|
+
}
|
|
626
|
+
if (input.updateNotificationUrl) {
|
|
627
|
+
body.updateNotificationUrl = input.updateNotificationUrl;
|
|
628
|
+
}
|
|
629
|
+
if (input.updateNotificationNameTag) {
|
|
630
|
+
body.updateNotificationNameTag = input.updateNotificationNameTag;
|
|
631
|
+
}
|
|
632
|
+
if (input.githubToken) {
|
|
633
|
+
body.githubToken = input.githubToken;
|
|
634
|
+
}
|
|
635
|
+
if (input.envVars) {
|
|
636
|
+
body.envVars = input.envVars;
|
|
637
|
+
}
|
|
638
|
+
return body;
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Single-repo launch entry point. Runs the plan-sync/upload check, then defers
|
|
642
|
+
* the body-build + POST to {@link launchSingleRepo} in single mode (keeps the
|
|
643
|
+
* interactive 409 resolver and process-exit-on-error behavior).
|
|
644
|
+
*/
|
|
393
645
|
async function executeLaunch(config, issueName, planPath, options,
|
|
394
646
|
// Explicit conflict mode override. Set when re-invoking after the interactive
|
|
395
647
|
// 409 resolver picks "kill and relaunch" — it both forces the server to
|
|
396
648
|
// cancel+relaunch and (being non-undefined) suppresses re-entering the prompt.
|
|
397
649
|
conflictModeOverride) {
|
|
398
650
|
logger.section('Launch');
|
|
651
|
+
const result = await launchSingleRepo(config, process.cwd(), issueName, planPath, options, {
|
|
652
|
+
mode: 'single',
|
|
653
|
+
interactive409: true,
|
|
654
|
+
conflictModeOverride,
|
|
655
|
+
groupId: options.groupId,
|
|
656
|
+
});
|
|
657
|
+
// In single mode every fatal error already called process.exit inside
|
|
658
|
+
// launchSingleRepo (or the interactive 409 resolver handled the flow); a
|
|
659
|
+
// returned failure that reached here still exits non-zero for safety.
|
|
660
|
+
if (!result.ok) {
|
|
661
|
+
process.exit(1);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Body-build + POST core of a launch, reused by both the single-repo path and
|
|
666
|
+
* the `--folder` fan-out. In single mode fatal errors call `process.exit` and a
|
|
667
|
+
* reject-409 opens the interactive resolver; in group mode fatal errors are
|
|
668
|
+
* returned as a failed {@link RepoLaunchResult} so the fan-out can continue.
|
|
669
|
+
*
|
|
670
|
+
* @param repoConfig - The repository's loaded config.
|
|
671
|
+
* @param repoRoot - Absolute path to the repository root (cwd for git/env/.env).
|
|
672
|
+
* @param issueName - Tracking name for the launch.
|
|
673
|
+
* @param planPath - Plan path (repo-relative or absolute).
|
|
674
|
+
* @param options - Parsed CLI options.
|
|
675
|
+
* @param ctx - Mode + shared-once context (see {@link SingleRepoLaunchContext}).
|
|
676
|
+
*/
|
|
677
|
+
async function launchSingleRepo(repoConfig, repoRoot, issueName, planPath, options, ctx) {
|
|
678
|
+
const isGroup = ctx.mode === 'group';
|
|
399
679
|
// Effective conflict mode: an explicit override (from the 409 resolver) or the
|
|
400
680
|
// --kill-and-relaunch flag. Undefined = default 'reject' (byte-identical body).
|
|
401
|
-
const conflictMode = deriveConflictMode(options, conflictModeOverride);
|
|
681
|
+
const conflictMode = deriveConflictMode(options, ctx.conflictModeOverride);
|
|
402
682
|
// Check if plan is synced to origin/main
|
|
403
|
-
const planSyncStatus = await checkPlanSyncStatus(
|
|
683
|
+
const planSyncStatus = await checkPlanSyncStatus(repoConfig, planPath, repoRoot);
|
|
404
684
|
if (!planSyncStatus.synced) {
|
|
405
685
|
if (planSyncStatus.location === 'not-found') {
|
|
406
686
|
logger.error('Plan not found locally or on origin/main');
|
|
407
687
|
logger.info('Make sure you created the plan with /hula-plan');
|
|
688
|
+
if (isGroup)
|
|
689
|
+
return { ok: false, message: 'Plan not found locally or on origin/main' };
|
|
408
690
|
process.exit(1);
|
|
409
691
|
}
|
|
410
692
|
// Plan exists locally but not on origin/main - auto-upload
|
|
411
693
|
logger.info('Plan found locally, pushing to origin/main...');
|
|
412
694
|
try {
|
|
413
|
-
await executeUpload(
|
|
695
|
+
await executeUpload(repoConfig, planPath, repoRoot);
|
|
414
696
|
logger.success('Plan synced to origin/main');
|
|
415
697
|
logger.blank();
|
|
416
698
|
}
|
|
417
699
|
catch (uploadError) {
|
|
418
|
-
|
|
419
|
-
logger.error(uploadError instanceof Error
|
|
700
|
+
const msg = uploadError instanceof Error
|
|
420
701
|
? uploadError.message
|
|
421
|
-
: String(uploadError)
|
|
702
|
+
: String(uploadError);
|
|
703
|
+
logger.error('Failed to upload plan:');
|
|
704
|
+
logger.error(msg);
|
|
705
|
+
if (isGroup)
|
|
706
|
+
return { ok: false, message: `Failed to upload plan: ${msg}` };
|
|
422
707
|
process.exit(1);
|
|
423
708
|
}
|
|
424
709
|
}
|
|
425
710
|
// Get server URL and API key
|
|
426
|
-
const serverUrl = options.url ||
|
|
427
|
-
const apiKey = options.apiKey ||
|
|
711
|
+
const serverUrl = options.url || repoConfig.hulaProjectUrl || HULA_PROJECT_URL;
|
|
712
|
+
const apiKey = options.apiKey || repoConfig.hulaApiKey || process.env.HULA_API_KEY;
|
|
428
713
|
if (!apiKey) {
|
|
429
714
|
logger.error('No API key configured.');
|
|
430
715
|
logger.info('Set hulaApiKey in your config or use --api-key option.');
|
|
431
716
|
logger.info('You can also set HULA_API_KEY environment variable.');
|
|
432
717
|
logger.info("Run 'hula login' to authenticate with hula-project.");
|
|
718
|
+
if (isGroup)
|
|
719
|
+
return { ok: false, message: 'No API key configured' };
|
|
433
720
|
process.exit(1);
|
|
434
721
|
}
|
|
435
|
-
// Verify the user is on the Pro tier before proceeding
|
|
436
|
-
|
|
437
|
-
if (!
|
|
438
|
-
|
|
722
|
+
// Verify the user is on the Pro tier before proceeding (skipped in group mode
|
|
723
|
+
// where the check ran once against the first repo's key).
|
|
724
|
+
if (!ctx.proTierVerified) {
|
|
725
|
+
const isProUser = await verifyProTier(repoConfig, serverUrl, apiKey);
|
|
726
|
+
if (!isProUser) {
|
|
727
|
+
if (isGroup)
|
|
728
|
+
return { ok: false, message: 'Not on the Pro tier' };
|
|
729
|
+
process.exit(1);
|
|
730
|
+
}
|
|
439
731
|
}
|
|
440
|
-
// Get project ID - from config or detect from git
|
|
441
|
-
const repositoryId = await resolveProject(
|
|
732
|
+
// Get project ID - from config or detect from git (repo cwd for the fallback)
|
|
733
|
+
const repositoryId = ctx.repositoryId ?? (await resolveProject(repoConfig, undefined, repoRoot));
|
|
442
734
|
// Validate fix requires resume
|
|
443
735
|
if (options.fix && options.resume === undefined) {
|
|
444
736
|
logger.error('--fix requires --resume to be set');
|
|
737
|
+
if (isGroup)
|
|
738
|
+
return { ok: false, message: '--fix requires --resume to be set' };
|
|
445
739
|
process.exit(1);
|
|
446
740
|
}
|
|
447
741
|
// Validate resume range
|
|
448
742
|
if (options.resume !== undefined &&
|
|
449
743
|
(options.resume < 1 || options.resume > 9)) {
|
|
450
744
|
logger.error('--resume must be between 1 and 9');
|
|
745
|
+
if (isGroup)
|
|
746
|
+
return { ok: false, message: '--resume must be between 1 and 9' };
|
|
451
747
|
process.exit(1);
|
|
452
748
|
}
|
|
453
749
|
// Build the API endpoint
|
|
454
750
|
const endpoint = `${serverUrl.replace(/\/$/, '')}/api/v1/ralph-run`;
|
|
455
751
|
const updateNotificationUrl = options.updateNotificationUrl ||
|
|
456
|
-
|
|
752
|
+
repoConfig.updateNotificationUrl ||
|
|
457
753
|
process.env.HULA_UPDATE_NOTIFICATION_URL;
|
|
458
754
|
const updateNotificationNameTag = options.updateNotificationNameTag ||
|
|
459
|
-
|
|
755
|
+
repoConfig.updateNotificationNameTag ||
|
|
460
756
|
process.env.HULA_UPDATE_NOTIFICATION_NAME_TAG;
|
|
461
757
|
logger.info(`Triggering launch job...`);
|
|
462
758
|
logger.log(` Issue Name: ${issueName}`);
|
|
@@ -483,106 +779,45 @@ conflictModeOverride) {
|
|
|
483
779
|
logger.log(` Test Mode: yes (mock Claude)`);
|
|
484
780
|
if (options.handoff)
|
|
485
781
|
logger.log(` Handoff To: ${options.handoff}`);
|
|
782
|
+
if (ctx.groupId)
|
|
783
|
+
logger.log(` Group: ${ctx.groupId}`);
|
|
486
784
|
if (updateNotificationUrl)
|
|
487
785
|
logger.log(` Notify on completion: enabled`);
|
|
488
786
|
if (updateNotificationNameTag)
|
|
489
787
|
logger.log(` Notify tag: ${updateNotificationNameTag}`);
|
|
490
|
-
if (
|
|
491
|
-
if (
|
|
788
|
+
if (repoConfig.envVars) {
|
|
789
|
+
if (repoConfig.envVars === 'all') {
|
|
492
790
|
logger.log(` Environment Variables: all (from .env)`);
|
|
493
791
|
}
|
|
494
|
-
else if (
|
|
495
|
-
logger.log(` Environment Variables: ${
|
|
792
|
+
else if (repoConfig.envVars.length > 0) {
|
|
793
|
+
logger.log(` Environment Variables: ${repoConfig.envVars.length} configured`);
|
|
496
794
|
}
|
|
497
795
|
}
|
|
498
796
|
logger.blank();
|
|
499
|
-
//
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
};
|
|
505
|
-
if (options.duration !== undefined) {
|
|
506
|
-
requestBody.duration = options.duration;
|
|
507
|
-
}
|
|
508
|
-
// Keep 'ralphPath' property name for server API compatibility
|
|
509
|
-
// even though the CLI option is now '--launch'
|
|
510
|
-
if (options.launch) {
|
|
511
|
-
requestBody.ralphPath = options.launch;
|
|
512
|
-
}
|
|
513
|
-
// Handle worktree: commander sets it to false for --no-worktree
|
|
514
|
-
if (options.worktree === false) {
|
|
515
|
-
requestBody.worktree = false;
|
|
516
|
-
}
|
|
517
|
-
else if (typeof options.worktree === 'string') {
|
|
518
|
-
requestBody.worktree = options.worktree;
|
|
519
|
-
}
|
|
520
|
-
if (options.resume !== undefined) {
|
|
521
|
-
requestBody.resume = options.resume;
|
|
522
|
-
}
|
|
523
|
-
if (options.fix) {
|
|
524
|
-
requestBody.fix = options.fix;
|
|
525
|
-
}
|
|
526
|
-
if (options.regression) {
|
|
527
|
-
requestBody.regression = true;
|
|
528
|
-
}
|
|
529
|
-
// Maps to RalphRunRequest.steps (hula-server PR #442): per-pipeline-step model,
|
|
530
|
-
// iteration-cap, and skip overrides from the config-file `steps` block plus the
|
|
531
|
-
// --skip-regression flag. resolveSteps() re-runs the same validation the action
|
|
532
|
-
// handler already passed (pure function), and returns `undefined` when there's
|
|
533
|
-
// nothing to send — omitted entirely then, keeping non-steps request bodies
|
|
534
|
-
// byte-identical (same pattern as `test`/`conflictMode` above).
|
|
535
|
-
const stepsResult = resolveSteps(config.steps, options.skipRegression, options.regression);
|
|
536
|
-
if (stepsResult.steps) {
|
|
537
|
-
requestBody.steps = stepsResult.steps;
|
|
538
|
-
}
|
|
539
|
-
// Maps to RalphRunRequest.test (hula-server PR #367): when true, the server
|
|
540
|
-
// runs the full production pipeline but swaps the real Claude CLI for a mock
|
|
541
|
-
// executable — a fast E2E run that still creates a real PR. Omitted entirely
|
|
542
|
-
// when --test is absent, keeping non-test request bodies byte-identical.
|
|
543
|
-
if (options.test) {
|
|
544
|
-
requestBody.test = true;
|
|
545
|
-
}
|
|
546
|
-
// Maps to RalphRunRequest.conflictMode (hula-server PR #391). Only added for
|
|
547
|
-
// 'killAndRelaunch' — the server default 'reject' is expressed by omitting the
|
|
548
|
-
// key entirely, keeping the default-launch request body byte-identical to
|
|
549
|
-
// before this change (same pattern as `test` above).
|
|
550
|
-
if (conflictMode) {
|
|
551
|
-
requestBody.conflictMode = conflictMode;
|
|
552
|
-
}
|
|
553
|
-
if (options.verbose) {
|
|
554
|
-
requestBody.verbose = true;
|
|
555
|
-
}
|
|
556
|
-
if (options.handoff) {
|
|
557
|
-
requestBody.handoff = options.handoff;
|
|
558
|
-
}
|
|
559
|
-
// Maps to RalphRunRequest.clientSessionId (hula-server PR #419): the Claude
|
|
560
|
-
// Code session id of the chat session that ran /hula-launch, captured by the
|
|
561
|
-
// hula-session-hook moments before this CLI runs. Provenance for client
|
|
562
|
-
// tooling (/hula-verify, /hula-info). Omitted entirely when no hook capture
|
|
563
|
-
// exists (plain-terminal launches, CI), keeping those request bodies
|
|
564
|
-
// byte-identical — same pattern as `test` above.
|
|
797
|
+
// Maps to RalphRunRequest.steps (hula-server PR #442). resolveSteps() re-runs
|
|
798
|
+
// the same validation the action handler already passed (pure function), and
|
|
799
|
+
// returns `undefined` when there's nothing to send.
|
|
800
|
+
const stepsResult = resolveSteps(repoConfig.steps, options.skipRegression, options.regression);
|
|
801
|
+
// Maps to RalphRunRequest.clientSessionId (hula-server PR #419).
|
|
565
802
|
const clientSessionId = readClientSessionId(issueName);
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
}
|
|
569
|
-
// Resolve ephemeral credentials through the shared resolver — the single
|
|
570
|
-
// source of truth for precedence/validation, also used by `execute`.
|
|
803
|
+
// Resolve provider credentials through the shared resolver — the single
|
|
804
|
+
// source of truth for precedence/validation, also used by `schedule`.
|
|
571
805
|
// Collect ALL errors before exiting (preserves prior launch behavior).
|
|
572
|
-
const resolved =
|
|
573
|
-
|
|
806
|
+
const resolved = resolveProviderCredentials(options, repoConfig);
|
|
807
|
+
const providerType = resolved.providerType;
|
|
808
|
+
let authToken = resolved.authToken;
|
|
574
809
|
const credentialErrors = resolved.errors;
|
|
575
810
|
if (credentialErrors.length > 0) {
|
|
576
811
|
// Interactive terminal + the ONLY problem is a totally-absent token:
|
|
577
812
|
// collect it inline rather than failing, then persist it for next time.
|
|
578
|
-
// Any other case (wrong-
|
|
813
|
+
// Any other case (wrong-format, non-TTY, multiple errors) keeps today's
|
|
579
814
|
// fail-fast behavior — identical to what `hula schedule` does.
|
|
580
|
-
if (resolved.
|
|
815
|
+
if (resolved.missingToken &&
|
|
581
816
|
credentialErrors.length === 1 &&
|
|
582
817
|
process.stdout.isTTY) {
|
|
583
818
|
let pasted;
|
|
584
819
|
try {
|
|
585
|
-
pasted = (await input(
|
|
820
|
+
pasted = (await input(`Paste your ${providerType} credential, or press Enter to cancel:`)).trim();
|
|
586
821
|
}
|
|
587
822
|
catch (error) {
|
|
588
823
|
// Treat an explicit cancel (Ctrl-C / escape) exactly like empty input.
|
|
@@ -596,81 +831,90 @@ conflictModeOverride) {
|
|
|
596
831
|
if (!pasted) {
|
|
597
832
|
// Empty / cancelled → the original resolver error, then exit.
|
|
598
833
|
credentialErrors.forEach((msg) => console.error(chalk.red(msg)));
|
|
834
|
+
if (isGroup)
|
|
835
|
+
return { ok: false, message: credentialErrors.join('; ') };
|
|
599
836
|
process.exit(1);
|
|
600
837
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
838
|
+
const formatError = validateTokenFormat(providerType, pasted);
|
|
839
|
+
if (formatError) {
|
|
840
|
+
// Wrong format for this provider → the per-provider error. One attempt.
|
|
841
|
+
console.error(chalk.red(formatError));
|
|
842
|
+
if (isGroup)
|
|
843
|
+
return { ok: false, message: formatError };
|
|
604
844
|
process.exit(1);
|
|
605
845
|
}
|
|
606
846
|
// Valid: use it for this launch and persist it to the config file. A
|
|
607
847
|
// persistence failure must NOT abort a valid launch — warn and continue
|
|
608
|
-
// with the in-memory key.
|
|
609
|
-
|
|
848
|
+
// with the in-memory key. In group mode the token is written to the
|
|
849
|
+
// member repo's own config file (repoRoot); in single mode to the git root.
|
|
850
|
+
authToken = pasted;
|
|
610
851
|
try {
|
|
611
|
-
const
|
|
612
|
-
const configFilePath = path.join(
|
|
852
|
+
const configDir = isGroup ? repoRoot : await findGitRoot();
|
|
853
|
+
const configFilePath = path.join(configDir, '.hublaunch', 'hublaunch.config.js');
|
|
613
854
|
const original = readFileSync(configFilePath, 'utf-8');
|
|
614
|
-
const updated =
|
|
855
|
+
const updated = upsertProviderApiKey(original, providerType, pasted);
|
|
615
856
|
writeFileSync(configFilePath, updated, { mode: 0o600 });
|
|
616
|
-
logger.success('
|
|
857
|
+
logger.success('provider credential saved to .hublaunch/hublaunch.config.js');
|
|
617
858
|
}
|
|
618
859
|
catch {
|
|
619
|
-
logger.warning("⚠️ Couldn't save the
|
|
860
|
+
logger.warning("⚠️ Couldn't save the credential to .hublaunch/hublaunch.config.js — continuing this launch with it in memory. Add it manually to avoid re-entering.");
|
|
620
861
|
}
|
|
621
862
|
}
|
|
622
863
|
else {
|
|
623
864
|
credentialErrors.forEach((msg) => console.error(chalk.red(msg)));
|
|
865
|
+
if (isGroup)
|
|
866
|
+
return { ok: false, message: credentialErrors.join('; ') };
|
|
624
867
|
process.exit(1);
|
|
625
868
|
}
|
|
626
869
|
}
|
|
627
|
-
|
|
628
|
-
const cpuRaw = options.containerCpu ??
|
|
629
|
-
const memRaw = options.containerMemory ??
|
|
630
|
-
const diskRaw = options.containerDisk ??
|
|
870
|
+
// Resolve container resources (validate ranges, mode-aware failure).
|
|
871
|
+
const cpuRaw = options.containerCpu ?? repoConfig.containerResources?.cpu;
|
|
872
|
+
const memRaw = options.containerMemory ?? repoConfig.containerResources?.memory;
|
|
873
|
+
const diskRaw = options.containerDisk ?? repoConfig.containerResources?.disk;
|
|
874
|
+
let containerResources;
|
|
631
875
|
if (cpuRaw !== undefined || memRaw !== undefined || diskRaw !== undefined) {
|
|
632
876
|
const cpu = cpuRaw ?? DEFAULT_CONTAINER_RESOURCES.cpu;
|
|
633
877
|
const memory = memRaw ?? DEFAULT_CONTAINER_RESOURCES.memory;
|
|
634
878
|
const disk = diskRaw ?? DEFAULT_CONTAINER_RESOURCES.disk;
|
|
635
879
|
if (!Number.isInteger(cpu) || cpu < 1 || cpu > 32) {
|
|
636
880
|
logger.error('containerResources.cpu must be an integer between 1 and 32');
|
|
881
|
+
if (isGroup)
|
|
882
|
+
return { ok: false, message: 'containerResources.cpu must be an integer between 1 and 32' };
|
|
637
883
|
process.exit(1);
|
|
638
884
|
}
|
|
639
885
|
if (!Number.isInteger(memory) || memory < 1 || memory > 128) {
|
|
640
886
|
logger.error('containerResources.memory must be an integer between 1 and 128 GiB');
|
|
887
|
+
if (isGroup)
|
|
888
|
+
return { ok: false, message: 'containerResources.memory must be an integer between 1 and 128 GiB' };
|
|
641
889
|
process.exit(1);
|
|
642
890
|
}
|
|
643
891
|
if (!Number.isInteger(disk) || disk < 5 || disk > 200) {
|
|
644
892
|
logger.error('containerResources.disk must be an integer between 5 and 200 GiB');
|
|
893
|
+
if (isGroup)
|
|
894
|
+
return { ok: false, message: 'containerResources.disk must be an integer between 5 and 200 GiB' };
|
|
645
895
|
process.exit(1);
|
|
646
896
|
}
|
|
647
|
-
|
|
897
|
+
containerResources = { cpu, memory, disk };
|
|
648
898
|
logger.log(` Container Resources: cpu=${cpu} memory=${memory}GiB disk=${disk}GiB`);
|
|
649
899
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
}
|
|
656
|
-
const tokenData = await loadGitHubToken();
|
|
657
|
-
const githubToken = tokenData?.access_token || process.env.GITHUB_TOKEN;
|
|
658
|
-
if (githubToken) {
|
|
659
|
-
requestBody.githubToken = githubToken;
|
|
660
|
-
}
|
|
661
|
-
// Collect environment variables if configured. Read from the project's .env,
|
|
900
|
+
// Resolve the GitHub token: shared (group mode) or loaded (single mode).
|
|
901
|
+
const githubToken = ctx.skipTokenLoad
|
|
902
|
+
? ctx.githubToken
|
|
903
|
+
: (await loadGitHubToken())?.access_token || process.env.GITHUB_TOKEN;
|
|
904
|
+
// Collect environment variables if configured. Read from the repo's .env,
|
|
662
905
|
// validate against the reserved blocklist, and confirm presence — failing fast
|
|
663
906
|
// with a clear message before the job is submitted.
|
|
664
|
-
|
|
907
|
+
let envVars;
|
|
908
|
+
if (repoConfig.envVars) {
|
|
665
909
|
logger.info('Collecting environment variables for container...');
|
|
666
910
|
try {
|
|
667
911
|
// Expand "all" to the actual list of non-reserved variables from .env.
|
|
668
|
-
let varsToCollect = Array.isArray(
|
|
669
|
-
?
|
|
912
|
+
let varsToCollect = Array.isArray(repoConfig.envVars)
|
|
913
|
+
? repoConfig.envVars
|
|
670
914
|
: [];
|
|
671
|
-
if (
|
|
915
|
+
if (repoConfig.envVars === 'all') {
|
|
672
916
|
// Read .env and get all non-reserved variables
|
|
673
|
-
const envPath = join(
|
|
917
|
+
const envPath = join(repoRoot, '.env');
|
|
674
918
|
if (!existsSync(envPath)) {
|
|
675
919
|
throw new Error(`Cannot read environment variables: .env file not found at ${envPath}\n` +
|
|
676
920
|
`Config specifies envVars: "all", but .env does not exist`);
|
|
@@ -686,22 +930,53 @@ conflictModeOverride) {
|
|
|
686
930
|
// Validate and collect
|
|
687
931
|
if (varsToCollect.length > 0) {
|
|
688
932
|
validateNoReservedVars(varsToCollect);
|
|
689
|
-
const
|
|
690
|
-
if (Object.keys(
|
|
691
|
-
|
|
692
|
-
logger.log(` ✓ ${Object.keys(
|
|
933
|
+
const collected = readAndFilterEnvVars(repoRoot, varsToCollect);
|
|
934
|
+
if (Object.keys(collected).length > 0) {
|
|
935
|
+
envVars = collected;
|
|
936
|
+
logger.log(` ✓ ${Object.keys(collected).length} variable(s) collected`);
|
|
693
937
|
}
|
|
694
938
|
}
|
|
695
939
|
}
|
|
696
940
|
catch (error) {
|
|
941
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
697
942
|
logger.error('Failed to collect environment variables:');
|
|
698
|
-
logger.error(
|
|
943
|
+
logger.error(msg);
|
|
944
|
+
if (isGroup)
|
|
945
|
+
return { ok: false, message: `Failed to collect environment variables: ${msg}` };
|
|
699
946
|
process.exit(1);
|
|
700
947
|
}
|
|
701
948
|
}
|
|
702
|
-
// Pre-flight credential validation
|
|
703
|
-
//
|
|
704
|
-
|
|
949
|
+
// Pre-flight credential validation (skipped in group mode where each distinct
|
|
950
|
+
// provider credential was validated once before the fan-out loop).
|
|
951
|
+
// authToken is guaranteed non-empty here — missing key exits above.
|
|
952
|
+
if (!ctx.credentialsValidated) {
|
|
953
|
+
await validateProviderCredentials(providerType, authToken, githubToken);
|
|
954
|
+
}
|
|
955
|
+
// Assemble the request body from the resolved values.
|
|
956
|
+
const requestBody = buildRalphRunRequestBody({
|
|
957
|
+
issueName,
|
|
958
|
+
planPath,
|
|
959
|
+
repositoryId,
|
|
960
|
+
duration: options.duration,
|
|
961
|
+
launchPath: options.launch,
|
|
962
|
+
worktree: options.worktree,
|
|
963
|
+
resume: options.resume,
|
|
964
|
+
fix: options.fix,
|
|
965
|
+
regression: options.regression,
|
|
966
|
+
steps: stepsResult.steps,
|
|
967
|
+
test: options.test,
|
|
968
|
+
conflictMode,
|
|
969
|
+
groupId: ctx.groupId,
|
|
970
|
+
verbose: options.verbose,
|
|
971
|
+
handoff: options.handoff,
|
|
972
|
+
clientSessionId: clientSessionId ?? undefined,
|
|
973
|
+
provider: { type: providerType, authToken },
|
|
974
|
+
containerResources,
|
|
975
|
+
updateNotificationUrl,
|
|
976
|
+
updateNotificationNameTag,
|
|
977
|
+
githubToken,
|
|
978
|
+
envVars,
|
|
979
|
+
});
|
|
705
980
|
try {
|
|
706
981
|
const response = await axios.post(endpoint, requestBody, {
|
|
707
982
|
headers: {
|
|
@@ -731,30 +1006,505 @@ conflictModeOverride) {
|
|
|
731
1006
|
}
|
|
732
1007
|
logger.blank();
|
|
733
1008
|
// Clean up local plan file — it's now on origin/main and submitted to server
|
|
734
|
-
const planService = new PlanService(
|
|
735
|
-
planService.deletePlan(planPath);
|
|
736
|
-
|
|
737
|
-
else {
|
|
738
|
-
logger.error(response.data.error || 'Unknown error occurred');
|
|
739
|
-
process.exit(1);
|
|
1009
|
+
const planService = new PlanService(repoConfig.planPath);
|
|
1010
|
+
planService.deletePlan(isAbsolute(planPath) ? planPath : join(repoRoot, planPath));
|
|
1011
|
+
return { ok: true, planId: response.data.planId };
|
|
740
1012
|
}
|
|
1013
|
+
logger.error(response.data.error || 'Unknown error occurred');
|
|
1014
|
+
if (isGroup)
|
|
1015
|
+
return { ok: false, message: response.data.error || 'Unknown error occurred' };
|
|
1016
|
+
process.exit(1);
|
|
741
1017
|
}
|
|
742
1018
|
catch (error) {
|
|
743
1019
|
// A 409 on the default `reject` path means a task is already running for this
|
|
744
|
-
// tracking name.
|
|
745
|
-
//
|
|
746
|
-
// already in play — that request cannot produce a reject 409 and must not
|
|
747
|
-
// re-enter the prompt (loop guard).
|
|
1020
|
+
// tracking name. Skip this whenever a conflictMode is already in play — that
|
|
1021
|
+
// request cannot produce a reject 409 and must not re-enter the prompt.
|
|
748
1022
|
if (!conflictMode &&
|
|
749
1023
|
axios.isAxiosError(error) &&
|
|
750
1024
|
error.response?.status === 409) {
|
|
751
|
-
|
|
752
|
-
|
|
1025
|
+
if (ctx.interactive409) {
|
|
1026
|
+
// Single mode: offer recovery options interactively (or, when scripted,
|
|
1027
|
+
// print the server message and exit). The resolver drives the flow.
|
|
1028
|
+
await handleReject409(error, repoConfig, issueName, planPath, options);
|
|
1029
|
+
return { ok: true };
|
|
1030
|
+
}
|
|
1031
|
+
// Group mode: record the failure with the server message plus a hint to
|
|
1032
|
+
// re-run with --kill-and-relaunch. Never enters the interactive resolver.
|
|
1033
|
+
const data = (error.response?.data ?? {});
|
|
1034
|
+
const serverMessage = data.error || `A task is already running for '${issueName}'.`;
|
|
1035
|
+
return {
|
|
1036
|
+
ok: false,
|
|
1037
|
+
status: 409,
|
|
1038
|
+
message: `${serverMessage} (re-run with --kill-and-relaunch to force)`,
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
if (isGroup) {
|
|
1042
|
+
const described = describeLaunchAxiosError(error, serverUrl, issueName, planPath);
|
|
1043
|
+
return { ok: false, status: described.status, message: described.message };
|
|
753
1044
|
}
|
|
754
1045
|
handleLaunchAxiosError(error, serverUrl, issueName, planPath);
|
|
755
1046
|
process.exit(1);
|
|
756
1047
|
}
|
|
757
1048
|
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Fan-out launch (`hula launch <issue> --folder <path>`). Discovers every
|
|
1051
|
+
* initialized repo under `<path>`, resolves each repo's plan, confirms the
|
|
1052
|
+
* roster, then launches each repo with its own config/key plus one shared
|
|
1053
|
+
* generated (or `--group-id`-supplied) groupId. Continues past per-repo
|
|
1054
|
+
* failures and prints a retry command carrying `--group-id`.
|
|
1055
|
+
*/
|
|
1056
|
+
async function executeGroupLaunch(_config, issueName, options) {
|
|
1057
|
+
const service = new GroupLaunchService();
|
|
1058
|
+
const folder = path.resolve(options.folder);
|
|
1059
|
+
if (!existsSync(folder)) {
|
|
1060
|
+
logger.error(`Folder not found: ${folder}`);
|
|
1061
|
+
process.exit(1);
|
|
1062
|
+
}
|
|
1063
|
+
const { repos, skipped, folderIsRepo } = service.discoverRepos(folder);
|
|
1064
|
+
logger.section('Group Launch');
|
|
1065
|
+
for (const s of skipped) {
|
|
1066
|
+
logger.log(` skipped ${s.name} (${s.reason})`);
|
|
1067
|
+
}
|
|
1068
|
+
if (repos.length === 0) {
|
|
1069
|
+
if (folderIsRepo) {
|
|
1070
|
+
logger.error(`${folder} looks like a repository itself; --folder expects the parent folder that contains your repos`);
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
logger.error(`No initialized repos found directly under ${folder}`);
|
|
1074
|
+
logger.info('Each repo needs a .git entry and a .hublaunch config (run hula init + hula login in it).');
|
|
1075
|
+
}
|
|
1076
|
+
process.exit(1);
|
|
1077
|
+
}
|
|
1078
|
+
if (repos.length === 1) {
|
|
1079
|
+
logger.warning('only one repo found — this launches a single-repo group');
|
|
1080
|
+
}
|
|
1081
|
+
// Resolve or accept the group id (already format-validated in the handler).
|
|
1082
|
+
const groupId = options.groupId ?? service.generateGroupId(issueName);
|
|
1083
|
+
// Validate --plan override repo names and reject path-traversal paths.
|
|
1084
|
+
const overrides = options.plan ?? {};
|
|
1085
|
+
const repoNames = new Set(repos.map((r) => r.name));
|
|
1086
|
+
for (const [name, overridePath] of Object.entries(overrides)) {
|
|
1087
|
+
if (!repoNames.has(name)) {
|
|
1088
|
+
logger.error(`Unknown repo in --plan override: "${name}". Discovered repos: ${[...repoNames].join(', ')}`);
|
|
1089
|
+
process.exit(1);
|
|
1090
|
+
}
|
|
1091
|
+
if (overridePath.includes('..')) {
|
|
1092
|
+
logger.error(`Invalid --plan path for "${name}" (path traversal not allowed): ${overridePath}`);
|
|
1093
|
+
process.exit(1);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
const isTty = Boolean(process.stdout.isTTY);
|
|
1097
|
+
// `--yes` opts a non-TTY caller (the /hula-launch skill runs this CLI as a
|
|
1098
|
+
// piped subprocess) into the same auto-resolve + roster flow a TTY gets,
|
|
1099
|
+
// skipping the interactive confirmation. Without it, non-TTY still requires
|
|
1100
|
+
// explicit --plan coverage (never guess silently in CI).
|
|
1101
|
+
const assumeYes = Boolean(options.yes);
|
|
1102
|
+
const canAutoResolve = isTty || assumeYes;
|
|
1103
|
+
// Load each repo's config INDEPENDENTLY (never the cached singleton), resolve
|
|
1104
|
+
// its project, and resolve its plan (override → auto-resolve, disabled non-TTY
|
|
1105
|
+
// unless --yes).
|
|
1106
|
+
const entries = [];
|
|
1107
|
+
for (const repo of repos) {
|
|
1108
|
+
const repoConfig = await new ConfigLoader(repo.path).load();
|
|
1109
|
+
const project = await resolveProject(repoConfig, undefined, repo.path);
|
|
1110
|
+
let loginError;
|
|
1111
|
+
const hasKey = Boolean(options.apiKey) ||
|
|
1112
|
+
Boolean(repoConfig.hulaApiKey) ||
|
|
1113
|
+
Boolean(process.env.HULA_API_KEY);
|
|
1114
|
+
if (!hasKey) {
|
|
1115
|
+
loginError = `NOT LOGGED IN (run hula login in ${repo.path})`;
|
|
1116
|
+
}
|
|
1117
|
+
let planPath;
|
|
1118
|
+
if (overrides[repo.name]) {
|
|
1119
|
+
planPath = overrides[repo.name];
|
|
1120
|
+
}
|
|
1121
|
+
else if (!canAutoResolve) {
|
|
1122
|
+
// Non-TTY without --yes: never guess — require an explicit --plan override.
|
|
1123
|
+
planPath = null;
|
|
1124
|
+
}
|
|
1125
|
+
else {
|
|
1126
|
+
planPath = service.resolvePlan(repo, repoConfig, issueName);
|
|
1127
|
+
}
|
|
1128
|
+
entries.push({ repo, repoConfig, project, planPath, loginError });
|
|
1129
|
+
}
|
|
1130
|
+
// Duplicate project detection: two members with the same repositoryId would
|
|
1131
|
+
// overwrite each other server-side ("latest launch wins"). Fail before launch.
|
|
1132
|
+
const projectToRepos = new Map();
|
|
1133
|
+
for (const e of entries) {
|
|
1134
|
+
projectToRepos.set(e.project, [
|
|
1135
|
+
...(projectToRepos.get(e.project) ?? []),
|
|
1136
|
+
e.repo.name,
|
|
1137
|
+
]);
|
|
1138
|
+
}
|
|
1139
|
+
for (const [project, names] of projectToRepos) {
|
|
1140
|
+
if (names.length > 1) {
|
|
1141
|
+
logger.error(`Duplicate project ${project} across discovered repos: ${names.join(', ')}. ` +
|
|
1142
|
+
`Each member must be a distinct repository.`);
|
|
1143
|
+
process.exit(1);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
// Non-TTY coverage: without --yes, every repo must be covered by a --plan
|
|
1147
|
+
// override (a piped/CI caller must opt in before the CLI guesses plans).
|
|
1148
|
+
if (!canAutoResolve) {
|
|
1149
|
+
const uncovered = entries
|
|
1150
|
+
.filter((e) => !overrides[e.repo.name])
|
|
1151
|
+
.map((e) => e.repo.name);
|
|
1152
|
+
if (uncovered.length > 0) {
|
|
1153
|
+
logger.error(`Non-interactive run requires a --plan <repo>=<path> override for every repo (or pass --yes to auto-resolve). Uncovered: ${uncovered.join(', ')}`);
|
|
1154
|
+
process.exit(1);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
// Print the roster and gate on any repo that can't launch.
|
|
1158
|
+
logger.blank();
|
|
1159
|
+
logger.info(`Feature group: ${groupId}`);
|
|
1160
|
+
let blocked = false;
|
|
1161
|
+
for (const e of entries) {
|
|
1162
|
+
let planLabel;
|
|
1163
|
+
if (e.loginError) {
|
|
1164
|
+
planLabel = e.loginError;
|
|
1165
|
+
blocked = true;
|
|
1166
|
+
}
|
|
1167
|
+
else if (!e.planPath) {
|
|
1168
|
+
planLabel = 'NO PLAN FOUND';
|
|
1169
|
+
blocked = true;
|
|
1170
|
+
}
|
|
1171
|
+
else {
|
|
1172
|
+
planLabel = e.planPath;
|
|
1173
|
+
}
|
|
1174
|
+
logger.log(` ${e.repo.name} → ${planLabel}`);
|
|
1175
|
+
}
|
|
1176
|
+
if (blocked) {
|
|
1177
|
+
logger.blank();
|
|
1178
|
+
logger.error('Cannot launch: every repo needs an API key and a resolvable plan.');
|
|
1179
|
+
logger.info('Supply a plan with --plan <repo>=<path>, run hula login in the repo, or remove it from the folder.');
|
|
1180
|
+
process.exit(1);
|
|
1181
|
+
}
|
|
1182
|
+
// Single interactive confirmation before any network launch (TTY only;
|
|
1183
|
+
// --yes skips it — the roster above stays in the output as the record).
|
|
1184
|
+
if (isTty && !assumeYes) {
|
|
1185
|
+
const { proceed } = await inquirer.prompt([
|
|
1186
|
+
{
|
|
1187
|
+
type: 'confirm',
|
|
1188
|
+
name: 'proceed',
|
|
1189
|
+
message: `Launch these ${entries.length} repos as group ${groupId}?`,
|
|
1190
|
+
default: true,
|
|
1191
|
+
},
|
|
1192
|
+
]);
|
|
1193
|
+
if (!proceed) {
|
|
1194
|
+
logger.info('Cancelled — no launches sent.');
|
|
1195
|
+
process.exit(0);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
// ── Shared-once work ──────────────────────────────────────────────────
|
|
1199
|
+
// Pro-tier check with the first repo's key.
|
|
1200
|
+
const first = entries[0];
|
|
1201
|
+
const proServerUrl = options.url || first.repoConfig.hulaProjectUrl || HULA_PROJECT_URL;
|
|
1202
|
+
const proApiKey = options.apiKey || first.repoConfig.hulaApiKey || process.env.HULA_API_KEY;
|
|
1203
|
+
if (proApiKey) {
|
|
1204
|
+
const isPro = await verifyProTier(first.repoConfig, proServerUrl, proApiKey, first.repo.path);
|
|
1205
|
+
if (!isPro)
|
|
1206
|
+
process.exit(1);
|
|
1207
|
+
}
|
|
1208
|
+
// GitHub token once (user-level).
|
|
1209
|
+
const tokenData = await loadGitHubToken();
|
|
1210
|
+
const githubToken = tokenData?.access_token || process.env.GITHUB_TOKEN;
|
|
1211
|
+
// Validate each DISTINCT resolvable provider credential once (keyed by
|
|
1212
|
+
// provider type + token, since members may select different providers).
|
|
1213
|
+
const validatedKeys = new Set();
|
|
1214
|
+
for (const e of entries) {
|
|
1215
|
+
const { providerType, authToken } = resolveProviderCredentials(options, e.repoConfig);
|
|
1216
|
+
const dedupKey = `${providerType}:${authToken}`;
|
|
1217
|
+
if (authToken && !validatedKeys.has(dedupKey)) {
|
|
1218
|
+
validatedKeys.add(dedupKey);
|
|
1219
|
+
await validateProviderCredentials(providerType, authToken, githubToken);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
// ── Sequential fan-out (continue on failure) ──────────────────────────
|
|
1223
|
+
const results = [];
|
|
1224
|
+
const successfulRepos = [];
|
|
1225
|
+
for (const e of entries) {
|
|
1226
|
+
logger.section(`Launch: ${e.repo.name}`);
|
|
1227
|
+
const result = await launchSingleRepo(e.repoConfig, e.repo.path, issueName, e.planPath, options, {
|
|
1228
|
+
mode: 'group',
|
|
1229
|
+
interactive409: false,
|
|
1230
|
+
groupId,
|
|
1231
|
+
repositoryId: e.project,
|
|
1232
|
+
proTierVerified: true,
|
|
1233
|
+
credentialsValidated: true,
|
|
1234
|
+
skipTokenLoad: true,
|
|
1235
|
+
githubToken,
|
|
1236
|
+
});
|
|
1237
|
+
results.push({ repo: e.repo, project: e.project, planPath: e.planPath, result });
|
|
1238
|
+
if (result.ok) {
|
|
1239
|
+
successfulRepos.push(e.repo);
|
|
1240
|
+
}
|
|
1241
|
+
else {
|
|
1242
|
+
logger.error(`✗ ${e.repo.name}: ${result.message ?? 'launch failed'}`);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
// Record the group locally in every successful member (best-effort).
|
|
1246
|
+
if (successfulRepos.length > 0) {
|
|
1247
|
+
const recordRepos = successfulRepos.map((r) => {
|
|
1248
|
+
const e = entries.find((x) => x.repo.path === r.path);
|
|
1249
|
+
return { name: r.name, path: r.path, project: e.project };
|
|
1250
|
+
});
|
|
1251
|
+
const record = {
|
|
1252
|
+
groupId,
|
|
1253
|
+
issueName,
|
|
1254
|
+
createdAt: new Date().toISOString(),
|
|
1255
|
+
folder,
|
|
1256
|
+
repos: recordRepos,
|
|
1257
|
+
};
|
|
1258
|
+
service.recordGroup(successfulRepos, record);
|
|
1259
|
+
}
|
|
1260
|
+
// ── Summary ───────────────────────────────────────────────────────────
|
|
1261
|
+
logger.section('Group Launch Summary');
|
|
1262
|
+
logger.log(` Group: ${groupId}`);
|
|
1263
|
+
for (const r of results) {
|
|
1264
|
+
if (r.result.ok) {
|
|
1265
|
+
logger.log(` ✓ ${r.repo.name}${r.result.planId ? ` (plan ${r.result.planId})` : ''}`);
|
|
1266
|
+
}
|
|
1267
|
+
else {
|
|
1268
|
+
logger.log(` ✗ ${r.repo.name} ${r.result.message ?? 'launch failed'}`);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
const failures = results.filter((r) => !r.result.ok);
|
|
1272
|
+
if (failures.length > 0) {
|
|
1273
|
+
logger.blank();
|
|
1274
|
+
logger.info('Retry the whole group (already-successful members may 409):');
|
|
1275
|
+
logger.info(` hula launch ${issueName} --folder "${options.folder}" --group-id ${groupId}`);
|
|
1276
|
+
logger.info('Or retry a single failed repo surgically:');
|
|
1277
|
+
for (const f of failures) {
|
|
1278
|
+
logger.info(` cd ${f.repo.path} && hula launch ${issueName} ${f.planPath} --group-id ${groupId}`);
|
|
1279
|
+
}
|
|
1280
|
+
process.exit(1);
|
|
1281
|
+
}
|
|
1282
|
+
logger.blank();
|
|
1283
|
+
logger.success(`Launched ${results.length} repos as group ${groupId}`);
|
|
1284
|
+
logger.info(`View combined status: hula launch --show-group ${groupId}`);
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* Cancel-only fan-out (`hula launch <issue> --folder <path> --kill`). Stops the
|
|
1288
|
+
* in-flight task in every member repo using each repo's own API key. Continues
|
|
1289
|
+
* past per-repo failures and exits non-zero when any cancel failed.
|
|
1290
|
+
*/
|
|
1291
|
+
async function executeGroupKill(_config, issueName, options) {
|
|
1292
|
+
const service = new GroupLaunchService();
|
|
1293
|
+
const folder = path.resolve(options.folder);
|
|
1294
|
+
if (!existsSync(folder)) {
|
|
1295
|
+
logger.error(`Folder not found: ${folder}`);
|
|
1296
|
+
process.exit(1);
|
|
1297
|
+
}
|
|
1298
|
+
const { repos, skipped, folderIsRepo } = service.discoverRepos(folder);
|
|
1299
|
+
logger.section('Group Cancel');
|
|
1300
|
+
for (const s of skipped) {
|
|
1301
|
+
logger.log(` skipped ${s.name} (${s.reason})`);
|
|
1302
|
+
}
|
|
1303
|
+
if (repos.length === 0) {
|
|
1304
|
+
if (folderIsRepo) {
|
|
1305
|
+
logger.error(`${folder} looks like a repository itself; --folder expects the parent folder that contains your repos`);
|
|
1306
|
+
}
|
|
1307
|
+
else {
|
|
1308
|
+
logger.error(`No initialized repos found directly under ${folder}`);
|
|
1309
|
+
}
|
|
1310
|
+
process.exit(1);
|
|
1311
|
+
}
|
|
1312
|
+
const entries = [];
|
|
1313
|
+
for (const repo of repos) {
|
|
1314
|
+
const repoConfig = await new ConfigLoader(repo.path).load();
|
|
1315
|
+
const project = await resolveProject(repoConfig, undefined, repo.path);
|
|
1316
|
+
entries.push({ repo, repoConfig, project });
|
|
1317
|
+
}
|
|
1318
|
+
// Shared Pro-tier check with the first repo's key.
|
|
1319
|
+
const first = entries[0];
|
|
1320
|
+
const proServerUrl = options.url || first.repoConfig.hulaProjectUrl || HULA_PROJECT_URL;
|
|
1321
|
+
const proApiKey = options.apiKey || first.repoConfig.hulaApiKey || process.env.HULA_API_KEY;
|
|
1322
|
+
if (proApiKey) {
|
|
1323
|
+
const isPro = await verifyProTier(first.repoConfig, proServerUrl, proApiKey, first.repo.path);
|
|
1324
|
+
if (!isPro)
|
|
1325
|
+
process.exit(1);
|
|
1326
|
+
}
|
|
1327
|
+
const results = [];
|
|
1328
|
+
for (const e of entries) {
|
|
1329
|
+
logger.section(`Cancel: ${e.repo.name}`);
|
|
1330
|
+
const result = await killSingleRepo(e.repoConfig, e.repo.path, issueName, options, {
|
|
1331
|
+
repositoryId: e.project,
|
|
1332
|
+
proTierVerified: true,
|
|
1333
|
+
});
|
|
1334
|
+
results.push({ repo: e.repo, result });
|
|
1335
|
+
if (!result.ok) {
|
|
1336
|
+
logger.error(`✗ ${e.repo.name}: ${result.message ?? 'cancel failed'}`);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
logger.section('Group Cancel Summary');
|
|
1340
|
+
for (const r of results) {
|
|
1341
|
+
logger.log(` ${r.result.ok ? '✓' : '✗'} ${r.repo.name}`);
|
|
1342
|
+
}
|
|
1343
|
+
if (results.some((r) => !r.result.ok)) {
|
|
1344
|
+
process.exit(1);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Cancel the in-flight task for one repo (per-repo helper for the `--kill
|
|
1349
|
+
* --folder` fan-out). Mirrors {@link executeKill} but returns a
|
|
1350
|
+
* {@link RepoLaunchResult} instead of exiting, and skips the shared Pro check.
|
|
1351
|
+
*/
|
|
1352
|
+
async function killSingleRepo(repoConfig, repoRoot, issueName, options, ctx) {
|
|
1353
|
+
const serverUrl = options.url || repoConfig.hulaProjectUrl || HULA_PROJECT_URL;
|
|
1354
|
+
const apiKey = options.apiKey || repoConfig.hulaApiKey || process.env.HULA_API_KEY;
|
|
1355
|
+
if (!apiKey) {
|
|
1356
|
+
return { ok: false, message: 'No API key configured' };
|
|
1357
|
+
}
|
|
1358
|
+
if (!ctx.proTierVerified) {
|
|
1359
|
+
const isPro = await verifyProTier(repoConfig, serverUrl, apiKey);
|
|
1360
|
+
if (!isPro)
|
|
1361
|
+
return { ok: false, message: 'Not on the Pro tier' };
|
|
1362
|
+
}
|
|
1363
|
+
const repositoryId = ctx.repositoryId ?? (await resolveProject(repoConfig, undefined, repoRoot));
|
|
1364
|
+
const endpoint = `${serverUrl.replace(/\/$/, '')}/api/v1/ralph-run`;
|
|
1365
|
+
const requestBody = buildKillRequestBody(issueName, repositoryId);
|
|
1366
|
+
logger.info('Stopping in-flight task...');
|
|
1367
|
+
logger.log(` Issue Name: ${issueName}`);
|
|
1368
|
+
logger.log(` Server: ${serverUrl}`);
|
|
1369
|
+
logger.blank();
|
|
1370
|
+
try {
|
|
1371
|
+
const response = await axios.post(endpoint, requestBody, {
|
|
1372
|
+
headers: {
|
|
1373
|
+
'Content-Type': 'application/json',
|
|
1374
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1375
|
+
},
|
|
1376
|
+
timeout: 30000,
|
|
1377
|
+
});
|
|
1378
|
+
const data = response.data;
|
|
1379
|
+
if (data.killed) {
|
|
1380
|
+
logger.success(data.message || `Cancelled '${issueName}'`);
|
|
1381
|
+
}
|
|
1382
|
+
else {
|
|
1383
|
+
logger.info(data.message || `No active task to cancel for '${issueName}'`);
|
|
1384
|
+
}
|
|
1385
|
+
return { ok: true };
|
|
1386
|
+
}
|
|
1387
|
+
catch (error) {
|
|
1388
|
+
const described = describeLaunchAxiosError(error, serverUrl, issueName, undefined);
|
|
1389
|
+
logger.error(described.message);
|
|
1390
|
+
return { ok: false, status: described.status, message: described.message };
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Render combined status for a feature group (`hula launch --show-group [id]`).
|
|
1395
|
+
* The id is explicit (string) or resolved from the newest local group record
|
|
1396
|
+
* (bare flag → `true`). Prints a `completed/total` header and one line per plan.
|
|
1397
|
+
*/
|
|
1398
|
+
async function showGroupStatus(config, groupIdOrTrue, options) {
|
|
1399
|
+
const serverUrl = options.url || config.hulaProjectUrl || HULA_PROJECT_URL;
|
|
1400
|
+
const apiKey = options.apiKey || config.hulaApiKey || process.env.HULA_API_KEY;
|
|
1401
|
+
if (!serverUrl || !apiKey) {
|
|
1402
|
+
logger.error('Server URL and API key are required.');
|
|
1403
|
+
logger.info("Run 'hula login' to authenticate with hula-project.");
|
|
1404
|
+
process.exit(1);
|
|
1405
|
+
}
|
|
1406
|
+
// Resolve the group id: an explicit value, else the newest local record.
|
|
1407
|
+
let groupId;
|
|
1408
|
+
if (typeof groupIdOrTrue === 'string') {
|
|
1409
|
+
groupId = groupIdOrTrue;
|
|
1410
|
+
}
|
|
1411
|
+
else {
|
|
1412
|
+
const service = new GroupLaunchService();
|
|
1413
|
+
const repoRoot = await findGitRoot();
|
|
1414
|
+
const latest = service.latestRecordedGroupId(repoRoot);
|
|
1415
|
+
if (!latest) {
|
|
1416
|
+
logger.error('No local group record found in this repo. Pass an id: hula launch --show-group <id>');
|
|
1417
|
+
process.exit(1);
|
|
1418
|
+
}
|
|
1419
|
+
groupId = latest;
|
|
1420
|
+
}
|
|
1421
|
+
const idError = validateGroupId(groupId);
|
|
1422
|
+
if (idError) {
|
|
1423
|
+
logger.error(idError);
|
|
1424
|
+
process.exit(1);
|
|
1425
|
+
}
|
|
1426
|
+
const apiClient = new HulaApiClient({
|
|
1427
|
+
baseUrl: serverUrl.replace(/\/$/, ''),
|
|
1428
|
+
apiKey,
|
|
1429
|
+
timeout: 30000,
|
|
1430
|
+
});
|
|
1431
|
+
try {
|
|
1432
|
+
const data = await apiClient.fetchGroupStatus(groupId);
|
|
1433
|
+
logger.section(`Group ${data.groupId}: ${data.completed}/${data.total} completed`);
|
|
1434
|
+
if (data.plans.length === 0) {
|
|
1435
|
+
logger.log(' (no plans yet)');
|
|
1436
|
+
}
|
|
1437
|
+
for (const plan of data.plans) {
|
|
1438
|
+
const taskStatus = plan.latestTask ? plan.latestTask.status : 'no task';
|
|
1439
|
+
const pr = plan.prUrl
|
|
1440
|
+
? `${plan.prUrl}${plan.prMerged ? ' (merged)' : ''}`
|
|
1441
|
+
: '—';
|
|
1442
|
+
logger.log(` ${plan.project.fullName} ${plan.trackingStatus} task:${taskStatus} PR:${pr} ${plan.updatedAt}`);
|
|
1443
|
+
}
|
|
1444
|
+
logger.blank();
|
|
1445
|
+
}
|
|
1446
|
+
catch (error) {
|
|
1447
|
+
const status = (axios.isAxiosError(error) && error.response?.status) ||
|
|
1448
|
+
(error instanceof ApiError ? error.statusCode : undefined);
|
|
1449
|
+
if (status === 404) {
|
|
1450
|
+
logger.error('Group not found (or you are not a member of any of its projects).');
|
|
1451
|
+
logger.info('The server may not support groups yet (requires hula-server with feature groups).');
|
|
1452
|
+
process.exit(1);
|
|
1453
|
+
}
|
|
1454
|
+
if (error instanceof ApiError) {
|
|
1455
|
+
logger.error(error.message);
|
|
1456
|
+
process.exit(1);
|
|
1457
|
+
}
|
|
1458
|
+
handleApiError(error, serverUrl);
|
|
1459
|
+
process.exit(1);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Non-logging sibling of {@link handleLaunchAxiosError}: map a launch/kill error
|
|
1464
|
+
* to a `{ status, message }` pair without printing or throwing. Used by the
|
|
1465
|
+
* `--folder` fan-out to CAPTURE a per-repo failure for the end-of-run summary
|
|
1466
|
+
* instead of logging-and-exiting. Message text mirrors the single-repo handler.
|
|
1467
|
+
*/
|
|
1468
|
+
function describeLaunchAxiosError(error, serverUrl, issueName, planPath) {
|
|
1469
|
+
if (axios.isAxiosError(error)) {
|
|
1470
|
+
if (error.response) {
|
|
1471
|
+
const status = error.response.status;
|
|
1472
|
+
const data = error.response.data;
|
|
1473
|
+
switch (status) {
|
|
1474
|
+
case 400:
|
|
1475
|
+
return { status, message: `Bad request: ${data.error || 'Invalid parameters'}` };
|
|
1476
|
+
case 401:
|
|
1477
|
+
return { status, message: 'Authentication failed. Your API key may be invalid.' };
|
|
1478
|
+
case 402:
|
|
1479
|
+
return { status, message: 'You cannot run launch because you are on the free tier.' };
|
|
1480
|
+
case 403:
|
|
1481
|
+
return {
|
|
1482
|
+
status,
|
|
1483
|
+
message: data.error || 'Access denied. You may not be authorized for launch.',
|
|
1484
|
+
};
|
|
1485
|
+
case 404:
|
|
1486
|
+
return { status, message: data.error || `Not found: ${planPath ?? issueName}` };
|
|
1487
|
+
case 409:
|
|
1488
|
+
return { status, message: data.error || `Job already running: ${issueName}` };
|
|
1489
|
+
case 500:
|
|
1490
|
+
return { status, message: `Server error: ${data.error || 'Internal server error'}` };
|
|
1491
|
+
default:
|
|
1492
|
+
return {
|
|
1493
|
+
status,
|
|
1494
|
+
message: `Request failed with status ${status}: ${data.error || error.message}`,
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
if (error.code === 'ECONNREFUSED') {
|
|
1499
|
+
return { message: `Cannot connect to server at ${serverUrl}` };
|
|
1500
|
+
}
|
|
1501
|
+
if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
|
|
1502
|
+
return { message: 'Request timed out' };
|
|
1503
|
+
}
|
|
1504
|
+
return { message: `Network error: ${error.message}` };
|
|
1505
|
+
}
|
|
1506
|
+
return { message: error instanceof Error ? error.message : String(error) };
|
|
1507
|
+
}
|
|
758
1508
|
/**
|
|
759
1509
|
* Shared axios error handler for the launch / kill request. Surfaces the server
|
|
760
1510
|
* `data.error` for 400/403/404/409 (PR #391 returns specific, actionable
|
|
@@ -1070,8 +1820,8 @@ export function handleApiError(error, serverUrl) {
|
|
|
1070
1820
|
/**
|
|
1071
1821
|
* Check if plan is synced to origin/main
|
|
1072
1822
|
*/
|
|
1073
|
-
async function checkPlanSyncStatus(config, planPath) {
|
|
1074
|
-
const gitService = new GitService();
|
|
1823
|
+
async function checkPlanSyncStatus(config, planPath, repoRoot) {
|
|
1824
|
+
const gitService = new GitService(repoRoot);
|
|
1075
1825
|
const planService = new PlanService(config.planPath);
|
|
1076
1826
|
// Fetch latest from origin
|
|
1077
1827
|
await gitService.fetch('origin', 'main');
|
|
@@ -1088,8 +1838,10 @@ async function checkPlanSyncStatus(config, planPath) {
|
|
|
1088
1838
|
}
|
|
1089
1839
|
// Plan is not on any git ref, but a plan freshly created by /hula-plan lives
|
|
1090
1840
|
// only in the working tree (never committed). Treat that as needing upload
|
|
1091
|
-
// rather than declaring it not found.
|
|
1092
|
-
|
|
1841
|
+
// rather than declaring it not found. In folder mode the plan path is
|
|
1842
|
+
// repo-relative, so resolve it against the repo root for the disk check.
|
|
1843
|
+
const planFsPath = isAbsolute(planPath) || !repoRoot ? planPath : join(repoRoot, planPath);
|
|
1844
|
+
if (existsSync(planFsPath)) {
|
|
1093
1845
|
return { synced: false, location: 'local-main' };
|
|
1094
1846
|
}
|
|
1095
1847
|
return { synced: false, location: 'not-found' };
|