hub-launch 1.20.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 (62) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +69 -14
  3. package/dist/commands/init.d.ts +21 -2
  4. package/dist/commands/init.d.ts.map +1 -1
  5. package/dist/commands/init.js +121 -38
  6. package/dist/commands/init.js.map +1 -1
  7. package/dist/commands/launch.d.ts +91 -0
  8. package/dist/commands/launch.d.ts.map +1 -1
  9. package/dist/commands/launch.js +885 -144
  10. package/dist/commands/launch.js.map +1 -1
  11. package/dist/commands/login.d.ts +38 -0
  12. package/dist/commands/login.d.ts.map +1 -1
  13. package/dist/commands/login.js +107 -11
  14. package/dist/commands/login.js.map +1 -1
  15. package/dist/commands/upload.d.ts +9 -2
  16. package/dist/commands/upload.d.ts.map +1 -1
  17. package/dist/commands/upload.js +16 -6
  18. package/dist/commands/upload.js.map +1 -1
  19. package/dist/scripts/launch-run.d.ts +6 -0
  20. package/dist/scripts/launch-run.d.ts.map +1 -1
  21. package/dist/scripts/launch-run.js +70 -9
  22. package/dist/scripts/launch-run.js.map +1 -1
  23. package/dist/services/api/HulaApiClient.d.ts +53 -0
  24. package/dist/services/api/HulaApiClient.d.ts.map +1 -1
  25. package/dist/services/api/HulaApiClient.js +18 -0
  26. package/dist/services/api/HulaApiClient.js.map +1 -1
  27. package/dist/services/git/FilePublishService.d.ts +9 -1
  28. package/dist/services/git/FilePublishService.d.ts.map +1 -1
  29. package/dist/services/git/FilePublishService.js +13 -3
  30. package/dist/services/git/FilePublishService.js.map +1 -1
  31. package/dist/services/git/GitService.d.ts +19 -0
  32. package/dist/services/git/GitService.d.ts.map +1 -1
  33. package/dist/services/git/GitService.js +56 -31
  34. package/dist/services/git/GitService.js.map +1 -1
  35. package/dist/services/git/WorktreeService.d.ts +9 -0
  36. package/dist/services/git/WorktreeService.d.ts.map +1 -1
  37. package/dist/services/git/WorktreeService.js +6 -3
  38. package/dist/services/git/WorktreeService.js.map +1 -1
  39. package/dist/services/group/GroupLaunchService.d.ts +230 -0
  40. package/dist/services/group/GroupLaunchService.d.ts.map +1 -0
  41. package/dist/services/group/GroupLaunchService.js +282 -0
  42. package/dist/services/group/GroupLaunchService.js.map +1 -0
  43. package/dist/templates/skills/hula-help/SKILL.md +1 -1
  44. package/dist/templates/skills/hula-launch/SKILL.md +38 -1
  45. package/dist/templates/skills/hula-schedule/SKILL.md +3 -3
  46. package/dist/utils/config-file-edit.d.ts +23 -0
  47. package/dist/utils/config-file-edit.d.ts.map +1 -0
  48. package/dist/utils/config-file-edit.js +37 -0
  49. package/dist/utils/config-file-edit.js.map +1 -0
  50. package/dist/utils/ephemeral-credentials.d.ts +8 -0
  51. package/dist/utils/ephemeral-credentials.d.ts.map +1 -1
  52. package/dist/utils/ephemeral-credentials.js +2 -1
  53. package/dist/utils/ephemeral-credentials.js.map +1 -1
  54. package/dist/utils/github-cli.d.ts +1 -1
  55. package/dist/utils/github-cli.d.ts.map +1 -1
  56. package/dist/utils/github-cli.js +15 -7
  57. package/dist/utils/github-cli.js.map +1 -1
  58. package/dist/utils/project-resolver.d.ts +1 -1
  59. package/dist/utils/project-resolver.d.ts.map +1 -1
  60. package/dist/utils/project-resolver.js +2 -2
  61. package/dist/utils/project-resolver.js.map +1 -1
  62. package/package.json +1 -1
