@wraps.dev/cli 2.26.0 → 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({
@@ -16120,9 +16125,38 @@ async function deployEmailStack(config2) {
16120
16125
  };
16121
16126
  }
16122
16127
 
16128
+ // src/utils/shared/agent-api.ts
16129
+ init_esm_shims();
16130
+ init_config();
16131
+ async function createAgentApiClient(tokenFlag) {
16132
+ const token = await resolveTokenAsync({ token: tokenFlag });
16133
+ if (!token) {
16134
+ return { ok: false, reason: "not-authenticated" };
16135
+ }
16136
+ const apiBase = getApiBaseUrl();
16137
+ const orgHeader = {};
16138
+ const orgs = (await readAuthConfig())?.auth?.organizations;
16139
+ if (orgs && orgs.length === 1) {
16140
+ orgHeader["X-Organization-Id"] = orgs[0].id;
16141
+ }
16142
+ const call = (method) => (path3, body) => fetch(`${apiBase}${path3}`, {
16143
+ method,
16144
+ headers: {
16145
+ Authorization: `Bearer ${token}`,
16146
+ ...body ? { "Content-Type": "application/json" } : {},
16147
+ ...orgHeader
16148
+ },
16149
+ ...body ? { body: JSON.stringify(body) } : {}
16150
+ });
16151
+ return { ok: true, get: call("GET"), post: call("POST") };
16152
+ }
16153
+ async function parseAgentApiError(resp) {
16154
+ const data = await resp.json().catch(() => ({}));
16155
+ return data.error ?? `HTTP ${resp.status}`;
16156
+ }
16157
+
16123
16158
  // src/commands/email/agent.ts
16124
16159
  init_aws();
16125
- init_config();
16126
16160
  init_fs();
16127
16161
  init_json_output();
16128
16162
  init_metadata();
@@ -16169,32 +16203,6 @@ var DEFAULT_AGENT_POLICY = {
16169
16203
  allowedRecipientDomains: []
16170
16204
  };
16171
16205
  var AGENT_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,51}$/;
16172
- async function createAgentApiClient(tokenFlag) {
16173
- const token = await resolveTokenAsync({ token: tokenFlag });
16174
- if (!token) {
16175
- return { ok: false, reason: "not-authenticated" };
16176
- }
16177
- const apiBase = getApiBaseUrl();
16178
- const orgHeader = {};
16179
- const orgs = (await readAuthConfig())?.auth?.organizations;
16180
- if (orgs && orgs.length === 1) {
16181
- orgHeader["X-Organization-Id"] = orgs[0].id;
16182
- }
16183
- const call = (method) => (path3, body) => fetch(`${apiBase}${path3}`, {
16184
- method,
16185
- headers: {
16186
- Authorization: `Bearer ${token}`,
16187
- ...body ? { "Content-Type": "application/json" } : {},
16188
- ...orgHeader
16189
- },
16190
- ...body ? { body: JSON.stringify(body) } : {}
16191
- });
16192
- return { ok: true, get: call("GET"), post: call("POST") };
16193
- }
16194
- async function parseError(resp) {
16195
- const data = await resp.json().catch(() => ({}));
16196
- return data.error ?? `HTTP ${resp.status}`;
16197
- }
16198
16206
  async function agentCreate(options) {
16199
16207
  if (!isJsonMode()) {
16200
16208
  clack15.intro(pc17.bold("Create Agent Mailbox"));
@@ -16250,7 +16258,7 @@ Sign in first: ${pc17.cyan("wraps auth login")}
16250
16258
  async () => {
16251
16259
  const resp = await api.get("/v1/connections");
16252
16260
  if (!resp.ok) {
16253
- throw new Error(await parseError(resp));
16261
+ throw new Error(await parseAgentApiError(resp));
16254
16262
  }
16255
16263
  const data = await resp.json();
16256
16264
  return data.connections.some((c) => c.accountId === identity.accountId);
@@ -16396,7 +16404,7 @@ Connect (or reconnect) first so it is stored locally:
16396
16404
  return { kind: "created", agent: await resp.json() };
16397
16405
  }
16398
16406
  if (resp.status !== 409) {
16399
- throw new Error(await parseError(resp));
16407
+ throw new Error(await parseAgentApiError(resp));
16400
16408
  }
16401
16409
  const listResp = await api.get("/v1/agents");
16402
16410
  const existing = listResp.ok ? (await listResp.json()).agents.find(
@@ -16567,7 +16575,7 @@ ${suggestion}
16567
16575
  awsAccountId: identity.accountId
16568
16576
  });
16569
16577
  if (!resp.ok) {
16570
- throw new Error(await parseError(resp));
16578
+ throw new Error(await parseAgentApiError(resp));
16571
16579
  }
16572
16580
  });
