hub-launch 1.21.0 → 1.22.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 +10 -0
- package/README.md +54 -0
- package/dist/commands/launch.d.ts +91 -0
- package/dist/commands/launch.d.ts.map +1 -1
- package/dist/commands/launch.js +831 -143
- package/dist/commands/launch.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/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-launch/SKILL.md +38 -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/package.json +1 -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';
|
|
@@ -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]')
|
|
@@ -64,8 +80,22 @@ export function launchCommand(program, config) {
|
|
|
64
80
|
.option('--lines <n>', 'Number of log lines to show (default: 100)', parseInt)
|
|
65
81
|
.option('--type <type>', 'Log type to fetch: log or output (default: log)')
|
|
66
82
|
.option('--editor', 'Open logs in editor instead of printing to stdout')
|
|
83
|
+
// ── Multi-repo feature groups (client-side fan-out) ────────────────────
|
|
84
|
+
// NOTE: this `--folder` (a PARENT directory that contains sibling repos) is
|
|
85
|
+
// DIFFERENT from /hula-plan's `--folder` (a plans SUBDIRECTORY). See README.
|
|
86
|
+
.option('--folder <path>', 'Launch the plan in every initialized repo under this folder as one feature group')
|
|
87
|
+
.option('--group-id <id>', 'Use this group id instead of generating one (join/retry an existing group)')
|
|
88
|
+
.option('--plan <repo>=<path>', 'Override the auto-resolved plan for one repo in --folder mode (repeatable)', collectPlanOverrides, {})
|
|
89
|
+
.option('--show-group [id]', 'Show combined status for a feature group (id optional inside a member repo)')
|
|
90
|
+
.option('--yes', 'Skip the group-launch confirmation and allow plan auto-resolve without a TTY (used by the /hula-launch skill)')
|
|
67
91
|
.action(async (issueName, planPath, options) => {
|
|
68
92
|
try {
|
|
93
|
+
// Handle --show-group flag (read-only group status). Routed before
|
|
94
|
+
// the other read flags and before any folder/launch work.
|
|
95
|
+
if (options.showGroup !== undefined) {
|
|
96
|
+
await showGroupStatus(config, options.showGroup, options);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
69
99
|
// Handle --show flag
|
|
70
100
|
if (options.show) {
|
|
71
101
|
await showJobStatus(config, options.show, options);
|
|
@@ -83,6 +113,21 @@ export function launchCommand(program, config) {
|
|
|
83
113
|
logger.error(killFlagError);
|
|
84
114
|
process.exit(1);
|
|
85
115
|
}
|
|
116
|
+
// Validate the multi-repo `--folder` / `--group-id` / `--plan` flag
|
|
117
|
+
// combination before any work.
|
|
118
|
+
const folderFlagError = validateFolderFlags(options, Boolean(planPath));
|
|
119
|
+
if (folderFlagError) {
|
|
120
|
+
logger.error(folderFlagError);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
// Validate an explicitly-supplied group id against the server format.
|
|
124
|
+
if (typeof options.groupId === 'string') {
|
|
125
|
+
const groupIdError = validateGroupId(options.groupId);
|
|
126
|
+
if (groupIdError) {
|
|
127
|
+
logger.error(groupIdError);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
86
131
|
// Validate per-step config + --skip-regression against the two
|
|
87
132
|
// server-parity cross-field rules before any credential resolution or
|
|
88
133
|
// network call. On conflict, fail fast with the server's exact wording.
|
|
@@ -91,6 +136,21 @@ export function launchCommand(program, config) {
|
|
|
91
136
|
logger.error(stepsValidation.error);
|
|
92
137
|
process.exit(1);
|
|
93
138
|
}
|
|
139
|
+
// Multi-repo fan-out: --folder launches (or kills) one ordinary
|
|
140
|
+
// single-repo job per discovered repo, correlated by a shared groupId.
|
|
141
|
+
if (options.folder) {
|
|
142
|
+
if (!issueName) {
|
|
143
|
+
logger.error('Usage: hula launch <issueName> --folder <path>');
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
if (options.kill) {
|
|
147
|
+
await executeGroupKill(config, issueName, options);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
await executeGroupLaunch(config, issueName, options);
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
94
154
|
// --kill is a cancel-only path: it needs ONLY the tracking name (no
|
|
95
155
|
// plan path) and routes to the dedicated executeKill function.
|
|
96
156
|
if (options.kill) {
|
|
@@ -158,6 +218,43 @@ export function validateKillFlags(options) {
|
|
|
158
218
|
export function requiresPlanPath(options) {
|
|
159
219
|
return !options.kill;
|
|
160
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* Validate the multi-repo `--folder` / `--group-id` / `--plan` flag matrix.
|
|
223
|
+
*
|
|
224
|
+
* - `--folder` resolves plans per repository, so it is incompatible with a
|
|
225
|
+
* positional `<planPath>` and with the read/resume flags `--show`, `--logs`,
|
|
226
|
+
* `--resume`, `--fix`.
|
|
227
|
+
* - `--plan` (per-repo override) only makes sense with `--folder`.
|
|
228
|
+
* - `--group-id` alone (no `--folder`) is allowed — it attaches a single-repo
|
|
229
|
+
* launch to an existing group.
|
|
230
|
+
* - `--kill --folder` is allowed (cancel-only fan-out).
|
|
231
|
+
*
|
|
232
|
+
* Returns an error message when the combination is invalid, or `null` when OK.
|
|
233
|
+
*/
|
|
234
|
+
export function validateFolderFlags(options, hasPlanPathPositional) {
|
|
235
|
+
if (options.folder) {
|
|
236
|
+
if (hasPlanPathPositional) {
|
|
237
|
+
return '--folder resolves plans per repository; use --plan <repo>=<path> to override a specific repo';
|
|
238
|
+
}
|
|
239
|
+
const incompatible = [
|
|
240
|
+
{ key: 'show', flag: '--show' },
|
|
241
|
+
{ key: 'logs', flag: '--logs' },
|
|
242
|
+
{ key: 'resume', flag: '--resume' },
|
|
243
|
+
{ key: 'fix', flag: '--fix' },
|
|
244
|
+
];
|
|
245
|
+
for (const { key, flag } of incompatible) {
|
|
246
|
+
const value = options[key];
|
|
247
|
+
if (value !== undefined && value !== false) {
|
|
248
|
+
return `--folder cannot be combined with ${flag}`;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const hasPlanOverrides = options.plan !== undefined && Object.keys(options.plan).length > 0;
|
|
253
|
+
if (hasPlanOverrides && !options.folder) {
|
|
254
|
+
return '--plan requires --folder (it overrides a per-repo plan in folder mode)';
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
161
258
|
/**
|
|
162
259
|
* Derive the request `conflictMode` from the CLI flags (plus an optional explicit
|
|
163
260
|
* override used when re-invoking after an interactive 409 resolution).
|
|
@@ -352,7 +449,11 @@ function printFreeTierError() {
|
|
|
352
449
|
* If the config records 'free', calls the API and updates the config on success.
|
|
353
450
|
* Returns false (and prints the error) when the user is not on Pro.
|
|
354
451
|
*/
|
|
355
|
-
async function verifyProTier(config, serverUrl, apiKey
|
|
452
|
+
async function verifyProTier(config, serverUrl, apiKey,
|
|
453
|
+
// Repo root whose config file records the verified tier. Group mode passes
|
|
454
|
+
// the member repo's path — process.cwd() there is the parent folder, which
|
|
455
|
+
// has no .hublaunch and would silently drop the persistence every run.
|
|
456
|
+
repoRoot = process.cwd()) {
|
|
356
457
|
if (config.usageTier === 'pro') {
|
|
357
458
|
return true;
|
|
358
459
|
}
|
|
@@ -363,7 +464,7 @@ async function verifyProTier(config, serverUrl, apiKey) {
|
|
|
363
464
|
});
|
|
364
465
|
const plan = response.data?.plan;
|
|
365
466
|
if (plan === 'pro') {
|
|
366
|
-
const saved = updateUsageTierInConfig(
|
|
467
|
+
const saved = updateUsageTierInConfig(repoRoot, 'pro');
|
|
367
468
|
if (saved) {
|
|
368
469
|
logger.success('Subscription verified as Pro — config updated for future launches.');
|
|
369
470
|
}
|
|
@@ -390,73 +491,207 @@ async function verifyProTier(config, serverUrl, apiKey) {
|
|
|
390
491
|
return true;
|
|
391
492
|
}
|
|
392
493
|
}
|
|
494
|
+
/**
|
|
495
|
+
* Assemble the `POST /api/v1/ralph-run` request body from already-resolved
|
|
496
|
+
* inputs. Fields are inserted in a fixed order and only when present, so a
|
|
497
|
+
* launch with no group flag produces a body byte-identical to the pre-fan-out
|
|
498
|
+
* CLI (verified by unit test — AC5).
|
|
499
|
+
*/
|
|
500
|
+
export function buildRalphRunRequestBody(input) {
|
|
501
|
+
const body = {
|
|
502
|
+
issueName: input.issueName,
|
|
503
|
+
planPath: input.planPath,
|
|
504
|
+
repositoryId: input.repositoryId,
|
|
505
|
+
};
|
|
506
|
+
if (input.duration !== undefined) {
|
|
507
|
+
body.duration = input.duration;
|
|
508
|
+
}
|
|
509
|
+
// Keep 'ralphPath' property name for server API compatibility
|
|
510
|
+
// even though the CLI option is now '--launch'
|
|
511
|
+
if (input.launchPath) {
|
|
512
|
+
body.ralphPath = input.launchPath;
|
|
513
|
+
}
|
|
514
|
+
// Handle worktree: commander sets it to false for --no-worktree
|
|
515
|
+
if (input.worktree === false) {
|
|
516
|
+
body.worktree = false;
|
|
517
|
+
}
|
|
518
|
+
else if (typeof input.worktree === 'string') {
|
|
519
|
+
body.worktree = input.worktree;
|
|
520
|
+
}
|
|
521
|
+
if (input.resume !== undefined) {
|
|
522
|
+
body.resume = input.resume;
|
|
523
|
+
}
|
|
524
|
+
if (input.fix) {
|
|
525
|
+
body.fix = input.fix;
|
|
526
|
+
}
|
|
527
|
+
if (input.regression) {
|
|
528
|
+
body.regression = true;
|
|
529
|
+
}
|
|
530
|
+
// Maps to RalphRunRequest.steps (hula-server PR #442).
|
|
531
|
+
if (input.steps) {
|
|
532
|
+
body.steps = input.steps;
|
|
533
|
+
}
|
|
534
|
+
// Maps to RalphRunRequest.test (hula-server PR #367).
|
|
535
|
+
if (input.test) {
|
|
536
|
+
body.test = true;
|
|
537
|
+
}
|
|
538
|
+
// Maps to RalphRunRequest.conflictMode (hula-server PR #391). Only added for
|
|
539
|
+
// 'killAndRelaunch' — the server default 'reject' is expressed by omitting.
|
|
540
|
+
if (input.conflictMode) {
|
|
541
|
+
body.conflictMode = input.conflictMode;
|
|
542
|
+
}
|
|
543
|
+
// Maps to RalphRunRequest.groupId (hula-server PR #505): the shared feature-
|
|
544
|
+
// group label correlating the N single-repo launches of a `--folder` fan-out.
|
|
545
|
+
// Omitted entirely for single-repo launches — the server default (ungrouped)
|
|
546
|
+
// is expressed by omitting the key, keeping non-group bodies byte-identical
|
|
547
|
+
// (same omit-when-absent pattern as `test`/`conflictMode` above).
|
|
548
|
+
if (input.groupId) {
|
|
549
|
+
body.groupId = input.groupId;
|
|
550
|
+
}
|
|
551
|
+
if (input.verbose) {
|
|
552
|
+
body.verbose = true;
|
|
553
|
+
}
|
|
554
|
+
if (input.handoff) {
|
|
555
|
+
body.handoff = input.handoff;
|
|
556
|
+
}
|
|
557
|
+
// Maps to RalphRunRequest.clientSessionId (hula-server PR #419).
|
|
558
|
+
if (input.clientSessionId) {
|
|
559
|
+
body.clientSessionId = input.clientSessionId;
|
|
560
|
+
}
|
|
561
|
+
body.anthropicApiKey = input.anthropicApiKey;
|
|
562
|
+
if (input.containerResources) {
|
|
563
|
+
body.containerResources = input.containerResources;
|
|
564
|
+
}
|
|
565
|
+
if (input.updateNotificationUrl) {
|
|
566
|
+
body.updateNotificationUrl = input.updateNotificationUrl;
|
|
567
|
+
}
|
|
568
|
+
if (input.updateNotificationNameTag) {
|
|
569
|
+
body.updateNotificationNameTag = input.updateNotificationNameTag;
|
|
570
|
+
}
|
|
571
|
+
if (input.githubToken) {
|
|
572
|
+
body.githubToken = input.githubToken;
|
|
573
|
+
}
|
|
574
|
+
if (input.envVars) {
|
|
575
|
+
body.envVars = input.envVars;
|
|
576
|
+
}
|
|
577
|
+
return body;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Single-repo launch entry point. Runs the plan-sync/upload check, then defers
|
|
581
|
+
* the body-build + POST to {@link launchSingleRepo} in single mode (keeps the
|
|
582
|
+
* interactive 409 resolver and process-exit-on-error behavior).
|
|
583
|
+
*/
|
|
393
584
|
async function executeLaunch(config, issueName, planPath, options,
|
|
394
585
|
// Explicit conflict mode override. Set when re-invoking after the interactive
|
|
395
586
|
// 409 resolver picks "kill and relaunch" — it both forces the server to
|
|
396
587
|
// cancel+relaunch and (being non-undefined) suppresses re-entering the prompt.
|
|
397
588
|
conflictModeOverride) {
|
|
398
589
|
logger.section('Launch');
|
|
590
|
+
const result = await launchSingleRepo(config, process.cwd(), issueName, planPath, options, {
|
|
591
|
+
mode: 'single',
|
|
592
|
+
interactive409: true,
|
|
593
|
+
conflictModeOverride,
|
|
594
|
+
groupId: options.groupId,
|
|
595
|
+
});
|
|
596
|
+
// In single mode every fatal error already called process.exit inside
|
|
597
|
+
// launchSingleRepo (or the interactive 409 resolver handled the flow); a
|
|
598
|
+
// returned failure that reached here still exits non-zero for safety.
|
|
599
|
+
if (!result.ok) {
|
|
600
|
+
process.exit(1);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Body-build + POST core of a launch, reused by both the single-repo path and
|
|
605
|
+
* the `--folder` fan-out. In single mode fatal errors call `process.exit` and a
|
|
606
|
+
* reject-409 opens the interactive resolver; in group mode fatal errors are
|
|
607
|
+
* returned as a failed {@link RepoLaunchResult} so the fan-out can continue.
|
|
608
|
+
*
|
|
609
|
+
* @param repoConfig - The repository's loaded config.
|
|
610
|
+
* @param repoRoot - Absolute path to the repository root (cwd for git/env/.env).
|
|
611
|
+
* @param issueName - Tracking name for the launch.
|
|
612
|
+
* @param planPath - Plan path (repo-relative or absolute).
|
|
613
|
+
* @param options - Parsed CLI options.
|
|
614
|
+
* @param ctx - Mode + shared-once context (see {@link SingleRepoLaunchContext}).
|
|
615
|
+
*/
|
|
616
|
+
async function launchSingleRepo(repoConfig, repoRoot, issueName, planPath, options, ctx) {
|
|
617
|
+
const isGroup = ctx.mode === 'group';
|
|
399
618
|
// Effective conflict mode: an explicit override (from the 409 resolver) or the
|
|
400
619
|
// --kill-and-relaunch flag. Undefined = default 'reject' (byte-identical body).
|
|
401
|
-
const conflictMode = deriveConflictMode(options, conflictModeOverride);
|
|
620
|
+
const conflictMode = deriveConflictMode(options, ctx.conflictModeOverride);
|
|
402
621
|
// Check if plan is synced to origin/main
|
|
403
|
-
const planSyncStatus = await checkPlanSyncStatus(
|
|
622
|
+
const planSyncStatus = await checkPlanSyncStatus(repoConfig, planPath, repoRoot);
|
|
404
623
|
if (!planSyncStatus.synced) {
|
|
405
624
|
if (planSyncStatus.location === 'not-found') {
|
|
406
625
|
logger.error('Plan not found locally or on origin/main');
|
|
407
626
|
logger.info('Make sure you created the plan with /hula-plan');
|
|
627
|
+
if (isGroup)
|
|
628
|
+
return { ok: false, message: 'Plan not found locally or on origin/main' };
|
|
408
629
|
process.exit(1);
|
|
409
630
|
}
|
|
410
631
|
// Plan exists locally but not on origin/main - auto-upload
|
|
411
632
|
logger.info('Plan found locally, pushing to origin/main...');
|
|
412
633
|
try {
|
|
413
|
-
await executeUpload(
|
|
634
|
+
await executeUpload(repoConfig, planPath, repoRoot);
|
|
414
635
|
logger.success('Plan synced to origin/main');
|
|
415
636
|
logger.blank();
|
|
416
637
|
}
|
|
417
638
|
catch (uploadError) {
|
|
418
|
-
|
|
419
|
-
logger.error(uploadError instanceof Error
|
|
639
|
+
const msg = uploadError instanceof Error
|
|
420
640
|
? uploadError.message
|
|
421
|
-
: String(uploadError)
|
|
641
|
+
: String(uploadError);
|
|
642
|
+
logger.error('Failed to upload plan:');
|
|
643
|
+
logger.error(msg);
|
|
644
|
+
if (isGroup)
|
|
645
|
+
return { ok: false, message: `Failed to upload plan: ${msg}` };
|
|
422
646
|
process.exit(1);
|
|
423
647
|
}
|
|
424
648
|
}
|
|
425
649
|
// Get server URL and API key
|
|
426
|
-
const serverUrl = options.url ||
|
|
427
|
-
const apiKey = options.apiKey ||
|
|
650
|
+
const serverUrl = options.url || repoConfig.hulaProjectUrl || HULA_PROJECT_URL;
|
|
651
|
+
const apiKey = options.apiKey || repoConfig.hulaApiKey || process.env.HULA_API_KEY;
|
|
428
652
|
if (!apiKey) {
|
|
429
653
|
logger.error('No API key configured.');
|
|
430
654
|
logger.info('Set hulaApiKey in your config or use --api-key option.');
|
|
431
655
|
logger.info('You can also set HULA_API_KEY environment variable.');
|
|
432
656
|
logger.info("Run 'hula login' to authenticate with hula-project.");
|
|
657
|
+
if (isGroup)
|
|
658
|
+
return { ok: false, message: 'No API key configured' };
|
|
433
659
|
process.exit(1);
|
|
434
660
|
}
|
|
435
|
-
// Verify the user is on the Pro tier before proceeding
|
|
436
|
-
|
|
437
|
-
if (!
|
|
438
|
-
|
|
661
|
+
// Verify the user is on the Pro tier before proceeding (skipped in group mode
|
|
662
|
+
// where the check ran once against the first repo's key).
|
|
663
|
+
if (!ctx.proTierVerified) {
|
|
664
|
+
const isProUser = await verifyProTier(repoConfig, serverUrl, apiKey);
|
|
665
|
+
if (!isProUser) {
|
|
666
|
+
if (isGroup)
|
|
667
|
+
return { ok: false, message: 'Not on the Pro tier' };
|
|
668
|
+
process.exit(1);
|
|
669
|
+
}
|
|
439
670
|
}
|
|
440
|
-
// Get project ID - from config or detect from git
|
|
441
|
-
const repositoryId = await resolveProject(
|
|
671
|
+
// Get project ID - from config or detect from git (repo cwd for the fallback)
|
|
672
|
+
const repositoryId = ctx.repositoryId ?? (await resolveProject(repoConfig, undefined, repoRoot));
|
|
442
673
|
// Validate fix requires resume
|
|
443
674
|
if (options.fix && options.resume === undefined) {
|
|
444
675
|
logger.error('--fix requires --resume to be set');
|
|
676
|
+
if (isGroup)
|
|
677
|
+
return { ok: false, message: '--fix requires --resume to be set' };
|
|
445
678
|
process.exit(1);
|
|
446
679
|
}
|
|
447
680
|
// Validate resume range
|
|
448
681
|
if (options.resume !== undefined &&
|
|
449
682
|
(options.resume < 1 || options.resume > 9)) {
|
|
450
683
|
logger.error('--resume must be between 1 and 9');
|
|
684
|
+
if (isGroup)
|
|
685
|
+
return { ok: false, message: '--resume must be between 1 and 9' };
|
|
451
686
|
process.exit(1);
|
|
452
687
|
}
|
|
453
688
|
// Build the API endpoint
|
|
454
689
|
const endpoint = `${serverUrl.replace(/\/$/, '')}/api/v1/ralph-run`;
|
|
455
690
|
const updateNotificationUrl = options.updateNotificationUrl ||
|
|
456
|
-
|
|
691
|
+
repoConfig.updateNotificationUrl ||
|
|
457
692
|
process.env.HULA_UPDATE_NOTIFICATION_URL;
|
|
458
693
|
const updateNotificationNameTag = options.updateNotificationNameTag ||
|
|
459
|
-
|
|
694
|
+
repoConfig.updateNotificationNameTag ||
|
|
460
695
|
process.env.HULA_UPDATE_NOTIFICATION_NAME_TAG;
|
|
461
696
|
logger.info(`Triggering launch job...`);
|
|
462
697
|
logger.log(` Issue Name: ${issueName}`);
|
|
@@ -483,93 +718,31 @@ conflictModeOverride) {
|
|
|
483
718
|
logger.log(` Test Mode: yes (mock Claude)`);
|
|
484
719
|
if (options.handoff)
|
|
485
720
|
logger.log(` Handoff To: ${options.handoff}`);
|
|
721
|
+
if (ctx.groupId)
|
|
722
|
+
logger.log(` Group: ${ctx.groupId}`);
|
|
486
723
|
if (updateNotificationUrl)
|
|
487
724
|
logger.log(` Notify on completion: enabled`);
|
|
488
725
|
if (updateNotificationNameTag)
|
|
489
726
|
logger.log(` Notify tag: ${updateNotificationNameTag}`);
|
|
490
|
-
if (
|
|
491
|
-
if (
|
|
727
|
+
if (repoConfig.envVars) {
|
|
728
|
+
if (repoConfig.envVars === 'all') {
|
|
492
729
|
logger.log(` Environment Variables: all (from .env)`);
|
|
493
730
|
}
|
|
494
|
-
else if (
|
|
495
|
-
logger.log(` Environment Variables: ${
|
|
731
|
+
else if (repoConfig.envVars.length > 0) {
|
|
732
|
+
logger.log(` Environment Variables: ${repoConfig.envVars.length} configured`);
|
|
496
733
|
}
|
|
497
734
|
}
|
|
498
735
|
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.
|
|
736
|
+
// Maps to RalphRunRequest.steps (hula-server PR #442). resolveSteps() re-runs
|
|
737
|
+
// the same validation the action handler already passed (pure function), and
|
|
738
|
+
// returns `undefined` when there's nothing to send.
|
|
739
|
+
const stepsResult = resolveSteps(repoConfig.steps, options.skipRegression, options.regression);
|
|
740
|
+
// Maps to RalphRunRequest.clientSessionId (hula-server PR #419).
|
|
565
741
|
const clientSessionId = readClientSessionId(issueName);
|
|
566
|
-
if (clientSessionId) {
|
|
567
|
-
requestBody.clientSessionId = clientSessionId;
|
|
568
|
-
}
|
|
569
742
|
// Resolve ephemeral credentials through the shared resolver — the single
|
|
570
743
|
// source of truth for precedence/validation, also used by `execute`.
|
|
571
744
|
// Collect ALL errors before exiting (preserves prior launch behavior).
|
|
572
|
-
const resolved = resolveEphemeralCredentials(options,
|
|
745
|
+
const resolved = resolveEphemeralCredentials(options, repoConfig);
|
|
573
746
|
let anthropicApiKey = resolved.anthropicApiKey;
|
|
574
747
|
const credentialErrors = resolved.errors;
|
|
575
748
|
if (credentialErrors.length > 0) {
|
|
@@ -596,20 +769,26 @@ conflictModeOverride) {
|
|
|
596
769
|
if (!pasted) {
|
|
597
770
|
// Empty / cancelled → the original resolver error, then exit.
|
|
598
771
|
credentialErrors.forEach((msg) => console.error(chalk.red(msg)));
|
|
772
|
+
if (isGroup)
|
|
773
|
+
return { ok: false, message: credentialErrors.join('; ') };
|
|
599
774
|
process.exit(1);
|
|
600
775
|
}
|
|
601
776
|
if (!pasted.startsWith('sk-ant-oat')) {
|
|
602
777
|
// Wrong prefix → the existing wrong-prefix error text. One attempt only.
|
|
603
|
-
|
|
778
|
+
const wrongPrefixMsg = 'Anthropic credential must be an OAuth token (starts with sk-ant-oat01-…). Standard API keys (sk-ant-api03-…) are not supported — the container requires CLAUDE_CODE_OAUTH_TOKEN.';
|
|
779
|
+
console.error(chalk.red(wrongPrefixMsg));
|
|
780
|
+
if (isGroup)
|
|
781
|
+
return { ok: false, message: wrongPrefixMsg };
|
|
604
782
|
process.exit(1);
|
|
605
783
|
}
|
|
606
784
|
// Valid: use it for this launch and persist it to the config file. A
|
|
607
785
|
// persistence failure must NOT abort a valid launch — warn and continue
|
|
608
|
-
// with the in-memory key.
|
|
786
|
+
// with the in-memory key. In group mode the token is written to the
|
|
787
|
+
// member repo's own config file (repoRoot); in single mode to the git root.
|
|
609
788
|
anthropicApiKey = pasted;
|
|
610
789
|
try {
|
|
611
|
-
const
|
|
612
|
-
const configFilePath = path.join(
|
|
790
|
+
const configDir = isGroup ? repoRoot : await findGitRoot();
|
|
791
|
+
const configFilePath = path.join(configDir, '.hublaunch', 'hublaunch.config.js');
|
|
613
792
|
const original = readFileSync(configFilePath, 'utf-8');
|
|
614
793
|
const updated = upsertAnthropicApiKey(original, pasted);
|
|
615
794
|
writeFileSync(configFilePath, updated, { mode: 0o600 });
|
|
@@ -621,56 +800,59 @@ conflictModeOverride) {
|
|
|
621
800
|
}
|
|
622
801
|
else {
|
|
623
802
|
credentialErrors.forEach((msg) => console.error(chalk.red(msg)));
|
|
803
|
+
if (isGroup)
|
|
804
|
+
return { ok: false, message: credentialErrors.join('; ') };
|
|
624
805
|
process.exit(1);
|
|
625
806
|
}
|
|
626
807
|
}
|
|
627
|
-
|
|
628
|
-
const cpuRaw = options.containerCpu ??
|
|
629
|
-
const memRaw = options.containerMemory ??
|
|
630
|
-
const diskRaw = options.containerDisk ??
|
|
808
|
+
// Resolve container resources (validate ranges, mode-aware failure).
|
|
809
|
+
const cpuRaw = options.containerCpu ?? repoConfig.containerResources?.cpu;
|
|
810
|
+
const memRaw = options.containerMemory ?? repoConfig.containerResources?.memory;
|
|
811
|
+
const diskRaw = options.containerDisk ?? repoConfig.containerResources?.disk;
|
|
812
|
+
let containerResources;
|
|
631
813
|
if (cpuRaw !== undefined || memRaw !== undefined || diskRaw !== undefined) {
|
|
632
814
|
const cpu = cpuRaw ?? DEFAULT_CONTAINER_RESOURCES.cpu;
|
|
633
815
|
const memory = memRaw ?? DEFAULT_CONTAINER_RESOURCES.memory;
|
|
634
816
|
const disk = diskRaw ?? DEFAULT_CONTAINER_RESOURCES.disk;
|
|
635
817
|
if (!Number.isInteger(cpu) || cpu < 1 || cpu > 32) {
|
|
636
818
|
logger.error('containerResources.cpu must be an integer between 1 and 32');
|
|
819
|
+
if (isGroup)
|
|
820
|
+
return { ok: false, message: 'containerResources.cpu must be an integer between 1 and 32' };
|
|
637
821
|
process.exit(1);
|
|
638
822
|
}
|
|
639
823
|
if (!Number.isInteger(memory) || memory < 1 || memory > 128) {
|
|
640
824
|
logger.error('containerResources.memory must be an integer between 1 and 128 GiB');
|
|
825
|
+
if (isGroup)
|
|
826
|
+
return { ok: false, message: 'containerResources.memory must be an integer between 1 and 128 GiB' };
|
|
641
827
|
process.exit(1);
|
|
642
828
|
}
|
|
643
829
|
if (!Number.isInteger(disk) || disk < 5 || disk > 200) {
|
|
644
830
|
logger.error('containerResources.disk must be an integer between 5 and 200 GiB');
|
|
831
|
+
if (isGroup)
|
|
832
|
+
return { ok: false, message: 'containerResources.disk must be an integer between 5 and 200 GiB' };
|
|
645
833
|
process.exit(1);
|
|
646
834
|
}
|
|
647
|
-
|
|
835
|
+
containerResources = { cpu, memory, disk };
|
|
648
836
|
logger.log(` Container Resources: cpu=${cpu} memory=${memory}GiB disk=${disk}GiB`);
|
|
649
837
|
}
|
|
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,
|
|
838
|
+
// Resolve the GitHub token: shared (group mode) or loaded (single mode).
|
|
839
|
+
const githubToken = ctx.skipTokenLoad
|
|
840
|
+
? ctx.githubToken
|
|
841
|
+
: (await loadGitHubToken())?.access_token || process.env.GITHUB_TOKEN;
|
|
842
|
+
// Collect environment variables if configured. Read from the repo's .env,
|
|
662
843
|
// validate against the reserved blocklist, and confirm presence — failing fast
|
|
663
844
|
// with a clear message before the job is submitted.
|
|
664
|
-
|
|
845
|
+
let envVars;
|
|
846
|
+
if (repoConfig.envVars) {
|
|
665
847
|
logger.info('Collecting environment variables for container...');
|
|
666
848
|
try {
|
|
667
849
|
// Expand "all" to the actual list of non-reserved variables from .env.
|
|
668
|
-
let varsToCollect = Array.isArray(
|
|
669
|
-
?
|
|
850
|
+
let varsToCollect = Array.isArray(repoConfig.envVars)
|
|
851
|
+
? repoConfig.envVars
|
|
670
852
|
: [];
|
|
671
|
-
if (
|
|
853
|
+
if (repoConfig.envVars === 'all') {
|
|
672
854
|
// Read .env and get all non-reserved variables
|
|
673
|
-
const envPath = join(
|
|
855
|
+
const envPath = join(repoRoot, '.env');
|
|
674
856
|
if (!existsSync(envPath)) {
|
|
675
857
|
throw new Error(`Cannot read environment variables: .env file not found at ${envPath}\n` +
|
|
676
858
|
`Config specifies envVars: "all", but .env does not exist`);
|
|
@@ -686,22 +868,53 @@ conflictModeOverride) {
|
|
|
686
868
|
// Validate and collect
|
|
687
869
|
if (varsToCollect.length > 0) {
|
|
688
870
|
validateNoReservedVars(varsToCollect);
|
|
689
|
-
const
|
|
690
|
-
if (Object.keys(
|
|
691
|
-
|
|
692
|
-
logger.log(` ✓ ${Object.keys(
|
|
871
|
+
const collected = readAndFilterEnvVars(repoRoot, varsToCollect);
|
|
872
|
+
if (Object.keys(collected).length > 0) {
|
|
873
|
+
envVars = collected;
|
|
874
|
+
logger.log(` ✓ ${Object.keys(collected).length} variable(s) collected`);
|
|
693
875
|
}
|
|
694
876
|
}
|
|
695
877
|
}
|
|
696
878
|
catch (error) {
|
|
879
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
697
880
|
logger.error('Failed to collect environment variables:');
|
|
698
|
-
logger.error(
|
|
881
|
+
logger.error(msg);
|
|
882
|
+
if (isGroup)
|
|
883
|
+
return { ok: false, message: `Failed to collect environment variables: ${msg}` };
|
|
699
884
|
process.exit(1);
|
|
700
885
|
}
|
|
701
886
|
}
|
|
702
|
-
// Pre-flight credential validation
|
|
887
|
+
// Pre-flight credential validation (skipped in group mode where each distinct
|
|
888
|
+
// Anthropic key was validated once before the fan-out loop).
|
|
703
889
|
// anthropicApiKey is guaranteed non-null here — missing key exits above
|
|
704
|
-
|
|
890
|
+
if (!ctx.credentialsValidated) {
|
|
891
|
+
await validateLaunchCredentials(anthropicApiKey, githubToken);
|
|
892
|
+
}
|
|
893
|
+
// Assemble the request body from the resolved values.
|
|
894
|
+
const requestBody = buildRalphRunRequestBody({
|
|
895
|
+
issueName,
|
|
896
|
+
planPath,
|
|
897
|
+
repositoryId,
|
|
898
|
+
duration: options.duration,
|
|
899
|
+
launchPath: options.launch,
|
|
900
|
+
worktree: options.worktree,
|
|
901
|
+
resume: options.resume,
|
|
902
|
+
fix: options.fix,
|
|
903
|
+
regression: options.regression,
|
|
904
|
+
steps: stepsResult.steps,
|
|
905
|
+
test: options.test,
|
|
906
|
+
conflictMode,
|
|
907
|
+
groupId: ctx.groupId,
|
|
908
|
+
verbose: options.verbose,
|
|
909
|
+
handoff: options.handoff,
|
|
910
|
+
clientSessionId: clientSessionId ?? undefined,
|
|
911
|
+
anthropicApiKey: anthropicApiKey,
|
|
912
|
+
containerResources,
|
|
913
|
+
updateNotificationUrl,
|
|
914
|
+
updateNotificationNameTag,
|
|
915
|
+
githubToken,
|
|
916
|
+
envVars,
|
|
917
|
+
});
|
|
705
918
|
try {
|
|
706
919
|
const response = await axios.post(endpoint, requestBody, {
|
|
707
920
|
headers: {
|
|
@@ -731,30 +944,503 @@ conflictModeOverride) {
|
|
|
731
944
|
}
|
|
732
945
|
logger.blank();
|
|
733
946
|
// 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);
|
|
947
|
+
const planService = new PlanService(repoConfig.planPath);
|
|
948
|
+
planService.deletePlan(isAbsolute(planPath) ? planPath : join(repoRoot, planPath));
|
|
949
|
+
return { ok: true, planId: response.data.planId };
|
|
740
950
|
}
|
|
951
|
+
logger.error(response.data.error || 'Unknown error occurred');
|
|
952
|
+
if (isGroup)
|
|
953
|
+
return { ok: false, message: response.data.error || 'Unknown error occurred' };
|
|
954
|
+
process.exit(1);
|
|
741
955
|
}
|
|
742
956
|
catch (error) {
|
|
743
957
|
// 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).
|
|
958
|
+
// tracking name. Skip this whenever a conflictMode is already in play — that
|
|
959
|
+
// request cannot produce a reject 409 and must not re-enter the prompt.
|
|
748
960
|
if (!conflictMode &&
|
|
749
961
|
axios.isAxiosError(error) &&
|
|
750
962
|
error.response?.status === 409) {
|
|
751
|
-
|
|
752
|
-
|
|
963
|
+
if (ctx.interactive409) {
|
|
964
|
+
// Single mode: offer recovery options interactively (or, when scripted,
|
|
965
|
+
// print the server message and exit). The resolver drives the flow.
|
|
966
|
+
await handleReject409(error, repoConfig, issueName, planPath, options);
|
|
967
|
+
return { ok: true };
|
|
968
|
+
}
|
|
969
|
+
// Group mode: record the failure with the server message plus a hint to
|
|
970
|
+
// re-run with --kill-and-relaunch. Never enters the interactive resolver.
|
|
971
|
+
const data = (error.response?.data ?? {});
|
|
972
|
+
const serverMessage = data.error || `A task is already running for '${issueName}'.`;
|
|
973
|
+
return {
|
|
974
|
+
ok: false,
|
|
975
|
+
status: 409,
|
|
976
|
+
message: `${serverMessage} (re-run with --kill-and-relaunch to force)`,
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
if (isGroup) {
|
|
980
|
+
const described = describeLaunchAxiosError(error, serverUrl, issueName, planPath);
|
|
981
|
+
return { ok: false, status: described.status, message: described.message };
|
|
753
982
|
}
|
|
754
983
|
handleLaunchAxiosError(error, serverUrl, issueName, planPath);
|
|
755
984
|
process.exit(1);
|
|
756
985
|
}
|
|
757
986
|
}
|
|
987
|
+
/**
|
|
988
|
+
* Fan-out launch (`hula launch <issue> --folder <path>`). Discovers every
|
|
989
|
+
* initialized repo under `<path>`, resolves each repo's plan, confirms the
|
|
990
|
+
* roster, then launches each repo with its own config/key plus one shared
|
|
991
|
+
* generated (or `--group-id`-supplied) groupId. Continues past per-repo
|
|
992
|
+
* failures and prints a retry command carrying `--group-id`.
|
|
993
|
+
*/
|
|
994
|
+
async function executeGroupLaunch(_config, issueName, options) {
|
|
995
|
+
const service = new GroupLaunchService();
|
|
996
|
+
const folder = path.resolve(options.folder);
|
|
997
|
+
if (!existsSync(folder)) {
|
|
998
|
+
logger.error(`Folder not found: ${folder}`);
|
|
999
|
+
process.exit(1);
|
|
1000
|
+
}
|
|
1001
|
+
const { repos, skipped, folderIsRepo } = service.discoverRepos(folder);
|
|
1002
|
+
logger.section('Group Launch');
|
|
1003
|
+
for (const s of skipped) {
|
|
1004
|
+
logger.log(` skipped ${s.name} (${s.reason})`);
|
|
1005
|
+
}
|
|
1006
|
+
if (repos.length === 0) {
|
|
1007
|
+
if (folderIsRepo) {
|
|
1008
|
+
logger.error(`${folder} looks like a repository itself; --folder expects the parent folder that contains your repos`);
|
|
1009
|
+
}
|
|
1010
|
+
else {
|
|
1011
|
+
logger.error(`No initialized repos found directly under ${folder}`);
|
|
1012
|
+
logger.info('Each repo needs a .git entry and a .hublaunch config (run hula init + hula login in it).');
|
|
1013
|
+
}
|
|
1014
|
+
process.exit(1);
|
|
1015
|
+
}
|
|
1016
|
+
if (repos.length === 1) {
|
|
1017
|
+
logger.warning('only one repo found — this launches a single-repo group');
|
|
1018
|
+
}
|
|
1019
|
+
// Resolve or accept the group id (already format-validated in the handler).
|
|
1020
|
+
const groupId = options.groupId ?? service.generateGroupId(issueName);
|
|
1021
|
+
// Validate --plan override repo names and reject path-traversal paths.
|
|
1022
|
+
const overrides = options.plan ?? {};
|
|
1023
|
+
const repoNames = new Set(repos.map((r) => r.name));
|
|
1024
|
+
for (const [name, overridePath] of Object.entries(overrides)) {
|
|
1025
|
+
if (!repoNames.has(name)) {
|
|
1026
|
+
logger.error(`Unknown repo in --plan override: "${name}". Discovered repos: ${[...repoNames].join(', ')}`);
|
|
1027
|
+
process.exit(1);
|
|
1028
|
+
}
|
|
1029
|
+
if (overridePath.includes('..')) {
|
|
1030
|
+
logger.error(`Invalid --plan path for "${name}" (path traversal not allowed): ${overridePath}`);
|
|
1031
|
+
process.exit(1);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
const isTty = Boolean(process.stdout.isTTY);
|
|
1035
|
+
// `--yes` opts a non-TTY caller (the /hula-launch skill runs this CLI as a
|
|
1036
|
+
// piped subprocess) into the same auto-resolve + roster flow a TTY gets,
|
|
1037
|
+
// skipping the interactive confirmation. Without it, non-TTY still requires
|
|
1038
|
+
// explicit --plan coverage (never guess silently in CI).
|
|
1039
|
+
const assumeYes = Boolean(options.yes);
|
|
1040
|
+
const canAutoResolve = isTty || assumeYes;
|
|
1041
|
+
// Load each repo's config INDEPENDENTLY (never the cached singleton), resolve
|
|
1042
|
+
// its project, and resolve its plan (override → auto-resolve, disabled non-TTY
|
|
1043
|
+
// unless --yes).
|
|
1044
|
+
const entries = [];
|
|
1045
|
+
for (const repo of repos) {
|
|
1046
|
+
const repoConfig = await new ConfigLoader(repo.path).load();
|
|
1047
|
+
const project = await resolveProject(repoConfig, undefined, repo.path);
|
|
1048
|
+
let loginError;
|
|
1049
|
+
const hasKey = Boolean(options.apiKey) ||
|
|
1050
|
+
Boolean(repoConfig.hulaApiKey) ||
|
|
1051
|
+
Boolean(process.env.HULA_API_KEY);
|
|
1052
|
+
if (!hasKey) {
|
|
1053
|
+
loginError = `NOT LOGGED IN (run hula login in ${repo.path})`;
|
|
1054
|
+
}
|
|
1055
|
+
let planPath;
|
|
1056
|
+
if (overrides[repo.name]) {
|
|
1057
|
+
planPath = overrides[repo.name];
|
|
1058
|
+
}
|
|
1059
|
+
else if (!canAutoResolve) {
|
|
1060
|
+
// Non-TTY without --yes: never guess — require an explicit --plan override.
|
|
1061
|
+
planPath = null;
|
|
1062
|
+
}
|
|
1063
|
+
else {
|
|
1064
|
+
planPath = service.resolvePlan(repo, repoConfig, issueName);
|
|
1065
|
+
}
|
|
1066
|
+
entries.push({ repo, repoConfig, project, planPath, loginError });
|
|
1067
|
+
}
|
|
1068
|
+
// Duplicate project detection: two members with the same repositoryId would
|
|
1069
|
+
// overwrite each other server-side ("latest launch wins"). Fail before launch.
|
|
1070
|
+
const projectToRepos = new Map();
|
|
1071
|
+
for (const e of entries) {
|
|
1072
|
+
projectToRepos.set(e.project, [
|
|
1073
|
+
...(projectToRepos.get(e.project) ?? []),
|
|
1074
|
+
e.repo.name,
|
|
1075
|
+
]);
|
|
1076
|
+
}
|
|
1077
|
+
for (const [project, names] of projectToRepos) {
|
|
1078
|
+
if (names.length > 1) {
|
|
1079
|
+
logger.error(`Duplicate project ${project} across discovered repos: ${names.join(', ')}. ` +
|
|
1080
|
+
`Each member must be a distinct repository.`);
|
|
1081
|
+
process.exit(1);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
// Non-TTY coverage: without --yes, every repo must be covered by a --plan
|
|
1085
|
+
// override (a piped/CI caller must opt in before the CLI guesses plans).
|
|
1086
|
+
if (!canAutoResolve) {
|
|
1087
|
+
const uncovered = entries
|
|
1088
|
+
.filter((e) => !overrides[e.repo.name])
|
|
1089
|
+
.map((e) => e.repo.name);
|
|
1090
|
+
if (uncovered.length > 0) {
|
|
1091
|
+
logger.error(`Non-interactive run requires a --plan <repo>=<path> override for every repo (or pass --yes to auto-resolve). Uncovered: ${uncovered.join(', ')}`);
|
|
1092
|
+
process.exit(1);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
// Print the roster and gate on any repo that can't launch.
|
|
1096
|
+
logger.blank();
|
|
1097
|
+
logger.info(`Feature group: ${groupId}`);
|
|
1098
|
+
let blocked = false;
|
|
1099
|
+
for (const e of entries) {
|
|
1100
|
+
let planLabel;
|
|
1101
|
+
if (e.loginError) {
|
|
1102
|
+
planLabel = e.loginError;
|
|
1103
|
+
blocked = true;
|
|
1104
|
+
}
|
|
1105
|
+
else if (!e.planPath) {
|
|
1106
|
+
planLabel = 'NO PLAN FOUND';
|
|
1107
|
+
blocked = true;
|
|
1108
|
+
}
|
|
1109
|
+
else {
|
|
1110
|
+
planLabel = e.planPath;
|
|
1111
|
+
}
|
|
1112
|
+
logger.log(` ${e.repo.name} → ${planLabel}`);
|
|
1113
|
+
}
|
|
1114
|
+
if (blocked) {
|
|
1115
|
+
logger.blank();
|
|
1116
|
+
logger.error('Cannot launch: every repo needs an API key and a resolvable plan.');
|
|
1117
|
+
logger.info('Supply a plan with --plan <repo>=<path>, run hula login in the repo, or remove it from the folder.');
|
|
1118
|
+
process.exit(1);
|
|
1119
|
+
}
|
|
1120
|
+
// Single interactive confirmation before any network launch (TTY only;
|
|
1121
|
+
// --yes skips it — the roster above stays in the output as the record).
|
|
1122
|
+
if (isTty && !assumeYes) {
|
|
1123
|
+
const { proceed } = await inquirer.prompt([
|
|
1124
|
+
{
|
|
1125
|
+
type: 'confirm',
|
|
1126
|
+
name: 'proceed',
|
|
1127
|
+
message: `Launch these ${entries.length} repos as group ${groupId}?`,
|
|
1128
|
+
default: true,
|
|
1129
|
+
},
|
|
1130
|
+
]);
|
|
1131
|
+
if (!proceed) {
|
|
1132
|
+
logger.info('Cancelled — no launches sent.');
|
|
1133
|
+
process.exit(0);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
// ── Shared-once work ──────────────────────────────────────────────────
|
|
1137
|
+
// Pro-tier check with the first repo's key.
|
|
1138
|
+
const first = entries[0];
|
|
1139
|
+
const proServerUrl = options.url || first.repoConfig.hulaProjectUrl || HULA_PROJECT_URL;
|
|
1140
|
+
const proApiKey = options.apiKey || first.repoConfig.hulaApiKey || process.env.HULA_API_KEY;
|
|
1141
|
+
if (proApiKey) {
|
|
1142
|
+
const isPro = await verifyProTier(first.repoConfig, proServerUrl, proApiKey, first.repo.path);
|
|
1143
|
+
if (!isPro)
|
|
1144
|
+
process.exit(1);
|
|
1145
|
+
}
|
|
1146
|
+
// GitHub token once (user-level).
|
|
1147
|
+
const tokenData = await loadGitHubToken();
|
|
1148
|
+
const githubToken = tokenData?.access_token || process.env.GITHUB_TOKEN;
|
|
1149
|
+
// Validate each DISTINCT resolvable Anthropic key once.
|
|
1150
|
+
const validatedKeys = new Set();
|
|
1151
|
+
for (const e of entries) {
|
|
1152
|
+
const key = resolveEphemeralCredentials(options, e.repoConfig).anthropicApiKey;
|
|
1153
|
+
if (key && !validatedKeys.has(key)) {
|
|
1154
|
+
validatedKeys.add(key);
|
|
1155
|
+
await validateLaunchCredentials(key, githubToken);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
// ── Sequential fan-out (continue on failure) ──────────────────────────
|
|
1159
|
+
const results = [];
|
|
1160
|
+
const successfulRepos = [];
|
|
1161
|
+
for (const e of entries) {
|
|
1162
|
+
logger.section(`Launch: ${e.repo.name}`);
|
|
1163
|
+
const result = await launchSingleRepo(e.repoConfig, e.repo.path, issueName, e.planPath, options, {
|
|
1164
|
+
mode: 'group',
|
|
1165
|
+
interactive409: false,
|
|
1166
|
+
groupId,
|
|
1167
|
+
repositoryId: e.project,
|
|
1168
|
+
proTierVerified: true,
|
|
1169
|
+
credentialsValidated: true,
|
|
1170
|
+
skipTokenLoad: true,
|
|
1171
|
+
githubToken,
|
|
1172
|
+
});
|
|
1173
|
+
results.push({ repo: e.repo, project: e.project, planPath: e.planPath, result });
|
|
1174
|
+
if (result.ok) {
|
|
1175
|
+
successfulRepos.push(e.repo);
|
|
1176
|
+
}
|
|
1177
|
+
else {
|
|
1178
|
+
logger.error(`✗ ${e.repo.name}: ${result.message ?? 'launch failed'}`);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
// Record the group locally in every successful member (best-effort).
|
|
1182
|
+
if (successfulRepos.length > 0) {
|
|
1183
|
+
const recordRepos = successfulRepos.map((r) => {
|
|
1184
|
+
const e = entries.find((x) => x.repo.path === r.path);
|
|
1185
|
+
return { name: r.name, path: r.path, project: e.project };
|
|
1186
|
+
});
|
|
1187
|
+
const record = {
|
|
1188
|
+
groupId,
|
|
1189
|
+
issueName,
|
|
1190
|
+
createdAt: new Date().toISOString(),
|
|
1191
|
+
folder,
|
|
1192
|
+
repos: recordRepos,
|
|
1193
|
+
};
|
|
1194
|
+
service.recordGroup(successfulRepos, record);
|
|
1195
|
+
}
|
|
1196
|
+
// ── Summary ───────────────────────────────────────────────────────────
|
|
1197
|
+
logger.section('Group Launch Summary');
|
|
1198
|
+
logger.log(` Group: ${groupId}`);
|
|
1199
|
+
for (const r of results) {
|
|
1200
|
+
if (r.result.ok) {
|
|
1201
|
+
logger.log(` ✓ ${r.repo.name}${r.result.planId ? ` (plan ${r.result.planId})` : ''}`);
|
|
1202
|
+
}
|
|
1203
|
+
else {
|
|
1204
|
+
logger.log(` ✗ ${r.repo.name} ${r.result.message ?? 'launch failed'}`);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
const failures = results.filter((r) => !r.result.ok);
|
|
1208
|
+
if (failures.length > 0) {
|
|
1209
|
+
logger.blank();
|
|
1210
|
+
logger.info('Retry the whole group (already-successful members may 409):');
|
|
1211
|
+
logger.info(` hula launch ${issueName} --folder "${options.folder}" --group-id ${groupId}`);
|
|
1212
|
+
logger.info('Or retry a single failed repo surgically:');
|
|
1213
|
+
for (const f of failures) {
|
|
1214
|
+
logger.info(` cd ${f.repo.path} && hula launch ${issueName} ${f.planPath} --group-id ${groupId}`);
|
|
1215
|
+
}
|
|
1216
|
+
process.exit(1);
|
|
1217
|
+
}
|
|
1218
|
+
logger.blank();
|
|
1219
|
+
logger.success(`Launched ${results.length} repos as group ${groupId}`);
|
|
1220
|
+
logger.info(`View combined status: hula launch --show-group ${groupId}`);
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Cancel-only fan-out (`hula launch <issue> --folder <path> --kill`). Stops the
|
|
1224
|
+
* in-flight task in every member repo using each repo's own API key. Continues
|
|
1225
|
+
* past per-repo failures and exits non-zero when any cancel failed.
|
|
1226
|
+
*/
|
|
1227
|
+
async function executeGroupKill(_config, issueName, options) {
|
|
1228
|
+
const service = new GroupLaunchService();
|
|
1229
|
+
const folder = path.resolve(options.folder);
|
|
1230
|
+
if (!existsSync(folder)) {
|
|
1231
|
+
logger.error(`Folder not found: ${folder}`);
|
|
1232
|
+
process.exit(1);
|
|
1233
|
+
}
|
|
1234
|
+
const { repos, skipped, folderIsRepo } = service.discoverRepos(folder);
|
|
1235
|
+
logger.section('Group Cancel');
|
|
1236
|
+
for (const s of skipped) {
|
|
1237
|
+
logger.log(` skipped ${s.name} (${s.reason})`);
|
|
1238
|
+
}
|
|
1239
|
+
if (repos.length === 0) {
|
|
1240
|
+
if (folderIsRepo) {
|
|
1241
|
+
logger.error(`${folder} looks like a repository itself; --folder expects the parent folder that contains your repos`);
|
|
1242
|
+
}
|
|
1243
|
+
else {
|
|
1244
|
+
logger.error(`No initialized repos found directly under ${folder}`);
|
|
1245
|
+
}
|
|
1246
|
+
process.exit(1);
|
|
1247
|
+
}
|
|
1248
|
+
const entries = [];
|
|
1249
|
+
for (const repo of repos) {
|
|
1250
|
+
const repoConfig = await new ConfigLoader(repo.path).load();
|
|
1251
|
+
const project = await resolveProject(repoConfig, undefined, repo.path);
|
|
1252
|
+
entries.push({ repo, repoConfig, project });
|
|
1253
|
+
}
|
|
1254
|
+
// Shared Pro-tier check with the first repo's key.
|
|
1255
|
+
const first = entries[0];
|
|
1256
|
+
const proServerUrl = options.url || first.repoConfig.hulaProjectUrl || HULA_PROJECT_URL;
|
|
1257
|
+
const proApiKey = options.apiKey || first.repoConfig.hulaApiKey || process.env.HULA_API_KEY;
|
|
1258
|
+
if (proApiKey) {
|
|
1259
|
+
const isPro = await verifyProTier(first.repoConfig, proServerUrl, proApiKey, first.repo.path);
|
|
1260
|
+
if (!isPro)
|
|
1261
|
+
process.exit(1);
|
|
1262
|
+
}
|
|
1263
|
+
const results = [];
|
|
1264
|
+
for (const e of entries) {
|
|
1265
|
+
logger.section(`Cancel: ${e.repo.name}`);
|
|
1266
|
+
const result = await killSingleRepo(e.repoConfig, e.repo.path, issueName, options, {
|
|
1267
|
+
repositoryId: e.project,
|
|
1268
|
+
proTierVerified: true,
|
|
1269
|
+
});
|
|
1270
|
+
results.push({ repo: e.repo, result });
|
|
1271
|
+
if (!result.ok) {
|
|
1272
|
+
logger.error(`✗ ${e.repo.name}: ${result.message ?? 'cancel failed'}`);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
logger.section('Group Cancel Summary');
|
|
1276
|
+
for (const r of results) {
|
|
1277
|
+
logger.log(` ${r.result.ok ? '✓' : '✗'} ${r.repo.name}`);
|
|
1278
|
+
}
|
|
1279
|
+
if (results.some((r) => !r.result.ok)) {
|
|
1280
|
+
process.exit(1);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Cancel the in-flight task for one repo (per-repo helper for the `--kill
|
|
1285
|
+
* --folder` fan-out). Mirrors {@link executeKill} but returns a
|
|
1286
|
+
* {@link RepoLaunchResult} instead of exiting, and skips the shared Pro check.
|
|
1287
|
+
*/
|
|
1288
|
+
async function killSingleRepo(repoConfig, repoRoot, issueName, options, ctx) {
|
|
1289
|
+
const serverUrl = options.url || repoConfig.hulaProjectUrl || HULA_PROJECT_URL;
|
|
1290
|
+
const apiKey = options.apiKey || repoConfig.hulaApiKey || process.env.HULA_API_KEY;
|
|
1291
|
+
if (!apiKey) {
|
|
1292
|
+
return { ok: false, message: 'No API key configured' };
|
|
1293
|
+
}
|
|
1294
|
+
if (!ctx.proTierVerified) {
|
|
1295
|
+
const isPro = await verifyProTier(repoConfig, serverUrl, apiKey);
|
|
1296
|
+
if (!isPro)
|
|
1297
|
+
return { ok: false, message: 'Not on the Pro tier' };
|
|
1298
|
+
}
|
|
1299
|
+
const repositoryId = ctx.repositoryId ?? (await resolveProject(repoConfig, undefined, repoRoot));
|
|
1300
|
+
const endpoint = `${serverUrl.replace(/\/$/, '')}/api/v1/ralph-run`;
|
|
1301
|
+
const requestBody = buildKillRequestBody(issueName, repositoryId);
|
|
1302
|
+
logger.info('Stopping in-flight task...');
|
|
1303
|
+
logger.log(` Issue Name: ${issueName}`);
|
|
1304
|
+
logger.log(` Server: ${serverUrl}`);
|
|
1305
|
+
logger.blank();
|
|
1306
|
+
try {
|
|
1307
|
+
const response = await axios.post(endpoint, requestBody, {
|
|
1308
|
+
headers: {
|
|
1309
|
+
'Content-Type': 'application/json',
|
|
1310
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1311
|
+
},
|
|
1312
|
+
timeout: 30000,
|
|
1313
|
+
});
|
|
1314
|
+
const data = response.data;
|
|
1315
|
+
if (data.killed) {
|
|
1316
|
+
logger.success(data.message || `Cancelled '${issueName}'`);
|
|
1317
|
+
}
|
|
1318
|
+
else {
|
|
1319
|
+
logger.info(data.message || `No active task to cancel for '${issueName}'`);
|
|
1320
|
+
}
|
|
1321
|
+
return { ok: true };
|
|
1322
|
+
}
|
|
1323
|
+
catch (error) {
|
|
1324
|
+
const described = describeLaunchAxiosError(error, serverUrl, issueName, undefined);
|
|
1325
|
+
logger.error(described.message);
|
|
1326
|
+
return { ok: false, status: described.status, message: described.message };
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Render combined status for a feature group (`hula launch --show-group [id]`).
|
|
1331
|
+
* The id is explicit (string) or resolved from the newest local group record
|
|
1332
|
+
* (bare flag → `true`). Prints a `completed/total` header and one line per plan.
|
|
1333
|
+
*/
|
|
1334
|
+
async function showGroupStatus(config, groupIdOrTrue, options) {
|
|
1335
|
+
const serverUrl = options.url || config.hulaProjectUrl || HULA_PROJECT_URL;
|
|
1336
|
+
const apiKey = options.apiKey || config.hulaApiKey || process.env.HULA_API_KEY;
|
|
1337
|
+
if (!serverUrl || !apiKey) {
|
|
1338
|
+
logger.error('Server URL and API key are required.');
|
|
1339
|
+
logger.info("Run 'hula login' to authenticate with hula-project.");
|
|
1340
|
+
process.exit(1);
|
|
1341
|
+
}
|
|
1342
|
+
// Resolve the group id: an explicit value, else the newest local record.
|
|
1343
|
+
let groupId;
|
|
1344
|
+
if (typeof groupIdOrTrue === 'string') {
|
|
1345
|
+
groupId = groupIdOrTrue;
|
|
1346
|
+
}
|
|
1347
|
+
else {
|
|
1348
|
+
const service = new GroupLaunchService();
|
|
1349
|
+
const repoRoot = await findGitRoot();
|
|
1350
|
+
const latest = service.latestRecordedGroupId(repoRoot);
|
|
1351
|
+
if (!latest) {
|
|
1352
|
+
logger.error('No local group record found in this repo. Pass an id: hula launch --show-group <id>');
|
|
1353
|
+
process.exit(1);
|
|
1354
|
+
}
|
|
1355
|
+
groupId = latest;
|
|
1356
|
+
}
|
|
1357
|
+
const idError = validateGroupId(groupId);
|
|
1358
|
+
if (idError) {
|
|
1359
|
+
logger.error(idError);
|
|
1360
|
+
process.exit(1);
|
|
1361
|
+
}
|
|
1362
|
+
const apiClient = new HulaApiClient({
|
|
1363
|
+
baseUrl: serverUrl.replace(/\/$/, ''),
|
|
1364
|
+
apiKey,
|
|
1365
|
+
timeout: 30000,
|
|
1366
|
+
});
|
|
1367
|
+
try {
|
|
1368
|
+
const data = await apiClient.fetchGroupStatus(groupId);
|
|
1369
|
+
logger.section(`Group ${data.groupId}: ${data.completed}/${data.total} completed`);
|
|
1370
|
+
if (data.plans.length === 0) {
|
|
1371
|
+
logger.log(' (no plans yet)');
|
|
1372
|
+
}
|
|
1373
|
+
for (const plan of data.plans) {
|
|
1374
|
+
const taskStatus = plan.latestTask ? plan.latestTask.status : 'no task';
|
|
1375
|
+
const pr = plan.prUrl
|
|
1376
|
+
? `${plan.prUrl}${plan.prMerged ? ' (merged)' : ''}`
|
|
1377
|
+
: '—';
|
|
1378
|
+
logger.log(` ${plan.project.fullName} ${plan.trackingStatus} task:${taskStatus} PR:${pr} ${plan.updatedAt}`);
|
|
1379
|
+
}
|
|
1380
|
+
logger.blank();
|
|
1381
|
+
}
|
|
1382
|
+
catch (error) {
|
|
1383
|
+
const status = (axios.isAxiosError(error) && error.response?.status) ||
|
|
1384
|
+
(error instanceof ApiError ? error.statusCode : undefined);
|
|
1385
|
+
if (status === 404) {
|
|
1386
|
+
logger.error('Group not found (or you are not a member of any of its projects).');
|
|
1387
|
+
logger.info('The server may not support groups yet (requires hula-server with feature groups).');
|
|
1388
|
+
process.exit(1);
|
|
1389
|
+
}
|
|
1390
|
+
if (error instanceof ApiError) {
|
|
1391
|
+
logger.error(error.message);
|
|
1392
|
+
process.exit(1);
|
|
1393
|
+
}
|
|
1394
|
+
handleApiError(error, serverUrl);
|
|
1395
|
+
process.exit(1);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* Non-logging sibling of {@link handleLaunchAxiosError}: map a launch/kill error
|
|
1400
|
+
* to a `{ status, message }` pair without printing or throwing. Used by the
|
|
1401
|
+
* `--folder` fan-out to CAPTURE a per-repo failure for the end-of-run summary
|
|
1402
|
+
* instead of logging-and-exiting. Message text mirrors the single-repo handler.
|
|
1403
|
+
*/
|
|
1404
|
+
function describeLaunchAxiosError(error, serverUrl, issueName, planPath) {
|
|
1405
|
+
if (axios.isAxiosError(error)) {
|
|
1406
|
+
if (error.response) {
|
|
1407
|
+
const status = error.response.status;
|
|
1408
|
+
const data = error.response.data;
|
|
1409
|
+
switch (status) {
|
|
1410
|
+
case 400:
|
|
1411
|
+
return { status, message: `Bad request: ${data.error || 'Invalid parameters'}` };
|
|
1412
|
+
case 401:
|
|
1413
|
+
return { status, message: 'Authentication failed. Your API key may be invalid.' };
|
|
1414
|
+
case 402:
|
|
1415
|
+
return { status, message: 'You cannot run launch because you are on the free tier.' };
|
|
1416
|
+
case 403:
|
|
1417
|
+
return {
|
|
1418
|
+
status,
|
|
1419
|
+
message: data.error || 'Access denied. You may not be authorized for launch.',
|
|
1420
|
+
};
|
|
1421
|
+
case 404:
|
|
1422
|
+
return { status, message: data.error || `Not found: ${planPath ?? issueName}` };
|
|
1423
|
+
case 409:
|
|
1424
|
+
return { status, message: data.error || `Job already running: ${issueName}` };
|
|
1425
|
+
case 500:
|
|
1426
|
+
return { status, message: `Server error: ${data.error || 'Internal server error'}` };
|
|
1427
|
+
default:
|
|
1428
|
+
return {
|
|
1429
|
+
status,
|
|
1430
|
+
message: `Request failed with status ${status}: ${data.error || error.message}`,
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
if (error.code === 'ECONNREFUSED') {
|
|
1435
|
+
return { message: `Cannot connect to server at ${serverUrl}` };
|
|
1436
|
+
}
|
|
1437
|
+
if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
|
|
1438
|
+
return { message: 'Request timed out' };
|
|
1439
|
+
}
|
|
1440
|
+
return { message: `Network error: ${error.message}` };
|
|
1441
|
+
}
|
|
1442
|
+
return { message: error instanceof Error ? error.message : String(error) };
|
|
1443
|
+
}
|
|
758
1444
|
/**
|
|
759
1445
|
* Shared axios error handler for the launch / kill request. Surfaces the server
|
|
760
1446
|
* `data.error` for 400/403/404/409 (PR #391 returns specific, actionable
|
|
@@ -1070,8 +1756,8 @@ export function handleApiError(error, serverUrl) {
|
|
|
1070
1756
|
/**
|
|
1071
1757
|
* Check if plan is synced to origin/main
|
|
1072
1758
|
*/
|
|
1073
|
-
async function checkPlanSyncStatus(config, planPath) {
|
|
1074
|
-
const gitService = new GitService();
|
|
1759
|
+
async function checkPlanSyncStatus(config, planPath, repoRoot) {
|
|
1760
|
+
const gitService = new GitService(repoRoot);
|
|
1075
1761
|
const planService = new PlanService(config.planPath);
|
|
1076
1762
|
// Fetch latest from origin
|
|
1077
1763
|
await gitService.fetch('origin', 'main');
|
|
@@ -1088,8 +1774,10 @@ async function checkPlanSyncStatus(config, planPath) {
|
|
|
1088
1774
|
}
|
|
1089
1775
|
// Plan is not on any git ref, but a plan freshly created by /hula-plan lives
|
|
1090
1776
|
// only in the working tree (never committed). Treat that as needing upload
|
|
1091
|
-
// rather than declaring it not found.
|
|
1092
|
-
|
|
1777
|
+
// rather than declaring it not found. In folder mode the plan path is
|
|
1778
|
+
// repo-relative, so resolve it against the repo root for the disk check.
|
|
1779
|
+
const planFsPath = isAbsolute(planPath) || !repoRoot ? planPath : join(repoRoot, planPath);
|
|
1780
|
+
if (existsSync(planFsPath)) {
|
|
1093
1781
|
return { synced: false, location: 'local-main' };
|
|
1094
1782
|
}
|
|
1095
1783
|
return { synced: false, location: 'not-found' };
|