@@ -3,13 +3,15 @@ import inquirer from 'inquirer';
3
3
  import chalk from 'chalk';
4
4
  import os from 'os';
5
5
  import path from 'path';
6
- import { existsSync, readFileSync } from 'fs';
7
- import { join } from 'path';
6
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
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';
@@ -19,6 +21,9 @@ import { HULA_PROJECT_URL } from '../config/constants.js';
19
21
  import { updateUsageTierInConfig } from '../utils/config-writer.js';
20
22
  import { resolveEphemeralCredentials } from '../utils/ephemeral-credentials.js';
21
23
  import { readClientSessionId } from '../utils/client-session.js';
24
+ import { input, UserCancelledError } from '../utils/prompts.js';
25
+ import { findGitRoot } from '../utils/git-utils.js';
26
+ import { upsertAnthropicApiKey } from '../utils/config-file-edit.js';
22
27
  import { validateNoReservedVars, readAndFilterEnvVars, RESERVED_ENV_VARS, } from '../utils/env-vars.js';
23
28
  const DEFAULT_CONTAINER_RESOURCES = { cpu: 2, memory: 4, disk: 10 };
24
29
  /**
@@ -28,6 +33,20 @@ const DEFAULT_CONTAINER_RESOURCES = { cpu: 2, memory: 4, disk: 10 };
28
33
  * Usage: hula launch --show <name>
29
34
  * Usage: hula launch --logs <name>
30
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
+ }
31
50
  export function launchCommand(program, config) {
32
51
  program
33
52
  .command('launch [issueName] [planPath]')
@@ -61,8 +80,22 @@ export function launchCommand(program, config) {
61
80
  .option('--lines <n>', 'Number of log lines to show (default: 100)', parseInt)
62
81
  .option('--type <type>', 'Log type to fetch: log or output (default: log)')
63
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)')
64
91
  .action(async (issueName, planPath, options) => {
65
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
+ }
66
99
  // Handle --show flag
67
100
  if (options.show) {
68
101
  await showJobStatus(config, options.show, options);
@@ -80,6 +113,21 @@ export function launchCommand(program, config) {
80
113
  logger.error(killFlagError);
81
114
  process.exit(1);
82
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
+ }
83
131
  // Validate per-step config + --skip-regression against the two
84
132
  // server-parity cross-field rules before any credential resolution or
85
133
  // network call. On conflict, fail fast with the server's exact wording.
@@ -88,6 +136,21 @@ export function launchCommand(program, config) {
88
136
  logger.error(stepsValidation.error);
89
137
  process.exit(1);
90
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
+ }
91
154
  // --kill is a cancel-only path: it needs ONLY the tracking name (no
92
155
  // plan path) and routes to the dedicated executeKill function.
93
156
  if (options.kill) {
@@ -155,6 +218,43 @@ export function validateKillFlags(options) {
155
218
  export function requiresPlanPath(options) {
156
219
  return !options.kill;
157
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
+ }
158
258
  /**
159
259
  * Derive the request `conflictMode` from the CLI flags (plus an optional explicit
160
260
  * override used when re-invoking after an interactive 409 resolution).
@@ -341,7 +441,7 @@ function printFreeTierError() {
341
441
  logger.info(' • Run /hula-create, which will assign the issue to Copilot on GitHub.');
342
442
  logger.info(' • Go to https://www.hublaunch.site and upgrade your subscription to Pro.');
343
443
  logger.blank();
344
- logger.info('The Pro tier allows launching as many plans as you like, subject to the constraints of your own Anthropic and Daytona subscriptions.');
444
+ logger.info('The Pro tier allows launching as many plans as you like, subject to the constraints of your own Anthropic subscription.');
345
445
  }
346
446
  /**
347
447
  * Checks whether the user is on the Pro tier.
@@ -349,7 +449,11 @@ function printFreeTierError() {
349
449
  * If the config records 'free', calls the API and updates the config on success.
350
450
  * Returns false (and prints the error) when the user is not on Pro.
351
451
  */
