hub-launch 1.22.0 → 1.24.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 +32 -0
- package/README.md +5 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +66 -23
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/launch.d.ts +14 -2
- package/dist/commands/launch.d.ts.map +1 -1
- package/dist/commands/launch.js +209 -70
- package/dist/commands/launch.js.map +1 -1
- package/dist/commands/schedule.d.ts.map +1 -1
- package/dist/commands/schedule.js +8 -7
- package/dist/commands/schedule.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +4 -1
- package/dist/config/index.js.map +1 -1
- package/dist/templates/proceed-instructions.md +2 -0
- package/dist/templates/skills/hula-confirm/SKILL.md +1 -1
- package/dist/templates/skills/hula-help/SKILL.md +1 -1
- package/dist/templates/skills/hula-plan/SKILL.md +1 -0
- package/dist/templates/skills/hula-schedule/SKILL.md +3 -3
- package/dist/types/config.schema.d.ts +34 -3
- package/dist/types/config.schema.d.ts.map +1 -1
- package/dist/types/config.schema.js +35 -1
- package/dist/types/config.schema.js.map +1 -1
- package/dist/utils/config-file-edit.d.ts +8 -4
- package/dist/utils/config-file-edit.d.ts.map +1 -1
- package/dist/utils/config-file-edit.js +13 -9
- package/dist/utils/config-file-edit.js.map +1 -1
- package/dist/utils/config-parser.d.ts.map +1 -1
- package/dist/utils/config-parser.js +4 -0
- package/dist/utils/config-parser.js.map +1 -1
- package/dist/utils/env-vars.d.ts.map +1 -1
- package/dist/utils/env-vars.js +4 -0
- package/dist/utils/env-vars.js.map +1 -1
- package/dist/utils/provider-credentials.d.ts +54 -0
- package/dist/utils/provider-credentials.d.ts.map +1 -0
- package/dist/utils/provider-credentials.js +83 -0
- package/dist/utils/provider-credentials.js.map +1 -0
- package/package.json +2 -2
- package/dist/utils/ephemeral-credentials.d.ts +0 -32
- package/dist/utils/ephemeral-credentials.d.ts.map +0 -1
- package/dist/utils/ephemeral-credentials.js +0 -26
- package/dist/utils/ephemeral-credentials.js.map +0 -1
package/dist/commands/launch.js
CHANGED
|
@@ -19,11 +19,11 @@ import { loadGitHubToken } from './login.js';
|
|
|
19
19
|
import { resolveProject } from '../utils/project-resolver.js';
|
|
20
20
|
import { HULA_PROJECT_URL } from '../config/constants.js';
|
|
21
21
|
import { updateUsageTierInConfig } from '../utils/config-writer.js';
|
|
22
|
-
import {
|
|
22
|
+
import { resolveProviderCredentials, validateTokenFormat, } from '../utils/provider-credentials.js';
|
|
23
23
|
import { readClientSessionId } from '../utils/client-session.js';
|
|
24
24
|
import { input, UserCancelledError } from '../utils/prompts.js';
|
|
25
25
|
import { findGitRoot } from '../utils/git-utils.js';
|
|
26
|
-
import {
|
|
26
|
+
import { upsertProviderApiKey } from '../utils/config-file-edit.js';
|
|
27
27
|
import { validateNoReservedVars, readAndFilterEnvVars, RESERVED_ENV_VARS, } from '../utils/env-vars.js';
|
|
28
28
|
const DEFAULT_CONTAINER_RESOURCES = { cpu: 2, memory: 4, disk: 10 };
|
|
29
29
|
/**
|
|
@@ -69,7 +69,8 @@ export function launchCommand(program, config) {
|
|
|
69
69
|
// 'Run in test mode (server uses a mock Claude — fast E2E run, still creates a real PR)',
|
|
70
70
|
// )
|
|
71
71
|
.option('--handoff <username>', 'Assign the created issue to this GitHub user')
|
|
72
|
-
.option('--
|
|
72
|
+
.option('--provider <type>', 'LLM provider: claude | openai | openrouter (default: config or claude)')
|
|
73
|
+
.option('--provider-key <key>', 'Provider credential (ephemeral; never stored server-side beyond the run)')
|
|
73
74
|
.option('--container-cpu <n>', 'vCPU cores for the sandbox (1–32)', parseInt)
|
|
74
75
|
.option('--container-memory <n>', 'GiB RAM for the sandbox (1–128)', parseInt)
|
|
75
76
|
.option('--container-disk <n>', 'GiB disk for the sandbox (5–200)', parseInt)
|
|
@@ -318,16 +319,56 @@ export function resolveSteps(configSteps, skipRegressionFlag, legacyRegressionFl
|
|
|
318
319
|
}
|
|
319
320
|
return { steps: Object.keys(steps).length > 0 ? steps : undefined };
|
|
320
321
|
}
|
|
321
|
-
|
|
322
|
+
/**
|
|
323
|
+
* Per-provider live-validation endpoint + copy. Claude accepts both subscription
|
|
324
|
+
* OAuth tokens (Bearer) and API keys (x-api-key); openai/openrouter use Bearer.
|
|
325
|
+
* See the endpoint table in the multi-provider plan (Requirements §4).
|
|
326
|
+
*/
|
|
327
|
+
function buildProviderValidation(providerType, authToken) {
|
|
328
|
+
switch (providerType) {
|
|
329
|
+
case 'claude': {
|
|
330
|
+
const isApiKey = authToken.startsWith('sk-ant-api');
|
|
331
|
+
return {
|
|
332
|
+
url: 'https://api.anthropic.com/v1/models',
|
|
333
|
+
headers: isApiKey
|
|
334
|
+
? { 'x-api-key': authToken, 'anthropic-version': '2023-06-01' }
|
|
335
|
+
: {
|
|
336
|
+
Authorization: `Bearer ${authToken}`,
|
|
337
|
+
'anthropic-version': '2023-06-01',
|
|
338
|
+
},
|
|
339
|
+
readyLabel: 'Claude Code is ready to go 🤖',
|
|
340
|
+
providerName: 'Anthropic',
|
|
341
|
+
invalidHint: 'Generate a new credential — a subscription OAuth token at https://claude.ai/settings (Pro/Max) or an API key at https://platform.claude.com.',
|
|
342
|
+
quotaHint: 'Your Anthropic account is out of quota — check your plan and usage.',
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
case 'openai':
|
|
346
|
+
return {
|
|
347
|
+
url: 'https://api.openai.com/v1/models',
|
|
348
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
349
|
+
readyLabel: 'Codex (OpenAI) is ready to go 🤖',
|
|
350
|
+
providerName: 'OpenAI',
|
|
351
|
+
invalidHint: 'Create a new API key at https://platform.openai.com/api-keys.',
|
|
352
|
+
quotaHint: 'Your OpenAI account is out of quota — check billing at https://platform.openai.com/account/billing.',
|
|
353
|
+
};
|
|
354
|
+
case 'openrouter':
|
|
355
|
+
return {
|
|
356
|
+
url: 'https://openrouter.ai/api/v1/key',
|
|
357
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
358
|
+
readyLabel: 'Codex (OpenRouter) is ready to go 🤖',
|
|
359
|
+
providerName: 'OpenRouter',
|
|
360
|
+
invalidHint: 'Create a new key at https://openrouter.ai/settings/keys.',
|
|
361
|
+
quotaHint: 'Your OpenRouter account is out of credits — top up at https://openrouter.ai/settings/credits.',
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
async function validateProviderCredentials(providerType, authToken, githubToken) {
|
|
322
366
|
console.log('Checking your credentials before launch...\n');
|
|
323
|
-
// ---
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const anthropicPromise = axios
|
|
329
|
-
.get('https://api.anthropic.com/v1/models', {
|
|
330
|
-
headers: anthropicAuthHeaders,
|
|
367
|
+
// --- LLM provider ---
|
|
368
|
+
const providerCfg = buildProviderValidation(providerType, authToken);
|
|
369
|
+
const providerPromise = axios
|
|
370
|
+
.get(providerCfg.url, {
|
|
371
|
+
headers: providerCfg.headers,
|
|
331
372
|
timeout: 8000,
|
|
332
373
|
})
|
|
333
374
|
.then(() => ({ ok: true }))
|
|
@@ -335,20 +376,37 @@ async function validateLaunchCredentials(anthropicApiKey, githubToken) {
|
|
|
335
376
|
if (axios.isAxiosError(err) &&
|
|
336
377
|
err.response &&
|
|
337
378
|
err.response.status < 500) {
|
|
338
|
-
|
|
379
|
+
const status = err.response.status;
|
|
380
|
+
if (status === 429) {
|
|
381
|
+
return {
|
|
382
|
+
ok: false,
|
|
383
|
+
hard: true,
|
|
384
|
+
message: `${providerCfg.providerName} rejected the request — rate limit or quota exceeded.`,
|
|
385
|
+
hint: providerCfg.quotaHint,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
if (status === 403) {
|
|
389
|
+
return {
|
|
390
|
+
ok: false,
|
|
391
|
+
hard: true,
|
|
392
|
+
message: `${providerCfg.providerName} credential is forbidden (account suspended or payment required).`,
|
|
393
|
+
hint: providerCfg.invalidHint,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
// 401 and any other 4xx → credential is rejected (invalid or expired)
|
|
339
397
|
return {
|
|
340
398
|
ok: false,
|
|
341
399
|
hard: true,
|
|
342
|
-
message:
|
|
343
|
-
hint:
|
|
400
|
+
message: `${providerCfg.providerName} credential is invalid or expired.`,
|
|
401
|
+
hint: providerCfg.invalidHint,
|
|
344
402
|
};
|
|
345
403
|
}
|
|
346
404
|
// Network error / timeout / 5xx — non-blocking warning
|
|
347
405
|
return {
|
|
348
406
|
ok: false,
|
|
349
407
|
hard: false,
|
|
350
|
-
message:
|
|
351
|
-
hint: 'Proceeding anyway — the server will validate the
|
|
408
|
+
message: `Could not reach the ${providerCfg.providerName} API to verify your credential (network issue).`,
|
|
409
|
+
hint: 'Proceeding anyway — the server will validate the credential on startup.',
|
|
352
410
|
};
|
|
353
411
|
});
|
|
354
412
|
// --- GitHub ---
|
|
@@ -382,22 +440,22 @@ async function validateLaunchCredentials(anthropicApiKey, githubToken) {
|
|
|
382
440
|
message: 'No GitHub token found.',
|
|
383
441
|
hint: "Run 'hula login' to authenticate with GitHub first.",
|
|
384
442
|
});
|
|
385
|
-
const [
|
|
386
|
-
|
|
443
|
+
const [providerResult, githubResult] = await Promise.allSettled([
|
|
444
|
+
providerPromise,
|
|
387
445
|
githubPromise,
|
|
388
446
|
]);
|
|
389
447
|
let hasHardFailure = false;
|
|
390
|
-
// Print
|
|
391
|
-
const result =
|
|
392
|
-
?
|
|
448
|
+
// Print LLM provider result
|
|
449
|
+
const result = providerResult.status === 'fulfilled'
|
|
450
|
+
? providerResult.value
|
|
393
451
|
: {
|
|
394
452
|
ok: false,
|
|
395
453
|
hard: false,
|
|
396
|
-
message:
|
|
454
|
+
message: `Unexpected error during ${providerCfg.providerName} validation.`,
|
|
397
455
|
hint: 'Proceeding anyway.',
|
|
398
456
|
};
|
|
399
457
|
if (result.ok) {
|
|
400
|
-
console.log(` ${chalk.green('✓')}
|
|
458
|
+
console.log(` ${chalk.green('✓')} ${providerCfg.providerName} credential is valid — ${providerCfg.readyLabel}`);
|
|
401
459
|
}
|
|
402
460
|
else if (result.hard) {
|
|
403
461
|
console.error(` ${chalk.red('✗')} ${result.message}`);
|
|
@@ -434,43 +492,104 @@ async function validateLaunchCredentials(anthropicApiKey, githubToken) {
|
|
|
434
492
|
process.exit(1);
|
|
435
493
|
}
|
|
436
494
|
}
|
|
437
|
-
|
|
438
|
-
|
|
495
|
+
/**
|
|
496
|
+
* Billing page of the server the CLI is actually talking to. Never hardcode
|
|
497
|
+
* hublaunch.site — a preview or self-hosted deployment must point the user at
|
|
498
|
+
* its own dashboard, otherwise they "fix" billing on the wrong instance.
|
|
499
|
+
*/
|
|
500
|
+
function billingUrl(serverUrl) {
|
|
501
|
+
return `${serverUrl.replace(/\/$/, '')}/dashboard/billing`;
|
|
502
|
+
}
|
|
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;
|
|
518
|
+
const url = actionUrl || billingUrl(serverUrl);
|
|
519
|
+
const neverSubscribed = subscriptionStatus === null || subscriptionStatus === undefined;
|
|
520
|
+
const paymentFailed = subscriptionStatus === 'past_due' || subscriptionStatus === 'unpaid';
|
|
521
|
+
if (serverError) {
|
|
522
|
+
logger.error(serverError);
|
|
523
|
+
}
|
|
524
|
+
else if (neverSubscribed) {
|
|
525
|
+
logger.error('Launching requires Pro — this account has no subscription yet.');
|
|
526
|
+
}
|
|
527
|
+
else if (subscriptionStatus === 'canceled') {
|
|
528
|
+
logger.error('Launching requires Pro — your subscription was cancelled.');
|
|
529
|
+
}
|
|
530
|
+
else if (paymentFailed) {
|
|
531
|
+
logger.error('Launching requires Pro — your last payment failed.');
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
logger.error(`Launching requires Pro — your subscription is ${subscriptionStatus}.`);
|
|
535
|
+
}
|
|
439
536
|
logger.blank();
|
|
440
537
|
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
|
+
}
|
|
441
547
|
logger.info(' • Run /hula-create, which will assign the issue to Copilot on GitHub.');
|
|
442
|
-
logger.info(' • Go to https://www.hublaunch.site and upgrade your subscription to Pro.');
|
|
443
548
|
logger.blank();
|
|
444
|
-
logger.info('The Pro tier allows launching as many plans as you like, subject to the constraints of your own
|
|
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.');
|
|
445
550
|
}
|
|
446
551
|
/**
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
552
|
+
* Check whether the account may launch — Pro, including an active free trial
|
|
553
|
+
* (the server reports `plan: 'pro'` with `subscriptionStatus: 'trialing'`).
|
|
554
|
+
*
|
|
555
|
+
* The server is asked on EVERY launch. `usageTier` in the config is a CACHE of
|
|
556
|
+
* the last answer, never a shortcut: skipping the call once it read 'pro' meant
|
|
557
|
+
* a cancelled subscription or an expired trial kept passing this local gate and
|
|
558
|
+
* then failed against the server with a misleading "free tier" message. The
|
|
559
|
+
* cache is refreshed in both directions here and only relied on when the server
|
|
560
|
+
* cannot be reached.
|
|
451
561
|
*/
|
|
452
562
|
async function verifyProTier(config, serverUrl, apiKey,
|
|
453
563
|
// Repo root whose config file records the verified tier. Group mode passes
|
|
454
564
|
// the member repo's path — process.cwd() there is the parent folder, which
|
|
455
565
|
// has no .hublaunch and would silently drop the persistence every run.
|
|
456
566
|
repoRoot = process.cwd()) {
|
|
457
|
-
if (config.usageTier === 'pro') {
|
|
458
|
-
return true;
|
|
459
|
-
}
|
|
460
567
|
try {
|
|
461
568
|
const response = await axios.get(`${serverUrl.replace(/\/$/, '')}/api/v1/user/subscription`, {
|
|
462
569
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
463
570
|
timeout: 10000,
|
|
464
571
|
});
|
|
465
|
-
const
|
|
466
|
-
if (plan === 'pro') {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
572
|
+
const data = response.data;
|
|
573
|
+
if (data.plan === 'pro') {
|
|
574
|
+
// Only announce (and rewrite the config) on an actual free -> pro flip,
|
|
575
|
+
// so a steady-state Pro user sees no extra line every launch.
|
|
576
|
+
if (config.usageTier !== 'pro') {
|
|
577
|
+
const saved = updateUsageTierInConfig(repoRoot, 'pro');
|
|
578
|
+
if (saved) {
|
|
579
|
+
logger.success('Subscription verified as Pro — config updated for future launches.');
|
|
580
|
+
}
|
|
470
581
|
}
|
|
471
582
|
return true;
|
|
472
583
|
}
|
|
473
|
-
|
|
584
|
+
// Clear a stale 'pro' cache so the config stops claiming Pro after a
|
|
585
|
+
// cancellation or an expired trial.
|
|
586
|
+
if (config.usageTier === 'pro') {
|
|
587
|
+
updateUsageTierInConfig(repoRoot, 'free');
|
|
588
|
+
}
|
|
589
|
+
printNoProAccessError({
|
|
590
|
+
serverUrl,
|
|
591
|
+
subscriptionStatus: data.subscriptionStatus ?? null,
|
|
592
|
+
});
|
|
474
593
|
return false;
|
|
475
594
|
}
|
|
476
595
|
catch (err) {
|
|
@@ -482,7 +601,7 @@ repoRoot = process.cwd()) {
|
|
|
482
601
|
process.exit(1);
|
|
483
602
|
}
|
|
484
603
|
if (status >= 400 && status < 500) {
|
|
485
|
-
|
|
604
|
+
printNoProAccessError({ serverUrl });
|
|
486
605
|
return false;
|
|
487
606
|
}
|
|
488
607
|
}
|
|
@@ -558,7 +677,10 @@ export function buildRalphRunRequestBody(input) {
|
|
|
558
677
|
if (input.clientSessionId) {
|
|
559
678
|
body.clientSessionId = input.clientSessionId;
|
|
560
679
|
}
|
|
561
|
-
body.
|
|
680
|
+
body.provider = {
|
|
681
|
+
type: input.provider.type,
|
|
682
|
+
authToken: input.provider.authToken,
|
|
683
|
+
};
|
|
562
684
|
if (input.containerResources) {
|
|
563
685
|
body.containerResources = input.containerResources;
|
|
564
686
|
}
|
|
@@ -739,23 +861,24 @@ async function launchSingleRepo(repoConfig, repoRoot, issueName, planPath, optio
|
|
|
739
861
|
const stepsResult = resolveSteps(repoConfig.steps, options.skipRegression, options.regression);
|
|
740
862
|
// Maps to RalphRunRequest.clientSessionId (hula-server PR #419).
|
|
741
863
|
const clientSessionId = readClientSessionId(issueName);
|
|
742
|
-
// Resolve
|
|
743
|
-
// source of truth for precedence/validation, also used by `
|
|
864
|
+
// Resolve provider credentials through the shared resolver — the single
|
|
865
|
+
// source of truth for precedence/validation, also used by `schedule`.
|
|
744
866
|
// Collect ALL errors before exiting (preserves prior launch behavior).
|
|
745
|
-
const resolved =
|
|
746
|
-
|
|
867
|
+
const resolved = resolveProviderCredentials(options, repoConfig);
|
|
868
|
+
const providerType = resolved.providerType;
|
|
869
|
+
let authToken = resolved.authToken;
|
|
747
870
|
const credentialErrors = resolved.errors;
|
|
748
871
|
if (credentialErrors.length > 0) {
|
|
749
872
|
// Interactive terminal + the ONLY problem is a totally-absent token:
|
|
750
873
|
// collect it inline rather than failing, then persist it for next time.
|
|
751
|
-
// Any other case (wrong-
|
|
874
|
+
// Any other case (wrong-format, non-TTY, multiple errors) keeps today's
|
|
752
875
|
// fail-fast behavior — identical to what `hula schedule` does.
|
|
753
|
-
if (resolved.
|
|
876
|
+
if (resolved.missingToken &&
|
|
754
877
|
credentialErrors.length === 1 &&
|
|
755
878
|
process.stdout.isTTY) {
|
|
756
879
|
let pasted;
|
|
757
880
|
try {
|
|
758
|
-
pasted = (await input(
|
|
881
|
+
pasted = (await input(`Paste your ${providerType} credential, or press Enter to cancel:`)).trim();
|
|
759
882
|
}
|
|
760
883
|
catch (error) {
|
|
761
884
|
// Treat an explicit cancel (Ctrl-C / escape) exactly like empty input.
|
|
@@ -773,29 +896,29 @@ async function launchSingleRepo(repoConfig, repoRoot, issueName, planPath, optio
|
|
|
773
896
|
return { ok: false, message: credentialErrors.join('; ') };
|
|
774
897
|
process.exit(1);
|
|
775
898
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
console.error(chalk.red(
|
|
899
|
+
const formatError = validateTokenFormat(providerType, pasted);
|
|
900
|
+
if (formatError) {
|
|
901
|
+
// Wrong format for this provider → the per-provider error. One attempt.
|
|
902
|
+
console.error(chalk.red(formatError));
|
|
780
903
|
if (isGroup)
|
|
781
|
-
return { ok: false, message:
|
|
904
|
+
return { ok: false, message: formatError };
|
|
782
905
|
process.exit(1);
|
|
783
906
|
}
|
|
784
907
|
// Valid: use it for this launch and persist it to the config file. A
|
|
785
908
|
// persistence failure must NOT abort a valid launch — warn and continue
|
|
786
909
|
// with the in-memory key. In group mode the token is written to the
|
|
787
910
|
// member repo's own config file (repoRoot); in single mode to the git root.
|
|
788
|
-
|
|
911
|
+
authToken = pasted;
|
|
789
912
|
try {
|
|
790
913
|
const configDir = isGroup ? repoRoot : await findGitRoot();
|
|
791
914
|
const configFilePath = path.join(configDir, '.hublaunch', 'hublaunch.config.js');
|
|
792
915
|
const original = readFileSync(configFilePath, 'utf-8');
|
|
793
|
-
const updated =
|
|
916
|
+
const updated = upsertProviderApiKey(original, providerType, pasted);
|
|
794
917
|
writeFileSync(configFilePath, updated, { mode: 0o600 });
|
|
795
|
-
logger.success('
|
|
918
|
+
logger.success('provider credential saved to .hublaunch/hublaunch.config.js');
|
|
796
919
|
}
|
|
797
920
|
catch {
|
|
798
|
-
logger.warning("⚠️ Couldn't save the
|
|
921
|
+
logger.warning("⚠️ Couldn't save the credential to .hublaunch/hublaunch.config.js — continuing this launch with it in memory. Add it manually to avoid re-entering.");
|
|
799
922
|
}
|
|
800
923
|
}
|
|
801
924
|
else {
|
|
@@ -885,10 +1008,10 @@ async function launchSingleRepo(repoConfig, repoRoot, issueName, planPath, optio
|
|
|
885
1008
|
}
|
|
886
1009
|
}
|
|
887
1010
|
// Pre-flight credential validation (skipped in group mode where each distinct
|
|
888
|
-
//
|
|
889
|
-
//
|
|
1011
|
+
// provider credential was validated once before the fan-out loop).
|
|
1012
|
+
// authToken is guaranteed non-empty here — missing key exits above.
|
|
890
1013
|
if (!ctx.credentialsValidated) {
|
|
891
|
-
await
|
|
1014
|
+
await validateProviderCredentials(providerType, authToken, githubToken);
|
|
892
1015
|
}
|
|
893
1016
|
// Assemble the request body from the resolved values.
|
|
894
1017
|
const requestBody = buildRalphRunRequestBody({
|
|
@@ -908,7 +1031,7 @@ async function launchSingleRepo(repoConfig, repoRoot, issueName, planPath, optio
|
|
|
908
1031
|
verbose: options.verbose,
|
|
909
1032
|
handoff: options.handoff,
|
|
910
1033
|
clientSessionId: clientSessionId ?? undefined,
|
|
911
|
-
|
|
1034
|
+
provider: { type: providerType, authToken },
|
|
912
1035
|
containerResources,
|
|
913
1036
|
updateNotificationUrl,
|
|
914
1037
|
updateNotificationNameTag,
|
|
@@ -1146,13 +1269,15 @@ async function executeGroupLaunch(_config, issueName, options) {
|
|
|
1146
1269
|
// GitHub token once (user-level).
|
|
1147
1270
|
const tokenData = await loadGitHubToken();
|
|
1148
1271
|
const githubToken = tokenData?.access_token || process.env.GITHUB_TOKEN;
|
|
1149
|
-
// Validate each DISTINCT resolvable
|
|
1272
|
+
// Validate each DISTINCT resolvable provider credential once (keyed by
|
|
1273
|
+
// provider type + token, since members may select different providers).
|
|
1150
1274
|
const validatedKeys = new Set();
|
|
1151
1275
|
for (const e of entries) {
|
|
1152
|
-
const
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1276
|
+
const { providerType, authToken } = resolveProviderCredentials(options, e.repoConfig);
|
|
1277
|
+
const dedupKey = `${providerType}:${authToken}`;
|
|
1278
|
+
if (authToken && !validatedKeys.has(dedupKey)) {
|
|
1279
|
+
validatedKeys.add(dedupKey);
|
|
1280
|
+
await validateProviderCredentials(providerType, authToken, githubToken);
|
|
1156
1281
|
}
|
|
1157
1282
|
}
|
|
1158
1283
|
// ── Sequential fan-out (continue on failure) ──────────────────────────
|
|
@@ -1412,7 +1537,15 @@ function describeLaunchAxiosError(error, serverUrl, issueName, planPath) {
|
|
|
1412
1537
|
case 401:
|
|
1413
1538
|
return { status, message: 'Authentication failed. Your API key may be invalid.' };
|
|
1414
1539
|
case 402:
|
|
1415
|
-
|
|
1540
|
+
// The server's 402 message is specific ('Pro plan required to launch
|
|
1541
|
+
// sandboxes', 'Out of PR credits — buy more to launch'); the old
|
|
1542
|
+
// hardcoded 'free tier' text was wrong for a Pro user who simply ran
|
|
1543
|
+
// out of credits, and for a lapsed trial.
|
|
1544
|
+
return {
|
|
1545
|
+
status,
|
|
1546
|
+
message: data.error ||
|
|
1547
|
+
'Launching requires Pro — this account is not entitled to launch.',
|
|
1548
|
+
};
|
|
1416
1549
|
case 403:
|
|
1417
1550
|
return {
|
|
1418
1551
|
status,
|
|
@@ -1464,7 +1597,13 @@ function handleLaunchAxiosError(error, serverUrl, issueName, planPath) {
|
|
|
1464
1597
|
logger.info("Run 'hula login' to re-authenticate.");
|
|
1465
1598
|
break;
|
|
1466
1599
|
case 402:
|
|
1467
|
-
|
|
1600
|
+
// Prefer the server's own message + billing link over anything the
|
|
1601
|
+
// CLI can infer (credit exhaustion vs. no subscription).
|
|
1602
|
+
printNoProAccessError({
|
|
1603
|
+
serverUrl,
|
|
1604
|
+
serverError: data.error,
|
|
1605
|
+
actionUrl: data.buyCreditsUrl || data.upgradeUrl,
|
|
1606
|
+
});
|
|
1468
1607
|
break;
|
|
1469
1608
|
case 403:
|
|
1470
1609
|
// Surface the server's specific ownership message (kill /
|