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.
- package/CHANGELOG.md +22 -0
- package/README.md +52 -0
- package/dist/commands/errorWatcher.d.ts +2 -0
- package/dist/commands/errorWatcher.d.ts.map +1 -1
- package/dist/commands/errorWatcher.js +13 -0
- package/dist/commands/errorWatcher.js.map +1 -1
- package/dist/commands/init.d.ts +12 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +52 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/launch.d.ts +64 -0
- package/dist/commands/launch.d.ts.map +1 -1
- package/dist/commands/launch.js +317 -36
- package/dist/commands/launch.js.map +1 -1
- package/dist/commands/login.d.ts +14 -1
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +26 -4
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/schedule.d.ts.map +1 -1
- package/dist/commands/schedule.js +12 -0
- package/dist/commands/schedule.js.map +1 -1
- package/dist/commands/script.d.ts +1 -1
- package/dist/commands/script.d.ts.map +1 -1
- package/dist/commands/script.js +3 -0
- package/dist/commands/script.js.map +1 -1
- package/dist/commands/session-hook.d.ts +9 -0
- package/dist/commands/session-hook.d.ts.map +1 -1
- package/dist/commands/session-hook.js +60 -15
- package/dist/commands/session-hook.js.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/scripts/backlog-add.d.ts +2 -0
- package/dist/scripts/backlog-add.d.ts.map +1 -0
- package/dist/scripts/backlog-add.js +145 -0
- package/dist/scripts/backlog-add.js.map +1 -0
- package/dist/scripts/backlog-init.d.ts +2 -0
- package/dist/scripts/backlog-init.d.ts.map +1 -0
- package/dist/scripts/backlog-init.js +84 -0
- package/dist/scripts/backlog-init.js.map +1 -0
- package/dist/scripts/backlog-resync.d.ts +2 -0
- package/dist/scripts/backlog-resync.d.ts.map +1 -0
- package/dist/scripts/backlog-resync.js +156 -0
- package/dist/scripts/backlog-resync.js.map +1 -0
- package/dist/scripts/launch-run.d.ts +1 -0
- package/dist/scripts/launch-run.d.ts.map +1 -1
- package/dist/scripts/launch-run.js +47 -3
- package/dist/scripts/launch-run.js.map +1 -1
- package/dist/scripts/lib/backlog-sync.d.ts +27 -0
- package/dist/scripts/lib/backlog-sync.d.ts.map +1 -0
- package/dist/scripts/lib/backlog-sync.js +48 -0
- package/dist/scripts/lib/backlog-sync.js.map +1 -0
- package/dist/scripts/lib/read-config.d.ts +16 -0
- package/dist/scripts/lib/read-config.d.ts.map +1 -1
- package/dist/scripts/lib/read-config.js +34 -0
- package/dist/scripts/lib/read-config.js.map +1 -1
- package/dist/services/api/HulaApiClient.d.ts +39 -0
- package/dist/services/api/HulaApiClient.d.ts.map +1 -1
- package/dist/services/api/HulaApiClient.js +15 -0
- package/dist/services/api/HulaApiClient.js.map +1 -1
- package/dist/services/github/IssueService.d.ts +3 -1
- package/dist/services/github/IssueService.d.ts.map +1 -1
- package/dist/services/github/IssueService.js +5 -2
- package/dist/services/github/IssueService.js.map +1 -1
- package/dist/services/github/PRService.d.ts +3 -1
- package/dist/services/github/PRService.d.ts.map +1 -1
- package/dist/services/github/PRService.js +2 -2
- package/dist/services/github/PRService.js.map +1 -1
- package/dist/services/plan/GitHubSourcePlanService.d.ts +67 -0
- package/dist/services/plan/GitHubSourcePlanService.d.ts.map +1 -0
- package/dist/services/plan/GitHubSourcePlanService.js +164 -0
- package/dist/services/plan/GitHubSourcePlanService.js.map +1 -0
- package/dist/services/plan/PlanBranchService.d.ts +5 -1
- package/dist/services/plan/PlanBranchService.d.ts.map +1 -1
- package/dist/services/plan/PlanBranchService.js +21 -3
- package/dist/services/plan/PlanBranchService.js.map +1 -1
- package/dist/templates/proceed-instructions.md +2 -1
- package/dist/templates/skills/hula-approve/SKILL.md +31 -0
- package/dist/templates/skills/hula-fix/SKILL.md +10 -0
- package/dist/templates/skills/hula-launch/SKILL.md +40 -1
- package/dist/templates/skills/hula-plan/SKILL.md +39 -4
- package/dist/types/config.schema.d.ts +24 -0
- package/dist/types/config.schema.d.ts.map +1 -1
- package/dist/types/config.schema.js +11 -0
- package/dist/types/config.schema.js.map +1 -1
- package/dist/types/issue.schema.d.ts +6 -0
- package/dist/types/issue.schema.d.ts.map +1 -1
- package/dist/types/issue.schema.js +8 -0
- package/dist/types/issue.schema.js.map +1 -1
- package/dist/types/pr.schema.d.ts +38 -0
- package/dist/types/pr.schema.d.ts.map +1 -1
- package/dist/types/pr.schema.js +7 -0
- package/dist/types/pr.schema.js.map +1 -1
- package/dist/utils/client-session.d.ts +16 -0
- package/dist/utils/client-session.d.ts.map +1 -1
- package/dist/utils/client-session.js +29 -0
- package/dist/utils/client-session.js.map +1 -1
- package/dist/utils/github-cli.d.ts +2 -0
- package/dist/utils/github-cli.d.ts.map +1 -1
- package/dist/utils/github-cli.js +1 -1
- package/dist/utils/github-cli.js.map +1 -1
- package/dist/utils/sandbox-profile.d.ts +44 -0
- package/dist/utils/sandbox-profile.d.ts.map +1 -0
- package/dist/utils/sandbox-profile.js +78 -0
- package/dist/utils/sandbox-profile.js.map +1 -0
- package/package.json +1 -1
package/dist/commands/launch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
539
|
-
logger.info(
|
|
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('
|
|
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,
|
|
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
|
-
|
|
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: ${
|
|
1429
|
+
logger.info(`Track it: ${dashboardUrl}/plans/${response.data.planId}`);
|
|
1156
1430
|
}
|
|
1157
1431
|
else {
|
|
1158
|
-
logger.info(
|
|
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 {
|
|
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(
|
|
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}`);
|