352
- 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()) {
353
457
  if (config.usageTier === 'pro') {
354
458
  return true;
355
459
  }
@@ -360,7 +464,7 @@ async function verifyProTier(config, serverUrl, apiKey) {
360
464
  });
361
465
  const plan = response.data?.plan;
362
466
  if (plan === 'pro') {
363
- const saved = updateUsageTierInConfig(process.cwd(), 'pro');
467
+ const saved = updateUsageTierInConfig(repoRoot, 'pro');
364
468
  if (saved) {
365
469
  logger.success('Subscription verified as Pro — config updated for future launches.');
366
470
  }
@@ -387,73 +491,207 @@ async function verifyProTier(config, serverUrl, apiKey) {
387
491
  return true;
388
492
  }
389
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
+ */
390
584
  async function executeLaunch(config, issueName, planPath, options,
391
585
  // Explicit conflict mode override. Set when re-invoking after the interactive
392
586
  // 409 resolver picks "kill and relaunch" — it both forces the server to
393
587
  // cancel+relaunch and (being non-undefined) suppresses re-entering the prompt.
394
588
  conflictModeOverride) {
395
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';
396
618
  // Effective conflict mode: an explicit override (from the 409 resolver) or the
397
619
  // --kill-and-relaunch flag. Undefined = default 'reject' (byte-identical body).
398
- const conflictMode = deriveConflictMode(options, conflictModeOverride);
620
+ const conflictMode = deriveConflictMode(options, ctx.conflictModeOverride);
399
621
  // Check if plan is synced to origin/main
400
- const planSyncStatus = await checkPlanSyncStatus(config, planPath);
622
+ const planSyncStatus = await checkPlanSyncStatus(repoConfig, planPath, repoRoot);
401
623
  if (!planSyncStatus.synced) {
402
624
  if (planSyncStatus.location === 'not-found') {
403
625
  logger.error('Plan not found locally or on origin/main');
404
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' };
405
629
  process.exit(1);
406
630
  }
407
631
  // Plan exists locally but not on origin/main - auto-upload
408
632
  logger.info('Plan found locally, pushing to origin/main...');
409
633
  try {
410
- await executeUpload(config, planPath);
634
+ await executeUpload(repoConfig, planPath, repoRoot);
411
635
  logger.success('Plan synced to origin/main');
412
636
  logger.blank();
413
637
  }
414
638
  catch (uploadError) {
415
- logger.error('Failed to upload plan:');
416
- logger.error(uploadError instanceof Error
639
+ const msg = uploadError instanceof Error
417
640
  ? uploadError.message
418
- : 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}` };
419
646
  process.exit(1);
420
647
  }
421
648
  }
422
649
  // Get server URL and API key
423
- const serverUrl = options.url || config.hulaProjectUrl || HULA_PROJECT_URL;
424
- 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;
425
652
  if (!apiKey) {
426
653
  logger.error('No API key configured.');
427
654
  logger.info('Set hulaApiKey in your config or use --api-key option.');
428
655
  logger.info('You can also set HULA_API_KEY environment variable.');
429
656
  logger.info("Run 'hula login' to authenticate with hula-project.");
657
+ if (isGroup)
658
+ return { ok: false, message: 'No API key configured' };
430
659
  process.exit(1);
431
660
  }
432
- // Verify the user is on the Pro tier before proceeding
433
- const isProUser = await verifyProTier(config, serverUrl, apiKey);
434
- if (!isProUser) {
435
- 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
+ }
436
670
  }
437
- // Get project ID - from config or detect from git
438
- 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));
439
673
  // Validate fix requires resume
440
674
  if (options.fix && options.resume === undefined) {
441
675
  logger.error('--fix requires --resume to be set');
676
+ if (isGroup)
677
+ return { ok: false, message: '--fix requires --resume to be set' };
442
678
  process.exit(1);
443
679
  }
444
680
  // Validate resume range
445
681
  if (options.resume !== undefined &&
446
682
  (options.resume < 1 || options.resume > 9)) {
447
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' };
448
686
  process.exit(1);
449
687
  }
450
688
  // Build the API endpoint
451
689
  const endpoint = `${serverUrl.replace(/\/$/, '')}/api/v1/ralph-run`;
452
690
  const updateNotificationUrl = options.updateNotificationUrl ||
453
- config.updateNotificationUrl ||
691
+ repoConfig.updateNotificationUrl ||
454
692
  process.env.HULA_UPDATE_NOTIFICATION_URL;
455
693
  const updateNotificationNameTag = options.updateNotificationNameTag ||
456
- config.updateNotificationNameTag ||
694
+ repoConfig.updateNotificationNameTag ||
457
695
  process.env.HULA_UPDATE_NOTIFICATION_NAME_TAG;
458
696
  logger.info(`Triggering launch job...`);
459
697
  logger.log(` Issue Name: ${issueName}`);
@@ -480,144 +718,141 @@ conflictModeOverride) {
480
718
  logger.log(` Test Mode: yes (mock Claude)`);
481
719
  if (options.handoff)
482
720
  logger.log(` Handoff To: ${options.handoff}`);
721
+ if (ctx.groupId)
722
+ logger.log(` Group: ${ctx.groupId}`);
483
723
  if (updateNotificationUrl)
484
724
  logger.log(` Notify on completion: enabled`);
485
725
  if (updateNotificationNameTag)
486
726
  logger.log(` Notify tag: ${updateNotificationNameTag}`);
487
- if (config.envVars) {
488
- if (config.envVars === 'all') {
727
+ if (repoConfig.envVars) {
728
+ if (repoConfig.envVars === 'all') {
489
729
  logger.log(` Environment Variables: all (from .env)`);
490
730
  }
491
- else if (config.envVars.length > 0) {
492
- logger.log(` Environment Variables: ${config.envVars.length} configured`);
731
+ else if (repoConfig.envVars.length > 0) {
732
+ logger.log(` Environment Variables: ${repoConfig.envVars.length} configured`);
493
733
  }
494
734
  }
495
735
  logger.blank();
496
- // Build request body
497
- const requestBody = {
498
- issueName,
499
- planPath,
500
- repositoryId,
501
- };
502
- if (options.duration !== undefined) {
503
- requestBody.duration = options.duration;
504
- }
505
- // Keep 'ralphPath' property name for server API compatibility
506
- // even though the CLI option is now '--launch'
507
- if (options.launch) {
508
- requestBody.ralphPath = options.launch;
509
- }
510
- // Handle worktree: commander sets it to false for --no-worktree
511
- if (options.worktree === false) {
512
- requestBody.worktree = false;
513
- }
514
- else if (typeof options.worktree === 'string') {
515
- requestBody.worktree = options.worktree;
516
- }
517
- if (options.resume !== undefined) {
518
- requestBody.resume = options.resume;
519
- }
520
- if (options.fix) {
521
- requestBody.fix = options.fix;
522
- }
523
- if (options.regression) {
524
- requestBody.regression = true;
525
- }
526
- // Maps to RalphRunRequest.steps (hula-server PR #442): per-pipeline-step model,
527
- // iteration-cap, and skip overrides from the config-file `steps` block plus the
528
- // --skip-regression flag. resolveSteps() re-runs the same validation the action
529
- // handler already passed (pure function), and returns `undefined` when there's
530
- // nothing to send — omitted entirely then, keeping non-steps request bodies
531
- // byte-identical (same pattern as `test`/`conflictMode` above).
532
- const stepsResult = resolveSteps(config.steps, options.skipRegression, options.regression);
533
- if (stepsResult.steps) {
534
- requestBody.steps = stepsResult.steps;
535
- }
536
- // Maps to RalphRunRequest.test (hula-server PR #367): when true, the server
537
- // runs the full production pipeline but swaps the real Claude CLI for a mock
538
- // executable — a fast E2E run that still creates a real PR. Omitted entirely
539
- // when --test is absent, keeping non-test request bodies byte-identical.
540
- if (options.test) {
541
- requestBody.test = true;
542
- }
543
- // Maps to RalphRunRequest.conflictMode (hula-server PR #391). Only added for
544
- // 'killAndRelaunch' — the server default 'reject' is expressed by omitting the
545
- // key entirely, keeping the default-launch request body byte-identical to
546
- // before this change (same pattern as `test` above).
547
- if (conflictMode) {
548
- requestBody.conflictMode = conflictMode;
549
- }
550
- if (options.verbose) {
551
- requestBody.verbose = true;
552
- }
553
- if (options.handoff) {
554
- requestBody.handoff = options.handoff;
555
- }
556
- // Maps to RalphRunRequest.clientSessionId (hula-server PR #419): the Claude
557
- // Code session id of the chat session that ran /hula-launch, captured by the
558
- // hula-session-hook moments before this CLI runs. Provenance for client
559
- // tooling (/hula-verify, /hula-info). Omitted entirely when no hook capture
560
- // exists (plain-terminal launches, CI), keeping those request bodies
561
- // 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).
562
741
  const clientSessionId = readClientSessionId(issueName);
563
- if (clientSessionId) {
564
- requestBody.clientSessionId = clientSessionId;
565
- }
566
742
  // Resolve ephemeral credentials through the shared resolver — the single
567
743
  // source of truth for precedence/validation, also used by `execute`.
568
744
  // Collect ALL errors before exiting (preserves prior launch behavior).
569
- const { anthropicApiKey, errors: credentialErrors } = resolveEphemeralCredentials(options, config);
745
+ const resolved = resolveEphemeralCredentials(options, repoConfig);
746
+ let anthropicApiKey = resolved.anthropicApiKey;
747
+ const credentialErrors = resolved.errors;
570
748
  if (credentialErrors.length > 0) {
571
- credentialErrors.forEach((msg) => console.error(chalk.red(msg)));
572
- process.exit(1);
749
+ // Interactive terminal + the ONLY problem is a totally-absent token:
750
+ // collect it inline rather than failing, then persist it for next time.
751
+ // Any other case (wrong-prefix, non-TTY, multiple errors) keeps today's
752
+ // fail-fast behavior — identical to what `hula schedule` does.
753
+ if (resolved.missingAnthropicKey &&
754
+ credentialErrors.length === 1 &&
755
+ process.stdout.isTTY) {
756
+ let pasted;
757
+ try {
758
+ pasted = (await input('Paste your Anthropic OAuth token (sk-ant-oat01-…), or press Enter to cancel:')).trim();
759
+ }
760
+ catch (error) {
761
+ // Treat an explicit cancel (Ctrl-C / escape) exactly like empty input.
762
+ if (error instanceof UserCancelledError) {
763
+ pasted = '';
764
+ }
765
+ else {
766
+ throw error;
767
+ }
768
+ }
769
+ if (!pasted) {
770
+ // Empty / cancelled → the original resolver error, then exit.
771
+ credentialErrors.forEach((msg) => console.error(chalk.red(msg)));
772
+ if (isGroup)
773
+ return { ok: false, message: credentialErrors.join('; ') };
774
+ process.exit(1);
775
+ }
776
+ if (!pasted.startsWith('sk-ant-oat')) {
777
+ // Wrong prefix → the existing wrong-prefix error text. One attempt only.
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 };
782
+ process.exit(1);
783
+ }
784
+ // Valid: use it for this launch and persist it to the config file. A
785
+ // persistence failure must NOT abort a valid launch — warn and continue
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.
788
+ anthropicApiKey = pasted;
789
+ try {
790
+ const configDir = isGroup ? repoRoot : await findGitRoot();
791
+ const configFilePath = path.join(configDir, '.hublaunch', 'hublaunch.config.js');
792
+ const original = readFileSync(configFilePath, 'utf-8');
793
+ const updated = upsertAnthropicApiKey(original, pasted);
794
+ writeFileSync(configFilePath, updated, { mode: 0o600 });
795
+ logger.success('anthropicApiKey saved to .hublaunch/hublaunch.config.js');
796
+ }
797
+ catch {
798
+ logger.warning("⚠️ Couldn't save the token to .hublaunch/hublaunch.config.js — continuing this launch with it in memory. Add it manually to avoid re-entering.");
799
+ }
800
+ }
801
+ else {
802
+ credentialErrors.forEach((msg) => console.error(chalk.red(msg)));
803
+ if (isGroup)
804
+ return { ok: false, message: credentialErrors.join('; ') };
805
+ process.exit(1);
806
+ }
573
807
  }
574
- requestBody.anthropicApiKey = anthropicApiKey;
575
- const cpuRaw = options.containerCpu ?? config.containerResources?.cpu;
576
- const memRaw = options.containerMemory ?? config.containerResources?.memory;
577
- 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;
578
813
  if (cpuRaw !== undefined || memRaw !== undefined || diskRaw !== undefined) {
579
814
  const cpu = cpuRaw ?? DEFAULT_CONTAINER_RESOURCES.cpu;
580
815
  const memory = memRaw ?? DEFAULT_CONTAINER_RESOURCES.memory;
581
816
  const disk = diskRaw ?? DEFAULT_CONTAINER_RESOURCES.disk;
582
817
  if (!Number.isInteger(cpu) || cpu < 1 || cpu > 32) {
583
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' };
584
821
  process.exit(1);
585
822
  }
586
823
  if (!Number.isInteger(memory) || memory < 1 || memory > 128) {
587
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' };
588
827
  process.exit(1);
589
828
  }
590
829
  if (!Number.isInteger(disk) || disk < 5 || disk > 200) {
591
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' };
592
833
  process.exit(1);
593
834
  }
594
- requestBody.containerResources = { cpu, memory, disk };
835
+ containerResources = { cpu, memory, disk };
595
836
  logger.log(` Container Resources: cpu=${cpu} memory=${memory}GiB disk=${disk}GiB`);
596
837
  }
597
- if (updateNotificationUrl) {
598
- requestBody.updateNotificationUrl = updateNotificationUrl;
599
- }
600
- if (updateNotificationNameTag) {
601
- requestBody.updateNotificationNameTag = updateNotificationNameTag;
602
- }
603
- const tokenData = await loadGitHubToken();
604
- const githubToken = tokenData?.access_token || process.env.GITHUB_TOKEN;
605
- if (githubToken) {
606
- requestBody.githubToken = githubToken;
607
- }
608
- // 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,
609
843
  // validate against the reserved blocklist, and confirm presence — failing fast
610
844
  // with a clear message before the job is submitted.
611
- if (config.envVars) {
845
+ let envVars;
846
+ if (repoConfig.envVars) {
612
847
  logger.info('Collecting environment variables for container...');
613
848
  try {
614
849
  // Expand "all" to the actual list of non-reserved variables from .env.
615
- let varsToCollect = Array.isArray(config.envVars)
616
- ? config.envVars
850
+ let varsToCollect = Array.isArray(repoConfig.envVars)
851
+ ? repoConfig.envVars
617
852
  : [];
618
- if (config.envVars === 'all') {
853
+ if (repoConfig.envVars === 'all') {
619
854
  // Read .env and get all non-reserved variables
620
- const envPath = join(process.cwd(), '.env');
855
+ const envPath = join(repoRoot, '.env');
621
856
  if (!existsSync(envPath)) {
622
857
  throw new Error(`Cannot read environment variables: .env file not found at ${envPath}\n` +
623
858
  `Config specifies envVars: "all", but .env does not exist`);
@@ -633,22 +868,53 @@ conflictModeOverride) {
633
868
  // Validate and collect
634
869
  if (varsToCollect.length > 0) {
635
870
  validateNoReservedVars(varsToCollect);
636
- const envVars = readAndFilterEnvVars(process.cwd(), varsToCollect);
637
- if (Object.keys(envVars).length > 0) {
638
- requestBody.envVars = envVars;
639
- 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`);
640
875
  }
641
876
  }
642
877
  }