16573
16581
  if (isJsonMode()) {
@@ -16630,7 +16638,7 @@ async function agentList(options) {
16630
16638
  }
16631
16639
  const resp = await api.get("/v1/agents");
16632
16640
  if (!resp.ok) {
16633
- const message = await parseError(resp);
16641
+ const message = await parseAgentApiError(resp);
16634
16642
  if (isJsonMode()) {
16635
16643
  jsonError("email.agent.list", { code: "API_ERROR", message });
16636
16644
  } else {
@@ -16670,7 +16678,7 @@ async function agentKill(options) {
16670
16678
  }
16671
16679
  const listResp = await api.get("/v1/agents");
16672
16680
  if (!listResp.ok) {
16673
- const message = await parseError(listResp);
16681
+ const message = await parseAgentApiError(listResp);
16674
16682
  if (isJsonMode()) {
16675
16683
  jsonError("email.agent.kill", { code: "API_ERROR", message });
16676
16684
  } else {
@@ -16729,7 +16737,7 @@ async function agentKill(options) {
16729
16737
  }
16730
16738
  const resp = await api.post(`/v1/agents/${target.id}/kill`);
16731
16739
  if (!resp.ok) {
16732
- const message = await parseError(resp);
16740
+ const message = await parseAgentApiError(resp);
16733
16741
  if (isJsonMode()) {
16734
16742
  jsonError("email.agent.kill", { code: "API_ERROR", message });
16735
16743
  } else {
@@ -20535,6 +20543,9 @@ ${pc20.dim("Example:")}`);
20535
20543
  init_esm_shims();
20536
20544
  init_events();
20537
20545
  init_route53();
20546
+ import * as clack19 from "@clack/prompts";
20547
+ import * as pulumi18 from "@pulumi/pulumi";
20548
+ import pc21 from "picocolors";
20538
20549
  init_aws();
20539
20550
  init_errors();
20540
20551
  init_fs();
@@ -20542,9 +20553,6 @@ init_json_output();
20542
20553
  init_metadata();
20543
20554
  init_output();
20544
20555
  init_pulumi();
20545
- import * as clack19 from "@clack/prompts";
20546
- import * as pulumi18 from "@pulumi/pulumi";
20547
- import pc21 from "picocolors";
20548
20556
  async function getEmailIdentityInfo(domain, region) {
20549
20557
  try {
20550
20558
  const { SESv2Client: SESv2Client10, GetEmailIdentityCommand: GetEmailIdentityCommand7 } = await import("@aws-sdk/client-sesv2");
@@ -20563,6 +20571,30 @@ async function getEmailIdentityInfo(domain, region) {
20563
20571
  throw error;
20564
20572
  }
20565
20573
  }
20574
+ function selectAgentsToDestroy(emailConfig) {
20575
+ const agentsConfig = emailConfig?.agents;
20576
+ if (!agentsConfig?.enabled) {
20577
+ return [];
20578
+ }
20579
+ return (agentsConfig.agents ?? []).map((a) => ({
20580
+ id: a.id,
20581
+ name: a.name,
20582
+ emailAddress: a.emailAddress
20583
+ }));
20584
+ }
20585
+ function buildAgentDestroySummaryLines(agents) {
20586
+ if (agents.length === 0) {
20587
+ return [];
20588
+ }
20589
+ const addresses = agents.map((a) => a.emailAddress).join(", ");
20590
+ return [
20591
+ "",
20592
+ `Agent mailboxes (${agents.length}): ${addresses}`,
20593
+ pc21.yellow(
20594
+ "Agent scoped credentials are revoked (IAM users wraps-agent-* deleted), the wraps-agent-enforcer Lambda and wraps-email-agent-policy table are destroyed, and any pending approvals become undeliverable."
20595
+ )
20596
+ ];
20597
+ }
20566
20598
  async function emailDestroy(options) {
20567
20599
  const startTime = Date.now();
20568
20600
  if (isJsonMode() && !options.force) {
@@ -20623,6 +20655,7 @@ async function emailDestroy(options) {
20623
20655
  const emailConfig = emailService?.config;
20624
20656
  const domain = emailConfig?.domain;
20625
20657
  const storedStackName = emailService?.pulumiStackName;
20658
+ const agentsToDestroy = selectAgentsToDestroy(emailConfig);
20626
20659
  if (!(options.force || options.preview)) {
20627
20660
  const additionalDomainNames = (emailConfig?.additionalDomains ?? []).map((d) => d.domain).filter(Boolean);
20628
20661
  const domainList = [domain, ...additionalDomainNames].filter(Boolean);
@@ -20635,6 +20668,7 @@ async function emailDestroy(options) {
20635
20668
  "Event streaming stops: the Wraps dashboard email timeline and event history for THIS ENTIRE AWS ACCOUNT stop receiving events. Historical events in DynamoDB are destroyed with the table. This cannot be undone."
20636
20669
  )
20637
20670
  ];
20671
+ summaryLines.push(...buildAgentDestroySummaryLines(agentsToDestroy));
20638
20672
  if (emailService?.webhookSecret) {
20639
20673
  summaryLines.push(
20640
20674
  "",
@@ -20731,6 +20765,13 @@ async function emailDestroy(options) {
20731
20765
  );
20732
20766
  }
20733
20767
  }
20768
+ const agentSummaryLines = buildAgentDestroySummaryLines(agentsToDestroy);
20769
+ if (agentSummaryLines.length > 0) {
20770
+ clack19.note(
20771
+ agentSummaryLines.filter(Boolean).join("\n"),
20772
+ "Agent mailboxes"
20773
+ );
20774
+ }
20734
20775
  clack19.outro(
20735
20776
  pc21.green("Preview complete. Run without --preview to destroy.")
20736
20777
  );
@@ -20850,6 +20891,33 @@ async function emailDestroy(options) {
20850
20891
  }
20851
20892
  } catch {
20852
20893
  }
20894
+ if (agentsToDestroy.length > 0) {
20895
+ try {
20896
+ const agentApi = await createAgentApiClient();
20897
+ if (agentApi.ok) {
20898
+ let killed = 0;
20899
+ for (const agent of agentsToDestroy) {
20900
+ try {
20901
+ const resp = await agentApi.post(`/v1/agents/${agent.id}/kill`);
20902
+ if (resp.ok) {
20903
+ killed++;
20904
+ }
20905
+ } catch {
20906
+ }
20907
+ }
20908
+ if (!isJsonMode()) {
20909
+ clack19.log.info(
20910
+ `Marked ${killed}/${agentsToDestroy.length} agent(s) as killed in the Wraps dashboard.`
20911
+ );
20912
+ }
20913
+ } else if (!isJsonMode()) {
20914
+ clack19.log.warn(
20915
+ "Could not reach the Wraps Platform to update agent status \u2014 your agents will still show as active in the dashboard. Kill them there: wraps email agent kill (or the dashboard)."
20916
+ );
20917
+ }
20918
+ } catch {
20919
+ }
20920
+ }
20853
20921
  await deleteConnectionMetadata(identity.accountId, region);
20854
20922
  progress.stop();
20855
20923
  if (isJsonMode()) {
@@ -20857,7 +20925,12 @@ async function emailDestroy(options) {
20857
20925
  destroyed: !destroyFailed,
20858
20926
  region,
20859
20927
  dns_cleaned: shouldCleanDNS,
20860
- partial_failure: destroyFailed
20928
+ partial_failure: destroyFailed,
20929
+ agents: agentsToDestroy.map((a) => ({
20930
+ id: a.id,
20931
+ name: a.name,
20932
+ emailAddress: a.emailAddress
20933
+ }))
20861
20934
  });
20862
20935
  trackServiceRemoved("email", {
20863
20936
  reason: "user_initiated",
@@ -35258,6 +35331,128 @@ import { dirname as dirname5, join as join20 } from "path";
35258
35331
  import { fileURLToPath as fileURLToPath5 } from "url";
35259
35332
  import * as clack45 from "@clack/prompts";
35260
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
35261
35456
  import pc48 from "picocolors";
35262
35457
 
35263
35458
  // src/infrastructure/selfhost-stack.ts
@@ -35514,6 +35709,29 @@ function buildNeonProjectName(accountId, region) {
35514
35709
  return `wraps-selfhost-${accountId}-${region}`;
35515
35710
  }
35516
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
+
35517
35735
  // src/commands/selfhost/deploy.ts
35518
35736
  init_aws();
35519
35737
  init_errors();
@@ -35571,17 +35789,42 @@ async function selfhostDeploy(options) {
35571
35789
  region
35572
35790
  );
35573
35791
  if (existingMetadata?.services?.selfhost) {
35792
+ const existing = existingMetadata.services.selfhost;
35574
35793
  clack45.log.warn(
35575
35794
  `Self-hosted deployment already exists for account ${pc48.cyan(identity.accountId)} in region ${pc48.cyan(region)}`
35576
35795
  );
35796
+ clack45.log.info(`API URL: ${pc48.cyan(existing.apiUrl)}`);
35797
+ clack45.log.info(`Deployed: ${existing.deployedAt}`);
35798
+ clack45.log.info(
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."
35811
+ );
35577
35812
  clack45.log.info(
35578
- `API URL: ${pc48.cyan(existingMetadata.services.selfhost.apiUrl)}`
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.`
35816
+ );
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."
35579
35823
  );
35580
35824
  clack45.log.info(
35581
- `Deployed: ${existingMetadata.services.selfhost.deployedAt}`
35825
+ `It was deployed from another machine. Run ${pc48.cyan("wraps selfhost upgrade")} there, or remove the existing stack before redeploying from this one.`
35582
35826
  );
35583
- clack45.log.info(`To update: run ${pc48.cyan("wraps selfhost upgrade")}`);
35584
- process.exit(0);
35827
+ process.exit(1);
35585
35828
  }
35586
35829
  let resolvedDatabaseUrl = options.databaseUrl;
35587
35830
  let resolvedNeonProjectId;
@@ -35709,6 +35952,7 @@ async function selfhostDeploy(options) {
35709
35952
  );
35710
35953
  }
35711
35954
  const databaseUrl = resolvedDatabaseUrl;
35955
+ assertPostgresUrl(databaseUrl);
35712
35956
  const unsubscribeSecret = randomBytes6(32).toString("hex");
35713
35957
  const betterAuthSecret = randomBytes6(32).toString("hex");
35714
35958
  const selfhostConfig = {
@@ -35734,19 +35978,26 @@ async function selfhostDeploy(options) {
35734
35978
  deployedAt,
35735
35979
  pulumiStackName: stackName,
35736
35980
  config: selfhostConfig,
35737
- apiUrl: priorApiUrl
35981
+ apiUrl: priorApiUrl,
35738
35982
  // Refreshed from Pulumi outputs after deploy succeeds
35983
+ variant: "pulumi"
35739
35984
  };
35740
35985
  savedMetadata.timestamp = deployedAt;
35741
35986
  await saveConnectionMetadata(savedMetadata);
35987
+ const { url: migrationUrl, notes: connectionNotes } = normalizeDatabaseUrl(databaseUrl);
35988
+ for (const note22 of connectionNotes) {
35989
+ progress.info(note22);
35990
+ }
35742
35991
  await progress.execute("Running database migrations", async () => {
35743
35992
  const { Pool } = await import("pg");
35744
35993
  const { drizzle } = await import("drizzle-orm/node-postgres");
35745
35994
  const { migrate } = await import("drizzle-orm/node-postgres/migrator");
35746
- const pool = new Pool({ connectionString: databaseUrl });
35995
+ const pool = new Pool({ connectionString: migrationUrl });
35747
35996
  const db = drizzle(pool);
35748
35997
  try {
35749
35998
  await migrate(db, { migrationsFolder: bundledMigrationsDir });
35999
+ } catch (error) {
36000
+ throw new Error(describeMigrationFailure(error));
35750
36001
  } finally {
35751
36002
  await pool.end();
35752
36003
  }
@@ -36018,7 +36269,7 @@ Run ${pc50.cyan("wraps selfhost deploy")} to deploy the self-hosted control plan
36018
36269
  );
36019
36270
  console.log(
36020
36271
  `
36021
- 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.
36022
36273
  `
36023
36274
  );
36024
36275
  process.exit(1);
@@ -36329,15 +36580,30 @@ init_output();
36329
36580
  init_region_resolver();
36330
36581
  function displaySelfhostStatus(options) {
36331
36582
  const lines = [];
36332
- 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
+ }
36333
36594
  lines.push("");
36334
36595
  lines.push(pc53.bold("API"));
36335
- lines.push(` URL: ${pc53.cyan(options.apiUrl)}`);
36596
+ lines.push(` URL: ${pc53.cyan(options.apiUrl || "(not deployed)")}`);
36336
36597
  lines.push(` Region: ${pc53.cyan(options.region)}`);
36337
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
+ }
36338
36604
  lines.push("");
36339
36605
  lines.push(pc53.bold("Configuration"));
36340
- lines.push(` App URL: ${pc53.cyan(options.appUrl)}`);
36606
+ lines.push(` App URL: ${pc53.cyan(options.appUrl || "(not deployed)")}`);
36341
36607
  lines.push(` License Key: ${pc53.dim(`${options.licenseKeyPrefix}...`)}`);
36342
36608
  if (options.neonProjectId) {
36343
36609
  lines.push(` Neon Project: ${pc53.dim(options.neonProjectId)}`);
@@ -36366,7 +36632,8 @@ async function selfhostStatus(options) {
36366
36632
  clack50.log.error("No self-hosted deployment found");
36367
36633
  console.log(
36368
36634
  `
36369
- 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.
36370
36637
  `
36371
36638
  );
36372
36639
  process.exit(1);
@@ -36375,13 +36642,23 @@ Run ${pc53.cyan("wraps selfhost deploy")} to deploy the self-hosted control plan
36375
36642
  const selfhostService = metadata.services.selfhost;
36376
36643
  const config2 = selfhostService.config;
36377
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;
36378
36654
  const statusData = {
36379
36655
  region,
36380
36656
  apiUrl,
36381
36657
  deployedAt: selfhostService.deployedAt,
36382
36658
  neonProjectId: config2.neonProjectId,
36383
36659
  appUrl: config2.appUrl,
36384
- licenseKeyPrefix: config2.licenseKey.slice(0, 12)
36660
+ licenseKeyPrefix: config2.licenseKey.slice(0, 12),
36661
+ variant
36385
36662
  };
36386
36663
  if (isJsonMode()) {
36387
36664
  jsonSuccess("selfhost.status", statusData);
@@ -36391,7 +36668,7 @@ Run ${pc53.cyan("wraps selfhost deploy")} to deploy the self-hosted control plan
36391
36668
  console.log("");
36392
36669
  clack50.log.info(pc53.bold("Commands:"));
36393
36670
  console.log(
36394
- ` ${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`
36395
36672
  );
36396
36673
  trackCommand("selfhost:status", {
36397
36674
  success: true,