@wraps.dev/cli 2.26.1 → 2.27.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.
Binary file
package/dist/cli.js CHANGED
@@ -2679,10 +2679,9 @@ var init_costs = __esm({
2679
2679
  // $0.20 per million API Destination invocations
2680
2680
  };
2681
2681
  FREE_TIER = {
2682
- // SES: 3,000 emails/month for first 12 months (new AWS accounts only)
2683
- // After 12 months or for existing accounts: NO free tier
2682
+ // SES: no free sending tier (the 3,000/mo-for-12-months offer was retired
2683
+ // 2026-07-21; new accounts get generic $200 AWS credits instead)
2684
2684
  SES_EMAILS: 0,
2685
- // Conservative: assume no free tier (most users are past 12 months)
2686
2685
  // Lambda: Permanently free tier
2687
2686
  LAMBDA_REQUESTS: 1e6,
2688
2687
  // 1M requests per month (always free)
@@ -6084,6 +6083,9 @@ async function findConnectionsWithService(accountId, service) {
6084
6083
  const accountConnections = await findConnectionsForAccount(accountId);
6085
6084
  return accountConnections.filter((conn) => hasService(conn, service));
6086
6085
  }
6086
+ function normalizeWebhookBaseUrl(webhookUrl) {
6087
+ return webhookUrl.replace(/\/v1\/ses-events\/?$/, "").replace(/\/+$/, "");
6088
+ }
6087
6089
  function buildEmailStackConfig(metadata, region, overrides) {
6088
6090
  const emailService = metadata.services.email;
6089
6091
  let webhook;
@@ -6092,7 +6094,10 @@ function buildEmailStackConfig(metadata, region, overrides) {
6092
6094
  } else if (emailService?.webhookSecret) {
6093
6095
  webhook = {
6094
6096
  awsAccountNumber: metadata.accountId,
6095
- webhookSecret: emailService.webhookSecret
6097
+ webhookSecret: emailService.webhookSecret,
6098
+ ...emailService.webhookUrl && {
6099
+ webhookUrl: normalizeWebhookBaseUrl(emailService.webhookUrl)
6100
+ }
6096
6101
  };
6097
6102
  }
6098
6103
  const emailConfig = overrides?.emailConfig ?? emailService?.config;
@@ -11450,7 +11455,7 @@ async function runFullSetup() {
11450
11455
  console.log(` ${pc9.cyan("https://aws.amazon.com/free")}`);
11451
11456
  console.log();
11452
11457
  console.log(
11453
- pc9.dim(" The AWS Free Tier includes 3,000 SES emails/month for 12 months")
11458
+ pc9.dim(" New AWS accounts get $200 in Free Tier credits (6 months)")
11454
11459
  );
11455
11460
  console.log();
11456
11461
  const hasAccount = await clack7.confirm({
@@ -35326,6 +35331,128 @@ import { dirname as dirname5, join as join20 } from "path";
35326
35331
  import { fileURLToPath as fileURLToPath5 } from "url";
35327
35332
  import * as clack45 from "@clack/prompts";
35328
35333
  import * as pulumi28 from "@pulumi/pulumi";
35334
+
35335
+ // ../db/dist/connection-url.js
35336
+ init_esm_shims();
35337
+
35338
+ // ../db/dist/connection-url-CJ0MYp2V.js
35339
+ init_esm_shims();
35340
+ var POSTGRES_SCHEMES = ["postgres:", "postgresql:"];
35341
+ var TRUST_STORE_SENTINELS = /* @__PURE__ */ new Set(["system", "os"]);
35342
+ var TLS_OPT_OUT = /* @__PURE__ */ new Set(["disable", "no-verify"]);
35343
+ function pinVerification(parsed) {
35344
+ const modes = parsed.searchParams.getAll("sslmode");
35345
+ const effective = modes.at(-1)?.toLowerCase();
35346
+ const hadCompat = parsed.searchParams.has("uselibpqcompat");
35347
+ if (effective && TLS_OPT_OUT.has(effective)) {
35348
+ parsed.searchParams.set("sslmode", effective);
35349
+ parsed.searchParams.delete("uselibpqcompat");
35350
+ return;
35351
+ }
35352
+ const alreadyVerifying = modes.length === 1 && effective === "verify-full" && !hadCompat;
35353
+ parsed.searchParams.set("sslmode", "verify-full");
35354
+ parsed.searchParams.delete("uselibpqcompat");
35355
+ return alreadyVerifying ? void 0 : "Pinned sslmode=verify-full \u2014 without the CA reference the original mode would have connected unverified (or in cleartext).";
35356
+ }
35357
+ function normalizeDatabaseUrl(raw) {
35358
+ const url = raw.trim();
35359
+ const notes = [];
35360
+ let parsed;
35361
+ try {
35362
+ parsed = new URL(url);
35363
+ } catch {
35364
+ return {
35365
+ url,
35366
+ notes
35367
+ };
35368
+ }
35369
+ const certKey = [...parsed.searchParams.keys()].find((key) => key.toLowerCase() === "sslrootcert");
35370
+ const sslRootCert = certKey ? parsed.searchParams.get(certKey) : void 0;
35371
+ if (!(sslRootCert && TRUST_STORE_SENTINELS.has(sslRootCert.toLowerCase()))) return {
35372
+ url,
35373
+ notes
35374
+ };
35375
+ parsed.searchParams.delete(certKey);
35376
+ notes.push(`Dropped sslrootcert=${sslRootCert} \u2014 node-postgres would read it as a filename. TLS stays on and the certificate is verified against Node's bundled CA set (set NODE_EXTRA_CA_CERTS for a private CA).`);
35377
+ const pinNote = pinVerification(parsed);
35378
+ if (pinNote) notes.push(pinNote);
35379
+ return {
35380
+ url: parsed.toString(),
35381
+ notes
35382
+ };
35383
+ }
35384
+ function describeMigrationError(error) {
35385
+ const parts = [];
35386
+ let current = error;
35387
+ const seen = /* @__PURE__ */ new Set();
35388
+ while (current instanceof Error && !seen.has(current)) {
35389
+ seen.add(current);
35390
+ const code = current.code;
35391
+ const message = current.message.startsWith("Failed query:") ? current.message.split("\n")[0] : current.message;
35392
+ const described = code ? `${message} (${code})` : message;
35393
+ if (!parts.includes(described)) parts.push(described);
35394
+ current = current.cause;
35395
+ }
35396
+ return parts.length > 0 ? parts.join(" \u2014 ") : String(error);
35397
+ }
35398
+ var HINTS = [
35399
+ {
35400
+ match: /\b(ENOENT|EACCES)\b/,
35401
+ hint: "A file named in DATABASE_URL (usually sslrootcert) is missing or unreadable. Point it at a readable CA file, or set NODE_EXTRA_CA_CERTS if the certificate chains to a private CA."
35402
+ },
35403
+ {
35404
+ match: /password authentication failed|28P01|28000/i,
35405
+ hint: "The credentials in DATABASE_URL were rejected. Check the user and password."
35406
+ },
35407
+ {
35408
+ match: /CREATE SCHEMA|permission denied for database|must be owner of database/i,
35409
+ hint: "The database user cannot create schemas. Grant it CREATE on the database (GRANT CREATE ON DATABASE <db> TO <user>), or use an owner role."
35410
+ },
35411
+ {
35412
+ match: /permission denied|42501/i,
35413
+ hint: "The database user lacks a privilege this migration needs. The failing statement above names the object \u2014 grant on that, or run migrations as the database owner."
35414
+ },
35415
+ {
35416
+ match: /database .* does not exist|3D000/i,
35417
+ hint: "The database named in DATABASE_URL does not exist. Create it first."
35418
+ },
35419
+ {
35420
+ match: /no pg_hba\.conf entry|SSL|self[- ]signed|certificate/i,
35421
+ hint: "TLS negotiation failed. Most managed Postgres requires ?sslmode=verify-full on DATABASE_URL."
35422
+ },
35423
+ {
35424
+ match: /ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN/i,
35425
+ hint: "Could not reach the database host. Check the host and port, and that this machine is allowed through the firewall or IP allowlist."
35426
+ }
35427
+ ];
35428
+ function migrationHint(description) {
35429
+ return HINTS.find(({ match }) => match.test(description))?.hint;
35430
+ }
35431
+ function describeMigrationFailure(error) {
35432
+ const description = describeMigrationError(error);
35433
+ const hint = migrationHint(description);
35434
+ return hint ? `Database migrations failed: ${description}
35435
+ ${hint}` : `Database migrations failed: ${description}`;
35436
+ }
35437
+ function assertPostgresUrl(raw) {
35438
+ const url = raw.trim();
35439
+ if (!url) throw new Error("DATABASE_URL is empty.");
35440
+ if (!url.includes("://")) {
35441
+ if (/(^|\s)(host|dbname|user)=/.test(url)) throw new Error("DATABASE_URL is a libpq keyword/value connection string, which node-postgres cannot parse. Use the URI form instead: postgresql://USER:PASSWORD@HOST:5432/DATABASE?sslmode=verify-full");
35442
+ throw new Error("DATABASE_URL is not a connection URI. Expected postgresql://USER:PASSWORD@HOST:5432/DATABASE");
35443
+ }
35444
+ let parsed;
35445
+ try {
35446
+ parsed = new URL(url);
35447
+ } catch {
35448
+ throw new Error("DATABASE_URL could not be parsed as a URI. If the password contains @ / : or ?, percent-encode it.");
35449
+ }
35450
+ if (POSTGRES_SCHEMES.includes(parsed.protocol)) return;
35451
+ if (parsed.protocol === "mysql:" || parsed.protocol === "mysql2:") throw new Error(`DATABASE_URL points at MySQL (${parsed.protocol}//). Wraps requires PostgreSQL \u2014 its schema uses Postgres types, enums and partial indexes that have no MySQL equivalent.`);
35452
+ throw new Error(`DATABASE_URL has an unsupported scheme (${parsed.protocol}//). Wraps requires PostgreSQL: postgresql://USER:PASSWORD@HOST:5432/DATABASE`);
35453
+ }
35454
+
35455
+ // src/commands/selfhost/deploy.ts
35329
35456
  import pc48 from "picocolors";
35330
35457
 
35331
35458
  // src/infrastructure/selfhost-stack.ts
@@ -35582,6 +35709,29 @@ function buildNeonProjectName(accountId, region) {
35582
35709
  return `wraps-selfhost-${accountId}-${region}`;
35583
35710
  }
35584
35711
 
35712
+ // src/utils/selfhost/variant.ts
35713
+ init_esm_shims();
35714
+ var SHARED_SCHEDULER_ROLE = "wraps-selfhost-scheduler-role";
35715
+ async function detectSelfhostVariant(region) {
35716
+ try {
35717
+ const { LambdaClient: LambdaClient3, GetFunctionCommand } = await import("@aws-sdk/client-lambda");
35718
+ const lambda7 = new LambdaClient3({ region });
35719
+ await lambda7.send(
35720
+ new GetFunctionCommand({ FunctionName: SELFHOST_API_FUNCTION_NAME })
35721
+ );
35722
+ return "pulumi";
35723
+ } catch {
35724
+ }
35725
+ try {
35726
+ const { IAMClient: IAMClient5, GetRoleCommand: GetRoleCommand3 } = await import("@aws-sdk/client-iam");
35727
+ const iam13 = new IAMClient5({ region });
35728
+ await iam13.send(new GetRoleCommand3({ RoleName: SHARED_SCHEDULER_ROLE }));
35729
+ return "sst";
35730
+ } catch {
35731
+ return null;
35732
+ }
35733
+ }
35734
+
35585
35735
  // src/commands/selfhost/deploy.ts
35586
35736
  init_aws();
35587
35737
  init_errors();
@@ -35639,17 +35789,42 @@ async function selfhostDeploy(options) {
35639
35789
  region
35640
35790
  );
35641
35791
  if (existingMetadata?.services?.selfhost) {
35792
+ const existing = existingMetadata.services.selfhost;
35642
35793
  clack45.log.warn(
35643
35794
  `Self-hosted deployment already exists for account ${pc48.cyan(identity.accountId)} in region ${pc48.cyan(region)}`
35644
35795
  );
35796
+ clack45.log.info(`API URL: ${pc48.cyan(existing.apiUrl)}`);
35797
+ clack45.log.info(`Deployed: ${existing.deployedAt}`);
35645
35798
  clack45.log.info(
35646
- `API URL: ${pc48.cyan(existingMetadata.services.selfhost.apiUrl)}`
35799
+ existing.variant === "sst" ? `This is a full-platform (SST) deployment. To update: run ${pc48.cyan("pnpm selfhost:upgrade")} from your fork.` : `To update: run ${pc48.cyan("wraps selfhost upgrade")}`
35800
+ );
35801
+ process.exit(0);
35802
+ }
35803
+ const deployedVariant = await progress.execute(
35804
+ "Checking for existing selfhost deployments",
35805
+ async () => detectSelfhostVariant(region)
35806
+ );
35807
+ if (deployedVariant === "sst") {
35808
+ progress.stop();
35809
+ clack45.log.error(
35810
+ "A full-platform (SST) selfhost deployment already exists in this AWS account."
35647
35811
  );
35648
35812
  clack45.log.info(
35649
- `Deployed: ${existingMetadata.services.selfhost.deployedAt}`
35813
+ `The two selfhost variants share IAM resources and cannot coexist.
35814
+ To update it: run ${pc48.cyan("pnpm selfhost:upgrade")} from your fork.
35815
+ To replace it with the API-only control plane: run ${pc48.cyan("pnpm selfhost:destroy")} from your fork first.`
35650
35816
  );
35651
- clack45.log.info(`To update: run ${pc48.cyan("wraps selfhost upgrade")}`);
35652
- process.exit(0);
35817
+ process.exit(1);
35818
+ }
35819
+ if (deployedVariant === "pulumi") {
35820
+ progress.stop();
35821
+ clack45.log.error(
35822
+ "An API-only selfhost control plane already exists in this AWS account, but no local metadata was found."
35823
+ );
35824
+ clack45.log.info(
35825
+ `It was deployed from another machine. Run ${pc48.cyan("wraps selfhost upgrade")} there, or remove the existing stack before redeploying from this one.`
35826
+ );
35827
+ process.exit(1);
35653
35828
  }
35654
35829
  let resolvedDatabaseUrl = options.databaseUrl;
35655
35830
  let resolvedNeonProjectId;
@@ -35777,6 +35952,7 @@ async function selfhostDeploy(options) {
35777
35952
  );
35778
35953
  }
35779
35954
  const databaseUrl = resolvedDatabaseUrl;
35955
+ assertPostgresUrl(databaseUrl);
35780
35956
  const unsubscribeSecret = randomBytes6(32).toString("hex");
35781
35957
  const betterAuthSecret = randomBytes6(32).toString("hex");
35782
35958
  const selfhostConfig = {
@@ -35802,19 +35978,26 @@ async function selfhostDeploy(options) {
35802
35978
  deployedAt,
35803
35979
  pulumiStackName: stackName,
35804
35980
  config: selfhostConfig,
35805
- apiUrl: priorApiUrl
35981
+ apiUrl: priorApiUrl,
35806
35982
  // Refreshed from Pulumi outputs after deploy succeeds
35983
+ variant: "pulumi"
35807
35984
  };
35808
35985
  savedMetadata.timestamp = deployedAt;
35809
35986
  await saveConnectionMetadata(savedMetadata);
35987
+ const { url: migrationUrl, notes: connectionNotes } = normalizeDatabaseUrl(databaseUrl);
35988
+ for (const note22 of connectionNotes) {
35989
+ progress.info(note22);
35990
+ }
35810
35991
  await progress.execute("Running database migrations", async () => {
35811
35992
  const { Pool } = await import("pg");
35812
35993
  const { drizzle } = await import("drizzle-orm/node-postgres");
35813
35994
  const { migrate } = await import("drizzle-orm/node-postgres/migrator");
35814
- const pool = new Pool({ connectionString: databaseUrl });
35995
+ const pool = new Pool({ connectionString: migrationUrl });
35815
35996
  const db = drizzle(pool);
35816
35997
  try {
35817
35998
  await migrate(db, { migrationsFolder: bundledMigrationsDir });
35999
+ } catch (error) {
36000
+ throw new Error(describeMigrationFailure(error));
35818
36001
  } finally {
35819
36002
  await pool.end();
35820
36003
  }
@@ -36086,7 +36269,7 @@ Run ${pc50.cyan("wraps selfhost deploy")} to deploy the self-hosted control plan
36086
36269
  );
36087
36270
  console.log(
36088
36271
  `
36089
- The deployment may have failed partway through. Re-run ${pc50.cyan("wraps selfhost deploy")} to complete it.
36272
+ The deployment may have failed partway through. Run ${pc50.cyan("pnpm selfhost:upgrade")} from your fork (SST deployments) or re-run ${pc50.cyan("wraps selfhost deploy")} (API-only deployments) to complete it.
36090
36273
  `
36091
36274
  );
36092
36275
  process.exit(1);
@@ -36397,15 +36580,30 @@ init_output();
36397
36580
  init_region_resolver();
36398
36581
  function displaySelfhostStatus(options) {
36399
36582
  const lines = [];
36400
- lines.push(pc53.bold(pc53.green("Self-Hosted Control Plane Active")));
36583
+ const incomplete = !(options.apiUrl && options.appUrl);
36584
+ lines.push(
36585
+ incomplete ? pc53.bold(pc53.yellow("Self-Hosted Control Plane Incomplete")) : pc53.bold(pc53.green("Self-Hosted Control Plane Active"))
36586
+ );
36587
+ if (incomplete) {
36588
+ lines.push(
36589
+ pc53.yellow(
36590
+ `A previous deploy did not finish. Run ${pc53.cyan("pnpm selfhost:upgrade")} from your fork to complete it.`
36591
+ )
36592
+ );
36593
+ }
36401
36594
  lines.push("");
36402
36595
  lines.push(pc53.bold("API"));
36403
- lines.push(` URL: ${pc53.cyan(options.apiUrl)}`);
36596
+ lines.push(` URL: ${pc53.cyan(options.apiUrl || "(not deployed)")}`);
36404
36597
  lines.push(` Region: ${pc53.cyan(options.region)}`);
36405
36598
  lines.push(` Deployed: ${pc53.dim(options.deployedAt)}`);
36599
+ if (options.variant) {
36600
+ lines.push(
36601
+ ` Type: ${pc53.cyan(options.variant === "sst" ? "Full platform (SST, deployed from fork)" : "API-only control plane (CLI)")}`
36602
+ );
36603
+ }
36406
36604
  lines.push("");
36407
36605
  lines.push(pc53.bold("Configuration"));
36408
- lines.push(` App URL: ${pc53.cyan(options.appUrl)}`);
36606
+ lines.push(` App URL: ${pc53.cyan(options.appUrl || "(not deployed)")}`);
36409
36607
  lines.push(` License Key: ${pc53.dim(`${options.licenseKeyPrefix}...`)}`);
36410
36608
  if (options.neonProjectId) {
36411
36609
  lines.push(` Neon Project: ${pc53.dim(options.neonProjectId)}`);
@@ -36434,7 +36632,8 @@ async function selfhostStatus(options) {
36434
36632
  clack50.log.error("No self-hosted deployment found");
36435
36633
  console.log(
36436
36634
  `
36437
- Run ${pc53.cyan("wraps selfhost deploy")} to deploy the self-hosted control plane.
36635
+ Run ${pc53.cyan("pnpm selfhost:deploy")} from your fork to deploy the full platform (API + dashboard),
36636
+ or ${pc53.cyan("wraps selfhost deploy")} for the API-only control plane.
36438
36637
  `
36439
36638
  );
36440
36639
  process.exit(1);
@@ -36443,13 +36642,23 @@ Run ${pc53.cyan("wraps selfhost deploy")} to deploy the self-hosted control plan
36443
36642
  const selfhostService = metadata.services.selfhost;
36444
36643
  const config2 = selfhostService.config;
36445
36644
  const apiUrl = await reconcileSelfhostApiUrl(metadata, region) ?? selfhostService.apiUrl;
36645
+ if (!selfhostService.variant) {
36646
+ const inferred = await detectSelfhostVariant(region);
36647
+ if (inferred) {
36648
+ selfhostService.variant = inferred;
36649
+ metadata.timestamp = (/* @__PURE__ */ new Date()).toISOString();
36650
+ await saveConnectionMetadata(metadata);
36651
+ }
36652
+ }
36653
+ const variant = selfhostService.variant;
36446
36654
  const statusData = {
36447
36655
  region,
36448
36656
  apiUrl,
36449
36657
  deployedAt: selfhostService.deployedAt,
36450
36658
  neonProjectId: config2.neonProjectId,
36451
36659
  appUrl: config2.appUrl,
36452
- licenseKeyPrefix: config2.licenseKey.slice(0, 12)
36660
+ licenseKeyPrefix: config2.licenseKey.slice(0, 12),
36661
+ variant
36453
36662
  };
36454
36663
  if (isJsonMode()) {
36455
36664
  jsonSuccess("selfhost.status", statusData);
@@ -36459,7 +36668,7 @@ Run ${pc53.cyan("wraps selfhost deploy")} to deploy the self-hosted control plan
36459
36668
  console.log("");
36460
36669
  clack50.log.info(pc53.bold("Commands:"));
36461
36670
  console.log(
36462
- ` ${pc53.cyan("wraps selfhost upgrade")} - Rebuild and redeploy the API Lambda`
36671
+ variant === "sst" ? ` ${pc53.cyan("pnpm selfhost:upgrade")} (from your fork) - Redeploy the platform and run migrations` : ` ${pc53.cyan("wraps selfhost upgrade")} - Rebuild and redeploy the API Lambda`
36463
36672
  );
36464
36673
  trackCommand("selfhost:status", {
36465
36674
  success: true,