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.
Files changed (44) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +54 -0
  3. package/dist/commands/launch.d.ts +91 -0
  4. package/dist/commands/launch.d.ts.map +1 -1
  5. package/dist/commands/launch.js +831 -143
  6. package/dist/commands/launch.js.map +1 -1
  7. package/dist/commands/upload.d.ts +9 -2
  8. package/dist/commands/upload.d.ts.map +1 -1
  9. package/dist/commands/upload.js +16 -6
  10. package/dist/commands/upload.js.map +1 -1
  11. package/dist/scripts/launch-run.d.ts +6 -0
  12. package/dist/scripts/launch-run.d.ts.map +1 -1
  13. package/dist/scripts/launch-run.js +70 -9
  14. package/dist/scripts/launch-run.js.map +1 -1
  15. package/dist/services/api/HulaApiClient.d.ts +53 -0
  16. package/dist/services/api/HulaApiClient.d.ts.map +1 -1
  17. package/dist/services/api/HulaApiClient.js +18 -0
  18. package/dist/services/api/HulaApiClient.js.map +1 -1
  19. package/dist/services/git/FilePublishService.d.ts +9 -1
  20. package/dist/services/git/FilePublishService.d.ts.map +1 -1
  21. package/dist/services/git/FilePublishService.js +13 -3
  22. package/dist/services/git/FilePublishService.js.map +1 -1
  23. package/dist/services/git/GitService.d.ts +19 -0
  24. package/dist/services/git/GitService.d.ts.map +1 -1
  25. package/dist/services/git/GitService.js +56 -31
  26. package/dist/services/git/GitService.js.map +1 -1
  27. package/dist/services/git/WorktreeService.d.ts +9 -0
  28. package/dist/services/git/WorktreeService.d.ts.map +1 -1
  29. package/dist/services/git/WorktreeService.js +6 -3
  30. package/dist/services/git/WorktreeService.js.map +1 -1
  31. package/dist/services/group/GroupLaunchService.d.ts +230 -0
  32. package/dist/services/group/GroupLaunchService.d.ts.map +1 -0
  33. package/dist/services/group/GroupLaunchService.js +282 -0
  34. package/dist/services/group/GroupLaunchService.js.map +1 -0
  35. package/dist/templates/skills/hula-launch/SKILL.md +38 -1
  36. package/dist/utils/github-cli.d.ts +1 -1
  37. package/dist/utils/github-cli.d.ts.map +1 -1
  38. package/dist/utils/github-cli.js +15 -7
  39. package/dist/utils/github-cli.js.map +1 -1
  40. package/dist/utils/project-resolver.d.ts +1 -1
  41. package/dist/utils/project-resolver.d.ts.map +1 -1
  42. package/dist/utils/project-resolver.js +2 -2
  43. package/dist/utils/project-resolver.js.map +1 -1
  44. package/package.json +1 -1
@@ -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 { HulaApiClient } from '../services/api/HulaApiClient.js';
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(process.cwd(), 'pro');
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(config, planPath);
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(config, planPath);
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
- logger.error('Failed to upload plan:');
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 || config.hulaProjectUrl || HULA_PROJECT_URL;
427
- const apiKey = options.apiKey || config.hulaApiKey || process.env.HULA_API_KEY;
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
- const isProUser = await verifyProTier(config, serverUrl, apiKey);
437
- if (!isProUser) {
438
- process.exit(1);
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(config);
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
- config.updateNotificationUrl ||
691
+ repoConfig.updateNotificationUrl ||
457
692
  process.env.HULA_UPDATE_NOTIFICATION_URL;
458
693
  const updateNotificationNameTag = options.updateNotificationNameTag ||
459
- config.updateNotificationNameTag ||
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 (config.envVars) {
491
- if (config.envVars === 'all') {
727
+ if (repoConfig.envVars) {
728
+ if (repoConfig.envVars === 'all') {
492
729
  logger.log(` Environment Variables: all (from .env)`);
493
730
  }
494
- else if (config.envVars.length > 0) {
495
- logger.log(` Environment Variables: ${config.envVars.length} configured`);
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
- // Build request body
500
- const requestBody = {
501
- issueName,
502
- planPath,
503
- repositoryId,
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, config);
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
- console.error(chalk.red('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.'));
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 gitRoot = await findGitRoot();
612
- const configFilePath = path.join(gitRoot, '.hublaunch', 'hublaunch.config.js');
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
- requestBody.anthropicApiKey = anthropicApiKey;
628
- const cpuRaw = options.containerCpu ?? config.containerResources?.cpu;
629
- const memRaw = options.containerMemory ?? config.containerResources?.memory;
630
- const diskRaw = options.containerDisk ?? config.containerResources?.disk;
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
- requestBody.containerResources = { cpu, memory, disk };
835
+ containerResources = { cpu, memory, disk };
648
836
  logger.log(` Container Resources: cpu=${cpu} memory=${memory}GiB disk=${disk}GiB`);
649
837
  }
650
- if (updateNotificationUrl) {
651
- requestBody.updateNotificationUrl = updateNotificationUrl;
652
- }
653
- if (updateNotificationNameTag) {
654
- requestBody.updateNotificationNameTag = updateNotificationNameTag;
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
- if (config.envVars) {
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(config.envVars)
669
- ? config.envVars
850
+ let varsToCollect = Array.isArray(repoConfig.envVars)
851
+ ? repoConfig.envVars
670
852
  : [];
671
- if (config.envVars === 'all') {
853
+ if (repoConfig.envVars === 'all') {
672
854
  // Read .env and get all non-reserved variables
673
- const envPath = join(process.cwd(), '.env');
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 envVars = readAndFilterEnvVars(process.cwd(), varsToCollect);
690
- if (Object.keys(envVars).length > 0) {
691
- requestBody.envVars = envVars;
692
- logger.log(` ✓ ${Object.keys(envVars).length} variable(s) collected`);
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(error instanceof Error ? error.message : String(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
- await validateLaunchCredentials(anthropicApiKey, githubToken);
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(config.planPath);
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. Offer recovery options interactively (or, when scripted,
745
- // print the server message and exit). Skip this whenever a conflictMode is
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
- await handleReject409(error, config, issueName, planPath, options);
752
- return;
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
- if (existsSync(planPath)) {
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' };