643
878
  catch (error) {
879
+ const msg = error instanceof Error ? error.message : String(error);
644
880
  logger.error('Failed to collect environment variables:');
645
- 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}` };
646
884
  process.exit(1);
647
885
  }
648
886
  }
649
- // 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).
650
889
  // anthropicApiKey is guaranteed non-null here — missing key exits above
651
- 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
+ });
652
918
  try {
653
919
  const response = await axios.post(endpoint, requestBody, {
654
920
  headers: {
@@ -678,30 +944,503 @@ conflictModeOverride) {
678
944
  }
679
945
  logger.blank();
680
946
  // Clean up local plan file — it's now on origin/main and submitted to server
681
- const planService = new PlanService(config.planPath);
682
- planService.deletePlan(planPath);
683
- }
684
- else {
685
- logger.error(response.data.error || 'Unknown error occurred');
686
- 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 };
687
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);
688
955
  }
689
956
  catch (error) {
690
957
  // A 409 on the default `reject` path means a task is already running for this
691
- // tracking name. Offer recovery options interactively (or, when scripted,
692
- // print the server message and exit). Skip this whenever a conflictMode is
693
- // already in play — that request cannot produce a reject 409 and must not
694
- // 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.
695
960
  if (!conflictMode &&
696
961
  axios.isAxiosError(error) &&
697
962
  error.response?.status === 409) {
698
- await handleReject409(error, config, issueName, planPath, options);
699
- 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 };
700
982
  }
701
983
  handleLaunchAxiosError(error, serverUrl, issueName, planPath);
702
984
  process.exit(1);
703
985
  }
704
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
+ }
705
1444
  /**
706
1445
  * Shared axios error handler for the launch / kill request. Surfaces the server
707
1446
  * `data.error` for 400/403/404/409 (PR #391 returns specific, actionable
@@ -819,7 +1558,7 @@ async function handleReject409(error, config, issueName, planPath, options) {
819
1558
  *
820
1559
  * Sends `{ issueName, repositoryId, conflictMode: 'kill' }` to the unified
821
1560
  * ralph-run endpoint and reports the outcome. This deliberately skips ALL
822
- * launch-only work: no plan sync/upload, no Anthropic/Daytona/GitHub credential
1561
+ * launch-only work: no plan sync/upload, no Anthropic/GitHub credential
823
1562
  * resolution or validation, no env-var collection, and no plan-file cleanup. It
824
1563
  * carries NO secrets — only the tracking name, repository id, and the API key
825
1564
  * header. The server enforces the Pro gate on every mode, so the pre-flight Pro
@@ -1017,8 +1756,8 @@ export function handleApiError(error, serverUrl) {
1017
1756
  /**
1018
1757
  * Check if plan is synced to origin/main
1019
1758
  */
1020
- async function checkPlanSyncStatus(config, planPath) {
1021
- const gitService = new GitService();
1759
+ async function checkPlanSyncStatus(config, planPath, repoRoot) {
1760
+ const gitService = new GitService(repoRoot);
1022
1761
  const planService = new PlanService(config.planPath);
1023
1762
  // Fetch latest from origin
1024
1763
  await gitService.fetch('origin', 'main');
@@ -1035,8 +1774,10 @@ async function checkPlanSyncStatus(config, planPath) {
1035
1774
  }
1036
1775
  // Plan is not on any git ref, but a plan freshly created by /hula-plan lives
1037
1776
  // only in the working tree (never committed). Treat that as needing upload
1038
- // rather than declaring it not found.
1039
- 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)) {
1040
1781
  return { synced: false, location: 'local-main' };
1041
1782
  }
1042
1783
  return { synced: false, location: 'not-found' };