hub-launch 1.30.0 → 1.32.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 (105) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +52 -0
  3. package/dist/commands/errorWatcher.d.ts +2 -0
  4. package/dist/commands/errorWatcher.d.ts.map +1 -1
  5. package/dist/commands/errorWatcher.js +13 -0
  6. package/dist/commands/errorWatcher.js.map +1 -1
  7. package/dist/commands/init.d.ts +12 -0
  8. package/dist/commands/init.d.ts.map +1 -1
  9. package/dist/commands/init.js +52 -1
  10. package/dist/commands/init.js.map +1 -1
  11. package/dist/commands/launch.d.ts +64 -0
  12. package/dist/commands/launch.d.ts.map +1 -1
  13. package/dist/commands/launch.js +317 -36
  14. package/dist/commands/launch.js.map +1 -1
  15. package/dist/commands/login.d.ts +14 -1
  16. package/dist/commands/login.d.ts.map +1 -1
  17. package/dist/commands/login.js +26 -4
  18. package/dist/commands/login.js.map +1 -1
  19. package/dist/commands/schedule.d.ts.map +1 -1
  20. package/dist/commands/schedule.js +12 -0
  21. package/dist/commands/schedule.js.map +1 -1
  22. package/dist/commands/script.d.ts +1 -1
  23. package/dist/commands/script.d.ts.map +1 -1
  24. package/dist/commands/script.js +3 -0
  25. package/dist/commands/script.js.map +1 -1
  26. package/dist/commands/session-hook.d.ts +9 -0
  27. package/dist/commands/session-hook.d.ts.map +1 -1
  28. package/dist/commands/session-hook.js +60 -15
  29. package/dist/commands/session-hook.js.map +1 -1
  30. package/dist/index.js +2 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/scripts/backlog-add.d.ts +2 -0
  33. package/dist/scripts/backlog-add.d.ts.map +1 -0
  34. package/dist/scripts/backlog-add.js +145 -0
  35. package/dist/scripts/backlog-add.js.map +1 -0
  36. package/dist/scripts/backlog-init.d.ts +2 -0
  37. package/dist/scripts/backlog-init.d.ts.map +1 -0
  38. package/dist/scripts/backlog-init.js +84 -0
  39. package/dist/scripts/backlog-init.js.map +1 -0
  40. package/dist/scripts/backlog-resync.d.ts +2 -0
  41. package/dist/scripts/backlog-resync.d.ts.map +1 -0
  42. package/dist/scripts/backlog-resync.js +156 -0
  43. package/dist/scripts/backlog-resync.js.map +1 -0
  44. package/dist/scripts/launch-run.d.ts +1 -0
  45. package/dist/scripts/launch-run.d.ts.map +1 -1
  46. package/dist/scripts/launch-run.js +47 -3
  47. package/dist/scripts/launch-run.js.map +1 -1
  48. package/dist/scripts/lib/backlog-sync.d.ts +27 -0
  49. package/dist/scripts/lib/backlog-sync.d.ts.map +1 -0
  50. package/dist/scripts/lib/backlog-sync.js +48 -0
  51. package/dist/scripts/lib/backlog-sync.js.map +1 -0
  52. package/dist/scripts/lib/read-config.d.ts +16 -0
  53. package/dist/scripts/lib/read-config.d.ts.map +1 -1
  54. package/dist/scripts/lib/read-config.js +34 -0
  55. package/dist/scripts/lib/read-config.js.map +1 -1
  56. package/dist/services/api/HulaApiClient.d.ts +39 -0
  57. package/dist/services/api/HulaApiClient.d.ts.map +1 -1
  58. package/dist/services/api/HulaApiClient.js +15 -0
  59. package/dist/services/api/HulaApiClient.js.map +1 -1
  60. package/dist/services/github/IssueService.d.ts +3 -1
  61. package/dist/services/github/IssueService.d.ts.map +1 -1
  62. package/dist/services/github/IssueService.js +5 -2
  63. package/dist/services/github/IssueService.js.map +1 -1
  64. package/dist/services/github/PRService.d.ts +3 -1
  65. package/dist/services/github/PRService.d.ts.map +1 -1
  66. package/dist/services/github/PRService.js +2 -2
  67. package/dist/services/github/PRService.js.map +1 -1
  68. package/dist/services/plan/GitHubSourcePlanService.d.ts +67 -0
  69. package/dist/services/plan/GitHubSourcePlanService.d.ts.map +1 -0
  70. package/dist/services/plan/GitHubSourcePlanService.js +164 -0
  71. package/dist/services/plan/GitHubSourcePlanService.js.map +1 -0
  72. package/dist/services/plan/PlanBranchService.d.ts +5 -1
  73. package/dist/services/plan/PlanBranchService.d.ts.map +1 -1
  74. package/dist/services/plan/PlanBranchService.js +21 -3
  75. package/dist/services/plan/PlanBranchService.js.map +1 -1
  76. package/dist/templates/proceed-instructions.md +2 -1
  77. package/dist/templates/skills/hula-approve/SKILL.md +31 -0
  78. package/dist/templates/skills/hula-fix/SKILL.md +10 -0
  79. package/dist/templates/skills/hula-launch/SKILL.md +40 -1
  80. package/dist/templates/skills/hula-plan/SKILL.md +39 -4
  81. package/dist/types/config.schema.d.ts +24 -0
  82. package/dist/types/config.schema.d.ts.map +1 -1
  83. package/dist/types/config.schema.js +11 -0
  84. package/dist/types/config.schema.js.map +1 -1
  85. package/dist/types/issue.schema.d.ts +6 -0
  86. package/dist/types/issue.schema.d.ts.map +1 -1
  87. package/dist/types/issue.schema.js +8 -0
  88. package/dist/types/issue.schema.js.map +1 -1
  89. package/dist/types/pr.schema.d.ts +38 -0
  90. package/dist/types/pr.schema.d.ts.map +1 -1
  91. package/dist/types/pr.schema.js +7 -0
  92. package/dist/types/pr.schema.js.map +1 -1
  93. package/dist/utils/client-session.d.ts +16 -0
  94. package/dist/utils/client-session.d.ts.map +1 -1
  95. package/dist/utils/client-session.js +29 -0
  96. package/dist/utils/client-session.js.map +1 -1
  97. package/dist/utils/github-cli.d.ts +2 -0
  98. package/dist/utils/github-cli.d.ts.map +1 -1
  99. package/dist/utils/github-cli.js +1 -1
  100. package/dist/utils/github-cli.js.map +1 -1
  101. package/dist/utils/sandbox-profile.d.ts +44 -0
  102. package/dist/utils/sandbox-profile.d.ts.map +1 -0
  103. package/dist/utils/sandbox-profile.js +78 -0
  104. package/dist/utils/sandbox-profile.js.map +1 -0
  105. package/package.json +1 -1
