@lukoweb/apitogo 0.2.21 → 0.2.22

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/dist/cli/cli.js CHANGED
@@ -5814,19 +5814,19 @@ import { createInterface as createInterface2 } from "node:readline/promises";
5814
5814
 
5815
5815
  // src/config/v2/errors.ts
5816
5816
  init_invariant();
5817
- function formatConfigIssue(issue3) {
5818
- const hint = issue3.hint ? ` ${issue3.hint}` : "";
5819
- const rest = issue3.message.slice(issue3.path.length);
5820
- const startsWithPath = issue3.path !== "" && issue3.message.startsWith(issue3.path) && (rest === "" || /^[\s:]/.test(rest));
5821
- const text = startsWithPath ? issue3.message : `${issue3.path === "" ? "(root)" : issue3.path}: ${issue3.message}`;
5822
- return `${text}${hint} [${issue3.code}]`;
5817
+ function formatConfigIssue(issue4) {
5818
+ const hint = issue4.hint ? ` ${issue4.hint}` : "";
5819
+ const rest = issue4.message.slice(issue4.path.length);
5820
+ const startsWithPath = issue4.path !== "" && issue4.message.startsWith(issue4.path) && (rest === "" || /^[\s:]/.test(rest));
5821
+ const text = startsWithPath ? issue4.message : `${issue4.path === "" ? "(root)" : issue4.path}: ${issue4.message}`;
5822
+ return `${text}${hint} [${issue4.code}]`;
5823
5823
  }
5824
5824
  var ConfigError = class extends ApitogoError {
5825
5825
  issues;
5826
5826
  constructor(file, issues) {
5827
5827
  super(
5828
5828
  `${file} has ${issues.length} problem${issues.length === 1 ? "" : "s"}:
5829
- ${issues.map((issue3) => ` - ${formatConfigIssue(issue3)}`).join("\n")}`,
5829
+ ${issues.map((issue4) => ` - ${formatConfigIssue(issue4)}`).join("\n")}`,
5830
5830
  { title: "Invalid apitogo config" }
5831
5831
  );
5832
5832
  this.name = "ConfigError";
@@ -6247,17 +6247,17 @@ function unknownKeyHint(fullPath, key, known) {
6247
6247
  if (closest) return `Did you mean "${closest.candidate}"?`;
6248
6248
  return known.length > 0 ? `Allowed keys: ${known.join(", ")}.` : void 0;
6249
6249
  }
6250
- function mapIssue(issue3) {
6251
- const path103 = pathToString(issue3.path);
6252
- switch (issue3.code) {
6250
+ function mapIssue(issue4) {
6251
+ const path103 = pathToString(issue4.path);
6252
+ switch (issue4.code) {
6253
6253
  case "unrecognized_keys":
6254
- return issue3.keys.map((key) => {
6255
- const fullPath = pathToString([...issue3.path, key]);
6254
+ return issue4.keys.map((key) => {
6255
+ const fullPath = pathToString([...issue4.path, key]);
6256
6256
  return {
6257
6257
  code: "CONFIG_UNKNOWN_KEY",
6258
6258
  path: fullPath,
6259
6259
  message: `Unknown key "${key}".`,
6260
- hint: unknownKeyHint(fullPath, key, knownKeysAt(issue3.path))
6260
+ hint: unknownKeyHint(fullPath, key, knownKeysAt(issue4.path))
6261
6261
  };
6262
6262
  });
6263
6263
  case "invalid_value":
@@ -6265,19 +6265,19 @@ function mapIssue(issue3) {
6265
6265
  {
6266
6266
  code: "CONFIG_INVALID_VALUE",
6267
6267
  path: path103,
6268
- message: `must be one of: ${issue3.values.map((v) => JSON.stringify(v)).join(", ")}.`
6268
+ message: `must be one of: ${issue4.values.map((v) => JSON.stringify(v)).join(", ")}.`
6269
6269
  }
6270
6270
  ];
6271
6271
  case "custom": {
6272
- const code = typeof issue3.params?.code === "string" ? issue3.params.code : "CONFIG_INVALID_VALUE";
6273
- return [{ code, path: path103, message: issue3.message ?? "is invalid." }];
6272
+ const code = typeof issue4.params?.code === "string" ? issue4.params.code : "CONFIG_INVALID_VALUE";
6273
+ return [{ code, path: path103, message: issue4.message ?? "is invalid." }];
6274
6274
  }
6275
6275
  case "invalid_type":
6276
6276
  return [
6277
- /received undefined/.test(issue3.message) ? { code: "CONFIG_MISSING_FIELD", path: path103, message: "is required." } : { code: "CONFIG_INVALID_VALUE", path: path103, message: issue3.message }
6277
+ /received undefined/.test(issue4.message) ? { code: "CONFIG_MISSING_FIELD", path: path103, message: "is required." } : { code: "CONFIG_INVALID_VALUE", path: path103, message: issue4.message }
6278
6278
  ];
6279
6279
  default:
6280
- return [{ code: "CONFIG_INVALID_VALUE", path: path103, message: issue3.message }];
6280
+ return [{ code: "CONFIG_INVALID_VALUE", path: path103, message: issue4.message }];
6281
6281
  }
6282
6282
  }
6283
6283
  function isRecord(value) {
@@ -6727,7 +6727,7 @@ function dependencyRules(config2, resolved, out) {
6727
6727
  function checkDependencies(config2, resolved) {
6728
6728
  const issues = [];
6729
6729
  dependencyRules(config2, resolved, {
6730
- error: (issue3) => void issues.push(issue3),
6730
+ error: (issue4) => void issues.push(issue4),
6731
6731
  warn: () => void 0
6732
6732
  });
6733
6733
  return issues;
@@ -7197,8 +7197,8 @@ async function checkConfigV2(config2, resolved, context = {}) {
7197
7197
  "online rules were not run (provider capabilities, origin reachability, OIDC discovery, tier limits)"
7198
7198
  ];
7199
7199
  const out = {
7200
- error: (issue3) => errors.push(issue3),
7201
- warn: (issue3) => warnings.push(issue3)
7200
+ error: (issue4) => errors.push(issue4),
7201
+ warn: (issue4) => warnings.push(issue4)
7202
7202
  };
7203
7203
  dependencyRules(config2, resolved, out);
7204
7204
  secretRules(config2, context, out, skipped);
@@ -7457,6 +7457,57 @@ function withGatewayEntry(existing, info) {
7457
7457
  }
7458
7458
  };
7459
7459
  }
7460
+ function withIdentityEntry(existing, info) {
7461
+ const base = existing ?? parseManifest({
7462
+ schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
7463
+ project: { name: info.projectName }
7464
+ });
7465
+ return {
7466
+ ...base,
7467
+ schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
7468
+ generatedAt: (info.now ?? /* @__PURE__ */ new Date()).toISOString(),
7469
+ preview: void 0,
7470
+ project: { ...base.project, id: info.projectId, name: info.projectName },
7471
+ services: {
7472
+ ...base.services,
7473
+ identity: {
7474
+ ...base.services.identity,
7475
+ provider: info.provider ?? "apitogo",
7476
+ status: "ready",
7477
+ issuer: info.issuer.replace(/\/+$/, ""),
7478
+ clientId: info.clientId,
7479
+ scopes: ["openid", "profile", "email"],
7480
+ ...info.audience ? { audience: info.audience } : {},
7481
+ capabilities: { nativeSignup: info.nativeSignup }
7482
+ }
7483
+ }
7484
+ };
7485
+ }
7486
+ function withPlansEntry(existing, info) {
7487
+ const base = existing ?? parseManifest({
7488
+ schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
7489
+ project: { name: info.projectName }
7490
+ });
7491
+ return {
7492
+ ...base,
7493
+ schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
7494
+ generatedAt: (info.now ?? /* @__PURE__ */ new Date()).toISOString(),
7495
+ preview: void 0,
7496
+ project: { ...base.project, id: info.projectId, name: info.projectName },
7497
+ services: {
7498
+ ...base.services,
7499
+ plans: {
7500
+ ...base.services.plans,
7501
+ provider: "apitogo",
7502
+ status: "ready",
7503
+ apiBaseUrl: info.apiBaseUrl.replace(/\/+$/, ""),
7504
+ billing: info.billing,
7505
+ catalogRevision: info.catalogRevision,
7506
+ capabilities: info.capabilities
7507
+ }
7508
+ }
7509
+ };
7510
+ }
7460
7511
  async function writeManifest(state, manifest) {
7461
7512
  const file = manifestPathFor(state);
7462
7513
  await mkdir3(path10.dirname(file), { recursive: true });
@@ -8266,7 +8317,8 @@ var PLAN_TEMPLATES = {
8266
8317
  displayName: "Free",
8267
8318
  default: true,
8268
8319
  limits: {
8269
- rateLimit: { calls: 10, periodSeconds: 60 },
8320
+ // The platform's Free tier allows at most 3 calls per 60 seconds per plan; a higher number is refused.
8321
+ rateLimit: { calls: 3, periodSeconds: 60 },
8270
8322
  quota: { calls: 1e3, periodDays: 30 }
8271
8323
  }
8272
8324
  }
@@ -8277,7 +8329,8 @@ var PLAN_TEMPLATES = {
8277
8329
  displayName: "Free",
8278
8330
  default: true,
8279
8331
  limits: {
8280
- rateLimit: { calls: 10, periodSeconds: 60 },
8332
+ // The platform's Free tier allows at most 3 calls per 60 seconds per plan; a higher number is refused.
8333
+ rateLimit: { calls: 3, periodSeconds: 60 },
8281
8334
  quota: { calls: 1e3, periodDays: 30 }
8282
8335
  }
8283
8336
  },
@@ -8564,12 +8617,12 @@ async function planAddService(service, options) {
8564
8617
  )
8565
8618
  );
8566
8619
  const added = dependencyProblems(next, resolved.configPath).filter(
8567
- (issue3) => !before.has(issue3.code + issue3.path)
8620
+ (issue4) => !before.has(issue4.code + issue4.path)
8568
8621
  );
8569
8622
  if (added.length > 0) {
8570
8623
  throw new ServiceEditError(
8571
- added.map((issue3) => issue3.message).join(" "),
8572
- added.map((issue3) => issue3.hint).filter(Boolean).join(" ") || "Add the missing service first."
8624
+ added.map((issue4) => issue4.message).join(" "),
8625
+ added.map((issue4) => issue4.hint).filter(Boolean).join(" ") || "Add the missing service first."
8573
8626
  );
8574
8627
  }
8575
8628
  }
@@ -8598,11 +8651,11 @@ async function planRemoveService(service, options) {
8598
8651
  dependencyProblems(config2, resolved.configPath).map((i) => i.code + i.path)
8599
8652
  );
8600
8653
  const caused = dependencyProblems(next, resolved.configPath).filter(
8601
- (issue3) => !before.has(issue3.code + issue3.path)
8654
+ (issue4) => !before.has(issue4.code + issue4.path)
8602
8655
  );
8603
8656
  if (caused.length > 0) {
8604
8657
  throw new ServiceEditError(
8605
- `${service} cannot be removed while something depends on it: ${caused.map((issue3) => issue3.message).join(" ")}`,
8658
+ `${service} cannot be removed while something depends on it: ${caused.map((issue4) => issue4.message).join(" ")}`,
8606
8659
  "Remove or change the dependent service first."
8607
8660
  );
8608
8661
  }
@@ -8836,7 +8889,7 @@ init_invariant();
8836
8889
 
8837
8890
  // src/cli/check/v2.ts
8838
8891
  init_apitogo_config_core();
8839
- import { readFile as readFile14 } from "node:fs/promises";
8892
+ import { readFile as readFile15 } from "node:fs/promises";
8840
8893
  import path49 from "node:path";
8841
8894
 
8842
8895
  // src/cli/platform/client/platform-api.ts
@@ -9264,6 +9317,52 @@ var PlatformApiClient = class {
9264
9317
  `/api/v1/projects/${projectId}/gateway`
9265
9318
  );
9266
9319
  }
9320
+ /**
9321
+ * Asks for the project's identity. The same request twice changes nothing; a different request for an identity that
9322
+ * exists is refused (409), because its audience and redirect addresses cannot change in place. Answers 200 when it is
9323
+ * already Active and 202 otherwise: read `status` and poll `getProjectIdentity`.
9324
+ */
9325
+ putProjectIdentity(projectId, body) {
9326
+ return this.request(
9327
+ "PUT",
9328
+ `/api/v1/projects/${projectId}/identity`,
9329
+ {
9330
+ headers: { "Content-Type": "application/json" },
9331
+ body: JSON.stringify(body)
9332
+ }
9333
+ );
9334
+ }
9335
+ /** The state of the project's identity, or null when it has none. */
9336
+ async getProjectIdentity(projectId) {
9337
+ try {
9338
+ return await this.request(
9339
+ "GET",
9340
+ `/api/v1/projects/${projectId}/identity`
9341
+ );
9342
+ } catch (error) {
9343
+ if (error instanceof PlatformApiError && error.status === 404)
9344
+ return null;
9345
+ throw error;
9346
+ }
9347
+ }
9348
+ /**
9349
+ * Removes the project's identity and DELETES EVERY USER in it, for good. Always sends `confirm=true`: ask the person
9350
+ * before calling. Returns null when there was nothing to remove, otherwise the state to poll until Released.
9351
+ */
9352
+ async deleteProjectIdentity(projectId) {
9353
+ const result = await this.request(
9354
+ "DELETE",
9355
+ `/api/v1/projects/${projectId}/identity?confirm=true`
9356
+ );
9357
+ return result ?? null;
9358
+ }
9359
+ /** Applies the project's identity to its plans backend as the sign-in provider. Queues a job. */
9360
+ linkDevPortalIdentity(projectId) {
9361
+ return this.request(
9362
+ "POST",
9363
+ `/api/v1/projects/${projectId}/dev-portal/identity-link`
9364
+ );
9365
+ }
9267
9366
  getDevPortalBackend(projectId) {
9268
9367
  return this.request(
9269
9368
  "GET",
@@ -9552,8 +9651,8 @@ var PlatformApiClient = class {
9552
9651
  }
9553
9652
  };
9554
9653
  async function readFileBytes(filePath) {
9555
- const { readFile: readFile31 } = await import("node:fs/promises");
9556
- const buffer = await readFile31(filePath);
9654
+ const { readFile: readFile32 } = await import("node:fs/promises");
9655
+ const buffer = await readFile32(filePath);
9557
9656
  const arrayBuffer = new ArrayBuffer(buffer.byteLength);
9558
9657
  new Uint8Array(arrayBuffer).set(buffer);
9559
9658
  return arrayBuffer;
@@ -9807,7 +9906,7 @@ import {
9807
9906
  // package.json
9808
9907
  var package_default = {
9809
9908
  name: "@lukoweb/apitogo",
9810
- version: "0.2.21",
9909
+ version: "0.2.22",
9811
9910
  type: "module",
9812
9911
  sideEffects: [
9813
9912
  "**/*.css",
@@ -14883,11 +14982,239 @@ async function onlineGatewayRules(gateway, projectId, api, derivedCors = []) {
14883
14982
  }
14884
14983
  }
14885
14984
 
14985
+ // src/cli/deploy/plans-file.ts
14986
+ init_invariant();
14987
+ import { readFile as readFile14 } from "node:fs/promises";
14988
+ import { z as z14 } from "zod";
14989
+ var PlansFileError = class extends ApitogoError {
14990
+ constructor(code, message, developerHint) {
14991
+ super(message, { developerHint, title: "Cannot read the plans file" });
14992
+ this.code = code;
14993
+ this.name = "PlansFileError";
14994
+ }
14995
+ };
14996
+ var Limits = z14.strictObject({
14997
+ rateLimit: z14.strictObject({
14998
+ calls: z14.int().positive(),
14999
+ periodSeconds: z14.int().positive()
15000
+ }).optional(),
15001
+ quota: z14.strictObject({
15002
+ calls: z14.int().positive(),
15003
+ periodDays: z14.int().positive()
15004
+ }).optional()
15005
+ });
15006
+ var Plan = z14.strictObject({
15007
+ sku: z14.string().trim().min(1),
15008
+ displayName: z14.string().trim().min(1),
15009
+ /** The plan the portal offers first. The platform has no such flag; the portal reads the file order. */
15010
+ default: z14.boolean().optional(),
15011
+ limits: Limits.optional(),
15012
+ priceAmount: z14.number().min(0).optional(),
15013
+ priceCurrency: z14.string().trim().min(1).optional(),
15014
+ billingInterval: z14.enum(["month", "year"]).optional(),
15015
+ pricingModel: z14.enum(["fixed", "custom"]).optional(),
15016
+ priceLabel: z14.string().optional(),
15017
+ sortOrder: z14.int().optional(),
15018
+ /** Action keys such as `get:/orders`. Left out, the plan includes every action of the gateway. */
15019
+ includedActions: z14.array(z14.string().trim().min(1)).optional()
15020
+ });
15021
+ var PlansFile = z14.strictObject({
15022
+ version: z14.literal(1),
15023
+ plans: z14.array(Plan)
15024
+ });
15025
+ var issuesOf = (error) => error.issues.map(
15026
+ (issue4) => `${issue4.path.join(".") || "(file)"}: ${issue4.message}`
15027
+ );
15028
+ async function readPlansFile(file) {
15029
+ let text;
15030
+ try {
15031
+ text = await readFile14(file, "utf8");
15032
+ } catch {
15033
+ throw new PlansFileError(
15034
+ "PLANS_FILE_UNREADABLE",
15035
+ `The plans file ${file} could not be read.`,
15036
+ "Create it with `apitogo add plans`, or set plans.file in the config."
15037
+ );
15038
+ }
15039
+ let raw;
15040
+ try {
15041
+ raw = JSON.parse(text);
15042
+ } catch (error) {
15043
+ throw new PlansFileError(
15044
+ "PLANS_FILE_INVALID",
15045
+ `The plans file ${file} is not valid JSON (${error.message}).`
15046
+ );
15047
+ }
15048
+ const parsed = PlansFile.safeParse(raw);
15049
+ if (!parsed.success)
15050
+ throw new PlansFileError(
15051
+ "PLANS_FILE_INVALID",
15052
+ `The plans file ${file} is not valid:
15053
+ ${issuesOf(parsed.error).join("\n ")}`,
15054
+ 'Expected { "version": 1, "plans": [ { "sku", "displayName", "limits": { "rateLimit": { "calls", "periodSeconds" }, "quota": { "calls", "periodDays" } } } ] }.'
15055
+ );
15056
+ const seen = /* @__PURE__ */ new Set();
15057
+ for (const plan2 of parsed.data.plans) {
15058
+ const sku = plan2.sku.toLowerCase();
15059
+ if (seen.has(sku))
15060
+ throw new PlansFileError(
15061
+ "PLANS_FILE_INVALID",
15062
+ `The plans file ${file} names the sku "${plan2.sku}" twice.`,
15063
+ "Every plan needs its own sku."
15064
+ );
15065
+ seen.add(sku);
15066
+ }
15067
+ return parsed.data.plans;
15068
+ }
15069
+ var BILLING_PERIOD = { month: "monthly", year: "annual" };
15070
+ function toPlatformPlans(plans) {
15071
+ return plans.map((plan2, index) => {
15072
+ const paid = plan2.pricingModel !== "custom" && (plan2.priceAmount ?? 0) > 0;
15073
+ if (paid)
15074
+ throw new PlansFileError(
15075
+ "PLANS_PAID_UNSUPPORTED",
15076
+ `The plan "${plan2.sku}" has a price, and deploying paid plans is not available in this CLI version yet.`,
15077
+ "Remove the plan, or set priceAmount to 0 (or pricingModel to custom for a contact-us plan)."
15078
+ );
15079
+ const limits = plan2.limits ? {
15080
+ ...plan2.limits.rateLimit ? {
15081
+ rateLimit: {
15082
+ calls: plan2.limits.rateLimit.calls,
15083
+ renewalPeriodSeconds: plan2.limits.rateLimit.periodSeconds
15084
+ }
15085
+ } : {},
15086
+ ...plan2.limits.quota ? {
15087
+ quota: {
15088
+ calls: plan2.limits.quota.calls,
15089
+ renewalPeriodDays: plan2.limits.quota.periodDays
15090
+ }
15091
+ } : {}
15092
+ } : void 0;
15093
+ return {
15094
+ sku: plan2.sku.trim().toLowerCase(),
15095
+ displayName: plan2.displayName,
15096
+ ...limits ? { limitsJson: JSON.stringify(limits) } : {},
15097
+ includedActionKeys: plan2.includedActions ?? null,
15098
+ ...plan2.pricingModel ? { pricingModel: plan2.pricingModel } : {},
15099
+ ...plan2.priceAmount !== void 0 ? { priceAmount: plan2.priceAmount } : {},
15100
+ ...plan2.priceLabel !== void 0 ? { priceLabel: plan2.priceLabel } : {},
15101
+ ...plan2.priceCurrency ? { priceCurrency: plan2.priceCurrency } : {},
15102
+ ...plan2.billingInterval ? { billingPeriod: BILLING_PERIOD[plan2.billingInterval] } : {},
15103
+ sortOrder: plan2.sortOrder ?? index
15104
+ };
15105
+ });
15106
+ }
15107
+
15108
+ // src/cli/check/services-online.ts
15109
+ var issue3 = (code, path103, message, hint) => ({
15110
+ code,
15111
+ path: path103,
15112
+ message,
15113
+ hint
15114
+ });
15115
+ var trimSlash = (value) => value.replace(/\/+$/, "");
15116
+ async function onlineIdentityRules(resolved, manifest, api) {
15117
+ const identity = resolved.identity;
15118
+ if (!identity?.enabled) return { errors: [], warnings: [] };
15119
+ const issuer = manifest?.services.identity?.issuer;
15120
+ if (!issuer)
15121
+ return {
15122
+ errors: [],
15123
+ warnings: [],
15124
+ skipped: "the identity's online rules were not run: identity is not deployed yet (run `apitogo deploy --only identity`)"
15125
+ };
15126
+ let answer;
15127
+ try {
15128
+ answer = await api.discover(issuer);
15129
+ } catch (error) {
15130
+ return {
15131
+ errors: [],
15132
+ warnings: [],
15133
+ skipped: `the identity's online rules were not run: the issuer could not be reached (${error instanceof Error ? error.message : String(error)})`
15134
+ };
15135
+ }
15136
+ if ("status" in answer)
15137
+ return {
15138
+ errors: [
15139
+ issue3(
15140
+ "IDENTITY_ISSUER_NOT_ANSWERING",
15141
+ "identity",
15142
+ `the issuer ${issuer} answered ${answer.status} instead of its discovery document.`,
15143
+ "Run `apitogo status --only identity` to see what the platform says, then deploy identity again."
15144
+ )
15145
+ ],
15146
+ warnings: []
15147
+ };
15148
+ if (!answer.document.issuer || trimSlash(answer.document.issuer) !== trimSlash(issuer))
15149
+ return {
15150
+ errors: [
15151
+ issue3(
15152
+ "IDENTITY_ISSUER_MISMATCH",
15153
+ "identity",
15154
+ `the discovery document of ${issuer} names a different issuer (${answer.document.issuer ?? "none"}).`,
15155
+ "Deploy identity again so services.json is rewritten."
15156
+ )
15157
+ ],
15158
+ warnings: []
15159
+ };
15160
+ return { errors: [], warnings: [] };
15161
+ }
15162
+ async function plansFileProblem(resolved) {
15163
+ const plans = resolved.plans;
15164
+ if (!plans?.enabled) return { wanted: [] };
15165
+ try {
15166
+ return { wanted: toPlatformPlans(await readPlansFile(plans.file)) };
15167
+ } catch (error) {
15168
+ if (!(error instanceof PlansFileError)) throw error;
15169
+ return {
15170
+ wanted: [],
15171
+ issue: issue3(
15172
+ error.code,
15173
+ "plans.file",
15174
+ error.message,
15175
+ error.developerHint
15176
+ )
15177
+ };
15178
+ }
15179
+ }
15180
+ async function onlinePlansRules(resolved, projectId, api) {
15181
+ const plans = resolved.plans;
15182
+ if (!plans?.enabled) return { errors: [], warnings: [] };
15183
+ const file = await plansFileProblem(resolved);
15184
+ if (file.issue) return { errors: [file.issue], warnings: [] };
15185
+ const wanted = file.wanted;
15186
+ if (wanted.length === 0) return { errors: [], warnings: [] };
15187
+ try {
15188
+ const answer = await api.validate(
15189
+ projectId,
15190
+ wanted.map(({ includedActionKeys, ...plan2 }) => ({
15191
+ ...plan2,
15192
+ ...includedActionKeys ? { includedActionKeys } : {}
15193
+ }))
15194
+ );
15195
+ const convert = (item) => issue3(
15196
+ "PLANS_PLAN_INVALID",
15197
+ `plans.file ${item.field}`,
15198
+ `${item.sku ? `plan ${item.sku}: ` : ""}${item.message}`
15199
+ );
15200
+ return {
15201
+ errors: answer.errors.map(convert),
15202
+ warnings: answer.warnings.map(convert)
15203
+ };
15204
+ } catch (error) {
15205
+ return {
15206
+ errors: [],
15207
+ warnings: [],
15208
+ skipped: `the plans' online rules were not run: the platform could not be asked (${error instanceof Error ? error.message : String(error)})`
15209
+ };
15210
+ }
15211
+ }
15212
+
14886
15213
  // src/cli/check/v2.ts
14887
15214
  async function isConfigV2(dir) {
14888
15215
  try {
14889
15216
  return /"version"\s*:\s*2\b/.test(
14890
- await readFile14(path49.join(dir, APITOGO_CONFIG_FILENAME), "utf8")
15217
+ await readFile15(path49.join(dir, APITOGO_CONFIG_FILENAME), "utf8")
14891
15218
  );
14892
15219
  } catch {
14893
15220
  return false;
@@ -14908,13 +15235,13 @@ async function fetchSecretNames(config2, configuredProjectId, args, deps) {
14908
15235
  }
14909
15236
  async function addGatewayOnlineRules(report, resolved, args, deps) {
14910
15237
  const gateway = resolved.gateway;
14911
- if (args.offline || !deps.gateway || !gateway?.enabled) return;
15238
+ if (args.offline || !deps.gateway || !gateway?.enabled) return false;
14912
15239
  const projectId = resolved.project.id || deps.envProjectId?.trim();
14913
15240
  if (!projectId) {
14914
15241
  report.skipped.push(
14915
15242
  "the gateway's online rules were not run: this folder is not linked to a project (run `apitogo project create`)"
14916
15243
  );
14917
- return;
15244
+ return false;
14918
15245
  }
14919
15246
  const manifest = await readManifest(resolved.state);
14920
15247
  const result = await onlineGatewayRules(
@@ -14928,7 +15255,7 @@ async function addGatewayOnlineRules(report, resolved, args, deps) {
14928
15255
  );
14929
15256
  if (result.skipped) {
14930
15257
  report.skipped.push(result.skipped);
14931
- return;
15258
+ return false;
14932
15259
  }
14933
15260
  const seen = new Set(
14934
15261
  [...report.errors, ...report.warnings].map((i) => `${i.code}|${i.path}`)
@@ -14939,12 +15266,60 @@ async function addGatewayOnlineRules(report, resolved, args, deps) {
14939
15266
  seen.add(key);
14940
15267
  report.errors.push(item);
14941
15268
  }
14942
- report.skipped = report.skipped.filter(
14943
- (note) => !note.startsWith("online rules were not run")
14944
- );
14945
- report.skipped.unshift(
14946
- "online rules ran for the gateway only; the other services have none yet"
15269
+ return true;
15270
+ }
15271
+ async function addServiceOnlineRules(report, resolved, args, deps) {
15272
+ if (args.offline) return [];
15273
+ const ran = [];
15274
+ const seen = new Set(
15275
+ [...report.errors, ...report.warnings].map((i) => `${i.code}|${i.path}`)
14947
15276
  );
15277
+ const take = (list2, into) => {
15278
+ for (const item of list2) {
15279
+ const key = `${item.code}|${item.path}`;
15280
+ if (seen.has(key)) continue;
15281
+ if (item.path === "plans.file" && report.errors.some((e) => e.path === "plans.file"))
15282
+ continue;
15283
+ seen.add(key);
15284
+ into.push(item);
15285
+ }
15286
+ };
15287
+ if (deps.identity && resolved.identity?.enabled) {
15288
+ const result = await onlineIdentityRules(
15289
+ resolved,
15290
+ await readManifest(resolved.state),
15291
+ deps.identity
15292
+ );
15293
+ if (result.skipped) report.skipped.push(result.skipped);
15294
+ else {
15295
+ take(result.errors, report.errors);
15296
+ take(result.warnings, report.warnings);
15297
+ ran.push("identity");
15298
+ }
15299
+ }
15300
+ if (deps.plans && resolved.plans?.enabled) {
15301
+ const projectId = resolved.project.id || deps.envProjectId?.trim();
15302
+ if (!projectId)
15303
+ report.skipped.push(
15304
+ "the plans' online rules were not run: this folder is not linked to a project (run `apitogo project create`)"
15305
+ );
15306
+ else {
15307
+ const result = await onlinePlansRules(resolved, projectId, deps.plans);
15308
+ if (result.skipped) report.skipped.push(result.skipped);
15309
+ else {
15310
+ take(result.errors, report.errors);
15311
+ take(result.warnings, report.warnings);
15312
+ ran.push("plans");
15313
+ }
15314
+ }
15315
+ }
15316
+ return ran;
15317
+ }
15318
+ function onlineRulesNote(ran) {
15319
+ if (ran.length === 1 && ran[0] === "gateway")
15320
+ return "online rules ran for the gateway only; the other services have none yet";
15321
+ const names = ran.length > 1 ? `${ran.slice(0, -1).join(", ")} and ${ran[ran.length - 1]}` : ran[0] ?? "";
15322
+ return `online rules ran for the ${names}`;
14948
15323
  }
14949
15324
  async function addApiReferenceWarning(report, resolved) {
14950
15325
  const portal = resolved.portal;
@@ -14968,7 +15343,7 @@ async function validatePortalBuild(dir) {
14968
15343
  return {
14969
15344
  code: error.issues[0]?.code ?? "PORTAL_BUILD_FAILED",
14970
15345
  path: error.issues[0]?.path ?? "portal",
14971
- message: error.issues.map((issue3) => issue3.message).join(" "),
15346
+ message: error.issues.map((issue4) => issue4.message).join(" "),
14972
15347
  hint: error.issues[0]?.hint
14973
15348
  };
14974
15349
  }
@@ -14997,7 +15372,19 @@ async function runCheckV2(dir, args = {}, deps = {}) {
14997
15372
  knownSecrets: online.names
14998
15373
  });
14999
15374
  if (online.failure) report.skipped.push(online.failure);
15000
- await addGatewayOnlineRules(report, resolved, args, deps);
15375
+ const file = await plansFileProblem(resolved);
15376
+ if (file.issue && !report.errors.some((e) => e.path === file.issue.path))
15377
+ report.errors.push(file.issue);
15378
+ const ran = [];
15379
+ if (await addGatewayOnlineRules(report, resolved, args, deps))
15380
+ ran.push("gateway");
15381
+ ran.push(...await addServiceOnlineRules(report, resolved, args, deps));
15382
+ if (ran.length > 0) {
15383
+ report.skipped = report.skipped.filter(
15384
+ (note) => !note.startsWith("online rules were not run")
15385
+ );
15386
+ report.skipped.unshift(onlineRulesNote(ran));
15387
+ }
15001
15388
  await addApiReferenceWarning(report, resolved);
15002
15389
  } catch (error) {
15003
15390
  if (!(error instanceof ConfigError)) throw error;
@@ -15025,11 +15412,11 @@ async function runCheckV2(dir, args = {}, deps = {}) {
15025
15412
  ...report
15026
15413
  };
15027
15414
  }
15028
- var line = (label, issue3) => `${label}: ${formatConfigIssue(issue3)}`;
15415
+ var line = (label, issue4) => `${label}: ${formatConfigIssue(issue4)}`;
15029
15416
  function formatCheckV2(output4) {
15030
15417
  const lines = [
15031
- ...output4.errors.map((issue3) => line("error", issue3)),
15032
- ...output4.warnings.map((issue3) => line("warning", issue3)),
15418
+ ...output4.errors.map((issue4) => line("error", issue4)),
15419
+ ...output4.warnings.map((issue4) => line("warning", issue4)),
15033
15420
  ...output4.skipped.map((note) => `not checked: ${note}`)
15034
15421
  ];
15035
15422
  const errors = `${output4.errors.length} error${output4.errors.length === 1 ? "" : "s"}`;
@@ -15039,6 +15426,14 @@ function formatCheckV2(output4) {
15039
15426
  );
15040
15427
  return lines;
15041
15428
  }
15429
+ async function discoverIssuer(issuer) {
15430
+ const response = await fetch(
15431
+ `${issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`,
15432
+ { signal: AbortSignal.timeout(1e4) }
15433
+ );
15434
+ if (!response.ok) return { status: response.status };
15435
+ return { document: await response.json() };
15436
+ }
15042
15437
  function platformDependencies(args) {
15043
15438
  if (args.offline) return {};
15044
15439
  try {
@@ -15050,6 +15445,10 @@ function platformDependencies(args) {
15050
15445
  gateway: {
15051
15446
  check: (projectId, body) => client.checkGateway(projectId, body),
15052
15447
  capabilities: (projectId) => client.getGatewayCapabilities(projectId)
15448
+ },
15449
+ identity: { discover: discoverIssuer },
15450
+ plans: {
15451
+ validate: (projectId, plans) => client.validateGatewayPlans(plans, { projectId })
15053
15452
  }
15054
15453
  };
15055
15454
  } catch {
@@ -15076,7 +15475,7 @@ init_package_json();
15076
15475
 
15077
15476
  // src/cli/platform/helpers/npm-build.ts
15078
15477
  import { spawn } from "node:child_process";
15079
- import { access as access5, readFile as readFile15 } from "node:fs/promises";
15478
+ import { access as access5, readFile as readFile16 } from "node:fs/promises";
15080
15479
  import path50 from "node:path";
15081
15480
  var BUILD_LOG_TAIL_BYTES = 8192;
15082
15481
  var MIN_NODE_MAJOR = 20;
@@ -16028,7 +16427,7 @@ function formatDevPortalBackendResponse(projectId, backend) {
16028
16427
  // src/cli/platform/helpers/dev-portal-manifest.ts
16029
16428
  init_gateway_limits();
16030
16429
  import { existsSync as existsSync7 } from "node:fs";
16031
- import { access as access6, mkdir as mkdir13, readFile as readFile16, writeFile as writeFile15 } from "node:fs/promises";
16430
+ import { access as access6, mkdir as mkdir13, readFile as readFile17, writeFile as writeFile15 } from "node:fs/promises";
16032
16431
  import path57 from "node:path";
16033
16432
 
16034
16433
  // src/cli/make-config/json-config.ts
@@ -17328,7 +17727,7 @@ function applyOpenApiToSiteConfig(config2, options) {
17328
17727
  async function readManifest2(siteDirectory) {
17329
17728
  const filePath = manifestFilePath(siteDirectory);
17330
17729
  if (await pathExists2(filePath)) {
17331
- const raw = await readFile16(filePath, "utf8");
17730
+ const raw = await readFile17(filePath, "utf8");
17332
17731
  const config2 = JSON.parse(raw);
17333
17732
  return extractManifestFromConfig(config2);
17334
17733
  }
@@ -17339,7 +17738,7 @@ async function writeManifest2(siteDirectory, manifest) {
17339
17738
  await mkdir13(path57.dirname(filePath), { recursive: true });
17340
17739
  let config2 = {};
17341
17740
  if (await pathExists2(filePath)) {
17342
- const raw = await readFile16(filePath, "utf8");
17741
+ const raw = await readFile17(filePath, "utf8");
17343
17742
  config2 = JSON.parse(raw);
17344
17743
  }
17345
17744
  const {
@@ -17358,7 +17757,7 @@ import {
17358
17757
  access as access7,
17359
17758
  appendFile,
17360
17759
  mkdir as mkdir14,
17361
- readFile as readFile17,
17760
+ readFile as readFile18,
17362
17761
  unlink,
17363
17762
  writeFile as writeFile16
17364
17763
  } from "node:fs/promises";
@@ -17464,7 +17863,7 @@ function applyEnvOverrides2(config2, env) {
17464
17863
  }
17465
17864
  async function readEnvFile(filePath) {
17466
17865
  try {
17467
- const raw = await readFile17(filePath, "utf8");
17866
+ const raw = await readFile18(filePath, "utf8");
17468
17867
  const env = {};
17469
17868
  for (const line2 of raw.split("\n")) {
17470
17869
  const trimmed = line2.trim();
@@ -17641,7 +18040,7 @@ async function writeScaffoldEnv(siteDirectory) {
17641
18040
  for (const legacyName of [".env.example"]) {
17642
18041
  const legacyPath = path58.join(resolvedDirectory, legacyName);
17643
18042
  try {
17644
- const legacy = await readFile17(legacyPath, "utf8");
18043
+ const legacy = await readFile18(legacyPath, "utf8");
17645
18044
  if (!envFileHasActiveAssignments(legacy)) {
17646
18045
  await unlink(legacyPath);
17647
18046
  filesWritten.push(`removed:${legacyName}`);
@@ -17652,7 +18051,7 @@ async function writeScaffoldEnv(siteDirectory) {
17652
18051
  const gitignorePath = path58.join(resolvedDirectory, ".gitignore");
17653
18052
  let gitignore = "";
17654
18053
  try {
17655
- gitignore = await readFile17(gitignorePath, "utf8");
18054
+ gitignore = await readFile18(gitignorePath, "utf8");
17656
18055
  } catch {
17657
18056
  gitignore = "";
17658
18057
  }
@@ -17684,7 +18083,7 @@ var SECRETS_COLLECTION_INSTRUCTION = "For go-live, plans must already be in apit
17684
18083
 
17685
18084
  // src/cli/platform/helpers/dev-portal-site-validation.ts
17686
18085
  init_gateway_limits();
17687
- import { readFile as readFile18 } from "node:fs/promises";
18086
+ import { readFile as readFile19 } from "node:fs/promises";
17688
18087
  import { createRequire as createRequire3 } from "node:module";
17689
18088
  import path59 from "node:path";
17690
18089
  var cachedFallbackTs;
@@ -17862,7 +18261,7 @@ async function validateDevPortalSiteConfig(sourceDirectory, options) {
17862
18261
  let parsed;
17863
18262
  let parseFailed = false;
17864
18263
  try {
17865
- parsed = JSON.parse(await readFile18(jsonPath, "utf8"));
18264
+ parsed = JSON.parse(await readFile19(jsonPath, "utf8"));
17866
18265
  } catch (error) {
17867
18266
  parseFailed = true;
17868
18267
  issues.push({
@@ -17901,7 +18300,7 @@ async function validateDevPortalSiteConfig(sourceDirectory, options) {
17901
18300
  } else {
17902
18301
  for (const relativeFile of existingTsxFiles) {
17903
18302
  checkedFiles.push(relativeFile);
17904
- const content = await readFile18(
18303
+ const content = await readFile19(
17905
18304
  path59.join(resolvedDir, relativeFile),
17906
18305
  "utf8"
17907
18306
  );
@@ -17922,7 +18321,7 @@ async function validateDevPortalSiteConfig(sourceDirectory, options) {
17922
18321
  };
17923
18322
  }
17924
18323
  function formatSiteValidationErrors(result) {
17925
- return result.issues.map((issue3) => `${issue3.file}: ${issue3.message}`).join("; ");
18324
+ return result.issues.map((issue4) => `${issue4.file}: ${issue4.message}`).join("; ");
17926
18325
  }
17927
18326
  function buildSiteValidationResponseFields(result) {
17928
18327
  if (result.valid) {
@@ -18326,7 +18725,7 @@ async function checkPublishReadiness(client, _config, args) {
18326
18725
  const mergedPrerequisites = mergePublishPrerequisitesWithPlatformValidation(
18327
18726
  publishPrerequisites,
18328
18727
  platformValidation.issues.map(
18329
- (issue3) => `${issue3.field}: ${issue3.message}`
18728
+ (issue4) => `${issue4.field}: ${issue4.message}`
18330
18729
  )
18331
18730
  );
18332
18731
  const siteValidation = await validateDevPortalSiteConfig(sourceDirectory);
@@ -18448,7 +18847,7 @@ var check_default = {
18448
18847
 
18449
18848
  // src/cli/make-config/handler.ts
18450
18849
  import { constants as constants2 } from "node:fs";
18451
- import { access as access12, mkdir as mkdir17, readFile as readFile23, writeFile as writeFile21 } from "node:fs/promises";
18850
+ import { access as access12, mkdir as mkdir17, readFile as readFile24, writeFile as writeFile21 } from "node:fs/promises";
18452
18851
  import path71 from "node:path";
18453
18852
  import { stdin as input2 } from "node:process";
18454
18853
  import { glob as glob8 } from "glob";
@@ -18588,7 +18987,7 @@ function withDefaultIncludedActionKeys(plan2, actionKeys) {
18588
18987
  }
18589
18988
 
18590
18989
  // src/cli/scaffold/dev-portal-config-pages.ts
18591
- import { access as access8, readFile as readFile19, rename as rename7, rm as rm9, writeFile as writeFile17 } from "node:fs/promises";
18990
+ import { access as access8, readFile as readFile20, rename as rename7, rm as rm9, writeFile as writeFile17 } from "node:fs/promises";
18592
18991
  import path63 from "node:path";
18593
18992
  async function writeConfigAtomically(filePath, content) {
18594
18993
  const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
@@ -19353,7 +19752,7 @@ async function wireNewDevPortalPageInConfig(sourceDirectory, slug, label) {
19353
19752
  if (!configPath) {
19354
19753
  return { updated: false };
19355
19754
  }
19356
- const original = await readFile19(configPath, "utf8");
19755
+ const original = await readFile20(configPath, "utf8");
19357
19756
  const patched = patchApitogoConfigForNewPage(original, slug, label, {
19358
19757
  typescript: configPath.endsWith(".tsx")
19359
19758
  });
@@ -19365,7 +19764,7 @@ async function wireNewDevPortalPageInConfig(sourceDirectory, slug, label) {
19365
19764
  }
19366
19765
 
19367
19766
  // src/cli/scaffold/wire-openapi.ts
19368
- import { access as access10, readFile as readFile21, writeFile as writeFile19 } from "node:fs/promises";
19767
+ import { access as access10, readFile as readFile22, writeFile as writeFile19 } from "node:fs/promises";
19369
19768
  import path68 from "node:path";
19370
19769
 
19371
19770
  // src/cli/platform/helpers/openapi.ts
@@ -19569,7 +19968,7 @@ async function validateOpenApiFile(filePath) {
19569
19968
  }
19570
19969
 
19571
19970
  // src/cli/scaffold/scaffold-dev-portal-site.ts
19572
- import { mkdir as mkdir15, readFile as readFile20, writeFile as writeFile18 } from "node:fs/promises";
19971
+ import { mkdir as mkdir15, readFile as readFile21, writeFile as writeFile18 } from "node:fs/promises";
19573
19972
  import path67 from "node:path";
19574
19973
 
19575
19974
  // src/cli/scaffold/apitogo-npm-packages.ts
@@ -19977,7 +20376,7 @@ function patchDashboardPageConfig(content) {
19977
20376
  async function ensureAccountPlansPage(outputDirectory) {
19978
20377
  const pagePath = path67.join(outputDirectory, ACCOUNT_PLANS_PAGE_PATH);
19979
20378
  try {
19980
- await readFile20(pagePath, "utf8");
20379
+ await readFile21(pagePath, "utf8");
19981
20380
  return void 0;
19982
20381
  } catch {
19983
20382
  await mkdir15(path67.dirname(pagePath), { recursive: true });
@@ -19988,7 +20387,7 @@ async function ensureAccountPlansPage(outputDirectory) {
19988
20387
  async function ensureDashboardPage(outputDirectory) {
19989
20388
  const pagePath = path67.join(outputDirectory, DASHBOARD_PAGE_PATH);
19990
20389
  try {
19991
- await readFile20(pagePath, "utf8");
20390
+ await readFile21(pagePath, "utf8");
19992
20391
  return void 0;
19993
20392
  } catch {
19994
20393
  await mkdir15(path67.dirname(pagePath), { recursive: true });
@@ -20050,7 +20449,7 @@ async function patchScaffoldedSite(outputDirectory, options) {
20050
20449
  const filesWritten = [];
20051
20450
  const packageJsonPath = path67.join(outputDirectory, "package.json");
20052
20451
  if (options.patchPackageJson !== false) {
20053
- const packageJson3 = await readFile20(packageJsonPath, "utf8");
20452
+ const packageJson3 = await readFile21(packageJsonPath, "utf8");
20054
20453
  await writeFile18(
20055
20454
  packageJsonPath,
20056
20455
  rewritePackageJson(packageJson3, {
@@ -20062,7 +20461,7 @@ async function patchScaffoldedSite(outputDirectory, options) {
20062
20461
  filesWritten.push("package.json");
20063
20462
  }
20064
20463
  const configPath = path67.join(outputDirectory, "apitogo.config.tsx");
20065
- const configContent = await readFile20(configPath, "utf8");
20464
+ const configContent = await readFile21(configPath, "utf8");
20066
20465
  await writeFile18(
20067
20466
  configPath,
20068
20467
  patchDashboardPageConfig(
@@ -20169,7 +20568,7 @@ async function wireDevPortalOpenApi(sourceDirectory, openApiPath) {
20169
20568
  }
20170
20569
  const jsonConfigPath = manifestFilePath(resolvedSource);
20171
20570
  if (await pathExists4(jsonConfigPath)) {
20172
- const raw = await readFile21(jsonConfigPath, "utf8");
20571
+ const raw = await readFile22(jsonConfigPath, "utf8");
20173
20572
  const siteConfig = JSON.parse(raw);
20174
20573
  const updated = applyOpenApiToSiteConfig(siteConfig, {
20175
20574
  input: input5,
@@ -20183,7 +20582,7 @@ async function wireDevPortalOpenApi(sourceDirectory, openApiPath) {
20183
20582
  );
20184
20583
  } else {
20185
20584
  const tsxConfigPath = path68.join(resolvedSource, "apitogo.config.tsx");
20186
- const configContent = await readFile21(tsxConfigPath, "utf8");
20585
+ const configContent = await readFile22(tsxConfigPath, "utf8");
20187
20586
  try {
20188
20587
  const patched = patchApitogoConfigOpenApiToFile(configContent, input5);
20189
20588
  await writeFile19(tsxConfigPath, patched);
@@ -20539,7 +20938,7 @@ export default function ${componentName}() {
20539
20938
  // src/cli/make-config/scaffold-project.ts
20540
20939
  init_package_json();
20541
20940
  import { constants } from "node:fs";
20542
- import { access as access11, mkdir as mkdir16, readFile as readFile22, writeFile as writeFile20 } from "node:fs/promises";
20941
+ import { access as access11, mkdir as mkdir16, readFile as readFile23, writeFile as writeFile20 } from "node:fs/promises";
20543
20942
  import path70 from "node:path";
20544
20943
  import { glob as glob7 } from "glob";
20545
20944
 
@@ -20621,7 +21020,7 @@ async function ensurePackageJson2(dir) {
20621
21020
  `, "utf8");
20622
21021
  return true;
20623
21022
  }
20624
- const raw = await readFile22(pkgPath, "utf8");
21023
+ const raw = await readFile23(pkgPath, "utf8");
20625
21024
  let pkg;
20626
21025
  try {
20627
21026
  pkg = JSON.parse(raw);
@@ -20750,7 +21149,7 @@ var fileExists3 = async (filePath) => {
20750
21149
  }
20751
21150
  };
20752
21151
  var readJsonConfig = async (filePath) => {
20753
- const raw = await readFile23(filePath, "utf8");
21152
+ const raw = await readFile24(filePath, "utf8");
20754
21153
  const parsed = JSON.parse(raw);
20755
21154
  if (!isPlainObject3(parsed)) {
20756
21155
  throw new Error(`${APITOGO_JSON_FILENAME} must be a JSON object.`);
@@ -20800,7 +21199,7 @@ async function persistConfig(config2, ctx) {
20800
21199
  `, "utf8");
20801
21200
  const tsxPath = path71.join(ctx.dir, "apitogo.config.tsx");
20802
21201
  if (await fileExists3(tsxPath) && tsxRoutes.length > 0) {
20803
- const tsxSource = await readFile23(tsxPath, "utf8");
21202
+ const tsxSource = await readFile24(tsxPath, "utf8");
20804
21203
  if (isManagedConfigWrapper(tsxSource)) {
20805
21204
  await writeFile21(
20806
21205
  tsxPath,
@@ -21196,15 +21595,15 @@ function buildGatewayPlansValidationRequest(plans) {
21196
21595
  };
21197
21596
  });
21198
21597
  }
21199
- function toConfigIssue(issue3) {
21598
+ function toConfigIssue(issue4) {
21200
21599
  return {
21201
- ...issue3,
21202
- field: issue3.field.replace(/\.limitsJson$/, ".limits")
21600
+ ...issue4,
21601
+ field: issue4.field.replace(/\.limitsJson$/, ".limits")
21203
21602
  };
21204
21603
  }
21205
- function formatGatewayPlanIssue(issue3) {
21206
- const sku = issue3.sku ? ` (${issue3.sku})` : "";
21207
- return `${issue3.field}${sku}: ${issue3.message}`;
21604
+ function formatGatewayPlanIssue(issue4) {
21605
+ const sku = issue4.sku ? ` (${issue4.sku})` : "";
21606
+ return `${issue4.field}${sku}: ${issue4.message}`;
21208
21607
  }
21209
21608
  async function checkGatewayPlansOnPlatform(client, args) {
21210
21609
  let result;
@@ -21227,10 +21626,10 @@ async function checkGatewayPlansOnPlatform(client, args) {
21227
21626
  async function assertGatewayPlansValidForPublish(client, args) {
21228
21627
  const localErrors = args.localErrors ?? [];
21229
21628
  const check2 = await checkGatewayPlansOnPlatform(client, args);
21230
- const localFields = new Set(localErrors.map((issue3) => issue3.field));
21629
+ const localFields = new Set(localErrors.map((issue4) => issue4.field));
21231
21630
  const errors = [
21232
21631
  ...localErrors,
21233
- ...(check2?.errors ?? []).filter((issue3) => !localFields.has(issue3.field))
21632
+ ...(check2?.errors ?? []).filter((issue4) => !localFields.has(issue4.field))
21234
21633
  ];
21235
21634
  if (errors.length === 0) {
21236
21635
  return { blocked: false };
@@ -22193,7 +22592,7 @@ async function upsertGatewayPlansFromManifest(client, config2, projectId, plans,
22193
22592
  }
22194
22593
 
22195
22594
  // src/cli/platform/helpers/openapi-gateway-servers.ts
22196
- import { mkdir as mkdir18, readFile as readFile24, writeFile as writeFile22 } from "node:fs/promises";
22595
+ import { mkdir as mkdir18, readFile as readFile25, writeFile as writeFile22 } from "node:fs/promises";
22197
22596
  import path74 from "node:path";
22198
22597
  import YAML3 from "yaml";
22199
22598
  var GATEWAY_URL_ENV_KEY = "APITOGO_GATEWAY_URL";
@@ -22225,7 +22624,7 @@ async function resolveProjectGatewayUrl(client, projectId) {
22225
22624
  return null;
22226
22625
  }
22227
22626
  async function readOpenApiDocument(filePath) {
22228
- const content = await readFile24(filePath, "utf8");
22627
+ const content = await readFile25(filePath, "utf8");
22229
22628
  const ext = path74.extname(filePath).toLowerCase();
22230
22629
  const parsed = ext === ".json" ? JSON.parse(content) : YAML3.parse(content);
22231
22630
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
@@ -22254,7 +22653,7 @@ async function pointSiteApisAtPublishOpenApi(siteDirectory, relativeInput) {
22254
22653
  path74.resolve(siteDirectory),
22255
22654
  APITOGO_CONFIG_FILENAME2
22256
22655
  );
22257
- const raw = await readFile24(configPath, "utf8");
22656
+ const raw = await readFile25(configPath, "utf8");
22258
22657
  const config2 = JSON.parse(raw);
22259
22658
  const previousApis = config2.apis;
22260
22659
  const previousBackend = config2.backend;
@@ -22271,7 +22670,7 @@ async function pointSiteApisAtPublishOpenApi(siteDirectory, relativeInput) {
22271
22670
  `, "utf8");
22272
22671
  return {
22273
22672
  restore: async () => {
22274
- const currentRaw = await readFile24(configPath, "utf8");
22673
+ const currentRaw = await readFile25(configPath, "utf8");
22275
22674
  const current = JSON.parse(currentRaw);
22276
22675
  if (previousApis === void 0) {
22277
22676
  delete current.apis;
@@ -22306,7 +22705,7 @@ async function prepareOpenApiServersForGatewayBuild(options) {
22306
22705
  );
22307
22706
  if (!openApiPath) {
22308
22707
  try {
22309
- const raw = await readFile24(
22708
+ const raw = await readFile25(
22310
22709
  path74.join(siteDirectory, APITOGO_CONFIG_FILENAME2),
22311
22710
  "utf8"
22312
22711
  );
@@ -22380,7 +22779,7 @@ function collectManifestPublicActionKeys(gateway) {
22380
22779
  }
22381
22780
 
22382
22781
  // src/cli/platform/helpers/publish-ledger.ts
22383
- import { mkdir as mkdir19, readFile as readFile25, writeFile as writeFile23 } from "node:fs/promises";
22782
+ import { mkdir as mkdir19, readFile as readFile26, writeFile as writeFile23 } from "node:fs/promises";
22384
22783
  import path75 from "node:path";
22385
22784
  var PUBLISH_LEDGER_RELATIVE_PATH = ".apitogo/publish-state.json";
22386
22785
  function publishLedgerPath(siteDirectory) {
@@ -22389,7 +22788,7 @@ function publishLedgerPath(siteDirectory) {
22389
22788
  async function readPublishLedger(siteDirectory) {
22390
22789
  const filePath = publishLedgerPath(siteDirectory);
22391
22790
  try {
22392
- const raw = await readFile25(filePath, "utf8");
22791
+ const raw = await readFile26(filePath, "utf8");
22393
22792
  const parsed = JSON.parse(raw);
22394
22793
  if (parsed?.version !== 1 || !Array.isArray(parsed.steps)) {
22395
22794
  return null;
@@ -24092,6 +24491,10 @@ function resumedCreateStep(projectId) {
24092
24491
  };
24093
24492
  }
24094
24493
  async function resolveBootstrapProjectId(client, args, steps) {
24494
+ if (args.projectId) {
24495
+ steps.push(resumedCreateStep(args.projectId));
24496
+ return { projectId: args.projectId, resumed: true };
24497
+ }
24095
24498
  const existing = await findExistingProjectByName(client, args.name);
24096
24499
  if (existing) {
24097
24500
  steps.push(resumedCreateStep(existing.id));
@@ -24151,11 +24554,14 @@ async function handleBootstrapProject(client, config2, args, progress) {
24151
24554
  {
24152
24555
  name: args.name,
24153
24556
  description: args.description,
24154
- modules
24557
+ modules,
24558
+ projectId: args.projectId
24155
24559
  },
24156
24560
  steps
24157
24561
  );
24158
- if (selectFreePlan && !resumed) {
24562
+ await args.onProjectResolved?.(projectId);
24563
+ const continuingWithoutPlan = selectFreePlan && args.projectId !== void 0 && isPlatformPlanUnselected((await client.getProject(projectId)).planTier);
24564
+ if (selectFreePlan && (!resumed || continuingWithoutPlan)) {
24159
24565
  await bootstrapProgress?.onStep("Selecting free plan");
24160
24566
  const freeResult = await handleSelectFreePlan(
24161
24567
  client,
@@ -24167,7 +24573,7 @@ async function handleBootstrapProject(client, config2, args, progress) {
24167
24573
  return freeResult;
24168
24574
  }
24169
24575
  steps.push({ step: "select_free_plan", status: "succeeded" });
24170
- } else if (selectFreePlan && resumed) {
24576
+ } else if (selectFreePlan && resumed && !continuingWithoutPlan) {
24171
24577
  steps.push({
24172
24578
  step: "select_free_plan",
24173
24579
  status: "skipped",
@@ -24356,9 +24762,9 @@ async function handleTeardownProject(client, config2, args, progress) {
24356
24762
 
24357
24763
  // src/cli/platform/tools/dev-portal-bootstrap-shared.ts
24358
24764
  var BootstrapStepError = class extends Error {
24359
- constructor(failedStep, message, recoveryHint) {
24765
+ constructor(failedStep2, message, recoveryHint) {
24360
24766
  super(message);
24361
- this.failedStep = failedStep;
24767
+ this.failedStep = failedStep2;
24362
24768
  this.recoveryHint = recoveryHint;
24363
24769
  this.name = "BootstrapStepError";
24364
24770
  }
@@ -24561,7 +24967,7 @@ async function resolveProjectIdForPublish(client, config2, args, wait, progress,
24561
24967
  import path81 from "node:path";
24562
24968
 
24563
24969
  // src/cli/platform/helpers/dev-portal-site-api-url.ts
24564
- import { readdir as readdir3, readFile as readFile26, writeFile as writeFile24 } from "node:fs/promises";
24970
+ import { readdir as readdir3, readFile as readFile27, writeFile as writeFile24 } from "node:fs/promises";
24565
24971
  import path78 from "node:path";
24566
24972
  var SITE_API_BASE_URL_ENV_KEY = "VITE_APITOGO_AUTHENTICATION_API_BASE_URL";
24567
24973
  var PLACEHOLDER_HOST_MARKERS = [
@@ -24586,7 +24992,7 @@ async function readSiteApiBaseUrl(sourceDirectory) {
24586
24992
  const resolved = path78.resolve(sourceDirectory);
24587
24993
  for (const envFile of [".env", ".env.production"]) {
24588
24994
  try {
24589
- const content = await readFile26(path78.join(resolved, envFile), "utf8");
24995
+ const content = await readFile27(path78.join(resolved, envFile), "utf8");
24590
24996
  const lines = [...content.matchAll(ENV_LINE_GLOBAL_RE)];
24591
24997
  if (lines.length === 0) {
24592
24998
  continue;
@@ -24617,7 +25023,7 @@ async function patchSiteApiBaseUrl(sourceDirectory, apiBaseUrl) {
24617
25023
  for (const envFile of [".env.production", ".env"]) {
24618
25024
  const envPath = path78.join(resolved, envFile);
24619
25025
  try {
24620
- const content = await readFile26(envPath, "utf8");
25026
+ const content = await readFile27(envPath, "utf8");
24621
25027
  const withoutAssignments = content.replace(ENV_LINE_GLOBAL_RE, "").replace(/\n{3,}/g, "\n\n").trimEnd();
24622
25028
  const patched = withoutAssignments ? `${withoutAssignments}
24623
25029
  ${line2}
@@ -24697,7 +25103,7 @@ async function assertDeployableDistHasLiveApiBaseUrl(distDirectory, expectedApiB
24697
25103
  let found = false;
24698
25104
  let foundPlaceholder = false;
24699
25105
  for await (const file of walkFiles(dist)) {
24700
- const content = await readFile26(file, "utf8");
25106
+ const content = await readFile27(file, "utf8");
24701
25107
  if (content.includes(needle)) {
24702
25108
  found = true;
24703
25109
  }
@@ -25382,7 +25788,7 @@ function throwOnManifestIssues(issues) {
25382
25788
  return;
25383
25789
  }
25384
25790
  throw new Error(
25385
- `apitogo.config.json is not platform-compatible: ${issues.map((issue3) => `${issue3.field}: ${issue3.message}`).join("; ")}`
25791
+ `apitogo.config.json is not platform-compatible: ${issues.map((issue4) => `${issue4.field}: ${issue4.message}`).join("; ")}`
25386
25792
  );
25387
25793
  }
25388
25794
  async function runApplyFromSiteDirectory(client, config2, args, progress) {
@@ -26400,14 +26806,14 @@ function formatBlockedOrFailedResultForHuman(parsed) {
26400
26806
  const lines = [
26401
26807
  typeof parsed.userMessage === "string" ? parsed.userMessage : "Deploy is blocked: apitogo.config.json has errors."
26402
26808
  ];
26403
- for (const issue3 of Array.isArray(parsed.errors) ? parsed.errors : []) {
26404
- lines.push(` - ${formatGatewayPlanIssue(issue3)}`);
26809
+ for (const issue4 of Array.isArray(parsed.errors) ? parsed.errors : []) {
26810
+ lines.push(` - ${formatGatewayPlanIssue(issue4)}`);
26405
26811
  }
26406
26812
  const warnings = Array.isArray(parsed.warnings) ? parsed.warnings : [];
26407
26813
  if (warnings.length > 0) {
26408
26814
  lines.push("", "Warnings:");
26409
- for (const issue3 of warnings) {
26410
- lines.push(` - ${formatGatewayPlanIssue(issue3)}`);
26815
+ for (const issue4 of warnings) {
26816
+ lines.push(` - ${formatGatewayPlanIssue(issue4)}`);
26411
26817
  }
26412
26818
  }
26413
26819
  lines.push(
@@ -26836,7 +27242,7 @@ import path83 from "node:path";
26836
27242
 
26837
27243
  // src/cli/deploy/gateway.ts
26838
27244
  import { createHash as createHash4 } from "node:crypto";
26839
- import { readFile as readFile27 } from "node:fs/promises";
27245
+ import { readFile as readFile28 } from "node:fs/promises";
26840
27246
  init_invariant();
26841
27247
  var GatewayDeployError = class extends ApitogoError {
26842
27248
  constructor(code, message, developerHint) {
@@ -26876,7 +27282,7 @@ async function readSpec(gateway) {
26876
27282
  "Add one to gateway.specs in the config."
26877
27283
  );
26878
27284
  try {
26879
- return await readFile27(file);
27285
+ return await readFile28(file);
26880
27286
  } catch {
26881
27287
  throw new GatewayDeployError(
26882
27288
  "GATEWAY_SPEC_UNREADABLE",
@@ -27070,73 +27476,699 @@ async function deployGateway(resolved, projectId, deps, options = {}) {
27070
27476
  };
27071
27477
  }
27072
27478
 
27073
- // src/cli/portal/report.ts
27074
- var DEPLOY_ORDER = [
27075
- "identity",
27076
- "gateway",
27077
- "plans",
27078
- "portal"
27079
- ];
27080
- var exists4 = (file) => access13(file).then(
27081
- () => true,
27082
- () => false
27083
- );
27084
- function sectionOf(resolved, service) {
27085
- return resolved[service];
27479
+ // src/cli/deploy/identity.ts
27480
+ init_invariant();
27481
+ var IdentityDeployError = class extends ApitogoError {
27482
+ constructor(code, message, developerHint) {
27483
+ super(message, { developerHint, title: "Cannot deploy identity" });
27484
+ this.code = code;
27485
+ this.name = "IdentityDeployError";
27486
+ }
27487
+ };
27488
+ var IDENTITY_STEPS = ["request", "activate"];
27489
+ var PORTAL_CALLBACK_PATH = "/oauth/callback";
27490
+ var defaultSleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
27491
+ function identityNotDeployableReason(resolved) {
27492
+ const identity = resolved.identity;
27493
+ if (!identity) return "identity is not in the config";
27494
+ if (identity.provider === "oidc" && (!identity.issuer || !identity.clientId))
27495
+ return 'identity.provider "oidc" needs issuer and clientId';
27496
+ return void 0;
27086
27497
  }
27087
- function chooseServices(only) {
27088
- return DEPLOY_ORDER.filter((name) => !only || only.includes(name));
27498
+ function identityRequestBody(resolved, portalUrl) {
27499
+ return {
27500
+ portalRedirectUris: [
27501
+ `${portalUrl.replace(/\/+$/, "")}${PORTAL_CALLBACK_PATH}`
27502
+ ],
27503
+ nativeSignup: resolved.identity?.native ?? true
27504
+ };
27089
27505
  }
27090
- async function planPortalService(resolved, ledger) {
27091
- const base = { service: "portal", details: [] };
27092
- try {
27093
- const manifest = await readManifest(resolved.state);
27094
- const prepared = await preparePortal(resolved, manifest, "production", {
27095
- write: false
27096
- });
27097
- const { profile, plan: plan2 } = prepared;
27098
- const details = [
27099
- `docs: ${!profile.docs.enabled ? "off" : profile.docs.provider === "external" ? `external (${profile.docs.url})` : "built by apitogo"}`,
27100
- `console: ${profile.console.enabled ? "on" : "off"}`,
27101
- `manifest: ${prepared.manifestSource === "file" ? "services.json" : "not needed"}`,
27102
- ...[
27103
- plan2.auth ? "sign-in" : void 0,
27104
- plan2.signup ? "sign-up" : void 0,
27105
- plan2.userPanel ? "account pages" : void 0,
27106
- plan2.plansPlugin ? "pricing, keys and usage pages" : void 0,
27107
- plan2.apiReference ? "API reference" : void 0
27108
- ].filter((feature) => feature !== void 0).map((feature) => `will appear: ${feature}`),
27109
- ...plan2.skipped.map((s) => `left out: ${s.piece} (${s.reason})`)
27110
- ];
27111
- const hash2 = await portalBuildHash(resolved, prepared);
27112
- const previous = stepOf(ledger, "portal", "build");
27113
- const built = await exists4(
27114
- path83.join(resolved.state.buildDir, "index.html")
27506
+ function identityInputsHash(projectId, body) {
27507
+ return stableJson({ projectId, body });
27508
+ }
27509
+ async function portalUrlOf(resolved, projectId, client) {
27510
+ const known = (await readManifest(resolved.state))?.services.portal?.url;
27511
+ const url2 = known ?? resolveSwaUrl(await client.getProject(projectId));
27512
+ if (!url2)
27513
+ throw new IdentityDeployError(
27514
+ "IDENTITY_PORTAL_ADDRESS_UNKNOWN",
27515
+ "The portal has no public address yet, and the identity needs it for the sign-in redirect.",
27516
+ "Run `apitogo deploy --only portal` once so the platform gives the portal an address, then deploy identity."
27115
27517
  );
27116
- if (previous?.status === "succeeded" && previous.hash === hash2 && built) {
27117
- return {
27118
- ...base,
27119
- action: "skip",
27120
- reason: "build skipped: nothing it depends on has changed",
27121
- details,
27122
- issues: []
27123
- };
27124
- }
27125
- const reason = !previous ? "no earlier build" : previous.status === "failed" ? `the last build failed (${previous.error ?? "no message"})` : previous.status === "running" ? "the last build did not finish" : !built ? "the build folder is gone" : "its inputs changed since the last build";
27126
- return {
27127
- ...base,
27128
- action: "build",
27129
- reason: `will build: ${reason}`,
27130
- details,
27131
- issues: []
27132
- };
27133
- } catch (error) {
27134
- if (!(error instanceof ConfigError)) throw error;
27135
- return {
27136
- ...base,
27137
- action: "blocked",
27138
- reason: "cannot build until these are fixed",
27139
- details: [],
27518
+ return url2;
27519
+ }
27520
+ function rejected2(error) {
27521
+ if (error instanceof PlatformApiError && error.status === 409) {
27522
+ throw new IdentityDeployError(
27523
+ "IDENTITY_SETTINGS_CHANGED",
27524
+ "This project already has an identity with other settings, and its audience and portal address cannot be changed in place.",
27525
+ "Keep the portal address as it was, or remove the identity first (this deletes every user in it)."
27526
+ );
27527
+ }
27528
+ if (error instanceof PlatformApiError && error.status === 400) {
27529
+ const lines = describePlatformProblem(error);
27530
+ throw new IdentityDeployError(
27531
+ "IDENTITY_REJECTED",
27532
+ `The platform refused the identity request:
27533
+ ${lines.join("\n ") || error.message}`,
27534
+ "Fix the cause in apitogo.config.json and run deploy again."
27535
+ );
27536
+ }
27537
+ throw error;
27538
+ }
27539
+ async function recordOwnProvider(resolved, projectId) {
27540
+ const identity = resolved.identity;
27541
+ const issuer = identity?.issuer?.replace(/\/+$/, "");
27542
+ const clientId = identity?.clientId;
27543
+ if (!issuer || !clientId)
27544
+ throw new Error("identity.issuer and identity.clientId are needed.");
27545
+ const known = (await readManifest(resolved.state))?.services.identity;
27546
+ const changed = known?.provider !== "oidc" || known.issuer !== issuer || known.clientId !== clientId;
27547
+ if (changed)
27548
+ await writeManifest(
27549
+ resolved.state,
27550
+ withIdentityEntry(await readManifest(resolved.state), {
27551
+ projectId,
27552
+ projectName: resolved.project.name,
27553
+ provider: "oidc",
27554
+ issuer,
27555
+ clientId,
27556
+ nativeSignup: false
27557
+ })
27558
+ );
27559
+ return {
27560
+ status: changed ? "deployed" : "unchanged",
27561
+ steps: [],
27562
+ issuer,
27563
+ clientId
27564
+ };
27565
+ }
27566
+ async function deployIdentity(resolved, projectId, deps, options = {}) {
27567
+ if (resolved.identity?.provider === "oidc")
27568
+ return recordOwnProvider(resolved, projectId);
27569
+ const client = deps.client;
27570
+ const sleep5 = deps.sleep ?? defaultSleep2;
27571
+ const now = deps.now ?? Date.now;
27572
+ const timeoutMs = deps.activateTimeoutMs ?? 12e4;
27573
+ const intervalMs = deps.pollIntervalMs ?? 2e3;
27574
+ const portalUrl = await portalUrlOf(resolved, projectId, client);
27575
+ const body = identityRequestBody(resolved, portalUrl);
27576
+ const inputsHash = identityInputsHash(projectId, body);
27577
+ const requestStep = {
27578
+ name: "request",
27579
+ hash: () => inputsHash,
27580
+ run: async () => {
27581
+ try {
27582
+ const answer = await client.putProjectIdentity(projectId, body);
27583
+ return { status: answer.status };
27584
+ } catch (error) {
27585
+ return rejected2(error);
27586
+ }
27587
+ },
27588
+ // An identity that was removed, or that failed, is not a result worth keeping.
27589
+ stillValid: async () => {
27590
+ const found = await client.getProjectIdentity(projectId);
27591
+ return found !== null && found.status !== "Failed" && found.status !== "Released" && found.status !== "Releasing";
27592
+ }
27593
+ };
27594
+ const activateStep = {
27595
+ name: "activate",
27596
+ hash: () => inputsHash,
27597
+ run: async () => {
27598
+ const deadline = now() + timeoutMs;
27599
+ for (; ; ) {
27600
+ const found = await client.getProjectIdentity(projectId);
27601
+ if (found?.status === "Active")
27602
+ return {
27603
+ issuer: found.issuer,
27604
+ clientId: found.clientId,
27605
+ audience: found.audience,
27606
+ nativeSignup: String(found.nativeSignup)
27607
+ };
27608
+ if (found?.status === "Failed")
27609
+ throw new IdentityDeployError(
27610
+ "IDENTITY_FAILED",
27611
+ `The platform could not make the identity: ${[found.errorCode, found.errorMessage].filter(Boolean).join(" ") || "it failed"}.`,
27612
+ "Fix the cause and run deploy again; the request is sent again."
27613
+ );
27614
+ if (now() >= deadline)
27615
+ throw new IdentityDeployError(
27616
+ "IDENTITY_TIMEOUT",
27617
+ `The identity did not become active within ${Math.round(timeoutMs / 1e3)} seconds (state: ${found?.status ?? "not found"}).`,
27618
+ "Run deploy again to keep waiting."
27619
+ );
27620
+ await sleep5(intervalMs);
27621
+ }
27622
+ },
27623
+ stillValid: async () => (await client.getProjectIdentity(projectId))?.status === "Active"
27624
+ };
27625
+ const outcomes = await runPipeline(
27626
+ resolved.state,
27627
+ "identity",
27628
+ [requestStep, activateStep],
27629
+ { force: options.force }
27630
+ );
27631
+ const activate = outcomes.find((outcome) => outcome.step === "activate");
27632
+ const ran = activate !== void 0 && !activate.skipped;
27633
+ const known = (await readManifest(resolved.state))?.services.identity;
27634
+ let learned;
27635
+ if (ran || known?.issuer === void 0) {
27636
+ learned = await client.getProjectIdentity(projectId) ?? void 0;
27637
+ if (learned?.status === "Active") {
27638
+ await writeManifest(
27639
+ resolved.state,
27640
+ withIdentityEntry(await readManifest(resolved.state), {
27641
+ projectId,
27642
+ projectName: resolved.project.name,
27643
+ issuer: learned.issuer,
27644
+ clientId: learned.clientId,
27645
+ audience: learned.audience,
27646
+ nativeSignup: learned.nativeSignup
27647
+ })
27648
+ );
27649
+ }
27650
+ }
27651
+ return {
27652
+ status: ran ? "deployed" : "unchanged",
27653
+ steps: outcomes.map((outcome) => ({
27654
+ step: outcome.step,
27655
+ skipped: outcome.skipped
27656
+ })),
27657
+ issuer: learned?.issuer ?? known?.issuer,
27658
+ clientId: learned?.clientId ?? known?.clientId
27659
+ };
27660
+ }
27661
+
27662
+ // src/cli/deploy/plans.ts
27663
+ init_invariant();
27664
+ var PlansDeployError = class extends ApitogoError {
27665
+ constructor(code, message, developerHint) {
27666
+ super(message, { developerHint, title: "Cannot deploy plans" });
27667
+ this.code = code;
27668
+ this.name = "PlansDeployError";
27669
+ }
27670
+ };
27671
+ var PLANS_STEPS = ["catalog", "backend", "identity-link"];
27672
+ var defaultSleep3 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
27673
+ var BACKEND_MODULE = "dev-portal";
27674
+ var PLANS_CAPABILITIES = { catalog: true, keys: true, usage: true };
27675
+ async function plansInputs(resolved, projectId) {
27676
+ const plans = resolved.plans;
27677
+ if (!plans) throw new Error("plans is not in the config.");
27678
+ const wanted = toPlatformPlans(await readPlansFile(plans.file));
27679
+ const manifest = await readManifest(resolved.state);
27680
+ const identity = manifest?.services.identity;
27681
+ const ownIdentity = Boolean(
27682
+ identity?.provider === "apitogo" && identity.issuer
27683
+ );
27684
+ return {
27685
+ wanted,
27686
+ manifest,
27687
+ ownIdentity,
27688
+ catalogHash: stableJson({ projectId, plans: wanted }),
27689
+ backendHash: stableJson({ projectId }),
27690
+ linkHash: stableJson({
27691
+ projectId,
27692
+ issuer: identity?.issuer,
27693
+ clientId: identity?.clientId
27694
+ })
27695
+ };
27696
+ }
27697
+ function plansNotDeployableReason(resolved) {
27698
+ return resolved.plans ? void 0 : "plans is not in the config";
27699
+ }
27700
+ async function allActionKeys(client, projectId) {
27701
+ const keys = [];
27702
+ try {
27703
+ for (let page = 1; ; page++) {
27704
+ const result = await client.listGatewayActions(projectId, page, 100);
27705
+ keys.push(...result.items.map((item) => item.actionKey));
27706
+ if (result.items.length === 0 || page * result.pageSize >= result.totalCount)
27707
+ break;
27708
+ }
27709
+ } catch {
27710
+ return [];
27711
+ }
27712
+ return keys;
27713
+ }
27714
+ function refused(error) {
27715
+ if (error instanceof PlatformApiError && error.status === 400) {
27716
+ const lines = describePlatformProblem(error);
27717
+ throw new PlansDeployError(
27718
+ "PLANS_REJECTED",
27719
+ `The platform refused a plan:
27720
+ ${lines.join("\n ") || error.message}`,
27721
+ "Fix the plan in the plans file and run deploy again."
27722
+ );
27723
+ }
27724
+ throw error;
27725
+ }
27726
+ async function deployPlans(resolved, projectId, deps, options = {}) {
27727
+ const plans = resolved.plans;
27728
+ if (!plans) throw new Error("plans is not in the config.");
27729
+ const client = deps.client;
27730
+ const sleep5 = deps.sleep ?? defaultSleep3;
27731
+ const now = deps.now ?? Date.now;
27732
+ const intervalMs = deps.pollIntervalMs ?? 2e3;
27733
+ const backendTimeoutMs = deps.backendTimeoutMs ?? 9e5;
27734
+ const linkTimeoutMs = deps.linkTimeoutMs ?? 6e5;
27735
+ const { wanted, manifest, ownIdentity, catalogHash, backendHash, linkHash } = await plansInputs(resolved, projectId);
27736
+ const catalogStep = {
27737
+ name: "catalog",
27738
+ hash: () => catalogHash,
27739
+ run: async () => {
27740
+ if (wanted.length === 0) return { created: "0", updated: "0" };
27741
+ const fallback = wanted.some((plan2) => plan2.includedActionKeys === null) ? await allActionKeys(client, projectId) : [];
27742
+ const bodies = wanted.map((plan2) => ({
27743
+ ...plan2,
27744
+ includedActionKeys: plan2.includedActionKeys ?? fallback
27745
+ }));
27746
+ const checked = await client.validateGatewayPlans(bodies, { projectId });
27747
+ if (!checked.valid)
27748
+ throw new PlansDeployError(
27749
+ "PLANS_REJECTED",
27750
+ `The platform refused the plans:
27751
+ ${checked.errors.map((issue4) => `${issue4.field}: ${issue4.message}`).join("\n ")}`,
27752
+ "Fix the listed problems in the plans file and run deploy again."
27753
+ );
27754
+ const existing = new Map(
27755
+ (await client.listGatewayPlans(projectId)).map((plan2) => [
27756
+ plan2.sku.toLowerCase(),
27757
+ plan2
27758
+ ])
27759
+ );
27760
+ let created = 0;
27761
+ let updated = 0;
27762
+ for (const body of bodies) {
27763
+ const found = existing.get(body.sku);
27764
+ try {
27765
+ if (found) {
27766
+ await client.updateGatewayPlan(projectId, found.id, {
27767
+ displayName: body.displayName,
27768
+ isActive: true,
27769
+ limitsJson: body.limitsJson ?? null,
27770
+ includedActionKeys: body.includedActionKeys,
27771
+ priceAmount: body.priceAmount,
27772
+ pricingModel: body.pricingModel,
27773
+ priceLabel: body.priceLabel,
27774
+ priceCurrency: body.priceCurrency,
27775
+ billingPeriod: body.billingPeriod,
27776
+ sortOrder: body.sortOrder
27777
+ });
27778
+ updated++;
27779
+ } else {
27780
+ await client.createGatewayPlan(projectId, body);
27781
+ created++;
27782
+ }
27783
+ } catch (error) {
27784
+ refused(error);
27785
+ }
27786
+ }
27787
+ return { created: String(created), updated: String(updated) };
27788
+ },
27789
+ // A plan someone deleted on the platform is not a result worth keeping.
27790
+ stillValid: async () => {
27791
+ const present = new Set(
27792
+ (await client.listGatewayPlans(projectId)).filter((plan2) => plan2.isActive).map((plan2) => plan2.sku.toLowerCase())
27793
+ );
27794
+ return wanted.every((plan2) => present.has(plan2.sku));
27795
+ }
27796
+ };
27797
+ const backendOf = async () => (await client.getDevPortalBackend(projectId))?.fqdn;
27798
+ const backendStep = {
27799
+ name: "backend",
27800
+ hash: () => backendHash,
27801
+ run: async () => {
27802
+ let fqdn = await backendOf();
27803
+ if (!fqdn) {
27804
+ await client.activateModule(projectId, BACKEND_MODULE);
27805
+ const deadline = now() + backendTimeoutMs;
27806
+ for (; ; ) {
27807
+ fqdn = await backendOf();
27808
+ if (fqdn) break;
27809
+ if (now() >= deadline)
27810
+ throw new PlansDeployError(
27811
+ "PLANS_BACKEND_TIMEOUT",
27812
+ `The plans backend did not get an address within ${Math.round(backendTimeoutMs / 1e3)} seconds.`,
27813
+ "Run deploy again to keep waiting."
27814
+ );
27815
+ await sleep5(intervalMs);
27816
+ }
27817
+ }
27818
+ return { fqdn };
27819
+ },
27820
+ stillValid: async () => Boolean(await backendOf())
27821
+ };
27822
+ const linkStep = {
27823
+ name: "identity-link",
27824
+ hash: () => linkHash,
27825
+ run: async () => {
27826
+ const queued = await client.linkDevPortalIdentity(projectId);
27827
+ const deadline = now() + linkTimeoutMs;
27828
+ for (; ; ) {
27829
+ const job = (await client.listProvisioningJobs(projectId)).jobs.find(
27830
+ (item) => item.id === queued.jobId
27831
+ );
27832
+ const status2 = job?.status.toLowerCase();
27833
+ if (status2?.startsWith("completed")) return { jobId: queued.jobId };
27834
+ if (status2 === "failed")
27835
+ throw new PlansDeployError(
27836
+ "PLANS_LINK_FAILED",
27837
+ `Applying the identity to the plans backend failed: ${job?.errorMessage ?? "no reason given"}.`,
27838
+ "Run deploy again; the link is applied again."
27839
+ );
27840
+ if (now() >= deadline)
27841
+ throw new PlansDeployError(
27842
+ "PLANS_LINK_TIMEOUT",
27843
+ `Applying the identity did not finish within ${Math.round(linkTimeoutMs / 1e3)} seconds (state: ${status2 ?? "unknown"}).`,
27844
+ "Run deploy again to keep waiting."
27845
+ );
27846
+ await sleep5(intervalMs);
27847
+ }
27848
+ }
27849
+ };
27850
+ const steps = [catalogStep, backendStep, ...ownIdentity ? [linkStep] : []];
27851
+ const outcomes = await runPipeline(
27852
+ resolved.state,
27853
+ "plans",
27854
+ steps,
27855
+ {
27856
+ force: options.force
27857
+ }
27858
+ );
27859
+ const catalog = outcomes.find((outcome) => outcome.step === "catalog");
27860
+ const ran = outcomes.some((outcome) => !outcome.skipped);
27861
+ const known = manifest?.services.plans;
27862
+ let apiBaseUrl = known?.apiBaseUrl;
27863
+ if (ran || known?.apiBaseUrl === void 0) {
27864
+ apiBaseUrl = toApiBaseUrl(await backendOf()) ?? apiBaseUrl;
27865
+ if (apiBaseUrl) {
27866
+ const previous = known?.catalogRevision ?? 0;
27867
+ await writeManifest(
27868
+ resolved.state,
27869
+ withPlansEntry(await readManifest(resolved.state), {
27870
+ projectId,
27871
+ projectName: resolved.project.name,
27872
+ apiBaseUrl,
27873
+ billing: plans.billing.provider,
27874
+ catalogRevision: catalog && !catalog.skipped ? previous + 1 : Math.max(previous, 1),
27875
+ capabilities: PLANS_CAPABILITIES
27876
+ })
27877
+ );
27878
+ }
27879
+ }
27880
+ return {
27881
+ status: ran ? "deployed" : "unchanged",
27882
+ steps: outcomes.map((outcome) => ({
27883
+ step: outcome.step,
27884
+ skipped: outcome.skipped
27885
+ })),
27886
+ apiBaseUrl
27887
+ };
27888
+ }
27889
+
27890
+ // src/cli/portal/services-report.ts
27891
+ function stepState(record, hash2) {
27892
+ if (!record) return "not done yet";
27893
+ if (record.status === "failed")
27894
+ return `failed last time (${record.error ?? "no message"})`;
27895
+ if (record.status === "running") return "did not finish last time";
27896
+ if (hash2 === void 0)
27897
+ return "done (cannot compare: an input is not known locally)";
27898
+ return record.hash === hash2 ? "done, unchanged" : "inputs changed";
27899
+ }
27900
+ var isDone = (record, hash2) => record?.status === "succeeded" && hash2 !== void 0 && record.hash === hash2;
27901
+ async function planIdentityService(resolved, ledger, manifest) {
27902
+ const base = { service: "identity", issues: [] };
27903
+ const identity = resolved.identity;
27904
+ if (!identity) throw new Error("identity is not configured");
27905
+ const why = identityNotDeployableReason(resolved);
27906
+ if (why)
27907
+ return {
27908
+ ...base,
27909
+ action: "not-deployable-yet",
27910
+ reason: `configured, but ${why}`,
27911
+ details: []
27912
+ };
27913
+ const known = manifest?.services.identity;
27914
+ if (identity.provider === "oidc") {
27915
+ const same = known?.provider === "oidc" && known.issuer === identity.issuer?.replace(/\/+$/, "") && known.clientId === identity.clientId;
27916
+ return {
27917
+ ...base,
27918
+ action: same ? "skip" : "deploy",
27919
+ reason: same ? "deploy skipped: services.json already records your sign-in provider" : "will record your own sign-in provider in services.json (nothing is made on the platform)",
27920
+ details: [
27921
+ `provider: oidc ${identity.issuer}`,
27922
+ `client id: ${identity.clientId}`
27923
+ ]
27924
+ };
27925
+ }
27926
+ const projectId = resolved.project.id;
27927
+ const portalUrl = manifest?.services.portal?.url;
27928
+ const body = portalUrl ? identityRequestBody(resolved, portalUrl) : void 0;
27929
+ const hash2 = projectId && body ? identityInputsHash(projectId, body) : void 0;
27930
+ const details = [
27931
+ "provider: apitogo (one issuer for this project)",
27932
+ `sign-up with email and password: ${identity.native ? "on" : "off"}`,
27933
+ portalUrl ? `sign-in redirect: ${portalUrl.replace(/\/+$/, "")}${PORTAL_CALLBACK_PATH}` : "sign-in redirect: the portal address is not known locally; deploy takes it from the project",
27934
+ ...known?.issuer ? [`issuer: ${known.issuer}`] : [],
27935
+ ...IDENTITY_STEPS.map(
27936
+ (name) => `step ${name}: ${stepState(stepOf(ledger, "identity", name), hash2)}`
27937
+ )
27938
+ ];
27939
+ const allDone = IDENTITY_STEPS.every(
27940
+ (name) => isDone(stepOf(ledger, "identity", name), hash2)
27941
+ );
27942
+ return {
27943
+ ...base,
27944
+ action: allDone ? "skip" : "deploy",
27945
+ reason: allDone ? "deploy skipped: nothing it depends on has changed" : "will deploy: the identity is not made yet, or its settings changed",
27946
+ details
27947
+ };
27948
+ }
27949
+ async function planPlansService(resolved, ledger) {
27950
+ const base = { service: "plans", issues: [] };
27951
+ const why = plansNotDeployableReason(resolved);
27952
+ if (why)
27953
+ return {
27954
+ ...base,
27955
+ action: "not-deployable-yet",
27956
+ reason: `configured, but ${why}`,
27957
+ details: []
27958
+ };
27959
+ const projectId = resolved.project.id ?? "";
27960
+ let inputs;
27961
+ try {
27962
+ inputs = await plansInputs(resolved, projectId);
27963
+ } catch (error) {
27964
+ if (!(error instanceof PlansFileError)) throw error;
27965
+ return {
27966
+ ...base,
27967
+ action: "blocked",
27968
+ reason: "cannot deploy until this is fixed",
27969
+ details: [],
27970
+ issues: [
27971
+ {
27972
+ code: error.code,
27973
+ path: "plans.file",
27974
+ message: error.message,
27975
+ hint: error.developerHint
27976
+ }
27977
+ ]
27978
+ };
27979
+ }
27980
+ const hashes = {
27981
+ catalog: projectId ? inputs.catalogHash : void 0,
27982
+ backend: projectId ? inputs.backendHash : void 0,
27983
+ "identity-link": projectId ? inputs.linkHash : void 0
27984
+ };
27985
+ const steps = PLANS_STEPS.filter(
27986
+ (name) => name !== "identity-link" || inputs.ownIdentity
27987
+ );
27988
+ const details = [
27989
+ ...inputs.wanted.length === 0 ? ["plans: none in the file"] : inputs.wanted.map((plan2) => `plan ${plan2.sku}: ${plan2.displayName}`),
27990
+ inputs.ownIdentity ? "sign-in: the project's identity will be applied to the plans backend" : "sign-in: no identity of the platform deployed, so nothing is applied to the plans backend",
27991
+ ...steps.map(
27992
+ (name) => `step ${name}: ${stepState(stepOf(ledger, "plans", name), hashes[name])}`
27993
+ )
27994
+ ];
27995
+ const allDone = steps.every(
27996
+ (name) => isDone(stepOf(ledger, "plans", name), hashes[name])
27997
+ );
27998
+ return {
27999
+ ...base,
28000
+ action: allDone ? "skip" : "deploy",
28001
+ reason: allDone ? "deploy skipped: nothing it depends on has changed" : "will deploy: the plans file, the backend or the identity is not applied yet",
28002
+ details
28003
+ };
28004
+ }
28005
+ var failedStep = (ledger, service, names) => names.map((name) => ({ name, record: stepOf(ledger, service, name) })).find((step2) => step2.record?.status === "failed");
28006
+ var unreachable = (error) => `platform: not reachable (${error instanceof Error ? error.message : String(error)}); showing local records only`;
28007
+ async function identityStatus(resolved, ledger, manifest, remote) {
28008
+ const entry = manifest?.services.identity;
28009
+ const failed = failedStep(ledger, "identity", IDENTITY_STEPS);
28010
+ const details = [];
28011
+ if (entry?.provider) details.push(`provider: ${entry.provider}`);
28012
+ if (entry?.issuer) details.push(`issuer: ${entry.issuer}`);
28013
+ if (entry?.clientId) details.push(`client id: ${entry.clientId}`);
28014
+ let state = entry ? "ready" : "not-deployed";
28015
+ const projectId = resolved.project.id ?? manifest?.project.id;
28016
+ if (resolved.identity?.provider === "oidc") {
28017
+ details.push("platform: nothing to ask (your own sign-in provider)");
28018
+ } else if (remote && projectId) {
28019
+ try {
28020
+ const platform = await remote(projectId);
28021
+ if (!platform) {
28022
+ details.push("platform: no identity for this project yet");
28023
+ if (entry) state = "drift";
28024
+ } else {
28025
+ details.push(
28026
+ `platform state: ${platform.status}`,
28027
+ `sign-up with email and password: ${platform.nativeSignup ? "on" : "off"}`,
28028
+ `sign-in redirect: ${platform.portalRedirectUris.join(", ") || "none"}`
28029
+ );
28030
+ if (platform.status === "Failed")
28031
+ details.push(
28032
+ `platform problem: ${[platform.errorCode, platform.errorMessage].filter(Boolean).join(" ") || "no message"}`
28033
+ );
28034
+ const same = entry?.issuer === platform.issuer.replace(/\/+$/, "");
28035
+ if (entry && (!same || platform.status !== "Active")) {
28036
+ details.push(
28037
+ "drift: services.json does not match the platform's identity"
28038
+ );
28039
+ state = "drift";
28040
+ }
28041
+ }
28042
+ } catch (error) {
28043
+ details.push(unreachable(error));
28044
+ }
28045
+ } else if (!projectId) {
28046
+ details.push("platform: not asked, this folder is not linked to a project");
28047
+ } else {
28048
+ details.push("platform: not asked, you are not logged in");
28049
+ }
28050
+ if (failed) {
28051
+ details.push(`error: ${failed.record?.error ?? "unknown"}`);
28052
+ state = "deploy-failed";
28053
+ }
28054
+ return { service: "identity", state, details };
28055
+ }
28056
+ async function plansStatus(resolved, ledger, manifest, remote, wantedSkus2) {
28057
+ const entry = manifest?.services.plans;
28058
+ const failed = failedStep(ledger, "plans", PLANS_STEPS);
28059
+ const details = [];
28060
+ if (entry?.apiBaseUrl) details.push(`plans backend: ${entry.apiBaseUrl}`);
28061
+ if (entry?.catalogRevision !== void 0)
28062
+ details.push(`catalog revision: ${entry.catalogRevision}`);
28063
+ let state = entry ? "ready" : "not-deployed";
28064
+ const projectId = resolved.project.id ?? manifest?.project.id;
28065
+ if (remote && projectId) {
28066
+ try {
28067
+ const platform = await remote(projectId);
28068
+ const active2 = platform.plans.filter((plan2) => plan2.isActive).map((plan2) => plan2.sku.toLowerCase());
28069
+ details.push(
28070
+ `plans on the platform: ${active2.length > 0 ? active2.join(", ") : "none"}`
28071
+ );
28072
+ const missing = (wantedSkus2 ?? []).filter((sku) => !active2.includes(sku));
28073
+ if (missing.length > 0) {
28074
+ details.push(
28075
+ `drift: plans in the file but not on the platform: ${missing.join(", ")}`
28076
+ );
28077
+ if (entry) state = "drift";
28078
+ }
28079
+ if (!platform.backendAddress) {
28080
+ details.push("platform: the plans backend has no address");
28081
+ if (entry) state = "drift";
28082
+ } else if (entry?.apiBaseUrl && entry.apiBaseUrl !== platform.backendAddress.replace(/\/+$/, "")) {
28083
+ details.push(
28084
+ `drift: the plans backend is at ${platform.backendAddress}, services.json says ${entry.apiBaseUrl}`
28085
+ );
28086
+ state = "drift";
28087
+ }
28088
+ } catch (error) {
28089
+ details.push(
28090
+ error instanceof PlatformApiError && error.status === 404 ? "platform: no plans for this project yet" : unreachable(error)
28091
+ );
28092
+ }
28093
+ } else if (!projectId) {
28094
+ details.push("platform: not asked, this folder is not linked to a project");
28095
+ } else {
28096
+ details.push("platform: not asked, you are not logged in");
28097
+ }
28098
+ if (failed) {
28099
+ details.push(`error: ${failed.record?.error ?? "unknown"}`);
28100
+ state = "deploy-failed";
28101
+ }
28102
+ return { service: "plans", state, details };
28103
+ }
28104
+
28105
+ // src/cli/portal/report.ts
28106
+ var DEPLOY_ORDER = [
28107
+ "identity",
28108
+ "gateway",
28109
+ "plans",
28110
+ "portal"
28111
+ ];
28112
+ var exists4 = (file) => access13(file).then(
28113
+ () => true,
28114
+ () => false
28115
+ );
28116
+ function sectionOf(resolved, service) {
28117
+ return resolved[service];
28118
+ }
28119
+ function chooseServices(only) {
28120
+ return DEPLOY_ORDER.filter((name) => !only || only.includes(name));
28121
+ }
28122
+ async function planPortalService(resolved, ledger) {
28123
+ const base = { service: "portal", details: [] };
28124
+ try {
28125
+ const manifest = await readManifest(resolved.state);
28126
+ const prepared = await preparePortal(resolved, manifest, "production", {
28127
+ write: false
28128
+ });
28129
+ const { profile, plan: plan2 } = prepared;
28130
+ const details = [
28131
+ `docs: ${!profile.docs.enabled ? "off" : profile.docs.provider === "external" ? `external (${profile.docs.url})` : "built by apitogo"}`,
28132
+ `console: ${profile.console.enabled ? "on" : "off"}`,
28133
+ `manifest: ${prepared.manifestSource === "file" ? "services.json" : "not needed"}`,
28134
+ ...[
28135
+ plan2.auth ? "sign-in" : void 0,
28136
+ plan2.signup ? "sign-up" : void 0,
28137
+ plan2.userPanel ? "account pages" : void 0,
28138
+ plan2.plansPlugin ? "pricing, keys and usage pages" : void 0,
28139
+ plan2.apiReference ? "API reference" : void 0
28140
+ ].filter((feature) => feature !== void 0).map((feature) => `will appear: ${feature}`),
28141
+ ...plan2.skipped.map((s) => `left out: ${s.piece} (${s.reason})`)
28142
+ ];
28143
+ const hash2 = await portalBuildHash(resolved, prepared);
28144
+ const previous = stepOf(ledger, "portal", "build");
28145
+ const built = await exists4(
28146
+ path83.join(resolved.state.buildDir, "index.html")
28147
+ );
28148
+ if (previous?.status === "succeeded" && previous.hash === hash2 && built) {
28149
+ return {
28150
+ ...base,
28151
+ action: "skip",
28152
+ reason: "build skipped: nothing it depends on has changed",
28153
+ details,
28154
+ issues: []
28155
+ };
28156
+ }
28157
+ const reason = !previous ? "no earlier build" : previous.status === "failed" ? `the last build failed (${previous.error ?? "no message"})` : previous.status === "running" ? "the last build did not finish" : !built ? "the build folder is gone" : "its inputs changed since the last build";
28158
+ return {
28159
+ ...base,
28160
+ action: "build",
28161
+ reason: `will build: ${reason}`,
28162
+ details,
28163
+ issues: []
28164
+ };
28165
+ } catch (error) {
28166
+ if (!(error instanceof ConfigError)) throw error;
28167
+ return {
28168
+ ...base,
28169
+ action: "blocked",
28170
+ reason: "cannot build until these are fixed",
28171
+ details: [],
27140
28172
  issues: [...error.issues]
27141
28173
  };
27142
28174
  }
@@ -27310,21 +28342,20 @@ async function planV2(dir, only, options = {}) {
27310
28342
  services.push(
27311
28343
  await planGatewayService(resolved, ledger, options.gatewayDryRun)
27312
28344
  );
28345
+ } else if (service === "identity") {
28346
+ services.push(
28347
+ await planIdentityService(
28348
+ resolved,
28349
+ ledger,
28350
+ await readManifest(resolved.state)
28351
+ )
28352
+ );
27313
28353
  } else {
27314
- services.push({
27315
- service,
27316
- action: "not-deployable-yet",
27317
- reason: "configured; deploying it is not available in this CLI version yet",
27318
- details: [],
27319
- issues: []
27320
- });
28354
+ services.push(await planPlansService(resolved, ledger));
27321
28355
  }
27322
28356
  }
27323
28357
  return { configPath: resolved.configPath, services };
27324
28358
  }
27325
- function entryStatus(manifest, service) {
27326
- return manifest?.services[service];
27327
- }
27328
28359
  function originHealthLines(origin) {
27329
28360
  if (!origin.lastProbeAtUtc) return ["origin check: not checked yet"];
27330
28361
  return [
@@ -27398,6 +28429,16 @@ async function gatewayStatus(resolved, ledger, manifest, remote) {
27398
28429
  if (failed) state = "deploy-failed";
27399
28430
  return { service: "gateway", state, details: [...details, ...local] };
27400
28431
  }
28432
+ async function wantedSkus(resolved) {
28433
+ if (!resolved.plans) return void 0;
28434
+ try {
28435
+ return (await readPlansFile(resolved.plans.file)).map(
28436
+ (plan2) => plan2.sku.trim().toLowerCase()
28437
+ );
28438
+ } catch {
28439
+ return void 0;
28440
+ }
28441
+ }
27401
28442
  async function statusV2(dir, only, options = {}) {
27402
28443
  const { resolved } = loadConfigV2(dir);
27403
28444
  const ledger = await readLedger(resolved.state);
@@ -27413,7 +28454,6 @@ async function statusV2(dir, only, options = {}) {
27413
28454
  services.push({ service, state: "disabled", details: [] });
27414
28455
  continue;
27415
28456
  }
27416
- const entry = entryStatus(manifest, service);
27417
28457
  const url2 = service === "portal" ? manifest?.services.portal?.url : void 0;
27418
28458
  if (service === "portal") {
27419
28459
  const build2 = stepOf(ledger, "portal", "build");
@@ -27442,14 +28482,26 @@ async function statusV2(dir, only, options = {}) {
27442
28482
  );
27443
28483
  continue;
27444
28484
  }
27445
- services.push({
27446
- service,
27447
- state: entry ? "ready" : "not-deployed",
27448
- details: entry ? [
27449
- `status: ${entry.status ?? "ready"}`,
27450
- ...entry.provider ? [`provider: ${entry.provider}`] : []
27451
- ] : ["not in services.json: not deployed yet"]
27452
- });
28485
+ if (service === "identity") {
28486
+ services.push(
28487
+ await identityStatus(
28488
+ resolved,
28489
+ ledger,
28490
+ manifest,
28491
+ options.identityRemote
28492
+ )
28493
+ );
28494
+ continue;
28495
+ }
28496
+ services.push(
28497
+ await plansStatus(
28498
+ resolved,
28499
+ ledger,
28500
+ manifest,
28501
+ options.plansRemote,
28502
+ await wantedSkus(resolved)
28503
+ )
28504
+ );
27453
28505
  }
27454
28506
  return {
27455
28507
  configPath: resolved.configPath,
@@ -27466,7 +28518,7 @@ function formatPlan(output4) {
27466
28518
  `${svc.service}: ${svc.reason}`,
27467
28519
  ...svc.details.map((line2) => ` ${line2}`),
27468
28520
  ...svc.issues.map(
27469
- (issue3) => ` problem: ${issue3.message}${issue3.hint ? ` ${issue3.hint}` : ""} [${issue3.code}]`
28521
+ (issue4) => ` problem: ${issue4.message}${issue4.hint ? ` ${issue4.hint}` : ""} [${issue4.code}]`
27470
28522
  )
27471
28523
  ]);
27472
28524
  }
@@ -27563,19 +28615,14 @@ async function deployV2(options, deps) {
27563
28615
  results.push(await deployGatewayService(options, deps, projectId, named));
27564
28616
  continue;
27565
28617
  }
27566
- if (service !== "portal") {
27567
- if (named)
27568
- throw new DeployError(
27569
- "SERVICE_NOT_DEPLOYABLE",
27570
- `Deploying ${service} is not available in this CLI version yet.`,
27571
- "Only the portal and the gateway can be deployed for now."
27572
- );
27573
- results.push({
27574
- service,
27575
- status: "skipped",
27576
- reason: "deploying it is not available in this CLI version yet",
27577
- steps: []
27578
- });
28618
+ if (service === "identity") {
28619
+ results.push(
28620
+ await deployIdentityService(options, deps, projectId, named)
28621
+ );
28622
+ continue;
28623
+ }
28624
+ if (service === "plans") {
28625
+ results.push(await deployPlansService(options, deps, projectId, named));
27579
28626
  continue;
27580
28627
  }
27581
28628
  results.push(await deployPortal(options, deps, projectId));
@@ -27619,6 +28666,65 @@ async function deployGatewayService(options, deps, projectId, named) {
27619
28666
  operationCount: result.operationCount
27620
28667
  };
27621
28668
  }
28669
+ async function deployIdentityService(options, deps, projectId, named) {
28670
+ const { resolved } = loadConfigV2(options.dir);
28671
+ const why = identityNotDeployableReason(resolved);
28672
+ if (why) {
28673
+ if (named)
28674
+ throw new DeployError(
28675
+ "SERVICE_NOT_DEPLOYABLE",
28676
+ `Identity cannot be deployed: ${why}.`,
28677
+ "Fix the identity section of the config, or deploy another service."
28678
+ );
28679
+ return { service: "identity", status: "skipped", reason: why, steps: [] };
28680
+ }
28681
+ const client = deps.client;
28682
+ if (resolved.identity?.provider !== "oidc" && (!client.putProjectIdentity || !client.getProjectIdentity || !client.getProject))
28683
+ throw new Error("The platform client has no identity calls.");
28684
+ const result = await deployIdentity(
28685
+ resolved,
28686
+ projectId,
28687
+ {
28688
+ ...deps.identity,
28689
+ client
28690
+ },
28691
+ { force: options.force }
28692
+ );
28693
+ return {
28694
+ service: "identity",
28695
+ status: result.status,
28696
+ steps: result.steps,
28697
+ url: result.issuer
28698
+ };
28699
+ }
28700
+ async function deployPlansService(options, deps, projectId, named) {
28701
+ const { resolved } = loadConfigV2(options.dir);
28702
+ const why = plansNotDeployableReason(resolved);
28703
+ if (why) {
28704
+ if (named)
28705
+ throw new DeployError(
28706
+ "SERVICE_NOT_DEPLOYABLE",
28707
+ `Plans cannot be deployed: ${why}.`,
28708
+ "Add them with `apitogo add plans`, or deploy another service."
28709
+ );
28710
+ return { service: "plans", status: "skipped", reason: why, steps: [] };
28711
+ }
28712
+ const client = deps.client;
28713
+ if (!client.listGatewayPlans || !client.createGatewayPlan || !client.updateGatewayPlan || !client.validateGatewayPlans || !client.listGatewayActions || !client.getDevPortalBackend || !client.activateModule || !client.linkDevPortalIdentity || !client.listProvisioningJobs)
28714
+ throw new Error("The platform client has no plans calls.");
28715
+ const result = await deployPlans(
28716
+ resolved,
28717
+ projectId,
28718
+ { ...deps.plans, client },
28719
+ { force: options.force }
28720
+ );
28721
+ return {
28722
+ service: "plans",
28723
+ status: result.status,
28724
+ steps: result.steps,
28725
+ url: result.apiBaseUrl
28726
+ };
28727
+ }
27622
28728
  async function deployPortal(options, deps, projectId) {
27623
28729
  const { resolved, root, prepared } = await preparePortalWorkspace(
27624
28730
  options.dir,
@@ -27882,11 +28988,11 @@ var export_default = {
27882
28988
  import path90 from "node:path";
27883
28989
 
27884
28990
  // src/cli/scaffold/run-scaffold-discovery.ts
27885
- import { access as access15, readFile as readFile30, writeFile as writeFile27 } from "node:fs/promises";
28991
+ import { access as access15, readFile as readFile31, writeFile as writeFile27 } from "node:fs/promises";
27886
28992
  import path89 from "node:path";
27887
28993
 
27888
28994
  // src/cli/scaffold/detect-backend.ts
27889
- import { readdir as readdir4, readFile as readFile28 } from "node:fs/promises";
28995
+ import { readdir as readdir4, readFile as readFile29 } from "node:fs/promises";
27890
28996
  import path87 from "node:path";
27891
28997
  var DEFAULT_RUNTIME_VERSION = {
27892
28998
  dotnet: "DOTNET_10",
@@ -27904,7 +29010,7 @@ async function listFiles(directory) {
27904
29010
  }
27905
29011
  async function readText(filePath) {
27906
29012
  try {
27907
- return await readFile28(filePath, "utf8");
29013
+ return await readFile29(filePath, "utf8");
27908
29014
  } catch {
27909
29015
  return void 0;
27910
29016
  }
@@ -27990,7 +29096,7 @@ import {
27990
29096
  lstat as lstat2,
27991
29097
  mkdir as mkdir21,
27992
29098
  readdir as readdir5,
27993
- readFile as readFile29,
29099
+ readFile as readFile30,
27994
29100
  readlink,
27995
29101
  symlink as symlink2,
27996
29102
  writeFile as writeFile26
@@ -28221,7 +29327,7 @@ async function applyDocsNavigation(siteDirectory, routes) {
28221
29327
  if (!await pathExists5(configPath)) {
28222
29328
  return;
28223
29329
  }
28224
- const raw = await readFile29(configPath, "utf8");
29330
+ const raw = await readFile30(configPath, "utf8");
28225
29331
  const parsed = JSON.parse(raw);
28226
29332
  if (!isPlainObject5(parsed)) {
28227
29333
  return;
@@ -28406,7 +29512,7 @@ async function pathExists6(filePath) {
28406
29512
  async function wireOpenApiIntoJsonConfig(siteDirectory, openApiPath) {
28407
29513
  const input5 = toSiteRelativeOpenApiInput(siteDirectory, openApiPath);
28408
29514
  const jsonConfigPath = path89.join(siteDirectory, "apitogo.config.json");
28409
- const raw = await readFile30(jsonConfigPath, "utf8");
29515
+ const raw = await readFile31(jsonConfigPath, "utf8");
28410
29516
  const siteConfig = JSON.parse(raw);
28411
29517
  const updated = applyOpenApiToSiteConfig(siteConfig, {
28412
29518
  input: input5,
@@ -28433,7 +29539,7 @@ async function discoverAndWireOpenApiLite(siteDirectory, mode) {
28433
29539
  const invalidCandidates = [];
28434
29540
  let selected;
28435
29541
  for (const candidate of search.candidates) {
28436
- const content = await readFile30(candidate, "utf8");
29542
+ const content = await readFile31(candidate, "utf8");
28437
29543
  const parsed = liteParseOpenApi(content, candidate);
28438
29544
  if (parsed.valid) {
28439
29545
  selected = {
@@ -28513,7 +29619,7 @@ async function discoverAndWireBackend(siteDirectory, mode) {
28513
29619
  }
28514
29620
  try {
28515
29621
  const siteConfig = JSON.parse(
28516
- await readFile30(jsonConfigPath, "utf8")
29622
+ await readFile31(jsonConfigPath, "utf8")
28517
29623
  );
28518
29624
  const existing = siteConfig.backend && typeof siteConfig.backend === "object" && !Array.isArray(siteConfig.backend) ? siteConfig.backend : {};
28519
29625
  if (typeof existing.sourceDirectory === "string" && existing.sourceDirectory.trim()) {
@@ -29019,10 +30125,35 @@ function gatewayDryRun() {
29019
30125
  return void 0;
29020
30126
  }
29021
30127
  }
30128
+ function identityRemote() {
30129
+ try {
30130
+ const client = new PlatformApiClient(loadConfig());
30131
+ return (projectId) => client.getProjectIdentity(projectId);
30132
+ } catch {
30133
+ return void 0;
30134
+ }
30135
+ }
30136
+ function plansRemote() {
30137
+ try {
30138
+ const client = new PlatformApiClient(loadConfig());
30139
+ return async (projectId) => ({
30140
+ plans: await client.listGatewayPlans(projectId),
30141
+ backendAddress: toApiBaseUrl(
30142
+ (await client.getDevPortalBackend(projectId))?.fqdn
30143
+ )
30144
+ });
30145
+ } catch {
30146
+ return void 0;
30147
+ }
30148
+ }
29022
30149
  var statusOfProject = (argv) => run2(
29023
30150
  "status",
29024
30151
  argv,
29025
- (dir, only) => statusV2(dir, only, { gatewayRemote: gatewayRemote() }),
30152
+ (dir, only) => statusV2(dir, only, {
30153
+ gatewayRemote: gatewayRemote(),
30154
+ identityRemote: identityRemote(),
30155
+ plansRemote: plansRemote()
30156
+ }),
29026
30157
  formatStatus
29027
30158
  );
29028
30159
 
@@ -29069,13 +30200,7 @@ function modulesForConfig(config2) {
29069
30200
  var hostsAnything = (config2) => config2.portal && config2.portal.enabled !== false || config2.gateway && config2.gateway.enabled !== false;
29070
30201
  async function createProjectV2(options, deps) {
29071
30202
  const { config: config2, resolved } = loadConfigV2(options.dir);
29072
- if (resolved.project.id) {
29073
- throw new ProjectCreateError(
29074
- "ALREADY_LINKED",
29075
- `This folder is already linked to project ${resolved.project.id}.`,
29076
- "Run `apitogo deploy`. To start over, run `apitogo teardown` first."
29077
- );
29078
- }
30203
+ const linkedProjectId = resolved.project.id;
29079
30204
  const modules = modulesForConfig(config2);
29080
30205
  if (!hostsAnything(config2)) {
29081
30206
  throw new ProjectCreateError(
@@ -29089,7 +30214,9 @@ async function createProjectV2(options, deps) {
29089
30214
  name,
29090
30215
  description: options.description,
29091
30216
  modules,
29092
- wait: options.wait !== false
30217
+ wait: options.wait !== false,
30218
+ projectId: linkedProjectId,
30219
+ onProjectResolved: (projectId) => setProjectId(options.dir, projectId)
29093
30220
  });
29094
30221
  const outcome = parse4(raw);
29095
30222
  if (outcome.requiresPlatformPlanSelection) {
@@ -29162,7 +30289,9 @@ async function createLinkedProject(argv, dir) {
29162
30289
  modules: args.modules,
29163
30290
  selectFreePlan: true,
29164
30291
  wait: args.wait,
29165
- configureGatewayBaseline: false
30292
+ configureGatewayBaseline: false,
30293
+ projectId: args.projectId,
30294
+ onProjectResolved: args.onProjectResolved
29166
30295
  },
29167
30296
  json ? void 0 : progress
29168
30297
  )
@@ -30090,12 +31219,14 @@ var TeardownError = class extends ApitogoError {
30090
31219
  };
30091
31220
  async function teardownV2(options, deps) {
30092
31221
  if (options.only) {
30093
- if (options.only.trim() === "gateway")
30094
- return teardownGateway(options, deps);
31222
+ const only = options.only.trim();
31223
+ if (only === "gateway") return teardownGateway(options, deps);
31224
+ if (only === "identity") return teardownIdentity(options, deps);
31225
+ if (only === "plans") return teardownPlans(options, deps);
30095
31226
  throw new TeardownError(
30096
31227
  "PARTIAL_TEARDOWN_UNSUPPORTED",
30097
31228
  "The platform cannot release the hosting of one service on its own: it is released by deleting the project.",
30098
- "Run `apitogo teardown` without --only to delete the whole project, or `apitogo teardown --only gateway` to remove just the gateway. Your local files are kept."
31229
+ "Run `apitogo teardown` without --only to delete the whole project, or `apitogo teardown --only gateway`, `--only identity` or `--only plans` to remove just that service. Your local files are kept."
30099
31230
  );
30100
31231
  }
30101
31232
  const { resolved } = loadConfigV2(options.dir);
@@ -30275,6 +31406,148 @@ Type the project name to confirm: `
30275
31406
  message: "The gateway was removed. The project and your local files are kept."
30276
31407
  };
30277
31408
  }
31409
+ function projectIdOf(options, deps, resolved, what) {
31410
+ const projectId = options.projectId?.trim() || resolved.project.id || deps.envProjectId?.trim() || "";
31411
+ if (!projectId) {
31412
+ throw new TeardownError(
31413
+ "PROJECT_REQUIRED",
31414
+ `This folder is not linked to a project, so there is no ${what} to remove.`,
31415
+ "Pass --project-id <id> to name the project."
31416
+ );
31417
+ }
31418
+ return projectId;
31419
+ }
31420
+ async function confirmByName(options, deps, name, prompt, what) {
31421
+ if (options.yes === true) return;
31422
+ if (!deps.isTerminal) {
31423
+ throw new TeardownError(
31424
+ "CONFIRMATION_REQUIRED",
31425
+ "This is not an interactive terminal.",
31426
+ `Pass --yes to remove the ${what} without asking.`
31427
+ );
31428
+ }
31429
+ const typed = await deps.askName(prompt);
31430
+ if (typed.trim() !== name) {
31431
+ throw new TeardownError(
31432
+ "CANCELLED",
31433
+ "The name did not match. Nothing was removed."
31434
+ );
31435
+ }
31436
+ }
31437
+ async function clearService(resolved, service) {
31438
+ const cleared = [];
31439
+ const ledger = await readLedger(resolved.state);
31440
+ if (ledger.services[service]) {
31441
+ await clearServices(resolved.state, [service]);
31442
+ cleared.push(`state.json entries of ${service}`);
31443
+ }
31444
+ if ((await readManifest(resolved.state))?.services[service]) {
31445
+ await removeServicesFromManifest(resolved.state, [service]);
31446
+ cleared.push(`the ${service} entry of services.json`);
31447
+ }
31448
+ return cleared;
31449
+ }
31450
+ async function teardownIdentity(options, deps) {
31451
+ const { resolved } = loadConfigV2(options.dir);
31452
+ const projectId = projectIdOf(options, deps, resolved, "identity");
31453
+ const client = deps.client;
31454
+ if (!client.getProjectIdentity || !client.deleteProjectIdentity)
31455
+ throw new Error("The platform client has no identity calls.");
31456
+ const project = await client.getProject(projectId);
31457
+ const name = project.name;
31458
+ const current = await client.getProjectIdentity(projectId);
31459
+ if (!current || current.status === "Released")
31460
+ throw new TeardownError(
31461
+ "IDENTITY_NOT_DEPLOYED",
31462
+ "The platform has no identity for this project.",
31463
+ "There is nothing to remove. Clear stale local records by deleting the identity entries in the state folder."
31464
+ );
31465
+ await confirmByName(
31466
+ options,
31467
+ deps,
31468
+ name,
31469
+ `This removes the identity of project "${name}" (${projectId}) and DELETES EVERY USER in it, for good. Nobody can sign in to the portal or the API until a new identity is made. The project, the portal and the gateway stay.
31470
+ Type the project name to confirm: `,
31471
+ "identity"
31472
+ );
31473
+ const answer = await client.deleteProjectIdentity(projectId);
31474
+ if (answer !== null) {
31475
+ const sleep5 = deps.identity?.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
31476
+ const deadline = Date.now() + (options.wait === false ? 0 : deps.identity?.timeoutMs ?? 12e4);
31477
+ for (; ; ) {
31478
+ const state = await client.getProjectIdentity(projectId);
31479
+ if (state === null || state.status === "Released") break;
31480
+ if (state.status === "Failed")
31481
+ throw new TeardownError(
31482
+ "DELETE_FAILED",
31483
+ `The platform could not remove the identity: ${[state.errorCode, state.errorMessage].filter(Boolean).join(" ") || state.status}.`,
31484
+ "Nothing local was changed. Try again, or contact support with the project id."
31485
+ );
31486
+ if (Date.now() >= deadline) {
31487
+ return {
31488
+ projectId,
31489
+ projectName: name,
31490
+ status: "pending",
31491
+ cleared: [],
31492
+ message: "The identity is queued for removal and the platform finishes it in the background. Local records are kept until then: run `apitogo teardown --only identity` again later to clear them."
31493
+ };
31494
+ }
31495
+ await sleep5(deps.identity?.pollIntervalMs ?? 2e3);
31496
+ }
31497
+ }
31498
+ return {
31499
+ projectId,
31500
+ projectName: name,
31501
+ status: "removed",
31502
+ scope: "identity",
31503
+ cleared: await clearService(resolved, "identity"),
31504
+ message: "The identity and its users were removed. The project and your local files are kept."
31505
+ };
31506
+ }
31507
+ async function teardownPlans(options, deps) {
31508
+ const { resolved } = loadConfigV2(options.dir);
31509
+ const projectId = projectIdOf(options, deps, resolved, "plans");
31510
+ const client = deps.client;
31511
+ if (!client.listGatewayPlans || !client.deleteGatewayPlan)
31512
+ throw new Error("The platform client has no plans calls.");
31513
+ const project = await client.getProject(projectId);
31514
+ const name = project.name;
31515
+ const plans = await client.listGatewayPlans(projectId);
31516
+ if (plans.length === 0)
31517
+ throw new TeardownError(
31518
+ "PLANS_NOT_DEPLOYED",
31519
+ "The platform has no plans for this project.",
31520
+ "There is nothing to remove. Clear stale local records by deleting the plans entries in the state folder."
31521
+ );
31522
+ await confirmByName(
31523
+ options,
31524
+ deps,
31525
+ name,
31526
+ `This removes the plans (${plans.map((plan2) => plan2.sku).join(", ")}) of project "${name}" (${projectId}). Customers' subscriptions and API keys on them stop working. The plans backend stays: it is released with the project.
31527
+ Type the project name to confirm: `,
31528
+ "plans"
31529
+ );
31530
+ for (const plan2 of plans) {
31531
+ try {
31532
+ await client.deleteGatewayPlan(projectId, plan2.id);
31533
+ } catch (error) {
31534
+ if (error instanceof PlatformApiError && error.status === 404) continue;
31535
+ throw new TeardownError(
31536
+ "DELETE_FAILED",
31537
+ `The platform could not remove the plan "${plan2.sku}": ${error instanceof Error ? error.message : String(error)}.`,
31538
+ "Plans removed before it are gone. Nothing local was changed; run the command again after fixing the cause."
31539
+ );
31540
+ }
31541
+ }
31542
+ return {
31543
+ projectId,
31544
+ projectName: name,
31545
+ status: "removed",
31546
+ scope: "plans",
31547
+ cleared: await clearService(resolved, "plans"),
31548
+ message: "The plans were removed. The plans backend, the project and your local files are kept."
31549
+ };
31550
+ }
30278
31551
 
30279
31552
  // src/cli/teardown/command.ts
30280
31553
  async function ask3(prompt) {
@@ -30324,7 +31597,7 @@ async function teardownProject(argv) {
30324
31597
  return;
30325
31598
  }
30326
31599
  printDiagnosticsToConsole(
30327
- result.scope === "gateway" ? `Removed the gateway of project ${result.projectName} (${result.projectId}).` : `${result.status === "removed" ? "Deleted" : "Queued deletion of"} project ${result.projectName} (${result.projectId}).`
31600
+ result.scope === "gateway" || result.scope === "identity" || result.scope === "plans" ? `Removed the ${result.scope} of project ${result.projectName} (${result.projectId}).` : `${result.status === "removed" ? "Deleted" : "Queued deletion of"} project ${result.projectName} (${result.projectId}).`
30328
31601
  );
30329
31602
  if (result.cleared.length > 0)
30330
31603
  printDiagnosticsToConsole(
@@ -30429,7 +31702,7 @@ var teardown_default = {
30429
31702
  describe: "Override project GUID"
30430
31703
  }).option("only", {
30431
31704
  type: "string",
30432
- describe: "Config version 2: only `gateway` is supported (removes the gateway, keeps the project); other hosting is released only by deleting the whole project"
31705
+ describe: "Config version 2: `gateway`, `identity` or `plans` removes just that service and keeps the project (`identity` deletes every user in it); other hosting is released only by deleting the whole project"
30433
31706
  }).option("yes", {
30434
31707
  alias: "y",
30435
31708
  type: "boolean",