@lukoweb/apitogo 0.2.61 → 0.2.65

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
@@ -6794,9 +6794,10 @@ function homeRedirect(input5) {
6794
6794
  return void 0;
6795
6795
  const landing = pickLandingRoute(input5.docRoutes, input5.pageOrders);
6796
6796
  if (landing) return { from: "/", to: landing };
6797
- return input5.apiReferencePath ? { from: "/", to: input5.apiReferencePath } : void 0;
6797
+ if (input5.apiReferencePath)
6798
+ return { from: "/", to: input5.apiReferencePath };
6798
6799
  }
6799
- if (input5.externalDocsUrl && !input5.userPanel) {
6800
+ if (!input5.hostsDocs && input5.externalDocsUrl && !input5.userPanel) {
6800
6801
  return { from: "/", to: input5.externalDocsUrl };
6801
6802
  }
6802
6803
  if (input5.userPanel) {
@@ -6805,6 +6806,54 @@ function homeRedirect(input5) {
6805
6806
  return void 0;
6806
6807
  }
6807
6808
 
6809
+ // src/config/v2/portal-profile.ts
6810
+ var NOT_BUILT = {
6811
+ enabled: false,
6812
+ docs: { enabled: false, provider: "apitogo", content: [] },
6813
+ console: { enabled: false },
6814
+ pipelines: { markdown: false, openapi: false, search: "none" },
6815
+ modules: { docs: false, userPanel: false }
6816
+ };
6817
+ function portalOnlyLinksExternalDocs(resolved) {
6818
+ const portal = resolved.portal;
6819
+ if (!portal?.enabled) return false;
6820
+ if (!portal.docs.enabled || portal.docs.provider !== "external") return false;
6821
+ const consoleFed = portal.console.enabled && (resolved.identity?.enabled === true || resolved.plans?.enabled === true);
6822
+ return !consoleFed;
6823
+ }
6824
+ function portalProfile(resolved) {
6825
+ const portal = resolved.portal;
6826
+ if (!portal?.enabled) return NOT_BUILT;
6827
+ const hostsDocs = portal.docs.enabled && portal.docs.provider === "apitogo";
6828
+ const externalDocs = portal.docs.enabled && portal.docs.provider === "external";
6829
+ const gatewayOn2 = resolved.gateway?.enabled === true;
6830
+ return {
6831
+ enabled: true,
6832
+ docs: {
6833
+ enabled: portal.docs.enabled,
6834
+ provider: portal.docs.provider,
6835
+ content: hostsDocs ? portal.docs.content : [],
6836
+ url: externalDocs ? portal.docs.url : void 0
6837
+ },
6838
+ console: { enabled: portal.console.enabled },
6839
+ pipelines: {
6840
+ markdown: hostsDocs,
6841
+ openapi: hostsDocs && gatewayOn2,
6842
+ search: hostsDocs ? portal.search : "none"
6843
+ },
6844
+ modules: { docs: hostsDocs, userPanel: portal.console.enabled },
6845
+ ...externalDocs ? {
6846
+ externalDocs: {
6847
+ url: portal.docs.url,
6848
+ openapiExport: gatewayOn2,
6849
+ cors: portal.console.enabled,
6850
+ consoleLinksInManifest: portal.console.enabled,
6851
+ whiteLabelConsole: portal.console.enabled
6852
+ }
6853
+ } : {}
6854
+ };
6855
+ }
6856
+
6808
6857
  // src/config/v2/tsx-pages.ts
6809
6858
  import { readFile as readFile4 } from "node:fs/promises";
6810
6859
  import path10 from "node:path";
@@ -6972,11 +7021,11 @@ function collectSecretRefs(config2) {
6972
7021
  return found;
6973
7022
  }
6974
7023
  function dependencyRules(config2, resolved, out) {
6975
- const gatewayOn = resolved.gateway?.enabled === true;
7024
+ const gatewayOn2 = resolved.gateway?.enabled === true;
6976
7025
  const identityOn = resolved.identity?.enabled === true;
6977
- const oidcGateway = gatewayOn && resolved.gateway?.auth?.mode === "oidc";
7026
+ const oidcGateway = gatewayOn2 && resolved.gateway?.auth?.mode === "oidc";
6978
7027
  const hasIdentitySource = identityOn || oidcGateway;
6979
- if (identityOn && resolved.portal?.enabled !== true && !gatewayOn && !resolved.project.id) {
7028
+ if (identityOn && resolved.portal?.enabled !== true && !gatewayOn2 && !resolved.project.id) {
6980
7029
  out.error({
6981
7030
  code: "NOTHING_TO_HOST",
6982
7031
  path: "identity",
@@ -6993,7 +7042,7 @@ function dependencyRules(config2, resolved, out) {
6993
7042
  });
6994
7043
  }
6995
7044
  if (resolved.plans?.enabled) {
6996
- if (!gatewayOn) {
7045
+ if (!gatewayOn2) {
6997
7046
  out.error({
6998
7047
  code: "PLANS_GATEWAY_REQUIRED",
6999
7048
  path: "plans",
@@ -7089,6 +7138,14 @@ async function portalRules(config2, resolved, out) {
7089
7138
  hint: "Enable identity or plans, or enable portal.docs."
7090
7139
  });
7091
7140
  }
7141
+ if (portalOnlyLinksExternalDocs(resolved)) {
7142
+ out.warn({
7143
+ code: "PORTAL_NOTHING_TO_HOST",
7144
+ path: "portal",
7145
+ message: "docs are on an external site and no service feeds the console, so there is no portal to host: `project create` takes no website hosting for it and `deploy` skips it.",
7146
+ hint: "Enable identity or plans for a console next to your docs, or remove the portal section."
7147
+ });
7148
+ }
7092
7149
  if (portal.footer && !await isFile(portal.footer))
7093
7150
  out.error({
7094
7151
  code: "PORTAL_FILE_MISSING",
@@ -7142,6 +7199,15 @@ async function docsContentRule(config2, patterns, resolved, out) {
7142
7199
  const shown = config2.portal?.docs?.content ?? [
7143
7200
  path11.relative(resolved.configDir, patterns[0] ?? "")
7144
7201
  ];
7202
+ if (portalHasNothingToShow(resolved)) {
7203
+ out.error({
7204
+ code: "PORTAL_HOME_EMPTY",
7205
+ path: "portal.docs",
7206
+ message: `portal.docs matched 0 files (${shown.join(", ")}), and the portal has no API reference, console pages or home page of its own, so its home page would say "Page not found".`,
7207
+ hint: "Add a .md or .mdx page there (for example introduction.mdx), point docs.content at your existing docs, or enable the gateway with an OpenAPI file for an API reference."
7208
+ });
7209
+ return;
7210
+ }
7145
7211
  out.warn({
7146
7212
  code: "PORTAL_DOCS_EMPTY",
7147
7213
  path: "portal.docs",
@@ -7149,6 +7215,13 @@ async function docsContentRule(config2, patterns, resolved, out) {
7149
7215
  hint: "Add .md, .mdx or .tsx pages, or point docs.content at your existing docs."
7150
7216
  });
7151
7217
  }
7218
+ function portalHasNothingToShow(resolved) {
7219
+ const portal = resolved.portal;
7220
+ if (!portal?.enabled || portal.landing) return false;
7221
+ if (resolved.gateway?.enabled === true) return false;
7222
+ const consoleFed = portal.console.enabled && (resolved.identity?.enabled === true || resolved.plans?.enabled === true);
7223
+ return !consoleFed;
7224
+ }
7152
7225
  var RESERVED_HEADER_ROUTES = [
7153
7226
  "/",
7154
7227
  "/index",
@@ -7618,47 +7691,6 @@ async function checkConfigV2(config2, resolved, context = {}) {
7618
7691
  return { errors, warnings, skipped };
7619
7692
  }
7620
7693
 
7621
- // src/config/v2/portal-profile.ts
7622
- var NOT_BUILT = {
7623
- enabled: false,
7624
- docs: { enabled: false, provider: "apitogo", content: [] },
7625
- console: { enabled: false },
7626
- pipelines: { markdown: false, openapi: false, search: "none" },
7627
- modules: { docs: false, userPanel: false }
7628
- };
7629
- function portalProfile(resolved) {
7630
- const portal = resolved.portal;
7631
- if (!portal?.enabled) return NOT_BUILT;
7632
- const hostsDocs = portal.docs.enabled && portal.docs.provider === "apitogo";
7633
- const externalDocs = portal.docs.enabled && portal.docs.provider === "external";
7634
- const gatewayOn = resolved.gateway?.enabled === true;
7635
- return {
7636
- enabled: true,
7637
- docs: {
7638
- enabled: portal.docs.enabled,
7639
- provider: portal.docs.provider,
7640
- content: hostsDocs ? portal.docs.content : [],
7641
- url: externalDocs ? portal.docs.url : void 0
7642
- },
7643
- console: { enabled: portal.console.enabled },
7644
- pipelines: {
7645
- markdown: hostsDocs,
7646
- openapi: hostsDocs && gatewayOn,
7647
- search: hostsDocs ? portal.search : "none"
7648
- },
7649
- modules: { docs: hostsDocs, userPanel: portal.console.enabled },
7650
- ...externalDocs ? {
7651
- externalDocs: {
7652
- url: portal.docs.url,
7653
- openapiExport: gatewayOn,
7654
- cors: portal.console.enabled,
7655
- consoleLinksInManifest: portal.console.enabled,
7656
- whiteLabelConsole: portal.console.enabled
7657
- }
7658
- } : {}
7659
- };
7660
- }
7661
-
7662
7694
  // src/config/v2/manifest.ts
7663
7695
  import { mkdir as mkdir3, readFile as readFile6, rename, rm, writeFile as writeFile4 } from "node:fs/promises";
7664
7696
  import path12 from "node:path";
@@ -7777,10 +7809,10 @@ function requiredServices(resolved) {
7777
7809
  if (!portal?.enabled) return [];
7778
7810
  const needs = [];
7779
7811
  const consoleOn = portal.console.enabled;
7780
- const gatewayOn = resolved.gateway?.enabled === true;
7812
+ const gatewayOn2 = resolved.gateway?.enabled === true;
7781
7813
  const identityOn = resolved.identity?.enabled === true;
7782
7814
  if (identityOn && (consoleOn || portal.requireAuth)) needs.push("identity");
7783
- if (gatewayOn && (consoleOn || portal.docs.enabled)) needs.push("gateway");
7815
+ if (gatewayOn2 && (consoleOn || portal.docs.enabled)) needs.push("gateway");
7784
7816
  if (resolved.plans?.enabled === true && consoleOn) needs.push("plans");
7785
7817
  return needs;
7786
7818
  }
@@ -8487,10 +8519,13 @@ function buildSiteConfig(resolved, plan2, theme, extras = {}) {
8487
8519
  }
8488
8520
  ] : []
8489
8521
  ];
8522
+ const logo = portal?.logo ? siteLogo(portal) : void 0;
8490
8523
  return {
8524
+ // The browser tab's icon is the project's logo; without one, the portal's own icon (API-134).
8525
+ ...logo ? { metadata: { favicon: logo.src.light } } : {},
8491
8526
  site: {
8492
8527
  title: portal?.name ?? resolved.project.name,
8493
- ...portal?.logo ? { logo: siteLogo(portal) } : {},
8528
+ ...logo ? { logo } : {},
8494
8529
  ...plan2.preview ? { banner: { message: PREVIEW_BANNER_MESSAGE, color: "caution" } } : {}
8495
8530
  },
8496
8531
  modules: {
@@ -10261,6 +10296,17 @@ var PlatformApiError = class extends Error {
10261
10296
  this.problem = problem;
10262
10297
  }
10263
10298
  };
10299
+ var ProjectGoneError = class extends PlatformApiError {
10300
+ constructor(projectId) {
10301
+ super(
10302
+ `The project ${projectId} no longer exists on the platform: it was deleted, or it is not in the account you are signed in to. Run \`apitogo project create\` to make a new project for this folder, or link another one with \`--project-id <id>\`.`,
10303
+ 410
10304
+ );
10305
+ this.projectId = projectId;
10306
+ this.name = "ProjectGoneError";
10307
+ }
10308
+ code = "PROJECT_GONE";
10309
+ };
10264
10310
  function formatProblemDetails(problem) {
10265
10311
  const parts = [];
10266
10312
  if (problem.title) parts.push(problem.title);
@@ -10352,6 +10398,11 @@ async function fetchWithRetry(input5, init3, options) {
10352
10398
  }
10353
10399
 
10354
10400
  // src/cli/platform/client/platform-api.ts
10401
+ function projectIdOfPath(path100) {
10402
+ return /^\/api\/v1\/projects\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[/?#]|$)/i.exec(
10403
+ path100
10404
+ )?.[1];
10405
+ }
10355
10406
  var MAX_RETRIES = 3;
10356
10407
  var UPLOAD_TIMEOUT_MS = 10 * 60 * 1e3;
10357
10408
  var PlatformApiClient = class {
@@ -10387,13 +10438,39 @@ var PlatformApiClient = class {
10387
10438
  }
10388
10439
  );
10389
10440
  if (!response.ok) {
10390
- throw await parseErrorResponse(response);
10441
+ throw await this.failure(response, path100);
10391
10442
  }
10392
10443
  if (response.status === 204) {
10393
10444
  return void 0;
10394
10445
  }
10395
10446
  return await response.json();
10396
10447
  }
10448
+ /**
10449
+ * The error for a failed answer. A 404 under a project may mean the project itself is gone; a bare "Not Found" hid
10450
+ * that (API-133), so the project is asked once and a missing one becomes {@link ProjectGoneError}.
10451
+ */
10452
+ async failure(response, path100) {
10453
+ const error = await parseErrorResponse(response);
10454
+ const projectId = response.status === 404 ? projectIdOfPath(path100) : void 0;
10455
+ if (projectId && await this.projectMissing(projectId, path100))
10456
+ return new ProjectGoneError(projectId);
10457
+ return error;
10458
+ }
10459
+ /** Whether the project answers 404 itself. A path that is the project needs no second call. */
10460
+ async projectMissing(projectId, path100) {
10461
+ const projectPath = `/api/v1/projects/${projectId}`;
10462
+ if (path100.replace(/[?#].*$/, "").replace(/\/+$/, "") === projectPath)
10463
+ return true;
10464
+ try {
10465
+ const response = await fetch(this.url(projectPath), {
10466
+ method: "GET",
10467
+ headers: this.headers()
10468
+ });
10469
+ return response.status === 404;
10470
+ } catch {
10471
+ return false;
10472
+ }
10473
+ }
10397
10474
  whoami() {
10398
10475
  return this.request("GET", "/api/v1/auth/me");
10399
10476
  }
@@ -10501,7 +10578,10 @@ var PlatformApiClient = class {
10501
10578
  { method: "POST", maxRetries: 0, retryMutatingOnServerError: false }
10502
10579
  );
10503
10580
  if (!response.ok) {
10504
- throw await parseErrorResponse(response);
10581
+ throw await this.failure(
10582
+ response,
10583
+ `/api/v1/projects/${projectId}/infrastructure/static-web/deployments`
10584
+ );
10505
10585
  }
10506
10586
  return await response.json();
10507
10587
  } catch (error) {
@@ -10577,7 +10657,10 @@ var PlatformApiClient = class {
10577
10657
  }
10578
10658
  );
10579
10659
  if (!response.ok) {
10580
- throw await parseErrorResponse(response);
10660
+ throw await this.failure(
10661
+ response,
10662
+ `/api/v1/projects/${projectId}/backend/openapi`
10663
+ );
10581
10664
  }
10582
10665
  return await response.json();
10583
10666
  }
@@ -10626,7 +10709,11 @@ var PlatformApiClient = class {
10626
10709
  maxRetries: MAX_RETRIES
10627
10710
  }
10628
10711
  );
10629
- if (!response.ok) throw await parseErrorResponse(response);
10712
+ if (!response.ok)
10713
+ throw await this.failure(
10714
+ response,
10715
+ `/api/v1/projects/${projectId}/gateway/openapi.json`
10716
+ );
10630
10717
  return response.text();
10631
10718
  }
10632
10719
  getGatewayCapabilities(projectId) {
@@ -10938,7 +11025,10 @@ var PlatformApiClient = class {
10938
11025
  }
10939
11026
  );
10940
11027
  if (!response.ok) {
10941
- throw await parseErrorResponse(response);
11028
+ throw await this.failure(
11029
+ response,
11030
+ `/api/v1/projects/${projectId}/backend/deployments`
11031
+ );
10942
11032
  }
10943
11033
  return await response.json();
10944
11034
  }
@@ -11343,7 +11433,7 @@ import {
11343
11433
  // package.json
11344
11434
  var package_default = {
11345
11435
  name: "@lukoweb/apitogo",
11346
- version: "0.2.61",
11436
+ version: "0.2.65",
11347
11437
  type: "module",
11348
11438
  sideEffects: [
11349
11439
  "**/*.css",
@@ -23103,6 +23193,9 @@ function waitForJobStepLabel(kind, moduleType) {
23103
23193
  return "Job";
23104
23194
  }
23105
23195
  }
23196
+ function deletionFinished(error) {
23197
+ return error instanceof ProjectGoneError || error instanceof PlatformApiError && error.status === 404;
23198
+ }
23106
23199
  function projectDeletionNotFoundResult(last, jobId) {
23107
23200
  return last ?? {
23108
23201
  status: "Completed",
@@ -23164,7 +23257,7 @@ function pollProvisionJob(poll, options) {
23164
23257
  stepLabel: options.stepLabel,
23165
23258
  progress: options.progress?.child(options.stepLabel),
23166
23259
  onPollError: options.kind === "projectDeletion" ? (error, last) => {
23167
- if (error instanceof PlatformApiError && error.status === 404) {
23260
+ if (deletionFinished(error)) {
23168
23261
  return {
23169
23262
  status: "succeeded",
23170
23263
  value: projectDeletionNotFoundResult(last, options.jobId),
@@ -26955,11 +27048,22 @@ function assertConfigChecksOut(output4) {
26955
27048
  ].join("\n")
26956
27049
  );
26957
27050
  }
26958
- var hostsAnything = (config2) => config2.portal && config2.portal.enabled !== false || config2.gateway && config2.gateway.enabled !== false;
27051
+ var gatewayOn = (config2) => config2.gateway !== void 0 && config2.gateway.enabled !== false;
27052
+ var hostsAnything = (config2) => config2.portal && config2.portal.enabled !== false || gatewayOn(config2);
26959
27053
  async function createProjectV2(options, deps) {
26960
27054
  const { config: config2, resolved } = loadConfigV2(options.dir);
26961
27055
  const linkedProjectId = resolved.project.id;
26962
- const modules = modulesForConfig(config2);
27056
+ const externalDocsOnly = portalOnlyLinksExternalDocs(resolved);
27057
+ const modules = modulesForConfig(config2).filter(
27058
+ (module) => module !== "dev-portal" || !externalDocsOnly
27059
+ );
27060
+ if (externalDocsOnly && !gatewayOn(config2)) {
27061
+ throw new ProjectCreateError(
27062
+ "NOTHING_TO_HOST",
27063
+ "No enabled service needs a project: the docs are on an external site and nothing feeds the portal console.",
27064
+ "Enable identity or plans for a console next to your docs (`apitogo add identity`), or a gateway (`apitogo add gateway`)."
27065
+ );
27066
+ }
26963
27067
  if (!hostsAnything(config2)) {
26964
27068
  throw new ProjectCreateError(
26965
27069
  "NOTHING_TO_HOST",
@@ -28472,6 +28576,16 @@ var IdentityDeployError = class extends ApitogoError {
28472
28576
  this.name = "IdentityDeployError";
28473
28577
  }
28474
28578
  };
28579
+ var IDENTITY_NOT_ACTIVE = "IDENTITY_NOT_ACTIVE";
28580
+ async function identityLost(client, projectId) {
28581
+ if (!client.listProjectIdentityConnections) return false;
28582
+ try {
28583
+ await client.listProjectIdentityConnections(projectId);
28584
+ return false;
28585
+ } catch (error) {
28586
+ return error instanceof PlatformApiError && error.status === 409 && error.problem?.code === IDENTITY_NOT_ACTIVE;
28587
+ }
28588
+ }
28475
28589
  var IDENTITY_STEPS = ["request", "activate"];
28476
28590
  var IDENTITY_CONNECTIONS_STEP = "connections";
28477
28591
  var SECRET_PREFIX = "secret:";
@@ -28671,10 +28785,13 @@ async function deployIdentity(resolved, projectId, deps, options = {}) {
28671
28785
  return rejected2(error);
28672
28786
  }
28673
28787
  },
28674
- // An identity that was removed, or that failed, is not a result worth keeping.
28788
+ // An identity that was removed, or that failed, is not a result worth keeping. Neither is one the platform still
28789
+ // lists as Active while the identity service lost it (API-132): asking again makes it again.
28675
28790
  stillValid: async () => {
28676
28791
  const found = await client.getProjectIdentity(projectId);
28677
- return found !== null && found.status !== "Failed" && found.status !== "Released" && found.status !== "Releasing";
28792
+ if (found === null || found.status === "Failed" || found.status === "Released" || found.status === "Releasing")
28793
+ return false;
28794
+ return found.status !== "Active" || !await identityLost(client, projectId);
28678
28795
  }
28679
28796
  };
28680
28797
  const activateStep = {
@@ -29604,6 +29721,25 @@ async function statusV2(dir, only, options = {}) {
29604
29721
  const ledger = await readLedger(resolved.state);
29605
29722
  const manifest = await readManifest(resolved.state);
29606
29723
  const services = [];
29724
+ const manifestSummary = {
29725
+ present: manifest !== void 0,
29726
+ schemaVersion: manifest?.schemaVersion,
29727
+ generatedAt: manifest?.generatedAt
29728
+ };
29729
+ const projectId = resolved.project.id ?? manifest?.project.id;
29730
+ if (options.projectRemote && projectId) {
29731
+ try {
29732
+ await options.projectRemote(projectId);
29733
+ } catch (error) {
29734
+ if (error instanceof ProjectGoneError)
29735
+ return {
29736
+ configPath: resolved.configPath,
29737
+ manifest: manifestSummary,
29738
+ project: { state: "gone", id: projectId, message: error.message },
29739
+ services: []
29740
+ };
29741
+ }
29742
+ }
29607
29743
  for (const service of chooseServices(only)) {
29608
29744
  const section = sectionOf(resolved, service);
29609
29745
  if (!section) {
@@ -29667,11 +29803,7 @@ async function statusV2(dir, only, options = {}) {
29667
29803
  }
29668
29804
  return {
29669
29805
  configPath: resolved.configPath,
29670
- manifest: {
29671
- present: manifest !== void 0,
29672
- schemaVersion: manifest?.schemaVersion,
29673
- generatedAt: manifest?.generatedAt
29674
- },
29806
+ manifest: manifestSummary,
29675
29807
  services
29676
29808
  };
29677
29809
  }
@@ -29685,6 +29817,8 @@ function formatPlan(output4, options = {}) {
29685
29817
  ]);
29686
29818
  }
29687
29819
  function formatStatus(output4) {
29820
+ if (output4.project?.state === "gone")
29821
+ return [`project: gone`, ` ${output4.project.message}`];
29688
29822
  return [
29689
29823
  output4.manifest.present ? `services.json: version ${output4.manifest.schemaVersion}${output4.manifest.generatedAt ? `, written ${output4.manifest.generatedAt}` : ""}` : "services.json: none yet",
29690
29824
  ...output4.services.flatMap((svc) => [
@@ -29837,6 +29971,15 @@ async function deployV2(options, deps) {
29837
29971
  results.push(await deployPlansService(options, deps, projectId, named));
29838
29972
  continue;
29839
29973
  }
29974
+ if (portalOnlyLinksExternalDocs(resolved)) {
29975
+ results.push({
29976
+ service: "portal",
29977
+ status: "skipped",
29978
+ reason: "docs are on an external site and no service feeds the console, so there is nothing to host",
29979
+ steps: []
29980
+ });
29981
+ continue;
29982
+ }
29840
29983
  results.push(await deployPortal(options, deps, projectId));
29841
29984
  }
29842
29985
  const gatewayAt = results.findIndex(
@@ -30609,6 +30752,14 @@ var plan = (argv) => run2(
30609
30752
  }),
30610
30753
  (output4) => formatPlan(output4, { verbose: argv.verbose === true })
30611
30754
  );
30755
+ function projectRemote() {
30756
+ try {
30757
+ const client = new PlatformApiClient(loadConfig());
30758
+ return (projectId) => client.getProject(projectId);
30759
+ } catch {
30760
+ return void 0;
30761
+ }
30762
+ }
30612
30763
  function gatewayRemote() {
30613
30764
  try {
30614
30765
  const client = new PlatformApiClient(loadConfig());
@@ -30666,6 +30817,7 @@ var statusOfProject = (argv) => run2(
30666
30817
  "status",
30667
30818
  argv,
30668
30819
  (dir, only) => statusV2(dir, only, {
30820
+ projectRemote: projectRemote(),
30669
30821
  hostingRemote: hostingRemote(),
30670
30822
  gatewayRemote: gatewayRemote(),
30671
30823
  identityRemote: identityRemote(),
@@ -30787,7 +30939,7 @@ async function switchProject(options, deps) {
30787
30939
  try {
30788
30940
  project = await deps.getProject(projectId);
30789
30941
  } catch (error) {
30790
- if (error instanceof PlatformApiError && (error.status === 404 || error.status === 403))
30942
+ if (error instanceof ProjectGoneError || error instanceof PlatformApiError && (error.status === 404 || error.status === 403))
30791
30943
  throw new ProjectUseError(
30792
30944
  "PROJECT_NOT_FOUND",
30793
30945
  `No project ${projectId} is yours.`,
@@ -1,4 +1,5 @@
1
1
  import type { ApitogoConfig } from "../config.js";
2
+ export declare function projectIdOfPath(path: string): string | undefined;
2
3
  export interface TenantDto {
3
4
  id: string;
4
5
  email: string;
@@ -545,6 +546,8 @@ export declare class PlatformApiClient {
545
546
  private url;
546
547
  private headers;
547
548
  private request;
549
+ private failure;
550
+ private projectMissing;
548
551
  whoami(): Promise<TenantDto>;
549
552
  listProjects(): Promise<ProjectDto[]>;
550
553
  getProject(projectId: string): Promise<ProjectDto>;
@@ -11,6 +11,11 @@ export declare class PlatformApiError extends Error {
11
11
  readonly problem?: ProblemDetails;
12
12
  constructor(message: string, status: number, problem?: ProblemDetails);
13
13
  }
14
+ export declare class ProjectGoneError extends PlatformApiError {
15
+ readonly projectId: string;
16
+ readonly code = "PROJECT_GONE";
17
+ constructor(projectId: string);
18
+ }
14
19
  export declare function formatProblemDetails(problem: ProblemDetails): string;
15
20
  export declare function parseErrorResponse(response: Response): Promise<PlatformApiError>;
16
21
  export declare function toolErrorPayload(error: unknown): string;
@@ -3,6 +3,7 @@ import { type ApitogoConfig } from "../config.js";
3
3
  import { recentJobMessages, type ProgressReporter } from "../helpers/progress.js";
4
4
  export type WaitForJobKind = "module" | "deployment" | "azureInfrastructure" | "projectDeletion" | "devPortalDeploy" | "devPortalBackendUpdate";
5
5
  export declare function waitForJobStepLabel(kind: WaitForJobKind, moduleType?: string): string;
6
+ export declare function deletionFinished(error: unknown): boolean;
6
7
  export declare function handleActivateModule(client: PlatformApiClient, config: ApitogoConfig, args: {
7
8
  projectId?: string;
8
9
  moduleType: string;
package/docs/mcp/setup.md CHANGED
@@ -22,9 +22,12 @@ Every host launches the same stdio process:
22
22
  | Field | Value |
23
23
  | ------- | ---------------------------------------------------- |
24
24
  | Command | `npx` |
25
- | Args | `-y`, `@lukoweb/apitogo-mcp` |
25
+ | Args | `-y`, `@lukoweb/apitogo-mcp@latest` |
26
26
  | Env | Optional: `APITOGO_API_KEY_ID`, `APITOGO_API_SECRET` |
27
27
 
28
+ Keep `@latest` in the package name: without it npx can start an older copy it has kept from an
29
+ earlier run, with tools that no longer match this guide.
30
+
28
31
  The tools run the `apitogo` CLI the project installed (`node_modules/@lukoweb/apitogo`), or the
29
32
  latest one through npx in a folder without it. Optional env: `APITOGO_API_KEY_ID` and
30
33
  `APITOGO_API_SECRET` instead of `apitogo login`, `APITOGO_API_URL`, and `APITOGO_CLI_PATH` (a local
@@ -44,7 +47,7 @@ Add to `~/.cursor/mcp.json` (user) or `.cursor/mcp.json` (project):
44
47
  "mcpServers": {
45
48
  "apitogo": {
46
49
  "command": "npx",
47
- "args": ["-y", "@lukoweb/apitogo-mcp"],
50
+ "args": ["-y", "@lukoweb/apitogo-mcp@latest"],
48
51
  "env": {
49
52
  "APITOGO_API_KEY_ID": "apk_...",
50
53
  "APITOGO_API_SECRET": "..."
@@ -70,7 +73,7 @@ Edit the Claude Desktop MCP config (same `mcpServers` shape as Cursor):
70
73
  "mcpServers": {
71
74
  "apitogo": {
72
75
  "command": "npx",
73
- "args": ["-y", "@lukoweb/apitogo-mcp"],
76
+ "args": ["-y", "@lukoweb/apitogo-mcp@latest"],
74
77
  "env": {
75
78
  "APITOGO_API_KEY_ID": "apk_...",
76
79
  "APITOGO_API_SECRET": "..."
@@ -88,7 +91,7 @@ Fully quit and reopen Claude Desktop after saving.
88
91
 
89
92
  ```bash
90
93
  claude mcp add --scope user --env APITOGO_API_KEY_ID=apk_... --env APITOGO_API_SECRET=... \
91
- apitogo -- npx -y @lukoweb/apitogo-mcp
94
+ apitogo -- npx -y @lukoweb/apitogo-mcp@latest
92
95
  ```
93
96
 
94
97
  **Project scope** (committed `.mcp.json` — use `${VAR}` for secrets):
@@ -96,7 +99,7 @@ claude mcp add --scope user --env APITOGO_API_KEY_ID=apk_... --env APITOGO_API_S
96
99
  ```bash
97
100
  claude mcp add --scope project --env APITOGO_API_KEY_ID=${APITOGO_API_KEY_ID} \
98
101
  --env APITOGO_API_SECRET=${APITOGO_API_SECRET} \
99
- apitogo -- npx -y @lukoweb/apitogo-mcp
102
+ apitogo -- npx -y @lukoweb/apitogo-mcp@latest
100
103
  ```
101
104
 
102
105
  Or hand-edit `.mcp.json` in the project root:
@@ -107,7 +110,7 @@ Or hand-edit `.mcp.json` in the project root:
107
110
  "apitogo": {
108
111
  "type": "stdio",
109
112
  "command": "npx",
110
- "args": ["-y", "@lukoweb/apitogo-mcp"],
113
+ "args": ["-y", "@lukoweb/apitogo-mcp@latest"],
111
114
  "env": {
112
115
  "APITOGO_API_KEY_ID": "${APITOGO_API_KEY_ID}",
113
116
  "APITOGO_API_SECRET": "${APITOGO_API_SECRET}"
@@ -128,7 +131,7 @@ Edit `~/.codex/config.toml` (or project `.codex/config.toml` in a trusted projec
128
131
  ```toml
129
132
  [mcp_servers.apitogo]
130
133
  command = "npx"
131
- args = ["-y", "@lukoweb/apitogo-mcp"]
134
+ args = ["-y", "@lukoweb/apitogo-mcp@latest"]
132
135
 
133
136
  [mcp_servers.apitogo.env]
134
137
  APITOGO_API_KEY_ID = "apk_..."
@@ -142,7 +145,7 @@ Restart Codex / open a new session. See [Codex MCP docs](https://developers.open
142
145
 
143
146
  Any client that supports **stdio** MCP can run APItoGo:
144
147
 
145
- 1. Set command `npx`, args `["-y", "@lukoweb/apitogo-mcp"]`
148
+ 1. Set command `npx`, args `["-y", "@lukoweb/apitogo-mcp@latest"]`
146
149
  2. Sign in with `apitogo login`, or pass `APITOGO_API_KEY_ID` and `APITOGO_API_SECRET` in the server
147
150
  env
148
151
  3. Restart the host and confirm tools load
@@ -158,9 +161,11 @@ In a new agent chat:
158
161
  `apitogo_create`, `apitogo_deploy` and `apitogo_status`.
159
162
  2. Ask it for the status of your project; it calls `apitogo_status`.
160
163
 
161
- If the server fails to start: confirm Node 20+ and that `npx -y @lukoweb/apitogo-mcp` works in a
162
- terminal. If a tool says you are not logged in, run `apitogo login` or check the API key id and
163
- secret.
164
+ If the server fails to start: confirm Node 20+ and that `npx -y @lukoweb/apitogo-mcp@latest` works
165
+ in a terminal. If the agent shows many more tools than the ones in [Tools](./tools.md), or asks for
166
+ an API key although you are logged in, the host is running an old copy: add `@latest` as above and
167
+ restart the host. If a tool says you are not logged in, run `apitogo login` or check the API key id
168
+ and secret.
164
169
 
165
170
  ## Next
166
171
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lukoweb/apitogo",
3
- "version": "0.2.61",
3
+ "version": "0.2.65",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -117,7 +117,7 @@ export type HomeRedirectInput = {
117
117
  * "Page not found".
118
118
  *
119
119
  * - Hosted docs without an index page: the first page, so the team's own docs never need an extra file.
120
- * - Hosted docs without any page: the API reference, when there is one.
120
+ * - Hosted docs without any page: the API reference, when there is one, else the console pages as below (API-116).
121
121
  * - External docs and no console: the external docs site, as the portal has nothing else to show.
122
122
  * - No hosted docs but a console: the pricing page when there is one, else the account pages, which lead a signed-out
123
123
  * visitor to sign in. The console has no page of its own at `/`, so the address `deploy` prints showed "Page not
@@ -131,11 +131,11 @@ export function homeRedirect(
131
131
  return undefined;
132
132
  const landing = pickLandingRoute(input.docRoutes, input.pageOrders);
133
133
  if (landing) return { from: "/", to: landing };
134
- return input.apiReferencePath
135
- ? { from: "/", to: input.apiReferencePath }
136
- : undefined;
134
+ if (input.apiReferencePath)
135
+ return { from: "/", to: input.apiReferencePath };
136
+ // No pages and no reference yet: the console pages, when there are any, rather than "Page not found" (API-116).
137
137
  }
138
- if (input.externalDocsUrl && !input.userPanel) {
138
+ if (!input.hostsDocs && input.externalDocsUrl && !input.userPanel) {
139
139
  return { from: "/", to: input.externalDocsUrl };
140
140
  }
141
141
  if (input.userPanel) {
@@ -54,7 +54,11 @@ export {
54
54
  ConfigV2Schema,
55
55
  type Layout,
56
56
  } from "./schema.js";
57
- export { type PortalProfile, portalProfile } from "./portal-profile.js";
57
+ export {
58
+ type PortalProfile,
59
+ portalOnlyLinksExternalDocs,
60
+ portalProfile,
61
+ } from "./portal-profile.js";
58
62
  export {
59
63
  hasCapability,
60
64
  type ManifestServiceName,
@@ -49,6 +49,23 @@ const NOT_BUILT: PortalProfile = {
49
49
  modules: { docs: false, userPanel: false },
50
50
  };
51
51
 
52
+ /**
53
+ * The portal's docs are on an external site and no service (identity or plans) feeds its console, so the portal would be
54
+ * an empty site. `project create` takes no website hosting for it and `deploy` does not build or publish it (API-101: such
55
+ * a portal waited on hosting it never needed, and deploy exited 1).
56
+ */
57
+ export function portalOnlyLinksExternalDocs(
58
+ resolved: Pick<ResolvedConfigV2, "portal" | "identity" | "plans">,
59
+ ): boolean {
60
+ const portal = resolved.portal;
61
+ if (!portal?.enabled) return false;
62
+ if (!portal.docs.enabled || portal.docs.provider !== "external") return false;
63
+ const consoleFed =
64
+ portal.console.enabled &&
65
+ (resolved.identity?.enabled === true || resolved.plans?.enabled === true);
66
+ return !consoleFed;
67
+ }
68
+
52
69
  export function portalProfile(resolved: ResolvedConfigV2): PortalProfile {
53
70
  const portal = resolved.portal;
54
71
  if (!portal?.enabled) return NOT_BUILT;
@@ -5,6 +5,7 @@ import { parse as parseYaml } from "yaml";
5
5
  import { billingNeeded, stripeKeySecretName } from "./billing.js";
6
6
  import type { ConfigIssue } from "./errors.js";
7
7
  import { docRoutes } from "./home-redirect.js";
8
+ import { portalOnlyLinksExternalDocs } from "./portal-profile.js";
8
9
  import { isLogoUrl, type ResolvedConfigV2, toGlobPath } from "./resolve.js";
9
10
  import type { ConfigV2 } from "./schema.js";
10
11
  import { findTsxPages } from "./tsx-pages.js";
@@ -248,6 +249,17 @@ async function portalRules(
248
249
  });
249
250
  }
250
251
 
252
+ // Docs on an external site and nothing for the console: the portal would be an empty site (API-101).
253
+ if (portalOnlyLinksExternalDocs(resolved)) {
254
+ out.warn({
255
+ code: "PORTAL_NOTHING_TO_HOST",
256
+ path: "portal",
257
+ message:
258
+ "docs are on an external site and no service feeds the console, so there is no portal to host: `project create` takes no website hosting for it and `deploy` skips it.",
259
+ hint: "Enable identity or plans for a console next to your docs, or remove the portal section.",
260
+ });
261
+ }
262
+
251
263
  // A missing footer or logo file would fail the build, so check says so first (API-129).
252
264
  if (portal.footer && !(await isFile(portal.footer)))
253
265
  out.error({
@@ -317,6 +329,16 @@ async function docsContentRule(
317
329
  const shown = config.portal?.docs?.content ?? [
318
330
  path.relative(resolved.configDir, patterns[0] ?? ""),
319
331
  ];
332
+ // No pages and nothing else for `/` either: the deployed home page would say "Page not found" (API-116).
333
+ if (portalHasNothingToShow(resolved)) {
334
+ out.error({
335
+ code: "PORTAL_HOME_EMPTY",
336
+ path: "portal.docs",
337
+ message: `portal.docs matched 0 files (${shown.join(", ")}), and the portal has no API reference, console pages or home page of its own, so its home page would say "Page not found".`,
338
+ hint: "Add a .md or .mdx page there (for example introduction.mdx), point docs.content at your existing docs, or enable the gateway with an OpenAPI file for an API reference.",
339
+ });
340
+ return;
341
+ }
320
342
  out.warn({
321
343
  code: "PORTAL_DOCS_EMPTY",
322
344
  path: "portal.docs",
@@ -325,6 +347,20 @@ async function docsContentRule(
325
347
  });
326
348
  }
327
349
 
350
+ /**
351
+ * A portal that hosts docs but has nothing else to show at `/`: no home page of its own, no gateway for an API reference,
352
+ * and no service (identity or plans) for console pages.
353
+ */
354
+ function portalHasNothingToShow(resolved: ResolvedConfigV2): boolean {
355
+ const portal = resolved.portal;
356
+ if (!portal?.enabled || portal.landing) return false;
357
+ if (resolved.gateway?.enabled === true) return false;
358
+ const consoleFed =
359
+ portal.console.enabled &&
360
+ (resolved.identity?.enabled === true || resolved.plans?.enabled === true);
361
+ return !consoleFed;
362
+ }
363
+
328
364
  /** Addresses the portal itself uses, so a header page cannot take them. */
329
365
  const RESERVED_HEADER_ROUTES = [
330
366
  "/",
@@ -400,10 +400,13 @@ export function buildSiteConfig(
400
400
  ]
401
401
  : []),
402
402
  ];
403
+ const logo = portal?.logo ? siteLogo(portal) : undefined;
403
404
  return {
405
+ // The browser tab's icon is the project's logo; without one, the portal's own icon (API-134).
406
+ ...(logo ? { metadata: { favicon: logo.src.light } } : {}),
404
407
  site: {
405
408
  title: portal?.name ?? resolved.project.name,
406
- ...(portal?.logo ? { logo: siteLogo(portal) } : {}),
409
+ ...(logo ? { logo } : {}),
407
410
  ...(plan.preview
408
411
  ? { banner: { message: PREVIEW_BANNER_MESSAGE, color: "caution" } }
409
412
  : {}),
@@ -518,9 +521,10 @@ export const PREVIEW_BANNER_MESSAGE =
518
521
  * The header logo as the site takes it: a file of the portal's `public/` folder becomes its address on the site, an https
519
522
  * address stays as it is (API-129). `check` refuses a file outside `public/`.
520
523
  */
521
- function siteLogo(
522
- portal: NonNullable<ResolvedConfigV2["portal"]>,
523
- ): Record<string, unknown> {
524
+ function siteLogo(portal: NonNullable<ResolvedConfigV2["portal"]>): {
525
+ src: { light: string; dark: string };
526
+ alt: string;
527
+ } {
524
528
  const logo = portal.logo as NonNullable<typeof portal.logo>;
525
529
  const publicDir = path.join(portal.dir, "public");
526
530
  const src = (file: string) =>
@@ -1,46 +1,49 @@
1
- import { Helmet } from "@zudoku/react-helmet-async";
2
- import type { PropsWithChildren } from "react";
3
- import { useLocation } from "react-router";
4
- import { joinUrl } from "../util/joinUrl.js";
5
- import { useZudoku } from "./context/ZudokuContext.js";
6
-
7
- export const Meta = ({ children }: PropsWithChildren) => {
8
- const { options } = useZudoku();
9
- const { metadata: meta } = options;
10
- const location = useLocation();
11
-
12
- return (
13
- <>
14
- <Helmet titleTemplate={meta?.title} defaultTitle={meta?.defaultTitle}>
15
- {options.canonicalUrlOrigin && (
16
- <link
17
- rel="canonical"
18
- href={joinUrl(
19
- options.canonicalUrlOrigin,
20
- options.basePath,
21
- location.pathname,
22
- )}
23
- />
24
- )}
25
- {meta?.description && (
26
- <meta name="description" content={meta.description} />
27
- )}
28
- {meta?.favicon && <link rel="icon" href={meta.favicon} />}
29
- {meta?.generator && <meta name="generator" content={meta.generator} />}
30
- {meta?.applicationName && (
31
- <meta name="application-name" content={meta.applicationName} />
32
- )}
33
- {meta?.referrer && <meta name="referrer" content={meta.referrer} />}
34
- {meta?.keywords && meta.keywords.length > 0 && (
35
- <meta name="keywords" content={meta.keywords.join(", ")} />
36
- )}
37
- {meta?.authors?.map((author) => (
38
- <meta key={author} name="author" content={author} />
39
- ))}
40
- {meta?.creator && <meta name="creator" content={meta.creator} />}
41
- {meta?.publisher && <meta name="publisher" content={meta.publisher} />}
42
- </Helmet>
43
- {children}
44
- </>
45
- );
46
- };
1
+ import { Helmet } from "@zudoku/react-helmet-async";
2
+ import type { PropsWithChildren } from "react";
3
+ import { useLocation } from "react-router";
4
+ // A site without its own icon would leave the browser asking for /favicon.ico, a 404 and a console error on every page
5
+ // (API-134). The build publishes this file with a hashed name, so no project has to carry it.
6
+ import defaultFavicon from "../assets/powered-by-icon.png";
7
+ import { joinUrl } from "../util/joinUrl.js";
8
+ import { useZudoku } from "./context/ZudokuContext.js";
9
+
10
+ export const Meta = ({ children }: PropsWithChildren) => {
11
+ const { options } = useZudoku();
12
+ const { metadata: meta } = options;
13
+ const location = useLocation();
14
+
15
+ return (
16
+ <>
17
+ <Helmet titleTemplate={meta?.title} defaultTitle={meta?.defaultTitle}>
18
+ {options.canonicalUrlOrigin && (
19
+ <link
20
+ rel="canonical"
21
+ href={joinUrl(
22
+ options.canonicalUrlOrigin,
23
+ options.basePath,
24
+ location.pathname,
25
+ )}
26
+ />
27
+ )}
28
+ {meta?.description && (
29
+ <meta name="description" content={meta.description} />
30
+ )}
31
+ <link rel="icon" href={meta?.favicon ?? defaultFavicon} />
32
+ {meta?.generator && <meta name="generator" content={meta.generator} />}
33
+ {meta?.applicationName && (
34
+ <meta name="application-name" content={meta.applicationName} />
35
+ )}
36
+ {meta?.referrer && <meta name="referrer" content={meta.referrer} />}
37
+ {meta?.keywords && meta.keywords.length > 0 && (
38
+ <meta name="keywords" content={meta.keywords.join(", ")} />
39
+ )}
40
+ {meta?.authors?.map((author) => (
41
+ <meta key={author} name="author" content={author} />
42
+ ))}
43
+ {meta?.creator && <meta name="creator" content={meta.creator} />}
44
+ {meta?.publisher && <meta name="publisher" content={meta.publisher} />}
45
+ </Helmet>
46
+ {children}
47
+ </>
48
+ );
49
+ };