@@ -14,6 +14,7 @@ import { GroupLaunchService, validateGroupId, } from '../services/group/GroupLau
14
14
  import { TempFileService } from '../services/logs/TempFileService.js';
15
15
  import { EditorService } from '../services/editor/EditorService.js';
16
16
  import { ensurePlanBranch, PlanNotFoundError, } from '../services/plan/PlanBranchService.js';
17
+ import { parseGitHubRef, findLinkedIssueNumber, resolveIssueSource, resolvePrSource, buildPlanMarkdown, writeGeneratedPlan, } from '../services/plan/GitHubSourcePlanService.js';
17
18
  import { loadGitHubToken } from './login.js';
18
19
  import { resolveProject } from '../utils/project-resolver.js';
19
20
  import { HULA_PROJECT_URL } from '../config/constants.js';
@@ -25,6 +26,7 @@ import { findGitRoot } from '../utils/git-utils.js';
25
26
  import { upsertProviderApiKey } from '../utils/config-file-edit.js';
26
27
  import { validateNoReservedVars, readAndFilterEnvVars, RESERVED_ENV_VARS, } from '../utils/env-vars.js';
27
28
  import { HookExecutor } from '../services/hooks/HookExecutor.js';
29
+ import { describeSandboxProfile, resolveSandboxProfile, sandboxProfileForRequest, } from '../utils/sandbox-profile.js';
28
30
  const DEFAULT_CONTAINER_RESOURCES = { cpu: 2, memory: 4, disk: 10 };
