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.
Files changed (43) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +5 -1
  3. package/dist/commands/init.d.ts.map +1 -1
  4. package/dist/commands/init.js +66 -23
  5. package/dist/commands/init.js.map +1 -1
  6. package/dist/commands/launch.d.ts +14 -2
  7. package/dist/commands/launch.d.ts.map +1 -1
  8. package/dist/commands/launch.js +209 -70
  9. package/dist/commands/launch.js.map +1 -1
  10. package/dist/commands/schedule.d.ts.map +1 -1
  11. package/dist/commands/schedule.js +8 -7
  12. package/dist/commands/schedule.js.map +1 -1
  13. package/dist/config/index.d.ts.map +1 -1
  14. package/dist/config/index.js +4 -1
  15. package/dist/config/index.js.map +1 -1
  16. package/dist/templates/proceed-instructions.md +2 -0
  17. package/dist/templates/skills/hula-confirm/SKILL.md +1 -1
  18. package/dist/templates/skills/hula-help/SKILL.md +1 -1
  19. package/dist/templates/skills/hula-plan/SKILL.md +1 -0
  20. package/dist/templates/skills/hula-schedule/SKILL.md +3 -3
  21. package/dist/types/config.schema.d.ts +34 -3
  22. package/dist/types/config.schema.d.ts.map +1 -1
  23. package/dist/types/config.schema.js +35 -1
  24. package/dist/types/config.schema.js.map +1 -1
  25. package/dist/utils/config-file-edit.d.ts +8 -4
  26. package/dist/utils/config-file-edit.d.ts.map +1 -1
  27. package/dist/utils/config-file-edit.js +13 -9
  28. package/dist/utils/config-file-edit.js.map +1 -1
  29. package/dist/utils/config-parser.d.ts.map +1 -1
  30. package/dist/utils/config-parser.js +4 -0
  31. package/dist/utils/config-parser.js.map +1 -1
  32. package/dist/utils/env-vars.d.ts.map +1 -1
  33. package/dist/utils/env-vars.js +4 -0
  34. package/dist/utils/env-vars.js.map +1 -1
  35. package/dist/utils/provider-credentials.d.ts +54 -0
  36. package/dist/utils/provider-credentials.d.ts.map +1 -0
  37. package/dist/utils/provider-credentials.js +83 -0
  38. package/dist/utils/provider-credentials.js.map +1 -0
  39. package/package.json +2 -2
  40. package/dist/utils/ephemeral-credentials.d.ts +0 -32
  41. package/dist/utils/ephemeral-credentials.d.ts.map +0 -1
  42. package/dist/utils/ephemeral-credentials.js +0 -26
  43. package/dist/utils/ephemeral-credentials.js.map +0 -1
@@ -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 { resolveEphemeralCredentials } from '../utils/ephemeral-credentials.js';
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 { upsertAnthropicApiKey } from '../utils/config-file-edit.js';
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('--anthropic-key <key>', 'Anthropic API key for Claude Code (ephemeral)')
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
- async function validateLaunchCredentials(anthropicApiKey, githubToken) {
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
- // --- Anthropic ---
324
- const anthropicAuthHeaders = {
325
- Authorization: `Bearer ${anthropicApiKey}`,
326
- 'anthropic-version': '2023-06-01',
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
- // Any 4xx → token is rejected (invalid, expired, or forbidden)
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: 'Anthropic OAuth token is invalid or expired.',
343
- hint: 'Generate a new OAuth token at https://claude.ai/settings (requires a paid Claude.ai plan — Pro or Max).',
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: 'Could not reach the Anthropic API to verify your OAuth token (network issue).',
351
- hint: 'Proceeding anyway — the server will validate the token on startup.',
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 [anthropicResult, githubResult] = await Promise.allSettled([
386
- anthropicPromise,
443
+ const [providerResult, githubResult] = await Promise.allSettled([
444
+ providerPromise,
387
445
  githubPromise,
388
446
  ]);
389
447
  let hasHardFailure = false;
390
- // Print Anthropic result
391
- const result = anthropicResult.status === 'fulfilled'
392
- ? anthropicResult.value
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: 'Unexpected error during Anthropic validation.',
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('✓')} Anthropic OAuth token is valid — Claude Code is ready to go 🤖`);
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
- function printFreeTierError() {
438
- logger.error('You cannot run launch because you are on the free tier.');
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 Anthropic subscription.');
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
- * Checks whether the user is on the Pro tier.
448
- * If the config already records 'pro', skips the network call.
449
- * If the config records 'free', calls the API and updates the config on success.
450
- * Returns false (and prints the error) when the user is not on Pro.
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 plan = response.data?.plan;
466
- if (plan === 'pro') {
467
- const saved = updateUsageTierInConfig(repoRoot, 'pro');
468
- if (saved) {
469
- logger.success('Subscription verified as Pro — config updated for future launches.');
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
- printFreeTierError();
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
- printFreeTierError();
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.anthropicApiKey = input.anthropicApiKey;
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 ephemeral credentials through the shared resolver — the single
743
- // source of truth for precedence/validation, also used by `execute`.
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 = resolveEphemeralCredentials(options, repoConfig);
746
- let anthropicApiKey = resolved.anthropicApiKey;
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-prefix, non-TTY, multiple errors) keeps today's
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.missingAnthropicKey &&
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('Paste your Anthropic OAuth token (sk-ant-oat01-…), or press Enter to cancel:')).trim();
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
- 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));
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: wrongPrefixMsg };
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
- anthropicApiKey = pasted;
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 = upsertAnthropicApiKey(original, pasted);
916
+ const updated = upsertProviderApiKey(original, providerType, pasted);
794
917
  writeFileSync(configFilePath, updated, { mode: 0o600 });
795
- logger.success('anthropicApiKey saved to .hublaunch/hublaunch.config.js');
918
+ logger.success('provider credential saved to .hublaunch/hublaunch.config.js');
796
919
  }
797
920
  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.");
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
- // Anthropic key was validated once before the fan-out loop).
889
- // anthropicApiKey is guaranteed non-null here — missing key exits above
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 validateLaunchCredentials(anthropicApiKey, githubToken);
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
- anthropicApiKey: anthropicApiKey,
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 Anthropic key once.
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 key = resolveEphemeralCredentials(options, e.repoConfig).anthropicApiKey;
1153
- if (key && !validatedKeys.has(key)) {
1154
- validatedKeys.add(key);
1155
- await validateLaunchCredentials(key, githubToken);
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
- return { status, message: 'You cannot run launch because you are on the free tier.' };
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
- printFreeTierError();
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 /