29
31
  /**
30
32
  * Launch command - Trigger a launch job on hula-project server
@@ -69,11 +71,15 @@ export function launchCommand(program, config) {
69
71
  // 'Run in test mode (server uses a mock Claude — fast E2E run, still creates a real PR)',
70
72
  // )
71
73
  .option('--handoff <username>', 'Assign the created issue to this GitHub user')
74
+ .option('--issue <ref>', 'Work on an existing GitHub issue (number, #number, or URL). Never creates a new issue. A single positional is the tracking name, not a plan path.')
75
+ .option('--pr <ref>', 'Continue an existing pull request in this repository (number, #number, or URL). Pushes to its branch. A single positional is the tracking name, not a plan path.')
76
+ .option('--base <branch>', 'Branch to cut the new work branch from and open the PR into (default: the repository default branch)')
72
77
  .option('--provider <type>', 'LLM provider: claude | openai | openrouter (default: config or claude)')
73
78
  .option('--provider-key <key>', 'Provider credential (ephemeral; never stored server-side beyond the run)')
74
79
  .option('--container-cpu <n>', 'vCPU cores for the sandbox (1–32)', parseInt)
75
80
  .option('--container-memory <n>', 'GiB RAM for the sandbox (1–128)', parseInt)
76
81
  .option('--container-disk <n>', 'GiB disk for the sandbox (5–200)', parseInt)
82
+ .option('--sandbox-profile <profile>', 'Sandbox image: base | android | android:17 | android:21 (default: config sandboxProfile, else base)')
77
83
  .option('--update-notification-url <url>', 'Webhook URL to POST a notification to on every terminal task transition')
78
84
  .option('--update-notification-name-tag <tag>', 'Verbatim "initiated by" label for notifications (Slack mention markup pings). Falls back to config then HULA_UPDATE_NOTIFICATION_NAME_TAG.')
79
85
  .option('--show <name>', 'Show job status for the specified issue')
@@ -121,6 +127,13 @@ export function launchCommand(program, config) {
121
127
  logger.error(folderFlagError);
122
128
  process.exit(1);
123
129
  }
130
+ // Validate the launch-from-existing `--issue` / `--pr` / `--base`
131
+ // flag combination before any work.
132
+ const sourceFlagError = validateSourceFlags(options, Boolean(planPath));
133
+ if (sourceFlagError) {
134
+ logger.error(sourceFlagError);
135
+ process.exit(1);
136
+ }
124
137
  // Validate an explicitly-supplied group id against the server format.
125
138
  if (typeof options.groupId === 'string') {
126
139
  const groupIdError = validateGroupId(options.groupId);
@@ -162,6 +175,13 @@ export function launchCommand(program, config) {
162
175
  await executeKill(config, issueName, options);
163
176
  return;
164
177
  }
178
+ // With --issue / --pr the plan (and tracking name) are derived from
179
+ // GitHub, so neither positional is required. A single positional is
180
+ // the tracking name; the plan path is generated.
181
+ if (options.issue || options.pr) {
182
+ await executeLaunch(config, issueName, planPath, options);
183
+ return;
184
+ }
165
185
  // Regular launch / --kill-and-relaunch requires issueName and planPath
166
186
  if (!issueName || !planPath) {
167
187
  logger.error('Usage: hula launch <issueName> <planPath>');
@@ -217,7 +237,52 @@ export function validateKillFlags(options) {
217
237
  * `--kill` needs only the tracking name; every other mode needs a plan.
218
238
  */
219
239
  export function requiresPlanPath(options) {
220
- return !options.kill;
240
+ return !options.kill && !options.issue && !options.pr;
241
+ }
242
+ /**
243
+ * Launch-only flags that cannot be combined with `--issue` / `--pr`. These are
244
+ * either read/group modes or the kill flows, none of which make sense when
245
+ * starting from an existing issue or PR (single-repo launch only).
246
+ */
247
+ const SOURCE_INCOMPATIBLE_FLAGS = [
248
+ { key: 'folder', flag: '--folder' },
249
+ { key: 'groupId', flag: '--group-id' },
250
+ { key: 'kill', flag: '--kill' },
251
+ { key: 'killAndRelaunch', flag: '--kill-and-relaunch' },
252
+ { key: 'show', flag: '--show' },
253
+ { key: 'logs', flag: '--logs' },
254
+ { key: 'showGroup', flag: '--show-group' },
255
+ ];
256
+ /**
257
+ * Validate the `--issue` / `--pr` / `--base` flag matrix.
258
+ *
259
+ * - A plan file may accompany `--issue` OR `--pr`, but not both — with both and
260
+ * a plan file there is no single instruction source. Without a plan file,
261
+ * `--issue` and `--pr` combine (plan generated from both).
262
+ * - `--base` is meaningless with `--pr` (the base comes from the PR itself).
263
+ * - `--issue` / `--pr` are single-repo launch flows, incompatible with the
264
+ * read/group/kill flags in {@link SOURCE_INCOMPATIBLE_FLAGS}.
265
+ *
266
+ * Returns an error message when the combination is invalid, or `null` when OK.
267
+ */
268
+ export function validateSourceFlags(options, hasPlanPathPositional) {
269
+ const hasIssue = Boolean(options.issue);
270
+ const hasPr = Boolean(options.pr);
271
+ if (hasPlanPathPositional && hasIssue && hasPr) {
272
+ return 'Pass a plan file with --issue or with --pr, not with both. Without a plan file, --issue and --pr can be combined.';
273
+ }
274
+ if (hasPr && options.base) {
275
+ return '--base cannot be used with --pr: the base branch comes from the pull request';
276
+ }
277
+ if (hasIssue || hasPr) {
278
+ for (const { key, flag } of SOURCE_INCOMPATIBLE_FLAGS) {
279
+ const value = options[key];
280
+ if (value !== undefined && value !== false) {
281
+ return `--issue/--pr cannot be combined with ${flag}`;
282
+ }
283
+ }
284
+ }
285
+ return null;
221
286
  }
222
287
  /**
223
288
  * Validate the multi-repo `--folder` / `--group-id` / `--plan` flag matrix.
@@ -492,6 +557,8 @@ async function validateProviderCredentials(providerType, authToken, githubToken)
492
557
  process.exit(1);
493
558
  }
494
559
  }
560
+ /** Client-side ceiling for the launch POST (ms). */
561
+ export const LAUNCH_REQUEST_TIMEOUT_MS = 120_000;
495
562
  /**
496
563
  * Billing page of the server the CLI is actually talking to. Never hardcode
497
564
  * hublaunch.site — a preview or self-hosted deployment must point the user at
@@ -500,23 +567,36 @@ async function validateProviderCredentials(providerType, authToken, githubToken)
500
567
  function billingUrl(serverUrl) {
501
568
  return `${serverUrl.replace(/\/$/, '')}/dashboard/billing`;
502
569
  }
503
- /**
504
- * Explain why a launch was refused for billing reasons.
505
- *
506
- * `subscriptionStatus` is what the server reported for this account (`null`
507
- * when it has never had a subscription, so a free trial is still available);
508
- * `serverError` is the server's own 402 message when the refusal came from the
509
- * launch endpoint rather than the pre-flight check — it is more specific than
510
- * anything the CLI can infer ("Out of PR credits — buy more to launch"), so it
511
- * wins. `actionUrl` is the `upgradeUrl`/`buyCreditsUrl` from that same payload.
512
- *
513
- * With none of them supplied, the wording stays generic rather than asserting a
514
- * tier the CLI has not confirmed.
515
- */
516
- function printNoProAccessError(opts) {
517
- const { serverUrl, subscriptionStatus, serverError, actionUrl } = opts;
570
+ /** The "Your options" lines printed under a billing refusal. */
571
+ export function billingRefusalOptions(opts) {
572
+ const { serverUrl, subscriptionStatus, actionUrl, refusal } = opts;
518
573
  const url = actionUrl || billingUrl(serverUrl);
519
- const neverSubscribed = subscriptionStatus === null || subscriptionStatus === undefined;
574
+ const neverSubscribed = subscriptionStatus === null;
575
+ const paymentFailed = subscriptionStatus === 'past_due' || subscriptionStatus === 'unpaid';
576
+ let first;
577
+ if (refusal === 'credits') {
578
+ first = ` • Buy more credits at ${url}. Purchased credits never expire.`;
579
+ }
580
+ else if (neverSubscribed) {
581
+ first = ` • Start a free trial at ${url} — no charge today, cancel anytime.`;
582
+ }
583
+ else if (paymentFailed) {
584
+ first = ` • Update your payment method at ${url}.`;
585
+ }
586
+ else if (subscriptionStatus === undefined) {
587
+ first = ` • See your plan and credits at ${url}.`;
588
+ }
589
+ else {
590
+ first = ` • Subscribe or top up credits at ${url}.`;
591
+ }
592
+ return [
593
+ first,
594
+ ' • Run /hula-create, which will assign the issue to Copilot on GitHub.',
595
+ ];
596
+ }
597
+ function printNoProAccessError(opts) {
598
+ const { subscriptionStatus, serverError } = opts;
599
+ const neverSubscribed = subscriptionStatus === null;
520
600
  const paymentFailed = subscriptionStatus === 'past_due' || subscriptionStatus === 'unpaid';
521
601
  if (serverError) {
522
602
  logger.error(serverError);
@@ -524,6 +604,9 @@ function printNoProAccessError(opts) {
524
604
  else if (neverSubscribed) {
525
605
  logger.error('Launching requires Pro — this account has no subscription yet.');
526
606
  }
607
+ else if (subscriptionStatus === undefined) {
608
+ logger.error('Launching requires Pro.');
609
+ }
527
610
  else if (subscriptionStatus === 'canceled') {
528
611
  logger.error('Launching requires Pro — your subscription was cancelled.');
529
612
  }
@@ -535,18 +618,10 @@ function printNoProAccessError(opts) {
535
618
  }
536
619
  logger.blank();
537
620
  logger.info('Your options:');
538
- if (neverSubscribed) {
539
- logger.info(` • Start a free trial at ${url} — no charge today, cancel anytime.`);
540
- }
541
- else if (paymentFailed) {
542
- logger.info(` • Update your payment method at ${url}.`);
543
- }
544
- else {
545
- logger.info(` • Subscribe or top up credits at ${url}.`);
546
- }
547
- logger.info(' • Run /hula-create, which will assign the issue to Copilot on GitHub.');
621
+ for (const line of billingRefusalOptions(opts))
622
+ logger.info(line);
548
623
  logger.blank();
549
- logger.info('The Pro tier allows launching as many plans as you like, subject to the constraints of your own LLM provider (Claude, OpenAI, or OpenRouter) subscription.');
624
+ logger.info('Pro includes a monthly allowance of PR credits, and you can buy more at any time. Runs also use your own LLM provider (Claude, OpenAI, or OpenRouter) subscription.');
550
625
  }
551
626
  /**
552
627
  * Check whether the account may launch — Pro, including an active free trial
@@ -720,6 +795,11 @@ export function buildRalphRunRequestBody(input) {
720
795
  if (input.containerResources) {
721
796
  body.containerResources = input.containerResources;
722
797
  }
798
+ // Maps to RalphRunRequest.sandboxProfile. Only non-base profiles are sent so
799
+ // base launches stay byte-identical (see sandboxProfileForRequest).
800
+ if (input.sandboxProfile) {
801
+ body.sandboxProfile = input.sandboxProfile;
802
+ }
723
803
  if (input.updateNotificationUrl) {
724
804
  body.updateNotificationUrl = input.updateNotificationUrl;
725
805
  }
@@ -737,8 +817,69 @@ export function buildRalphRunRequestBody(input) {
737
817
  if (input.branchExists) {
738
818
  body.branchExists = true;
739
819
  }
820
+ // Launch-from-existing linkage (appended after branchExists, each only when
821
+ // defined, so non-source launches stay byte-identical). An older server
822
+ // ignores these unknown fields.
823
+ if (input.issueNumber !== undefined) {
824
+ body.issueNumber = input.issueNumber;
825
+ }
826
+ if (input.prNumber !== undefined) {
827
+ body.prNumber = input.prNumber;
828
+ }
829
+ if (input.headBranch !== undefined) {
830
+ body.headBranch = input.headBranch;
831
+ }
832
+ if (input.baseBranch !== undefined) {
833
+ body.baseBranch = input.baseBranch;
834
+ }
740
835
  return body;
741
836
  }
837
+ /**
838
+ * True when `msg` is one of the deliberate validation errors thrown by
839
+ * {@link resolveIssueSource} / {@link resolvePrSource} (closed, merged, fork,
840
+ * wrong kind) — those must pass through verbatim, not be re-wrapped as a fetch
841
+ * failure.
842
+ */
843
+ function isGitHubSourceValidationError(msg) {
844
+ return /is a pull request, not an issue|is an issue, not a pull request|is closed\. Reopen|is already merged|comes from a fork/.test(msg);
845
+ }
846
+ /**
847
+ * {@link resolveIssueSource} with the GitHub-fetch error mapping from the plan's
848
+ * edge cases: 404 → a clear "not found in <owner/repo>", any other fetch error
849
+ * → "Could not fetch issue #<n>: …". Validation errors pass through unchanged.
850
+ */
851
+ async function resolveIssueSourceWrapped(n, repo, repoFullName) {
852
+ try {
853
+ return await resolveIssueSource(n, repo);
854
+ }
855
+ catch (err) {
856
+ const msg = err instanceof Error ? err.message : String(err);
857
+ if (isGitHubSourceValidationError(msg))
858
+ throw err;
859
+ if (/404|not found/i.test(msg)) {
860
+ throw new Error(`Issue #${n} not found in ${repoFullName}`);
861
+ }
862
+ throw new Error(`Could not fetch issue #${n}: ${msg}`);
863
+ }
864
+ }
865
+ /**
866
+ * {@link resolvePrSource} with the same GitHub-fetch error mapping as
867
+ * {@link resolveIssueSourceWrapped}.
868
+ */
869
+ async function resolvePrSourceWrapped(n, repoFullName) {
870
+ try {
871
+ return await resolvePrSource(n);
872
+ }
873
+ catch (err) {
874
+ const msg = err instanceof Error ? err.message : String(err);
875
+ if (isGitHubSourceValidationError(msg))
876
+ throw err;
877
+ if (/404|not found/i.test(msg)) {
878
+ throw new Error(`PR #${n} not found in ${repoFullName}`);
879
+ }
880
+ throw new Error(`Could not fetch PR #${n}: ${msg}`);
881
+ }
882
+ }
742
883
  /**
743
884
  * Single-repo launch entry point. Runs the plan-sync/upload check, then defers
744
885
  * the body-build + POST to {@link launchSingleRepo} in single mode (keeps the
@@ -776,11 +917,121 @@ conflictModeOverride) {
776
917
  * @param options - Parsed CLI options.
777
918
  * @param ctx - Mode + shared-once context (see {@link SingleRepoLaunchContext}).
778
919
  */
779
- async function launchSingleRepo(repoConfig, repoRoot, issueName, planPath, options, ctx) {
920
+ async function launchSingleRepo(repoConfig, repoRoot, issueNameInput, planPathInput, options, ctx) {
780
921
  const isGroup = ctx.mode === 'group';
781
922
  // Effective conflict mode: an explicit override (from the 409 resolver) or the
782
923
  // --kill-and-relaunch flag. Undefined = default 'reject' (byte-identical body).
783
924
  const conflictMode = deriveConflictMode(options, ctx.conflictModeOverride);
925
+ // ── Launch-from-existing resolution (FR 8) ────────────────────────────────
926
+ // With --issue / --pr the tracking name and plan are derived from GitHub, and
927
+ // --base / --pr change the branch the plan is published to and the PR base.
928
+ // Resolve all of that here, before ensurePlanBranch, so the proven publish
929
+ // path is reused with overrides and the request body gains the linkage fields.
930
+ let resolvedIssueName = issueNameInput;
931
+ let resolvedPlanPath = planPathInput;
932
+ let planBranchOpts;
933
+ const sourceBody = {};
934
+ if (options.issue || options.pr || options.base) {
935
+ try {
936
+ const repoFullName = ctx.repositoryId ??
937
+ (await resolveProject(repoConfig, undefined, repoRoot));
938
+ const [repoOwner, repoName] = repoFullName.split('/');
939
+ // Reject a URL that points at a different repository than this one.
940
+ const assertSameRepo = (ref, kind) => {
941
+ if (ref.owner &&
942
+ ref.repo &&
943
+ (ref.owner.toLowerCase() !== (repoOwner ?? '').toLowerCase() ||
944
+ ref.repo.toLowerCase() !== (repoName ?? '').toLowerCase())) {
945
+ throw new Error(`--${kind} URL points to ${ref.owner}/${ref.repo}, but this repository is ${repoOwner}/${repoName}`);
946
+ }
947
+ };
948
+ let pr;
949
+ let issue;
950
+ if (options.pr) {
951
+ const prRef = parseGitHubRef(options.pr, 'pr');
952
+ assertSameRepo(prRef, 'pr');
953
+ pr = await resolvePrSourceWrapped(prRef.number, repoFullName);
954
+ // Linked issue: an explicit --issue wins; otherwise auto-detect from the
955
+ // PR body and drop a closed linked issue with a warning.
956
+ if (options.issue) {
957
+ const issueRef = parseGitHubRef(options.issue, 'issue');
958
+ assertSameRepo(issueRef, 'issue');
959
+ issue = await resolveIssueSourceWrapped(issueRef.number, { owner: issueRef.owner, name: issueRef.repo }, repoFullName);
960
+ }
961
+ else {
962
+ const linked = findLinkedIssueNumber(pr.body);
963
+ if (linked !== null) {
964
+ try {
965
+ issue = await resolveIssueSourceWrapped(linked, {}, repoFullName);
966
+ }
967
+ catch (linkedErr) {
968
+ const linkedMsg = linkedErr instanceof Error
969
+ ? linkedErr.message
970
+ : String(linkedErr);
971
+ if (linkedMsg.includes('is closed')) {
972
+ logger.warning(`Linked issue #${linked} is closed — continuing without linking it`);
973
+ }
974
+ else {
975
+ throw linkedErr;
976
+ }
977
+ }
978
+ }
979
+ }
980
+ }
981
+ else if (options.issue) {
982
+ const issueRef = parseGitHubRef(options.issue, 'issue');
983
+ assertSameRepo(issueRef, 'issue');
984
+ issue = await resolveIssueSourceWrapped(issueRef.number, { owner: issueRef.owner, name: issueRef.repo }, repoFullName);
985
+ }
986
+ // Tracking name: the positional if given, else pr-<n> / issue-<n>.
987
+ if (!resolvedIssueName) {
988
+ resolvedIssueName = pr
989
+ ? `pr-${pr.number}`
990
+ : issue
991
+ ? `issue-${issue.number}`
992
+ : resolvedIssueName;
993
+ }
994
+ // Plan path: the positional if given, else a generated plan from the
995
+ // GitHub text (only reachable with --issue / --pr; --base alone still
996
+ // requires a positional plan because requiresPlanPath stays true).
997
+ if (!resolvedPlanPath && (pr || issue)) {
998
+ const slug = pr ? `pr-${pr.number}` : `issue-${issue.number}`;
999
+ const markdown = buildPlanMarkdown({ issue, pr, repoFullName });
1000
+ resolvedPlanPath = writeGeneratedPlan(repoConfig.planPath, slug, markdown, repoRoot);
1001
+ logger.info(`Generated plan from ${pr ? `PR #${pr.number}` : `issue #${issue.number}`}: ${resolvedPlanPath}`);
1002
+ }
1003
+ // Branch overrides for ensurePlanBranch.
1004
+ if (pr) {
1005
+ planBranchOpts = { branch: pr.head.ref, requireExistingBranch: true };
1006
+ }
1007
+ else if (options.base) {
1008
+ planBranchOpts = { baseBranch: options.base };
1009
+ }
1010
+ // Request-body linkage fields.
1011
+ if (pr) {
1012
+ sourceBody.prNumber = pr.number;
1013
+ sourceBody.headBranch = pr.head.ref;
1014
+ sourceBody.baseBranch = pr.base.ref;
1015
+ }
1016
+ else if (options.base) {
1017
+ sourceBody.baseBranch = options.base;
1018
+ }
1019
+ if (issue) {
1020
+ sourceBody.issueNumber = issue.number;
1021
+ }
1022
+ }
1023
+ catch (sourceErr) {
1024
+ const msg = sourceErr instanceof Error ? sourceErr.message : String(sourceErr);
1025
+ logger.error(msg);
1026
+ if (isGroup)
1027
+ return { ok: false, message: msg };
1028
+ process.exit(1);
1029
+ }
1030
+ }
1031
+ // After resolution both are guaranteed: the handler requires positionals
1032
+ // except with --issue / --pr, which populate them above.
1033
+ const issueName = resolvedIssueName;
1034
+ const planPath = resolvedPlanPath;
784
1035
  // Plan-branch flow: guarantee the feature branch exists on origin with the
785
1036
  // current plan content BEFORE anything else — the beforeLaunch hook below
786
1037
  // may register branch-scoped external resources (e.g. Vercel Preview env
@@ -791,7 +1042,7 @@ async function launchSingleRepo(repoConfig, repoRoot, issueName, planPath, optio
791
1042
  // that only exist on origin/main.
792
1043
  let planBranch;
793
1044
  try {
794
- const ensured = await ensurePlanBranch(repoConfig, planPath, issueName, repoRoot);
1045
+ const ensured = await ensurePlanBranch(repoConfig, planPath, issueName, repoRoot, planBranchOpts);
795
1046
  planBranch = ensured.branch;
796
1047
  if (ensured.created) {
797
1048
  logger.success(`Branch '${ensured.branch}' created on origin with the plan`);
@@ -1009,6 +1260,18 @@ async function launchSingleRepo(repoConfig, repoRoot, issueName, planPath, optio
1009
1260
  containerResources = { cpu, memory, disk };
1010
1261
  logger.log(` Container Resources: cpu=${cpu} memory=${memory}GiB disk=${disk}GiB`);
1011
1262
  }
1263
+ // Resolve the sandbox profile: --sandbox-profile, then config, then base.
1264
+ const profileResult = resolveSandboxProfile(options.sandboxProfile, repoConfig.sandboxProfile);
1265
+ if (profileResult.error) {
1266
+ logger.error(profileResult.error);
1267
+ if (isGroup)
1268
+ return { ok: false, message: profileResult.error };
1269
+ process.exit(1);
1270
+ }
1271
+ const sandboxProfile = sandboxProfileForRequest(profileResult.profile);
1272
+ if (sandboxProfile) {
1273
+ logger.log(` Sandbox Profile: ${describeSandboxProfile(sandboxProfile)}`);
1274
+ }
1012
1275
  // Resolve the GitHub token: shared (group mode) or loaded (single mode).
1013
1276
  const githubToken = ctx.skipTokenLoad
1014
1277
  ? ctx.githubToken
@@ -1124,6 +1387,7 @@ async function launchSingleRepo(repoConfig, repoRoot, issueName, planPath, optio
1124
1387
  clientSessionId: clientSessionId ?? undefined,
1125
1388
  provider: { type: providerType, authToken },
1126
1389
  containerResources,
1390
+ sandboxProfile,
1127
1391
  updateNotificationUrl,
1128
1392
  updateNotificationNameTag,
1129
1393
  githubToken,
@@ -1132,6 +1396,11 @@ async function launchSingleRepo(repoConfig, repoRoot, issueName, planPath, optio
1132
1396
  // plan committed) exists on origin, so the server must check out the
1133
1397
  // existing branch instead of creating one from the base branch.
1134
1398
  branchExists: true,
1399
+ // Launch-from-existing linkage (undefined unless --issue / --pr / --base).
1400
+ issueNumber: sourceBody.issueNumber,
1401
+ prNumber: sourceBody.prNumber,
1402
+ headBranch: sourceBody.headBranch,
1403
+ baseBranch: sourceBody.baseBranch,
1135
1404
  });
1136
1405
  try {
1137
1406
  const response = await axios.post(endpoint, requestBody, {
@@ -1139,7 +1408,10 @@ async function launchSingleRepo(repoConfig, repoRoot, issueName, planPath, optio
1139
1408
  'Content-Type': 'application/json',
1140
1409
  Authorization: `Bearer ${apiKey}`,
1141
1410
  },
1142
- timeout: 30000, // 30 second timeout
1411
+ // The launch route does GitHub, credential and DB work before it answers;
1412
+ // 30s was shorter than the server's own limit and dropped launches on a
1413
+ // slow (cold) server. See LAUNCH_REQUEST_TIMEOUT_MS.
1414
+ timeout: LAUNCH_REQUEST_TIMEOUT_MS,
1143
1415
  });
1144
1416
  if (response.data.success) {
1145
1417
  // killAndRelaunch over an active task: note the cancellation before the
@@ -1149,13 +1421,15 @@ async function launchSingleRepo(repoConfig, repoRoot, issueName, planPath, optio
1149
1421
  }
1150
1422
  logger.success(response.data.message);
1151
1423
  logger.blank();
1424
+ // Link to the dashboard of the server we launched on, so a preview or
1425
+ // self-hosted launch is not sent to hublaunch.site.
1426
+ const dashboardUrl = `${serverUrl.replace(/\/$/, '')}/dashboard`;
1152
1427
  if (response.data.planId) {
1153
- const planUrl = `https://www.hublaunch.site/dashboard/plans/${response.data.planId}`;
1154
1428
  logger.info(`Plan ID: ${response.data.planId}`);
1155
- logger.info(`Track it: ${planUrl}`);
1429
+ logger.info(`Track it: ${dashboardUrl}/plans/${response.data.planId}`);
1156
1430
  }
1157
1431
  else {
1158
- logger.info('Track it: https://www.hublaunch.site/dashboard');
1432
+ logger.info(`Track it: ${dashboardUrl}`);
1159
1433
  }
1160
1434
  if (!updateNotificationUrl) {
1161
1435
  logger.info('Set updateNotificationUrl in your local hublaunch.config.js file to be able to get update messages about the status.');
@@ -1663,7 +1937,9 @@ function describeLaunchAxiosError(error, serverUrl, issueName, planPath) {
1663
1937
  return { message: `Cannot connect to server at ${serverUrl}` };
1664
1938
  }
1665
1939
  if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
1666
- return { message: 'Request timed out' };
1940
+ return {
1941
+ message: `Request timed out. The launch may still have been queued: check with \`hula info ${issueName} --lastLogs\` before retrying.`,
1942
+ };
1667
1943
  }
1668
1944
  return { message: `Network error: ${error.message}` };
1669
1945
  }
@@ -1698,6 +1974,11 @@ function handleLaunchAxiosError(error, serverUrl, issueName, planPath) {
1698
1974
  serverUrl,
1699
1975
  serverError: data.error,
1700
1976
  actionUrl: data.buyCreditsUrl || data.upgradeUrl,
1977
+ refusal: data.buyCreditsUrl
1978
+ ? 'credits'
1979
+ : data.upgradeUrl
1980
+ ? 'plan'
1981
+ : undefined,
1701
1982
  });
1702
1983
  break;
1703
1984
  case 403:
@@ -1724,7 +2005,7 @@ function handleLaunchAxiosError(error, serverUrl, issueName, planPath) {
1724
2005
  }
1725
2006
  else if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
1726
2007
  logger.error('Request timed out');
1727
- logger.info('The server may be slow or unreachable.');
2008
+ logger.info(`The server may be slow or unreachable. The launch may still have been queued: check with \`hula info ${issueName} --lastLogs\`. Re-running is safe; a launch that did queue is reported as already running.`);
1728
2009
  }
1729
2010
  else {
1730
2011
  logger.error(`Network error: ${error.message}`);