@geekmidas/cli 10.0.0-alpha.57 → 10.0.0-alpha.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/dist/{HostingerProvider-cL-6oQ5r.mjs → HostingerProvider-YQY3rMpV.mjs} +2 -2
  2. package/dist/{HostingerProvider-cL-6oQ5r.mjs.map → HostingerProvider-YQY3rMpV.mjs.map} +1 -1
  3. package/dist/{LegacyStateStore-ByRWQ9s1.mjs → LegacyStateStore-CB4ch3Dl.mjs} +2 -2
  4. package/dist/{LegacyStateStore-ByRWQ9s1.mjs.map → LegacyStateStore-CB4ch3Dl.mjs.map} +1 -1
  5. package/dist/{LegacyStateStore-C_OFs1gN.cjs → LegacyStateStore-CQJqvmXw.cjs} +7 -7
  6. package/dist/{LegacyStateStore-C_OFs1gN.cjs.map → LegacyStateStore-CQJqvmXw.cjs.map} +1 -1
  7. package/dist/{OpenApiTsGenerator-BLRQ7Iu9.cjs → OpenApiTsGenerator-BCWPRZwD.cjs} +153 -4
  8. package/dist/OpenApiTsGenerator-BCWPRZwD.cjs.map +1 -0
  9. package/dist/{OpenApiTsGenerator-NThANByQ.mjs → OpenApiTsGenerator-BRWD1Wit.mjs} +149 -6
  10. package/dist/OpenApiTsGenerator-BRWD1Wit.mjs.map +1 -0
  11. package/dist/{S3StateStore-FhzyENrN.mjs → S3StateStore-CXTenDTW.mjs} +2 -2
  12. package/dist/{S3StateStore-FhzyENrN.mjs.map → S3StateStore-CXTenDTW.mjs.map} +1 -1
  13. package/dist/{S3StateStore-C7eBae0G.cjs → S3StateStore-DLS4rEc0.cjs} +5 -5
  14. package/dist/{S3StateStore-C7eBae0G.cjs.map → S3StateStore-DLS4rEc0.cjs.map} +1 -1
  15. package/dist/{SSMStateStore-C7KcDSTb.cjs → SSMStateStore-EqsTBIOs.cjs} +10 -8
  16. package/dist/SSMStateStore-EqsTBIOs.cjs.map +1 -0
  17. package/dist/{SSMStateStore-7hPWW7v7.mjs → SSMStateStore-hnP5deFJ.mjs} +4 -2
  18. package/dist/SSMStateStore-hnP5deFJ.mjs.map +1 -0
  19. package/dist/StateStore-B4u0wx2t.cjs +412 -0
  20. package/dist/StateStore-B4u0wx2t.cjs.map +1 -0
  21. package/dist/StateStore-CYUfGYcx.mjs +359 -0
  22. package/dist/StateStore-CYUfGYcx.mjs.map +1 -0
  23. package/dist/{backends-BcHL7Gy_.mjs → backends-CjuCiE8h.mjs} +2 -2
  24. package/dist/{backends-BcHL7Gy_.mjs.map → backends-CjuCiE8h.mjs.map} +1 -1
  25. package/dist/{backends-_gQdAzBK.cjs → backends-v3xDuhO0.cjs} +2 -2
  26. package/dist/{backends-_gQdAzBK.cjs.map → backends-v3xDuhO0.cjs.map} +1 -1
  27. package/dist/{bundler-URImKj-A.mjs → bundler-ouDdNvk9.mjs} +2 -2
  28. package/dist/{bundler-URImKj-A.mjs.map → bundler-ouDdNvk9.mjs.map} +1 -1
  29. package/dist/{config-20V08LZp.mjs → config-EuzFEkcH.mjs} +2 -2
  30. package/dist/config-EuzFEkcH.mjs.map +1 -0
  31. package/dist/{config-CP6wsLHA.cjs → config-WMOB9bLL.cjs} +2 -2
  32. package/dist/config-WMOB9bLL.cjs.map +1 -0
  33. package/dist/config.cjs +2 -2
  34. package/dist/config.d.cts +65 -3
  35. package/dist/config.d.cts.map +1 -1
  36. package/dist/config.d.mts +65 -3
  37. package/dist/config.d.mts.map +1 -1
  38. package/dist/config.mjs +2 -2
  39. package/dist/{encryption-10Srqiix.cjs → encryption-BP8zfxEJ.cjs} +4 -4
  40. package/dist/{encryption-10Srqiix.cjs.map → encryption-BP8zfxEJ.cjs.map} +1 -1
  41. package/dist/{encryption-KQTYFEzU.mjs → encryption-BW7Gh51G.mjs} +4 -4
  42. package/dist/{encryption-KQTYFEzU.mjs.map → encryption-BW7Gh51G.mjs.map} +1 -1
  43. package/dist/{github-DtRUkHN8.cjs → github-DHpDMBT7.cjs} +3 -3
  44. package/dist/{github-DtRUkHN8.cjs.map → github-DHpDMBT7.cjs.map} +1 -1
  45. package/dist/{github-y7OEj59M.mjs → github-T_6sK3Rz.mjs} +3 -3
  46. package/dist/{github-y7OEj59M.mjs.map → github-T_6sK3Rz.mjs.map} +1 -1
  47. package/dist/{index-DMcjpvd-.d.cts → index-B5TJ-mB6.d.mts} +2 -2
  48. package/dist/{index-DMcjpvd-.d.cts.map → index-B5TJ-mB6.d.mts.map} +1 -1
  49. package/dist/{index-RM92lx8A.d.mts → index-DV6j4Ydq.d.cts} +2 -2
  50. package/dist/{index-RM92lx8A.d.mts.map → index-DV6j4Ydq.d.cts.map} +1 -1
  51. package/dist/index.cjs +1633 -771
  52. package/dist/index.cjs.map +1 -1
  53. package/dist/index.mjs +1648 -784
  54. package/dist/index.mjs.map +1 -1
  55. package/dist/{migrate-CS3DkO6y.mjs → migrate-BrFREmgQ.mjs} +4 -4
  56. package/dist/{migrate-CS3DkO6y.mjs.map → migrate-BrFREmgQ.mjs.map} +1 -1
  57. package/dist/{migrate-DQ54y2WD.cjs → migrate-CThJfVHR.cjs} +4 -4
  58. package/dist/{migrate-DQ54y2WD.cjs.map → migrate-CThJfVHR.cjs.map} +1 -1
  59. package/dist/openapi.cjs +2 -2
  60. package/dist/openapi.d.cts +1 -1
  61. package/dist/openapi.d.mts +1 -1
  62. package/dist/openapi.mjs +2 -2
  63. package/dist/{ready-BSpUZnNo.mjs → ready-DbDHneLR.mjs} +2 -2
  64. package/dist/ready-DbDHneLR.mjs.map +1 -0
  65. package/dist/{ready-MUhVJDHe.cjs → ready-mR96orkJ.cjs} +2 -2
  66. package/dist/ready-mR96orkJ.cjs.map +1 -0
  67. package/dist/reconcile/public.d.cts +1 -1
  68. package/dist/reconcile/public.d.mts +1 -1
  69. package/dist/{types-BIyIQUR-.d.cts → types-CipIIm8f.d.mts} +65 -3
  70. package/dist/types-CipIIm8f.d.mts.map +1 -0
  71. package/dist/{types-CmQl6zaP.d.mts → types-D9b-IYdW.d.cts} +66 -2
  72. package/dist/types-D9b-IYdW.d.cts.map +1 -0
  73. package/dist/vitest.cjs +1 -1
  74. package/dist/vitest.mjs +1 -1
  75. package/dist/workspace/index.cjs +1 -1
  76. package/dist/workspace/index.d.cts +2 -2
  77. package/dist/workspace/index.d.mts +2 -2
  78. package/dist/workspace/index.mjs +1 -1
  79. package/dist/{workspace-BpdXIeWV.cjs → workspace-B64Xk1Mb.cjs} +16 -4
  80. package/dist/workspace-B64Xk1Mb.cjs.map +1 -0
  81. package/dist/{workspace-CgWcE_Yr.mjs → workspace-BRZ14o-o.mjs} +11 -8
  82. package/dist/{workspace-CgWcE_Yr.mjs.map → workspace-BRZ14o-o.mjs.map} +1 -1
  83. package/dist/{workspace-C2mFV53i.mjs → workspace-Bp1xVaOz.mjs} +16 -4
  84. package/dist/workspace-Bp1xVaOz.mjs.map +1 -0
  85. package/dist/{workspace-BbPJVCto.cjs → workspace-Dbn07ILl.cjs} +9 -6
  86. package/dist/{workspace-BbPJVCto.cjs.map → workspace-Dbn07ILl.cjs.map} +1 -1
  87. package/package.json +13 -13
  88. package/dist/LocalStateProvider-7HR6SS2t.cjs +0 -48
  89. package/dist/LocalStateProvider-7HR6SS2t.cjs.map +0 -1
  90. package/dist/LocalStateProvider-D7QyJoLM.mjs +0 -48
  91. package/dist/LocalStateProvider-D7QyJoLM.mjs.map +0 -1
  92. package/dist/LocalStateStore-BupdV-VN.mjs +0 -163
  93. package/dist/LocalStateStore-BupdV-VN.mjs.map +0 -1
  94. package/dist/LocalStateStore-DDDU0HkI.cjs +0 -163
  95. package/dist/LocalStateStore-DDDU0HkI.cjs.map +0 -1
  96. package/dist/OpenApiTsGenerator-BLRQ7Iu9.cjs.map +0 -1
  97. package/dist/OpenApiTsGenerator-NThANByQ.mjs.map +0 -1
  98. package/dist/SSMStateProvider-B0Hu1MKZ.mjs +0 -75
  99. package/dist/SSMStateProvider-B0Hu1MKZ.mjs.map +0 -1
  100. package/dist/SSMStateProvider-VrQ-yeqd.cjs +0 -74
  101. package/dist/SSMStateProvider-VrQ-yeqd.cjs.map +0 -1
  102. package/dist/SSMStateStore-7hPWW7v7.mjs.map +0 -1
  103. package/dist/SSMStateStore-C7KcDSTb.cjs.map +0 -1
  104. package/dist/config-20V08LZp.mjs.map +0 -1
  105. package/dist/config-CP6wsLHA.cjs.map +0 -1
  106. package/dist/ready-BSpUZnNo.mjs.map +0 -1
  107. package/dist/ready-MUhVJDHe.cjs.map +0 -1
  108. package/dist/types-BIyIQUR-.d.cts.map +0 -1
  109. package/dist/types-CmQl6zaP.d.mts.map +0 -1
  110. package/dist/workspace-BpdXIeWV.cjs.map +0 -1
  111. package/dist/workspace-C2mFV53i.mjs.map +0 -1
package/dist/index.mjs CHANGED
@@ -1,29 +1,31 @@
1
1
  #!/usr/bin/env -S npx tsx
2
2
  import { n as __require, t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
3
3
  import { i as ConstructGenerator, t as discover } from "./discover-Cf2jAcgF.mjs";
4
- import { d as parseModuleConfig, f as appKey, l as loadWorkspaceConfig, n as NotABackendApp, o as loadAppConfig, p as derivedApps, r as NotInAnApp, s as loadConfig, t as ConfigNotFound, u as loadWorkspaceSettings } from "./config-20V08LZp.mjs";
5
- import { D as assertDeployedStage, E as TEST_STAGE, O as deployedProblems, T as InvalidStages, a as getAppBuildOrder, b as getDeployTargetError, k as stageProblems, m as stripPublicPrefix, o as getAppGkmConfig, p as getPublicEnvPrefix, t as allConstructGlobs, x as isDeployTargetSupported } from "./workspace-C2mFV53i.mjs";
4
+ import { d as parseModuleConfig, f as appKey, l as loadWorkspaceConfig, n as NotABackendApp, o as loadAppConfig, p as derivedApps, r as NotInAnApp, s as loadConfig, t as ConfigNotFound, u as loadWorkspaceSettings } from "./config-EuzFEkcH.mjs";
5
+ import { D as assertDeployedStage, E as TEST_STAGE, O as deployedProblems, T as InvalidStages, a as getAppBuildOrder, b as getDeployTargetError, k as stageProblems, m as stripPublicPrefix, o as getAppGkmConfig, p as getPublicEnvPrefix, t as allConstructGlobs, x as isDeployTargetSupported } from "./workspace-Bp1xVaOz.mjs";
6
6
  import { a as withCustomSecret, i as toEmbeddableSecrets, n as maskPassword, t as initStageSecrets } from "./storage-BzaUxmqe.mjs";
7
7
  import { a as getKeyPath, i as FileSecretsStore, n as secretsStoreFor } from "./store-DeGATCS-.mjs";
8
- import { C as loadEnvFiles, S as isPortAvailable, T as prepareEntryCredentials, _ as fakeKey, a as reconcileWorkspace, b as createEntryWrapper, c as LOCAL_CA_PATH, d as postgresDatabaseNames, f as quoteIdentifier, g as describeServices, h as rootSite, i as derivedContainers, l as forgetState, m as resourceName, n as backendsOf$1, p as pgClient, r as constructGlobs, s as COMPOSE_PATH, t as FAKE_ENV, u as applyPostgres, v as primaryPortKey, w as loadSecretsForApp, x as findAvailablePort, y as createCredentialsPreload } from "./workspace-CgWcE_Yr.mjs";
9
- import { n as EndpointGenerator, r as runtimeFor } from "./OpenApiTsGenerator-NThANByQ.mjs";
8
+ import { C as loadEnvFiles, S as isPortAvailable, T as prepareEntryCredentials, _ as fakeKey, a as reconcileWorkspace, b as createEntryWrapper, c as LOCAL_CA_PATH, d as postgresDatabaseNames, f as quoteIdentifier, g as describeServices, h as rootSite, i as derivedContainers, l as forgetState, m as resourceName, n as backendsOf$1, p as pgClient, r as constructGlobs, s as COMPOSE_PATH, t as FAKE_ENV, u as applyPostgres, v as primaryPortKey, w as loadSecretsForApp, x as findAvailablePort, y as createCredentialsPreload } from "./workspace-BRZ14o-o.mjs";
9
+ import { i as telemetryFor, n as EndpointGenerator, r as runtimeFor } from "./OpenApiTsGenerator-BRWD1Wit.mjs";
10
10
  import { generateOpenApi, openapiCommand, resolveOpenApiConfig } from "./openapi.mjs";
11
- import { a as writeTestHarness, c as driversFor, i as TEST_MANIFEST_ENV, n as TEST_READY_FILE, o as SubscriberGenerator, s as cacheBackendsIn, t as TEST_READY_ENV } from "./ready-BSpUZnNo.mjs";
12
- import { i as storageBackendFor, n as eventsBackendFor, r as providerOf, t as cacheBackendFor } from "./backends-BcHL7Gy_.mjs";
11
+ import { a as writeTestHarness, c as driversFor, i as TEST_MANIFEST_ENV, n as TEST_READY_FILE, o as SubscriberGenerator, s as cacheBackendsIn, t as TEST_READY_ENV } from "./ready-DbDHneLR.mjs";
12
+ import { i as storageBackendFor, n as eventsBackendFor, r as providerOf, t as cacheBackendFor } from "./backends-CjuCiE8h.mjs";
13
13
  import { n as withCompute, r as withRoutes, t as manifestModule } from "./emit-C1e8qD08.mjs";
14
+ import { c as createStateStore, i as StateStoreBusy, o as StateVersionConflict, r as StateLocked, s as contentVersion, t as DocumentStateStore } from "./StateStore-CYUfGYcx.mjs";
14
15
  import { createRequire } from "node:module";
15
16
  import { Command } from "commander";
16
17
  import { stdin, stdout } from "node:process";
17
18
  import * as readline from "node:readline/promises";
18
- import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
19
- import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
20
- import { homedir, hostname, tmpdir, userInfo } from "node:os";
19
+ import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
20
+ import { chmod, mkdir, mkdtemp, open, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
21
+ import { homedir, tmpdir } from "node:os";
21
22
  import { basename, dirname, isAbsolute, join, parse, relative, resolve } from "node:path";
22
23
  import { cacheTable, canonicalId, cloudName, cookieDomain, dependentsOf, externalApiUrl, kebabCase, migrationFolder, mobileOrigins, provideKey, provisionOrder, schemeBase, scopedName, serviceKey } from "@geekmidas/manifest";
23
24
  import fg from "fast-glob";
24
25
  import { execFile, execFileSync, execSync, spawn } from "node:child_process";
25
26
  import chokidar from "chokidar";
26
- import { createCipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
27
+ import { connect } from "node:net";
28
+ import { createCipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
27
29
  import { fileURLToPath, pathToFileURL } from "node:url";
28
30
  import { generateKeyring } from "@geekmidas/constructs/encryption";
29
31
  import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
@@ -32,9 +34,11 @@ import { parse as parse$1, parseDocument } from "yaml";
32
34
  import { promisify } from "node:util";
33
35
  import { cacheTableStatements } from "@geekmidas/cache/postgres";
34
36
  import prompts from "prompts";
35
- import { createServer } from "http";
37
+ import { createServer as createServer$1 } from "node:http";
38
+ import { Readable } from "node:stream";
39
+ import { createServer as createServer$2 } from "http";
36
40
  import { Http2ServerRequest } from "http2";
37
- import { Readable } from "stream";
41
+ import { Readable as Readable$1 } from "stream";
38
42
  import crypto from "crypto";
39
43
  import { Cron } from "@geekmidas/constructs/crons";
40
44
  import { Function } from "@geekmidas/constructs/functions";
@@ -42,7 +46,7 @@ import { Queue } from "@geekmidas/constructs/queue";
42
46
  import { Topic } from "@geekmidas/constructs/topic";
43
47
  import { lookup } from "node:dns/promises";
44
48
  //#region package.json
45
- var version = "10.0.0-alpha.57";
49
+ var version = "10.0.0-alpha.59";
46
50
  //#endregion
47
51
  //#region src/auth/credentials.ts
48
52
  /**
@@ -136,15 +140,6 @@ async function getDokployToken(options) {
136
140
  return null;
137
141
  }
138
142
  /**
139
- * Store Dokploy registry ID
140
- */
141
- async function storeDokployRegistryId(registryId, options) {
142
- const credentials = await readCredentials(options);
143
- if (!credentials.dokploy) throw new Error("Dokploy credentials not found. Run \"gkm login --service dokploy\" first.");
144
- credentials.dokploy.registryId = registryId;
145
- await writeCredentials(credentials, options);
146
- }
147
- /**
148
143
  * Get Dokploy registry ID from stored credentials
149
144
  */
150
145
  async function getDokployRegistryId(options) {
@@ -765,6 +760,621 @@ async function assignAppPorts(workspace, names, deps) {
765
760
  running
766
761
  };
767
762
  }
763
+ /** Overrides `dev.discoveryPort` — `0` serves on a port of its own. */
764
+ const DISCOVERY_PORT_ENV = "GKM_DISCOVERY_PORT";
765
+ /**
766
+ * Set by a workspace's `gkm dev` for the app sessions turbo starts under it:
767
+ * the workspace has printed the connect URL, so they do not repeat it.
768
+ */
769
+ const DISCOVERY_QUIET_ENV = "GKM_DISCOVERY_QUIET";
770
+ /** The route everything is served under. */
771
+ const BASE = "/__gkm";
772
+ /** `GKM_DISCOVERY_PORT` is set to something that is not a port. */
773
+ var InvalidDiscoveryPort = class extends Error {
774
+ value;
775
+ constructor(value) {
776
+ super(`${DISCOVERY_PORT_ENV}=${value} is not a port. Set it to a number from 0 to 65535 (0 serves discovery on a free port of its own), or unset it to use dev.discoveryPort.`);
777
+ this.value = value;
778
+ this.name = "InvalidDiscoveryPort";
779
+ }
780
+ };
781
+ /** The port the endpoint uses: the env, then `dev.discoveryPort`, then 4983. */
782
+ function discoveryPort(config, env = process.env) {
783
+ const raw = env[DISCOVERY_PORT_ENV];
784
+ if (raw !== void 0 && raw !== "") {
785
+ const port = Number(raw);
786
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new InvalidDiscoveryPort(raw);
787
+ return port;
788
+ }
789
+ return config?.discoveryPort ?? 4983;
790
+ }
791
+ /** The machine's registry, shared by every `gkm dev`. */
792
+ function discoveryRegistry(env = process.env) {
793
+ return env["GKM_DEV_REGISTRY"] || join(homedir(), ".gkm", "dev");
794
+ }
795
+ function sessionsDir(registry) {
796
+ return join(registry, "sessions");
797
+ }
798
+ /** Whether a process is alive. `EPERM` is someone else's — alive. */
799
+ function alive(pid) {
800
+ try {
801
+ process.kill(pid, 0);
802
+ return true;
803
+ } catch (error) {
804
+ return error.code === "EPERM";
805
+ }
806
+ }
807
+ /**
808
+ * Every live session, pruning the ones whose process is gone — a `gkm dev`
809
+ * killed outright never got to remove its own.
810
+ */
811
+ function liveSessions(registry) {
812
+ const dir = sessionsDir(registry);
813
+ let files;
814
+ try {
815
+ files = readdirSync(dir).filter((file) => file.endsWith(".json"));
816
+ } catch {
817
+ return [];
818
+ }
819
+ const records = [];
820
+ for (const file of files) {
821
+ const path = join(dir, file);
822
+ let record;
823
+ try {
824
+ record = JSON.parse(readFileSync(path, "utf8"));
825
+ } catch {
826
+ continue;
827
+ }
828
+ if (alive(record.pid)) {
829
+ records.push(record);
830
+ continue;
831
+ }
832
+ try {
833
+ unlinkSync(path);
834
+ } catch {}
835
+ }
836
+ return records;
837
+ }
838
+ /** Written whole, then renamed into place, so a reader never sees half. */
839
+ function writeRecord(registry, record) {
840
+ const path = join(sessionsDir(registry), `${record.id}.json`);
841
+ const partial = `${path}.${process.pid}.tmp`;
842
+ writeFileSync(partial, JSON.stringify(record), { mode: 384 });
843
+ renameSync(partial, path);
844
+ }
845
+ function removeRecord(registry, id) {
846
+ try {
847
+ unlinkSync(join(sessionsDir(registry), `${id}.json`));
848
+ } catch {}
849
+ }
850
+ /**
851
+ * The token every session prints and every host checks.
852
+ *
853
+ * Shared through the registry, so a URL printed by one session still works
854
+ * after another has taken over hosting. It lives as long as some session does:
855
+ * a session that finds none but itself starts a new one, so a token printed
856
+ * yesterday stops working once everything it was printed by has stopped.
857
+ */
858
+ function sessionToken(registry, self) {
859
+ const path = join(registry, "token");
860
+ if (liveSessions(registry).filter((r) => r.id !== self).length === 0) {
861
+ const stale = `${path}.${self}.stale`;
862
+ try {
863
+ renameSync(path, stale);
864
+ unlinkSync(stale);
865
+ } catch {}
866
+ }
867
+ const fresh = `${path}.${self}.new`;
868
+ writeFileSync(fresh, randomBytes(32).toString("base64url"), { mode: 384 });
869
+ try {
870
+ linkSync(fresh, path);
871
+ } catch {}
872
+ unlinkSync(fresh);
873
+ return readFileSync(path, "utf8").trim();
874
+ }
875
+ /** `<name>-<8 hex>`: readable, and two checkouts of a project stay apart. */
876
+ function workspaceId(name, root) {
877
+ return `${name.toLowerCase().replace(/^@/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "workspace"}-${createHash("sha256").update(root).digest("hex").slice(0, 8)}`;
878
+ }
879
+ /** Whether anything accepts connections on a local port. */
880
+ function listening(port) {
881
+ return new Promise((resolve) => {
882
+ const socket = connect({
883
+ port,
884
+ host: "localhost"
885
+ });
886
+ const done = (open) => {
887
+ socket.destroy();
888
+ resolve(open);
889
+ };
890
+ socket.setTimeout(300, () => done(false));
891
+ socket.once("connect", () => done(true));
892
+ socket.once("error", () => done(false));
893
+ });
894
+ }
895
+ /** Which data APIs the forwarder matches by prefix, rather than exactly. */
896
+ const PREFIXED = /* @__PURE__ */ new Set(["telescope", "database"]);
897
+ /** Whether `path` on an app is one of its data APIs. */
898
+ function servesData(app, path) {
899
+ return app.dataApis.some((api) => PREFIXED.has(api.kind) ? path === api.path || path.startsWith(`${api.path}/`) : path === api.path);
900
+ }
901
+ /**
902
+ * Every workspace the sessions describe, one entry per root.
903
+ *
904
+ * An app listed only by its workspace's session — a web app turbo started, an
905
+ * API whose own `gkm dev` has not registered yet — is `ready` when its port
906
+ * answers and `starting` until then.
907
+ */
908
+ async function collect(records) {
909
+ const byRoot = /* @__PURE__ */ new Map();
910
+ for (const record of records) {
911
+ const group = byRoot.get(record.workspace.root) ?? [];
912
+ group.push(record);
913
+ byRoot.set(record.workspace.root, group);
914
+ }
915
+ const views = [];
916
+ for (const [root, group] of byRoot) {
917
+ group.sort((a, b) => Number(b.role === "workspace") - Number(a.role === "workspace") || a.startedAt.localeCompare(b.startedAt));
918
+ const lead = group[0];
919
+ const id = workspaceId(lead.workspace.name, root);
920
+ const apps = /* @__PURE__ */ new Map();
921
+ for (const record of group) for (const app of record.apps) {
922
+ const own = record.role === "app";
923
+ if (!own && apps.has(app.name)) continue;
924
+ apps.set(app.name, {
925
+ app,
926
+ pid: own ? record.pid : null
927
+ });
928
+ }
929
+ const discovered = await Promise.all([...apps.values()].map(async ({ app, pid }) => {
930
+ const url = `http://localhost:${app.port}`;
931
+ const status = pid === null ? await listening(app.port) ? "ready" : "starting" : app.status;
932
+ return {
933
+ name: app.name,
934
+ type: app.type,
935
+ port: app.port,
936
+ url,
937
+ publicUrl: app.publicUrl ?? null,
938
+ status,
939
+ reloads: app.reloads,
940
+ pid,
941
+ dataApis: app.dataApis.map((api) => ({
942
+ kind: api.kind,
943
+ path: api.path,
944
+ url: `${url}${api.path}`,
945
+ proxy: `${BASE}/workspaces/${id}/apps/${encodeURIComponent(app.name)}${api.path}`
946
+ }))
947
+ };
948
+ }));
949
+ discovered.sort((a, b) => a.name.localeCompare(b.name));
950
+ views.push({
951
+ id,
952
+ name: lead.workspace.name,
953
+ root,
954
+ stage: lead.workspace.stage,
955
+ manifest: group.find((r) => r.manifest)?.manifest ?? null,
956
+ apps: discovered,
957
+ allowedOrigins: [...new Set(group.flatMap((r) => r.allowedOrigins))]
958
+ });
959
+ }
960
+ return views.sort((a, b) => a.name.localeCompare(b.name) || a.root.localeCompare(b.root));
961
+ }
962
+ /** What changed between two views, as events. */
963
+ function changes(before, after) {
964
+ const out = [];
965
+ const previous = new Map(before.map((w) => [w.id, w]));
966
+ const next = new Map(after.map((w) => [w.id, w]));
967
+ for (const workspace of after) {
968
+ const was = previous.get(workspace.id);
969
+ if (!was) out.push({
970
+ workspace,
971
+ event: {
972
+ type: "workspace.started",
973
+ workspace: workspace.id
974
+ }
975
+ });
976
+ const wasApps = new Map((was?.apps ?? []).map((a) => [a.name, a]));
977
+ for (const app of workspace.apps) {
978
+ const old = wasApps.get(app.name);
979
+ const type = !old ? "app.started" : app.reloads > old.reloads ? "app.reloaded" : app.status !== old.status || app.port !== old.port || app.dataApis.length !== old.dataApis.length ? "app.status" : void 0;
980
+ if (type) out.push({
981
+ workspace,
982
+ event: {
983
+ type,
984
+ workspace: workspace.id,
985
+ app
986
+ }
987
+ });
988
+ }
989
+ for (const old of was?.apps ?? []) if (!workspace.apps.some((a) => a.name === old.name)) out.push({
990
+ workspace,
991
+ event: {
992
+ type: "app.stopped",
993
+ workspace: workspace.id,
994
+ app: old
995
+ }
996
+ });
997
+ }
998
+ for (const workspace of before) {
999
+ if (next.has(workspace.id)) continue;
1000
+ for (const app of workspace.apps) out.push({
1001
+ workspace,
1002
+ event: {
1003
+ type: "app.stopped",
1004
+ workspace: workspace.id,
1005
+ app
1006
+ }
1007
+ });
1008
+ out.push({
1009
+ workspace,
1010
+ event: {
1011
+ type: "workspace.stopped",
1012
+ workspace: workspace.id
1013
+ }
1014
+ });
1015
+ }
1016
+ return out;
1017
+ }
1018
+ /** A browser with no `Origin` — curl, a local process — sees everything. */
1019
+ function visibleTo(workspace, origin) {
1020
+ return origin === void 0 || workspace.allowedOrigins.includes(origin);
1021
+ }
1022
+ function publicView({ allowedOrigins: _, ...workspace }) {
1023
+ return workspace;
1024
+ }
1025
+ /** Headers an app's response is passed on with; nothing that sets state. */
1026
+ const FORWARDED_RESPONSE_HEADERS = [
1027
+ "content-type",
1028
+ "cache-control",
1029
+ "etag",
1030
+ "last-modified"
1031
+ ];
1032
+ /**
1033
+ * The HTTP side: serves the registry it reads, checking every request.
1034
+ * @internal Exported for testing
1035
+ */
1036
+ var DiscoveryHost = class {
1037
+ registry;
1038
+ token;
1039
+ pollMs;
1040
+ server;
1041
+ view = [];
1042
+ streams = /* @__PURE__ */ new Set();
1043
+ scanning;
1044
+ timers = [];
1045
+ sequence = 0;
1046
+ constructor(registry, token, pollMs) {
1047
+ this.registry = registry;
1048
+ this.token = token;
1049
+ this.pollMs = pollMs;
1050
+ }
1051
+ /** The port it is bound to, once it is. */
1052
+ get port() {
1053
+ return (this.server?.address())?.port;
1054
+ }
1055
+ /** Binds the port; `false` when another process already has it. */
1056
+ async listen(port) {
1057
+ const server = createServer$1((request, response) => {
1058
+ this.handle(request, response).catch(() => {
1059
+ if (!response.headersSent) reply(response, 500, { error: "DiscoveryFailed" });
1060
+ else response.destroy();
1061
+ });
1062
+ });
1063
+ if (!await new Promise((resolve) => {
1064
+ server.once("error", () => resolve(false));
1065
+ server.listen(port, "127.0.0.1", () => resolve(true));
1066
+ })) return false;
1067
+ server.unref();
1068
+ this.server = server;
1069
+ this.view = await this.scan();
1070
+ this.timers.push(setInterval(() => void this.scan(), this.pollMs).unref(), setInterval(() => {
1071
+ for (const { response } of this.streams) response.write(": ping\n\n");
1072
+ }, 15e3).unref());
1073
+ return true;
1074
+ }
1075
+ async close() {
1076
+ for (const timer of this.timers) clearInterval(timer);
1077
+ this.timers = [];
1078
+ for (const { response } of this.streams) response.end();
1079
+ this.streams.clear();
1080
+ const server = this.server;
1081
+ this.server = void 0;
1082
+ if (!server) return;
1083
+ await new Promise((resolve) => {
1084
+ server.close(() => resolve());
1085
+ server.closeAllConnections();
1086
+ });
1087
+ }
1088
+ /** Reads the registry, and tells every stream what changed. One at a time. */
1089
+ scan() {
1090
+ const run = (this.scanning ?? Promise.resolve()).then(async () => {
1091
+ const next = await collect(liveSessions(this.registry));
1092
+ for (const { workspace, event } of changes(this.view, next)) this.broadcast(workspace, event);
1093
+ this.view = next;
1094
+ return next;
1095
+ });
1096
+ this.scanning = run.catch(() => this.view);
1097
+ return run;
1098
+ }
1099
+ broadcast(workspace, event) {
1100
+ this.sequence += 1;
1101
+ for (const stream of this.streams) {
1102
+ if (!visibleTo(workspace, stream.origin)) continue;
1103
+ send(stream.response, event, this.sequence);
1104
+ }
1105
+ }
1106
+ snapshot(origin) {
1107
+ return {
1108
+ version: 1,
1109
+ host: {
1110
+ pid: process.pid,
1111
+ port: this.port ?? 0
1112
+ },
1113
+ events: `${BASE}/events`,
1114
+ workspaces: this.view.filter((w) => visibleTo(w, origin)).map(publicView)
1115
+ };
1116
+ }
1117
+ async handle(request, response) {
1118
+ const port = this.port;
1119
+ const host = request.headers.host;
1120
+ if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {
1121
+ reply(response, 403, {
1122
+ error: "HostNotAllowed",
1123
+ message: `Reach discovery at http://127.0.0.1:${port}.`
1124
+ });
1125
+ return;
1126
+ }
1127
+ const origin = request.headers.origin;
1128
+ const allowed = this.view.flatMap((w) => w.allowedOrigins);
1129
+ if (origin !== void 0 && !allowed.includes(origin)) {
1130
+ reply(response, 403, {
1131
+ error: "OriginNotAllowed",
1132
+ message: `Add ${origin} to dev.allowedOrigins in gkm.config.ts to let it read gkm dev.`
1133
+ });
1134
+ return;
1135
+ }
1136
+ if (origin !== void 0) {
1137
+ response.setHeader("access-control-allow-origin", origin);
1138
+ response.setHeader("vary", "Origin");
1139
+ }
1140
+ if (request.method === "OPTIONS") {
1141
+ response.setHeader("access-control-allow-methods", "GET, HEAD");
1142
+ response.setHeader("access-control-allow-headers", "Authorization");
1143
+ response.setHeader("access-control-max-age", "600");
1144
+ if (request.headers["access-control-request-private-network"]) response.setHeader("access-control-allow-private-network", "true");
1145
+ response.writeHead(204).end();
1146
+ return;
1147
+ }
1148
+ const url = new URL(request.url ?? "/", `http://${host}`);
1149
+ if (!this.authorized(request, url)) {
1150
+ reply(response, 401, {
1151
+ error: "TokenRequired",
1152
+ message: "Use the connect URL gkm dev printed, or send its token as `Authorization: Bearer <token>`."
1153
+ });
1154
+ return;
1155
+ }
1156
+ if (request.method !== "GET" && request.method !== "HEAD") {
1157
+ response.setHeader("allow", "GET, HEAD, OPTIONS");
1158
+ reply(response, 405, {
1159
+ error: "ReadOnly",
1160
+ message: "Discovery is read-only: GET and HEAD."
1161
+ });
1162
+ return;
1163
+ }
1164
+ const path = url.pathname.replace(/\/+$/, "") || "/";
1165
+ if (path === BASE) {
1166
+ await this.scan();
1167
+ reply(response, 200, this.snapshot(origin));
1168
+ return;
1169
+ }
1170
+ if (path === `${BASE}/events`) {
1171
+ await this.scan();
1172
+ this.stream(response, origin);
1173
+ response.once("close", () => {
1174
+ for (const stream of this.streams) if (stream.response === response) this.streams.delete(stream);
1175
+ });
1176
+ return;
1177
+ }
1178
+ const forwarded = path.match(/^\/__gkm\/workspaces\/([^/]+)\/apps\/([^/]+)(\/.*)$/);
1179
+ if (forwarded) {
1180
+ const [, id, name, rest] = forwarded;
1181
+ await this.forward(request, response, origin, id, decodeURIComponent(name), rest, url);
1182
+ return;
1183
+ }
1184
+ reply(response, 404, { error: "NotFound" });
1185
+ }
1186
+ authorized(request, url) {
1187
+ const header = request.headers.authorization;
1188
+ const given = header?.startsWith("Bearer ") ? header.slice(7).trim() : url.searchParams.get("token");
1189
+ if (!given) return false;
1190
+ const a = Buffer.from(given);
1191
+ const b = Buffer.from(this.token);
1192
+ return a.length === b.length && timingSafeEqual(a, b);
1193
+ }
1194
+ stream(response, origin) {
1195
+ response.writeHead(200, {
1196
+ "content-type": "text/event-stream",
1197
+ "cache-control": "no-cache",
1198
+ connection: "keep-alive"
1199
+ });
1200
+ response.write("retry: 1000\n\n");
1201
+ send(response, {
1202
+ type: "snapshot",
1203
+ snapshot: this.snapshot(origin)
1204
+ });
1205
+ this.streams.add({
1206
+ origin,
1207
+ response
1208
+ });
1209
+ }
1210
+ /** Passes a read of one data API on to the app that serves it. */
1211
+ async forward(request, response, origin, id, name, path, url) {
1212
+ const app = this.view.find((w) => w.id === id && visibleTo(w, origin))?.apps.find((a) => a.name === name);
1213
+ if (!app) {
1214
+ reply(response, 404, { error: "AppNotFound" });
1215
+ return;
1216
+ }
1217
+ if (!servesData(app, path)) {
1218
+ reply(response, 404, {
1219
+ error: "NotADataApi",
1220
+ message: `Only ${name}'s data APIs are forwarded: ${app.dataApis.map((a) => a.path).join(", ") || "it has none"}.`
1221
+ });
1222
+ return;
1223
+ }
1224
+ const search = new URLSearchParams(url.searchParams);
1225
+ search.delete("token");
1226
+ const query = search.size > 0 ? `?${search}` : "";
1227
+ const abort = new AbortController();
1228
+ response.once("close", () => abort.abort());
1229
+ let upstream;
1230
+ try {
1231
+ upstream = await fetch(`http://localhost:${app.port}${path}${query}`, {
1232
+ method: request.method,
1233
+ headers: { accept: request.headers.accept ?? "*/*" },
1234
+ signal: abort.signal
1235
+ });
1236
+ } catch {
1237
+ reply(response, 502, {
1238
+ error: "AppUnavailable",
1239
+ message: `${name} is not answering on port ${app.port}.`
1240
+ });
1241
+ return;
1242
+ }
1243
+ const headers = {};
1244
+ for (const key of FORWARDED_RESPONSE_HEADERS) {
1245
+ const value = upstream.headers.get(key);
1246
+ if (value) headers[key] = value;
1247
+ }
1248
+ response.writeHead(upstream.status, headers);
1249
+ if (!upstream.body || request.method === "HEAD") {
1250
+ response.end();
1251
+ return;
1252
+ }
1253
+ Readable.fromWeb(upstream.body).on("error", () => response.destroy()).pipe(response);
1254
+ }
1255
+ };
1256
+ function reply(response, status, body) {
1257
+ response.writeHead(status, { "content-type": "application/json" });
1258
+ response.end(JSON.stringify(body));
1259
+ }
1260
+ function send(response, event, id) {
1261
+ response.write(`${id === void 0 ? "" : `id: ${id}\n`}event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
1262
+ }
1263
+ /**
1264
+ * One `gkm dev`'s membership: its record in the registry, and — while it holds
1265
+ * the port — the endpoint itself.
1266
+ */
1267
+ var DiscoverySession = class DiscoverySession {
1268
+ registry;
1269
+ port;
1270
+ record;
1271
+ token;
1272
+ pollMs;
1273
+ host;
1274
+ election;
1275
+ left = false;
1276
+ cleanup = () => removeRecord(this.registry, this.record.id);
1277
+ constructor(registry, port, record, token, pollMs) {
1278
+ this.registry = registry;
1279
+ this.port = port;
1280
+ this.record = record;
1281
+ this.token = token;
1282
+ this.pollMs = pollMs;
1283
+ }
1284
+ static async join(options) {
1285
+ const registry = options.registry ?? discoveryRegistry();
1286
+ mkdirSync(sessionsDir(registry), {
1287
+ recursive: true,
1288
+ mode: 448
1289
+ });
1290
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1291
+ const record = {
1292
+ id: `${process.pid}-${randomBytes(4).toString("hex")}`,
1293
+ pid: process.pid,
1294
+ role: options.role,
1295
+ startedAt: now,
1296
+ updatedAt: now,
1297
+ workspace: options.workspace,
1298
+ allowedOrigins: [...options.allowedOrigins ?? []],
1299
+ ...options.manifest ? { manifest: options.manifest } : {},
1300
+ apps: options.apps
1301
+ };
1302
+ writeRecord(registry, record);
1303
+ const token = sessionToken(registry, record.id);
1304
+ const session = new DiscoverySession(registry, options.port ?? 4983, record, token, options.pollMs ?? 500);
1305
+ process.once("exit", session.cleanup);
1306
+ await session.elect();
1307
+ if (!session.host && session.port !== 0) session.election = setInterval(() => void session.elect(), options.electionMs ?? 1e3).unref();
1308
+ return session;
1309
+ }
1310
+ /** Whether this session is the one serving the endpoint. */
1311
+ get hosting() {
1312
+ return this.host !== void 0;
1313
+ }
1314
+ /** The port clients reach — this host's own, when it chose one. */
1315
+ get endpointPort() {
1316
+ return this.host?.port ?? this.port;
1317
+ }
1318
+ /** Jupyter-style: the endpoint, with the token that opens it. */
1319
+ get connectUrl() {
1320
+ return `http://127.0.0.1:${this.endpointPort}${BASE}?token=${this.token}`;
1321
+ }
1322
+ /** Changes one of this session's apps, and tells the endpoint. */
1323
+ updateApp(name, change) {
1324
+ if (this.left) return;
1325
+ this.record.apps = this.record.apps.map((app) => app.name === name ? change(app) : app);
1326
+ this.record.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1327
+ writeRecord(this.registry, this.record);
1328
+ this.host?.scan();
1329
+ }
1330
+ /** Leaves the registry, and the port to whichever session takes it next. */
1331
+ async leave() {
1332
+ if (this.left) return;
1333
+ this.left = true;
1334
+ if (this.election) clearInterval(this.election);
1335
+ process.removeListener("exit", this.cleanup);
1336
+ this.cleanup();
1337
+ await this.host?.close();
1338
+ this.host = void 0;
1339
+ if (liveSessions(this.registry).length === 0) try {
1340
+ unlinkSync(join(this.registry, "token"));
1341
+ } catch {}
1342
+ }
1343
+ async elect() {
1344
+ if (this.host || this.left) return;
1345
+ const host = new DiscoveryHost(this.registry, this.token, this.pollMs);
1346
+ if (!await host.listen(this.port)) return;
1347
+ if (this.left) {
1348
+ await host.close();
1349
+ return;
1350
+ }
1351
+ this.host = host;
1352
+ if (this.election) clearInterval(this.election);
1353
+ this.election = void 0;
1354
+ }
1355
+ };
1356
+ /** Joins the machine's discovery; see {@link DiscoverySession}. */
1357
+ function joinDiscovery(options) {
1358
+ return DiscoverySession.join(options);
1359
+ }
1360
+ /** The data APIs a generated dev app mounts, from what `gkm dev` built. */
1361
+ function dataApisOf(options) {
1362
+ if (options.selfServing) return [];
1363
+ return [
1364
+ ...options.telescopePath ? [{
1365
+ kind: "telescope",
1366
+ path: `${options.telescopePath}/api`
1367
+ }] : [],
1368
+ ...options.databaseApi ? [{
1369
+ kind: "database",
1370
+ path: options.databaseApi
1371
+ }] : [],
1372
+ ...options.openApi ? [{
1373
+ kind: "openapi",
1374
+ path: "/__docs"
1375
+ }] : []
1376
+ ];
1377
+ }
768
1378
  //#endregion
769
1379
  //#region ../../node_modules/.pnpm/@hono+node-server@1.19.5_hono@4.13.8/node_modules/@hono/node-server/dist/index.mjs
770
1380
  var RequestError = class extends Error {
@@ -818,7 +1428,7 @@ var newRequestFromIncoming = (method, url, headers, incoming, abortController) =
818
1428
  let reader;
819
1429
  init.body = new ReadableStream({ async pull(controller) {
820
1430
  try {
821
- reader ||= Readable.toWeb(incoming).getReader();
1431
+ reader ||= Readable$1.toWeb(incoming).getReader();
822
1432
  const { done, value } = await reader.read();
823
1433
  if (done) controller.close();
824
1434
  else controller.enqueue(value);
@@ -826,7 +1436,7 @@ var newRequestFromIncoming = (method, url, headers, incoming, abortController) =
826
1436
  controller.error(error);
827
1437
  }
828
1438
  } });
829
- } else init.body = Readable.toWeb(incoming);
1439
+ } else init.body = Readable$1.toWeb(incoming);
830
1440
  }
831
1441
  return new Request(url, init);
832
1442
  };
@@ -1197,7 +1807,7 @@ var createAdaptorServer = (options) => {
1197
1807
  overrideGlobalObjects: options.overrideGlobalObjects,
1198
1808
  autoCleanupIncoming: options.autoCleanupIncoming
1199
1809
  });
1200
- return (options.createServer || createServer)(options.serverOptions || {}, requestListener);
1810
+ return (options.createServer || createServer$2)(options.serverOptions || {}, requestListener);
1201
1811
  };
1202
1812
  var serve = (options, listeningListener) => {
1203
1813
  const server = createAdaptorServer(options);
@@ -1430,6 +2040,7 @@ async function devCommand(options) {
1430
2040
  const runtime = config.runtime ?? "node";
1431
2041
  let secretsJsonPath;
1432
2042
  let publicUrl;
2043
+ let manifest;
1433
2044
  const appSecrets = await loadSecretsForApp(workspace ? await secretsStoreFor(workspace, config.stages.local) : new FileSecretsStore(secretsRoot), config.stages.local, workspaceAppName);
1434
2045
  if (workspace) {
1435
2046
  const reconciled = await reconcileWorkspace(workspace, {
@@ -1444,6 +2055,7 @@ async function devCommand(options) {
1444
2055
  Object.assign(appSecrets, reconciled.env);
1445
2056
  const own = reconciled.plan.resources.find((r) => (r.kind === "rest-api" || r.kind === "site") && appKey(r.id) === workspaceAppName);
1446
2057
  publicUrl = own ? reconciled.env[own.envKey] : void 0;
2058
+ manifest = reconciled.manifest;
1447
2059
  }
1448
2060
  if (Object.keys(appSecrets).length > 0) {
1449
2061
  const secretsDir = join(secretsRoot, ".gkm");
@@ -1456,6 +2068,38 @@ async function devCommand(options) {
1456
2068
  devServer.label = workspaceAppName ?? "server";
1457
2069
  devServer.publicUrl = publicUrl;
1458
2070
  await devServer.start();
2071
+ const discovery = await joinDiscovery({
2072
+ role: "app",
2073
+ port: discoveryPort(workspace.dev),
2074
+ workspace: {
2075
+ name: workspace.name,
2076
+ root: workspace.root,
2077
+ stage: workspace.stages.local
2078
+ },
2079
+ allowedOrigins: workspace.dev?.allowedOrigins ?? [],
2080
+ ...manifest ? { manifest } : {},
2081
+ apps: [{
2082
+ name: workspaceAppName,
2083
+ type: "backend",
2084
+ port: devServer.port,
2085
+ ...publicUrl ? { publicUrl } : {},
2086
+ status: "ready",
2087
+ reloads: 0,
2088
+ dataApis: dataApisOf({
2089
+ selfServing: devServer.selfServing,
2090
+ telescopePath: telescope?.path,
2091
+ databaseApi: initial.databaseApi,
2092
+ openApi: enableOpenApi
2093
+ })
2094
+ }]
2095
+ });
2096
+ if (!process.env["GKM_DISCOVERY_QUIET"]) announce(discovery);
2097
+ devServer.onStatus = (status) => discovery.updateApp(workspaceAppName, (app) => ({
2098
+ ...app,
2099
+ status,
2100
+ port: devServer.port,
2101
+ reloads: app.status === "reloading" && status === "ready" ? app.reloads + 1 : app.reloads
2102
+ }));
1459
2103
  const hooksFile = (config.hooks?.server?.split("#"))?.[0];
1460
2104
  const normalizedPatterns = [config.constructs, ...hooksFile ? [hooksFile.endsWith(".ts") ? hooksFile : `${hooksFile}.ts`] : []].flat().filter((p) => typeof p === "string").map((p) => isAbsolute(p) ? relative(appRoot, p) : p.replace(/^\.\//, ""));
1461
2105
  const resolvedFiles = await fg(normalizedPatterns, {
@@ -1496,7 +2140,11 @@ async function devCommand(options) {
1496
2140
  if (isShuttingDown) return;
1497
2141
  isShuttingDown = true;
1498
2142
  logger$9.log("\n🛑 Shutting down...");
1499
- Promise.all([watcher.close(), devServer.stop()]).catch((err) => {
2143
+ Promise.all([
2144
+ watcher.close(),
2145
+ devServer.stop(),
2146
+ discovery.leave()
2147
+ ]).catch((err) => {
1500
2148
  logger$9.error("Error during shutdown:", err);
1501
2149
  }).finally(() => {
1502
2150
  process.exit(0);
@@ -1740,9 +2388,9 @@ async function workspaceDevCommand(configured, options) {
1740
2388
  for (const { id, port } of fakes) logger$9.log(`🎭 ${id} fake: http://localhost:${port}`);
1741
2389
  logger$9.log(options.migrate || options.seed ? "🗄️ Migrating the databases" : "🗄️ Checking for pending migrations");
1742
2390
  if (options.migrate || options.seed) {
1743
- const { prepareDevDatabases } = await import("./migrate-CS3DkO6y.mjs");
2391
+ const { prepareDevDatabases } = await import("./migrate-BrFREmgQ.mjs");
1744
2392
  await prepareDevDatabases(workspace, reconciled.env, { seed: !!options.seed });
1745
- } else await import("./migrate-CS3DkO6y.mjs").then(({ reportPendingMigrations }) => reportPendingMigrations(workspace, reconciled.env)).catch((error) => {
2393
+ } else await import("./migrate-BrFREmgQ.mjs").then(({ reportPendingMigrations }) => reportPendingMigrations(workspace, reconciled.env)).catch((error) => {
1746
2394
  logger$9.log(`⚠️ Could not check for pending migrations: ${error instanceof Error ? error.message : String(error)}`);
1747
2395
  });
1748
2396
  const appUrls = {};
@@ -1797,12 +2445,39 @@ async function workspaceDevCommand(configured, options) {
1797
2445
  break;
1798
2446
  }
1799
2447
  }
2448
+ const discovery = await joinDiscovery({
2449
+ role: "workspace",
2450
+ port: discoveryPort(workspace.dev),
2451
+ workspace: {
2452
+ name: workspace.name,
2453
+ root: workspace.root,
2454
+ stage: workspace.stages.local
2455
+ },
2456
+ allowedOrigins: workspace.dev?.allowedOrigins ?? [],
2457
+ ...reconciled.manifest ? { manifest: reconciled.manifest } : {},
2458
+ apps: buildOrder.flatMap((name) => {
2459
+ const app = workspace.apps[name];
2460
+ if (!app) return [];
2461
+ const publicUrl = Object.entries(appUrls).find(([id]) => appKey(id) === name)?.[1];
2462
+ return [{
2463
+ name,
2464
+ type: app.type,
2465
+ port: app.port,
2466
+ ...publicUrl ? { publicUrl } : {},
2467
+ status: "starting",
2468
+ reloads: 0,
2469
+ dataApis: []
2470
+ }];
2471
+ })
2472
+ });
2473
+ announce(discovery);
1800
2474
  const turboEnv = {
1801
2475
  ...process.env,
1802
2476
  ...secretsEnv,
1803
2477
  NODE_ENV: "development",
1804
2478
  ...configPath ? { GKM_CONFIG_PATH: configPath } : {},
1805
- ...options.fake ? { [FAKE_ENV]: "1" } : {}
2479
+ ...options.fake ? { [FAKE_ENV]: "1" } : {},
2480
+ [DISCOVERY_QUIET_ENV]: "1"
1806
2481
  };
1807
2482
  logger$9.log(`🚀 Starting ${appCount} app(s) — each builds before it answers, and says so when it is ready`);
1808
2483
  const turboProcess = spawn("pnpm", [
@@ -1822,6 +2497,7 @@ async function workspaceDevCommand(configured, options) {
1822
2497
  isShuttingDown = true;
1823
2498
  logger$9.log("\n🛑 Shutting down workspace...");
1824
2499
  closeFakes(fakes);
2500
+ discovery.leave();
1825
2501
  const pid = turboProcess.pid;
1826
2502
  if (pid) try {
1827
2503
  process.kill(-pid, "SIGTERM");
@@ -2062,6 +2738,13 @@ start({
2062
2738
  });
2063
2739
  `;
2064
2740
  }
2741
+ /**
2742
+ * The discovery endpoint's connect URL, Jupyter-style: the token in it is what
2743
+ * lets a console read what this machine is running.
2744
+ */
2745
+ function announce(discovery) {
2746
+ logger$9.log(`🧭 Discovery: ${discovery.connectUrl}`);
2747
+ }
2065
2748
  /** Run `fn` with `console.log` muted — warnings and errors still print. */
2066
2749
  async function quietly(fn) {
2067
2750
  const log = console.log;
@@ -2159,6 +2842,12 @@ var DevServer = class {
2159
2842
  }
2160
2843
  /** What the ready line calls this app. */
2161
2844
  label = "server";
2845
+ /** Told when the server is ready, reloading, or exits on its own. */
2846
+ onStatus;
2847
+ /** The port it is serving on. */
2848
+ get port() {
2849
+ return this.actualPort;
2850
+ }
2162
2851
  /**
2163
2852
  * Where the app is reached — its HTTPS address behind the edge, when it has
2164
2853
  * one. The local port is only what the edge forwards to.
@@ -2194,9 +2883,11 @@ var DevServer = class {
2194
2883
  this.serverProcess.on("error", (error) => {
2195
2884
  logger$9.error("❌ Server error:", error);
2196
2885
  });
2197
- this.serverProcess.on("exit", (code, signal) => {
2886
+ const child = this.serverProcess;
2887
+ child.on("exit", (code, signal) => {
2198
2888
  if (code !== null && code !== 0 && signal !== "SIGTERM") logger$9.error(`❌ Server exited with code ${code}`);
2199
2889
  this.isRunning = false;
2890
+ if (this.serverProcess === child) this.onStatus?.("stopped");
2200
2891
  });
2201
2892
  await new Promise((resolve) => setTimeout(resolve, 1e3));
2202
2893
  if (this.isRunning) {
@@ -2210,6 +2901,7 @@ var DevServer = class {
2210
2901
  ...this.databaseApi ? [`db ${this.databaseApi}`] : []
2211
2902
  ];
2212
2903
  if (tools.length > 0) logger$9.log(` ${tools.join(" · ")}`);
2904
+ this.onStatus?.("ready");
2213
2905
  }
2214
2906
  }
2215
2907
  async stop() {
@@ -2235,6 +2927,7 @@ var DevServer = class {
2235
2927
  }
2236
2928
  async restart() {
2237
2929
  const portToReuse = this.actualPort;
2930
+ this.onStatus?.("reloading");
2238
2931
  await this.stop();
2239
2932
  let attempts = 0;
2240
2933
  while (attempts < 30) {
@@ -2768,14 +3461,34 @@ var CommandTimedOut = class extends Error {
2768
3461
  * once `timeoutMs` passes (after the child is gone), and with the spawn error
2769
3462
  * itself when the program cannot be started at all — `ENOENT` already names it.
2770
3463
  */
2771
- function run(command, args, options = {}) {
3464
+ async function run(command, args, options = {}) {
3465
+ await spawnChild(command, args, options, false);
3466
+ }
3467
+ /**
3468
+ * {@link run}, resolving with what the program wrote to stdout — for a value
3469
+ * the CLI reads back, such as the digest `docker inspect` reports. stderr still
3470
+ * reaches the terminal, so a failure explains itself the same way.
3471
+ */
3472
+ function runOutput(command, args, options = {}) {
3473
+ return spawnChild(command, args, options, true);
3474
+ }
3475
+ function spawnChild(command, args, options, capture) {
2772
3476
  const { timeoutMs = DEFAULT_TIMEOUT_MS, ...spawnOptions } = options;
2773
3477
  return new Promise((resolve, reject) => {
2774
3478
  const child = spawn(command, [...args], {
2775
3479
  stdio: "inherit",
2776
3480
  ...spawnOptions,
3481
+ ...capture ? { stdio: [
3482
+ "ignore",
3483
+ "pipe",
3484
+ "inherit"
3485
+ ] } : {},
2777
3486
  shell: false
2778
3487
  });
3488
+ let stdout = "";
3489
+ child.stdout?.on("data", (chunk) => {
3490
+ stdout += chunk.toString();
3491
+ });
2779
3492
  let timedOut = false;
2780
3493
  let killer;
2781
3494
  const timer = setTimeout(() => {
@@ -2796,7 +3509,7 @@ function run(command, args, options = {}) {
2796
3509
  child.on("close", (code, signal) => {
2797
3510
  settle();
2798
3511
  if (timedOut) reject(new CommandTimedOut(command, args, timeoutMs));
2799
- else if (code === 0) resolve();
3512
+ else if (code === 0) resolve(stdout);
2800
3513
  else reject(new CommandFailed(command, args, code, signal));
2801
3514
  });
2802
3515
  });
@@ -3103,7 +3816,8 @@ async function buildOneApp(input) {
3103
3816
  hooks,
3104
3817
  markOptional: options.markOptional ?? false,
3105
3818
  skipBundle: options.skipBundle ?? false,
3106
- stage: options.stage
3819
+ stage: options.stage,
3820
+ workspaceName: workspace.name
3107
3821
  });
3108
3822
  }
3109
3823
  /**
@@ -3181,7 +3895,12 @@ async function buildApp(input) {
3181
3895
  appRoot,
3182
3896
  cache: cacheBackendsIn(declared, cacheBackend)
3183
3897
  }),
3184
- markOptional: input.markOptional ?? false
3898
+ markOptional: input.markOptional ?? false,
3899
+ ...production && { telemetry: telemetryFor({
3900
+ appRoot,
3901
+ surfaceId: derived.surface?.id,
3902
+ workspaceName: input.workspaceName
3903
+ }) }
3185
3904
  };
3186
3905
  const endpointGenerator = new EndpointGenerator();
3187
3906
  const functionGenerator = new FunctionGenerator();
@@ -3282,7 +4001,7 @@ async function buildForTarget(target, context, appRoot, workspaceRoot, endpointG
3282
4001
  let masterKey;
3283
4002
  if (context.production?.bundle && !skipBundle) {
3284
4003
  logger$8.log(`\n📦 Bundling production server...`);
3285
- const { bundleServer } = await import("./bundler-URImKj-A.mjs");
4004
+ const { bundleServer } = await import("./bundler-ouDdNvk9.mjs");
3286
4005
  const allConstructs = [
3287
4006
  ...endpoints.map((e) => e.construct),
3288
4007
  ...functions.map((f) => f.construct),
@@ -3590,7 +4309,7 @@ const PROVISIONERS = {
3590
4309
  const name = resourceName(declaration.id, declaration.kind, context.stage);
3591
4310
  const { postgres } = await context.api.findOrCreatePostgres(serviceName({
3592
4311
  stage: context.stage,
3593
- app: context.project
4312
+ app: context.scope ?? context.project
3594
4313
  }, declaration.id), context.projectId, context.environmentId, {
3595
4314
  databaseName: name,
3596
4315
  databasePassword: derivedPassword(context, `${name}:master`)
@@ -3724,7 +4443,7 @@ const PROVISIONERS = {
3724
4443
  if (backend !== "minio") throw new UnprovisionableBucket(declaration.id, backend);
3725
4444
  const service = serviceName({
3726
4445
  stage: context.stage,
3727
- app: context.project
4446
+ app: context.scope ?? context.project
3728
4447
  }, declaration.id);
3729
4448
  const bucket = resourceName(declaration.id, declaration.kind, context.stage);
3730
4449
  const user = `${bucket}-root`;
@@ -4185,6 +4904,7 @@ async function provisionDeclared(options) {
4185
4904
  environmentId: options.environmentId,
4186
4905
  stage: options.stage,
4187
4906
  project: workspace.name,
4907
+ scope: options.scope ?? workspace.name,
4188
4908
  cache: cacheBackendFor(providerOf(workspace)),
4189
4909
  events: eventsBackendFor(providerOf(workspace)),
4190
4910
  storage: storageBackendFor(providerOf(workspace)),
@@ -4283,18 +5003,21 @@ function createEmptyState(stage, projectId, environmentId) {
4283
5003
  };
4284
5004
  }
4285
5005
  /**
4286
- * Get application ID from state
4287
- */
4288
- function getApplicationId(state, appName) {
4289
- return state?.applications[appName];
4290
- }
4291
- /**
4292
5006
  * Set application ID in state (mutates state)
4293
5007
  */
4294
5008
  function setApplicationId(state, appName, applicationId) {
4295
5009
  state.applications[appName] = applicationId;
4296
5010
  }
4297
5011
  /**
5012
+ * Record the image an app was deployed with (mutates state)
5013
+ */
5014
+ function setDeployedImage(state, appName, image) {
5015
+ state.images = {
5016
+ ...state.images,
5017
+ [appName]: image
5018
+ };
5019
+ }
5020
+ /**
4298
5021
  * Get a generated secret for an app
4299
5022
  */
4300
5023
  function getGeneratedSecret(state, appName, secretName) {
@@ -4381,7 +5104,7 @@ async function createDnsProvider(options) {
4381
5104
  if (isDnsProvider(config.provider)) return config.provider;
4382
5105
  const provider = config.provider;
4383
5106
  if (provider === "hostinger") {
4384
- const { HostingerProvider } = await import("./HostingerProvider-cL-6oQ5r.mjs");
5107
+ const { HostingerProvider } = await import("./HostingerProvider-YQY3rMpV.mjs");
4385
5108
  return new HostingerProvider();
4386
5109
  }
4387
5110
  if (provider === "route53") {
@@ -6072,6 +6795,33 @@ async function pushImage(imageRef) {
6072
6795
  }
6073
6796
  }
6074
6797
  /**
6798
+ * The digest the registry gave a pushed image, `sha256:…`.
6799
+ *
6800
+ * A tag can be pushed over — by the next deploy, or by anything else that
6801
+ * shares the repository — and the digest is what says which image a stage
6802
+ * actually ran. `RepoDigests` has one entry per repository the image was
6803
+ * pushed to, so the one for this ref's repository is picked by name.
6804
+ *
6805
+ * @internal Exported for testing
6806
+ */
6807
+ async function pushedDigest(imageRef) {
6808
+ const repository = imageRef.split("@")[0].replace(/:[\w][\w.-]*$/, "");
6809
+ const output = await runOutput("docker", [
6810
+ "image",
6811
+ "inspect",
6812
+ "--format={{json .RepoDigests}}",
6813
+ imageRef
6814
+ ], { cwd: process.cwd() });
6815
+ let digests;
6816
+ try {
6817
+ digests = JSON.parse(output.trim() || "[]");
6818
+ } catch {
6819
+ return;
6820
+ }
6821
+ if (!Array.isArray(digests)) return void 0;
6822
+ return digests.find((entry) => typeof entry === "string" && entry.startsWith(`${repository}@`))?.slice(repository.length + 1);
6823
+ }
6824
+ /**
6075
6825
  * Deploy using Docker (build and optionally push image)
6076
6826
  */
6077
6827
  async function deployDocker(options) {
@@ -6079,9 +6829,13 @@ async function deployDocker(options) {
6079
6829
  const imageName = config.imageName;
6080
6830
  const imageRef = validateImageRef(getImageRef(config.registry, imageName, tag));
6081
6831
  await buildImage(imageRef, buildArgs, credentials);
6832
+ let digest;
6082
6833
  if (!skipPush) {
6083
6834
  if (!config.registry) logger$5.warn("\n⚠️ No registry configured. Use --skip-push or configure docker.registry in gkm.config.ts");
6084
- else await pushImage(imageRef);
6835
+ else {
6836
+ await pushImage(imageRef);
6837
+ digest = await pushedDigest(imageRef);
6838
+ }
6085
6839
  }
6086
6840
  logger$5.log("\n✅ Docker deployment ready!");
6087
6841
  logger$5.log(`\n📋 Deployment details:`);
@@ -6090,25 +6844,10 @@ async function deployDocker(options) {
6090
6844
  if (masterKey) logger$5.log(`\n🔐 Run the container with GKM_MASTER_KEY set to the key with fingerprint ${keyFingerprint(masterKey)}.`);
6091
6845
  return {
6092
6846
  imageRef,
6847
+ ...digest ? { digest } : {},
6093
6848
  masterKey
6094
6849
  };
6095
6850
  }
6096
- /**
6097
- * What one application is called on a provider.
6098
- *
6099
- * The same rule the constructs use, through the same `scopedName`: the
6100
- * application beside `production-kitchen-sink-database` is
6101
- * `production-kitchen-sink-api`, not `api`. Both deploy paths call this — the
6102
- * workspace one named its applications by the bare app key, so a project held
6103
- * an `api` and a `web` that every stage would collide on.
6104
- *
6105
- * The app id is dropped when it repeats the project, so a project named for its
6106
- * one application is `production-kitchen-sink` rather than
6107
- * `production-kitchen-sink-kitchen-sink`.
6108
- */
6109
- function applicationName(stage, project, app) {
6110
- return app === project ? `${stage}-${project}`.toLowerCase() : scopedName([stage, project], app);
6111
- }
6112
6851
  //#endregion
6113
6852
  //#region src/deploy/dokploy-api.ts
6114
6853
  var dokploy_api_exports = /* @__PURE__ */ __exportAll({
@@ -6325,6 +7064,18 @@ var DokployApi = class {
6325
7064
  });
6326
7065
  }
6327
7066
  /**
7067
+ * Change a project's name or description.
7068
+ *
7069
+ * The name is sent with the description because `project.update` validates
7070
+ * the whole project on some Dokploy versions, and a missing name fails it.
7071
+ */
7072
+ async updateProject(projectId, updates) {
7073
+ await this.post("project.update", {
7074
+ projectId,
7075
+ ...updates
7076
+ });
7077
+ }
7078
+ /**
6328
7079
  * Delete a project and all its resources
6329
7080
  */
6330
7081
  async deleteProject(projectId) {
@@ -7024,71 +7775,423 @@ function random() {
7024
7775
  return randomBytes(32).toString("base64url");
7025
7776
  }
7026
7777
  //#endregion
7027
- //#region src/deploy/StateProvider.ts
7778
+ //#region src/deploy/identity.ts
7779
+ /**
7780
+ * Who a deploy is, on a target it shares with other workspaces.
7781
+ *
7782
+ * A Dokploy server is one flat namespace of projects, images and Docker
7783
+ * service names. Deploys used to identify everything they made by the
7784
+ * workspace name alone, so two workspaces called `shop` — or `Shop` and
7785
+ * `shop` — deploying to one server landed in the same project, pushed over
7786
+ * each other's images and redeployed each other's applications. A
7787
+ * `DeployIdentity` is what a deploy names and claims its resources by instead.
7788
+ */
7789
+ /** A namespace that cannot be part of a Dokploy name or an image path. */
7790
+ var DeployNamespaceInvalid = class extends Error {
7791
+ namespace;
7792
+ constructor(namespace) {
7793
+ super(`deploy.namespace '${namespace}' is not usable as a name: use lowercase letters, digits and single '-' between them (e.g. 'acme' or 'acme-platform') in gkm.config.ts.`);
7794
+ this.namespace = namespace;
7795
+ this.name = "DeployNamespaceInvalid";
7796
+ }
7797
+ };
7798
+ /** A workspace name with nothing a target can name a resource by. */
7799
+ var DeployProjectNameInvalid = class extends Error {
7800
+ workspaceName;
7801
+ constructor(workspaceName) {
7802
+ super(`The workspace name '${workspaceName}' has no letters or digits to name deployed resources by. Set "name" in gkm.config.ts.`);
7803
+ this.workspaceName = workspaceName;
7804
+ this.name = "DeployProjectNameInvalid";
7805
+ }
7806
+ };
7807
+ const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7028
7808
  /**
7029
- * Check if value is a StateProvider implementation.
7809
+ * Lowercase `[a-z0-9-]`, runs of anything else collapsed to one `-`: valid as
7810
+ * a Docker image path component, a Docker service name and a Dokploy name.
7030
7811
  */
7031
- function isStateProvider(value) {
7032
- return typeof value === "object" && value !== null && typeof value.read === "function" && typeof value.write === "function";
7812
+ function slug$1(value) {
7813
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
7814
+ }
7815
+ /** The namespace a workspace is in when it does not name one. */
7816
+ function defaultNamespace(workspaceName) {
7817
+ return slug$1(kebabCase(workspaceName));
7818
+ }
7819
+ function deployIdentity(workspace, stage) {
7820
+ const project = slug$1(workspace.name);
7821
+ if (!project) throw new DeployProjectNameInvalid(workspace.name);
7822
+ const fallback = defaultNamespace(workspace.name);
7823
+ const namespace = workspace.deploy?.namespace ?? fallback;
7824
+ if (!NAME.test(namespace)) throw new DeployNamespaceInvalid(namespace);
7825
+ return {
7826
+ namespace,
7827
+ project,
7828
+ stage,
7829
+ key: `${namespace}/${project}`,
7830
+ scope: namespace === fallback ? project : `${namespace}-${project}`
7831
+ };
7033
7832
  }
7034
7833
  /**
7035
- * Create a state provider based on configuration.
7036
- *
7037
- * - 'local': LocalStateProvider (default)
7038
- * - 'ssm': CachedStateProvider with SSM as source of truth
7039
- * - 's3': S3StateStore behind the provider interface
7040
- * - Custom: Use provided StateProvider implementation
7041
- *
7042
- * Deploy moves to `createStateStore` (locks, versions, per-resource records);
7043
- * this stays for the call sites that read and write whole states.
7834
+ * The token a Dokploy project's description carries to say which identity
7835
+ * created it. A project with the right name and no marker belongs to someone
7836
+ * else, and a deploy never adopts it.
7044
7837
  */
7045
- async function createStateProvider(options) {
7046
- const { config, workspaceRoot, workspaceName } = options;
7047
- if (!config) {
7048
- const { LocalStateProvider } = await import("./LocalStateProvider-D7QyJoLM.mjs");
7049
- return new LocalStateProvider(workspaceRoot);
7050
- }
7051
- const { isStateStore } = await Promise.resolve().then(() => StateStore_exports);
7052
- if (isStateStore(config.provider)) {
7053
- const { StateStoreProvider } = await Promise.resolve().then(() => StateStore_exports);
7054
- return new StateStoreProvider(config.provider);
7055
- }
7056
- if (isStateProvider(config.provider)) return config.provider;
7057
- const provider = config.provider;
7058
- if (provider === "local") {
7059
- const { LocalStateProvider } = await import("./LocalStateProvider-D7QyJoLM.mjs");
7060
- return new LocalStateProvider(workspaceRoot);
7061
- }
7062
- if (provider === "ssm") {
7063
- if (!workspaceName) {
7064
- const { StateStoreNeedsWorkspaceName } = await Promise.resolve().then(() => StateStore_exports);
7065
- throw new StateStoreNeedsWorkspaceName("ssm");
7066
- }
7067
- const { LocalStateProvider } = await import("./LocalStateProvider-D7QyJoLM.mjs");
7068
- const { SSMStateProvider } = await import("./SSMStateProvider-B0Hu1MKZ.mjs");
7069
- const { CachedStateProvider } = await Promise.resolve().then(() => CachedStateProvider_exports);
7070
- const ssmConfig = config;
7071
- const local = new LocalStateProvider(workspaceRoot);
7072
- return new CachedStateProvider(SSMStateProvider.create({
7073
- workspaceName,
7074
- region: ssmConfig.region,
7075
- profile: ssmConfig.profile
7076
- }), local);
7077
- }
7078
- if (provider === "s3") {
7079
- const { createStateStore, StateStoreProvider } = await Promise.resolve().then(() => StateStore_exports);
7080
- return new StateStoreProvider(await createStateStore(options));
7081
- }
7082
- const { UnknownStateProvider } = await Promise.resolve().then(() => StateStore_exports);
7083
- throw new UnknownStateProvider(config);
7838
+ function ownershipMarker(identity) {
7839
+ return `gkm:${identity.key}`;
7840
+ }
7841
+ /** Whether a project description carries `identity`'s marker. */
7842
+ function isOwnedBy(description, identity) {
7843
+ const marker = ownershipMarker(identity);
7844
+ return (description ?? "").split(/\s+/).includes(marker);
7845
+ }
7846
+ /** Any `gkm:` marker a description carries — whoever's it is. */
7847
+ function markerOf(description) {
7848
+ return (description ?? "").split(/\s+/).find((t) => t.startsWith("gkm:"));
7849
+ }
7850
+ /** What the Dokploy project is called. */
7851
+ function projectName(identity) {
7852
+ return identity.scope;
7084
7853
  }
7085
- //#endregion
7086
- //#region src/deploy/secrets.ts
7087
7854
  /**
7088
- * Filter secrets to only include the env vars that an app requires.
7855
+ * The repository an app's image is pushed to, below the registry:
7856
+ * `<namespace>/<project>-<app>`.
7089
7857
  *
7090
- * @param stageSecrets - All secrets for the stage
7091
- * @param sniffedEnv - The sniffed environment requirements for the app
7858
+ * The namespace is a path segment of its own so that a registry that maps
7859
+ * paths to owners (an organisation's packages, a Harbor project) can grant it
7860
+ * as one; the app is joined to the project so the two read as one name.
7861
+ */
7862
+ function imageName(identity, app) {
7863
+ return `${identity.namespace}/${identity.project}-${slug$1(kebabCase(app))}`;
7864
+ }
7865
+ /** The full image ref an app is built, pushed and deployed as. */
7866
+ function imageRef(identity, app, registry, tag) {
7867
+ const name = imageName(identity, app);
7868
+ return registry ? `${registry.replace(/\/+$/, "")}/${name}:${tag}` : `${name}:${tag}`;
7869
+ }
7870
+ /**
7871
+ * What one application is called on the target.
7872
+ *
7873
+ * The same rule the constructs use, through the same `scopedName`, scoped by
7874
+ * the stage and the identity: the application beside
7875
+ * `production-kitchen-sink-database` is `production-kitchen-sink-api`. A
7876
+ * Dokploy application's name is also its Docker service name, which is unique
7877
+ * on the whole server, so the namespace has to be in it too — `acme`'s and
7878
+ * `globex`'s `shop` would otherwise both run `production-shop-api`.
7879
+ *
7880
+ * The app id is dropped when it repeats the project, so a project named for its
7881
+ * one application is `production-kitchen-sink` rather than
7882
+ * `production-kitchen-sink-kitchen-sink`.
7883
+ */
7884
+ function applicationName(identity, app) {
7885
+ return slug$1(app) === identity.project ? `${identity.stage}-${identity.scope}` : scopedName([identity.stage, identity.scope], app);
7886
+ }
7887
+ //#endregion
7888
+ //#region src/deploy/journal.ts
7889
+ var DeployJournal = class DeployJournal {
7890
+ store;
7891
+ stage;
7892
+ state;
7893
+ resources;
7894
+ version;
7895
+ existed;
7896
+ constructor(store, stage, state, resources, version, existed) {
7897
+ this.store = store;
7898
+ this.stage = stage;
7899
+ this.state = state;
7900
+ this.resources = resources;
7901
+ this.version = version;
7902
+ this.existed = existed;
7903
+ }
7904
+ /**
7905
+ * The stage's journal — its stored state, or `initial` written as the
7906
+ * stage's first state, so there is somewhere to record the very first
7907
+ * resource before it is created.
7908
+ */
7909
+ static async open(store, stage, initial) {
7910
+ const stored = await store.read(stage);
7911
+ if (stored) return new DeployJournal(store, stage, stored.state, stored.resources, stored.version, true);
7912
+ const state = initial();
7913
+ const version = await store.write(stage, state, { expectedVersion: null });
7914
+ return new DeployJournal(store, stage, state, {}, version, false);
7915
+ }
7916
+ record(key) {
7917
+ return this.resources[key];
7918
+ }
7919
+ /** The keys a previous run marked `pending` and never saw created. */
7920
+ unfinished() {
7921
+ return Object.values(this.resources).filter((record) => record.status === "pending").map((record) => record.key);
7922
+ }
7923
+ /** Records that `entry` is about to be created. */
7924
+ async pending(entry) {
7925
+ await this.put({
7926
+ ...entry,
7927
+ status: "pending"
7928
+ });
7929
+ }
7930
+ /** Records `entry` as existing with `id`; a no-op if it already is. */
7931
+ async ready(entry, id) {
7932
+ const current = this.resources[entry.key];
7933
+ if (current?.status === "ready" && current.id === id) return;
7934
+ await this.put({
7935
+ ...entry,
7936
+ status: "ready",
7937
+ id
7938
+ });
7939
+ }
7940
+ /**
7941
+ * The resource `entry` names: by its recorded id, else by looking it up,
7942
+ * else created — with `pending` written before the create and `ready`
7943
+ * after, so a run that dies in between leaves a record the next run
7944
+ * resolves by looking the resource up rather than creating another.
7945
+ */
7946
+ async ensure(entry, steps) {
7947
+ const record = this.resources[entry.key];
7948
+ let staleId;
7949
+ if (record?.status === "ready" && record.id && steps.get) {
7950
+ const known = await steps.get(record.id);
7951
+ if (known) {
7952
+ await this.ready(entry, steps.id(known));
7953
+ return {
7954
+ resource: known,
7955
+ via: "recorded"
7956
+ };
7957
+ }
7958
+ staleId = record.id;
7959
+ }
7960
+ const existing = await steps.find();
7961
+ if (existing) {
7962
+ await this.ready(entry, steps.id(existing));
7963
+ return {
7964
+ resource: existing,
7965
+ via: record?.status === "pending" ? "resumed" : "found",
7966
+ ...staleId ? { staleId } : {}
7967
+ };
7968
+ }
7969
+ await this.pending(entry);
7970
+ const created = await steps.create();
7971
+ await this.ready(entry, steps.id(created));
7972
+ return {
7973
+ resource: created,
7974
+ via: "created",
7975
+ ...staleId ? { staleId } : {}
7976
+ };
7977
+ }
7978
+ /** Writes the stage's state, keeping its resource records. */
7979
+ async save() {
7980
+ this.state.lastDeployedAt = (/* @__PURE__ */ new Date()).toISOString();
7981
+ this.version = await this.store.write(this.stage, this.state, { expectedVersion: this.version });
7982
+ }
7983
+ async put(record) {
7984
+ this.version = await this.store.putResource(this.stage, record, { expectedVersion: this.version });
7985
+ this.resources = {
7986
+ ...this.resources,
7987
+ [record.key]: {
7988
+ ...record,
7989
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
7990
+ }
7991
+ };
7992
+ }
7993
+ };
7994
+ //#endregion
7995
+ //#region src/deploy/ownership.ts
7996
+ /**
7997
+ * Which Dokploy project a deploy is allowed to deploy into.
7998
+ *
7999
+ * Deploy used to take any project whose name matched the workspace's, in any
8000
+ * case — so a second workspace called `shop` (or `Shop`) on the same server
8001
+ * deployed into the first one's project, over its images and applications. A
8002
+ * project is now claimed by an ownership marker in its description, and a
8003
+ * project with the right name and no marker is somebody else's.
8004
+ */
8005
+ /**
8006
+ * A Dokploy project with the name this deploy would use exists, and nothing
8007
+ * says this workspace created it.
8008
+ */
8009
+ var ProjectNotOwned = class extends Error {
8010
+ projectName;
8011
+ projectId;
8012
+ identity;
8013
+ ownedBy;
8014
+ constructor(projectName, projectId, identity, ownedBy) {
8015
+ super(`The Dokploy project '${projectName}' (${projectId}) was not created by this workspace (${identity})${ownedBy ? ` — it is marked '${ownedBy}'` : ""}, so deploy will not use it. Set deploy.namespace in gkm.config.ts to deploy under a name of your own, or, if this project really is this workspace's, add 'gkm:${identity}' to its description in Dokploy.`);
8016
+ this.projectName = projectName;
8017
+ this.projectId = projectId;
8018
+ this.identity = identity;
8019
+ this.ownedBy = ownedBy;
8020
+ this.name = "ProjectNotOwned";
8021
+ }
8022
+ };
8023
+ /** A description with the marker added, keeping what was there. */
8024
+ function withMarker(description, marker) {
8025
+ const existing = (description ?? "").trim();
8026
+ return existing ? `${existing}\n${marker}` : marker;
8027
+ }
8028
+ /** `project.one`, or `null` when Dokploy no longer has the project. */
8029
+ async function projectById(api, projectId) {
8030
+ try {
8031
+ return await api.getProject(projectId) ?? null;
8032
+ } catch (error) {
8033
+ if (error instanceof DokployApiError) return null;
8034
+ throw error;
8035
+ }
8036
+ }
8037
+ /**
8038
+ * The project this deploy deploys into:
8039
+ *
8040
+ * 1. The one the stage's state names, if Dokploy still has it. State written
8041
+ * before markers existed is trusted — it holds an id this workspace was
8042
+ * given when it created the project — and the marker is written now, so
8043
+ * the project is claimed for the next deploy that has no state.
8044
+ * 2. One with this name *and* this identity's marker.
8045
+ * 3. A new one, marked.
8046
+ *
8047
+ * A project that matches by name (in any case) but carries no marker, or
8048
+ * another identity's, raises `ProjectNotOwned` — it is never adopted.
8049
+ */
8050
+ async function resolveProject(api, identity, stateProjectId, log = () => {}, hooks = {}) {
8051
+ const name = projectName(identity);
8052
+ const marker = ownershipMarker(identity);
8053
+ if (stateProjectId) {
8054
+ const known = await projectById(api, stateProjectId);
8055
+ if (known) {
8056
+ const claimed = markerOf(known.description);
8057
+ if (claimed && claimed !== marker) throw new ProjectNotOwned(known.name, known.projectId, identity.key, claimed);
8058
+ if (!claimed) {
8059
+ await api.updateProject(known.projectId, {
8060
+ name: known.name,
8061
+ description: withMarker(known.description, marker)
8062
+ });
8063
+ log(` Claimed project ${known.name} for ${identity.key}`);
8064
+ }
8065
+ return {
8066
+ projectId: known.projectId,
8067
+ name: known.name,
8068
+ environments: known.environments ?? [],
8069
+ via: "state"
8070
+ };
8071
+ }
8072
+ }
8073
+ const projects = await api.listProjects();
8074
+ const owned = projects.find((p) => p.name.toLowerCase() === name.toLowerCase() && isOwnedBy(p.description, identity));
8075
+ if (owned) {
8076
+ const details = await api.getProject(owned.projectId);
8077
+ return {
8078
+ projectId: owned.projectId,
8079
+ name: owned.name,
8080
+ environments: details.environments ?? [],
8081
+ via: "marker"
8082
+ };
8083
+ }
8084
+ const clash = projects.find((p) => p.name.toLowerCase() === name.toLowerCase());
8085
+ if (clash) throw new ProjectNotOwned(clash.name, clash.projectId, identity.key, markerOf(clash.description));
8086
+ await hooks.beforeCreate?.();
8087
+ const created = await api.createProject(name, withMarker("Deployed by gkm.", marker));
8088
+ return {
8089
+ projectId: created.project.projectId,
8090
+ name: created.project.name,
8091
+ environments: [created.environment],
8092
+ via: "created"
8093
+ };
8094
+ }
8095
+ //#endregion
8096
+ //#region src/deploy/registry.ts
8097
+ /**
8098
+ * Which Dokploy registry a stage's images are pulled through.
8099
+ *
8100
+ * It used to be one id stored per machine (`~/.gkm/credentials.json`), and
8101
+ * when that was empty, the first registry Dokploy listed — so an image could be
8102
+ * deployed with credentials for a registry nobody chose, and two workspaces
8103
+ * deployed from one machine shared whichever was stored last. The id is now
8104
+ * kept in the stage's state, and found only from what the workspace
8105
+ * configured.
8106
+ */
8107
+ /** There is nowhere to push images to, so Dokploy has nothing to pull. */
8108
+ var RegistryNotConfigured = class extends Error {
8109
+ stage;
8110
+ constructor(stage) {
8111
+ super(`Deploying '${stage}' needs a container registry: Dokploy pulls each app's image from one. Set deploy.dokploy.registry in gkm.config.ts (e.g. 'ghcr.io/acme'), and deploy.dokploy.registryId if Dokploy already holds that registry's credentials.`);
8112
+ this.stage = stage;
8113
+ this.name = "RegistryNotConfigured";
8114
+ }
8115
+ };
8116
+ /** `deploy.dokploy.registryId` names a registry Dokploy does not have. */
8117
+ var RegistryNotFound = class extends Error {
8118
+ registryId;
8119
+ constructor(registryId) {
8120
+ super(`deploy.dokploy.registryId '${registryId}' is not a registry on this Dokploy server. Find the right id under Settings → Docker Registry in Dokploy and set it in gkm.config.ts, or remove it to have deploy find the registry by its URL.`);
8121
+ this.registryId = registryId;
8122
+ this.name = "RegistryNotFound";
8123
+ }
8124
+ };
8125
+ /** More than one Dokploy registry serves the configured registry. */
8126
+ var RegistryAmbiguous = class extends Error {
8127
+ registry;
8128
+ registryIds;
8129
+ constructor(registry, registryIds) {
8130
+ super(`Dokploy has ${registryIds.length} registries for '${registry}' (${registryIds.join(", ")}). Set deploy.dokploy.registryId in gkm.config.ts to the one this workspace pushes with.`);
8131
+ this.registry = registry;
8132
+ this.registryIds = registryIds;
8133
+ this.name = "RegistryAmbiguous";
8134
+ }
8135
+ };
8136
+ /** `https://ghcr.io/acme/` → `{ host: 'ghcr.io', path: 'acme' }`. */
8137
+ function parts(url) {
8138
+ const [host = "", ...path] = url.replace(/^[a-z]+:\/\//i, "").replace(/\/+$/, "").toLowerCase().split("/");
8139
+ return {
8140
+ host,
8141
+ path: path.join("/")
8142
+ };
8143
+ }
8144
+ /**
8145
+ * The Dokploy registries that serve `registry`: the same host, and — where
8146
+ * Dokploy's own entry names one — the same path.
8147
+ */
8148
+ function serving(registries, registry) {
8149
+ const wanted = parts(registry);
8150
+ const sameHost = registries.filter((r) => parts(r.registryUrl ?? "").host === wanted.host);
8151
+ if (sameHost.length <= 1) return sameHost;
8152
+ return sameHost.filter((r) => {
8153
+ return (parts(r.registryUrl ?? "").path || (r.imagePrefix ?? "").toLowerCase()) === wanted.path;
8154
+ });
8155
+ }
8156
+ /** `registry.one`, or `null` when Dokploy says it has no such registry. */
8157
+ async function registryById(api, registryId) {
8158
+ try {
8159
+ return await api.getRegistry(registryId) ?? null;
8160
+ } catch (error) {
8161
+ if (error instanceof DokployApiError) return null;
8162
+ throw error;
8163
+ }
8164
+ }
8165
+ /**
8166
+ * The registry id for a stage, in order: the configured id, the one in the
8167
+ * stage's state, the one Dokploy has for the configured registry URL, and
8168
+ * otherwise a new one. Never a registry the workspace did not point at.
8169
+ */
8170
+ async function resolveRegistry(api, options) {
8171
+ const { stage, registry, configuredId, stateId, log = () => {} } = options;
8172
+ if (!registry) throw new RegistryNotConfigured(stage);
8173
+ if (configuredId) {
8174
+ const configured = await registryById(api, configuredId);
8175
+ if (!configured) throw new RegistryNotFound(configuredId);
8176
+ return configured;
8177
+ }
8178
+ if (stateId) {
8179
+ const known = await registryById(api, stateId);
8180
+ if (known) return known;
8181
+ log(` ⚠ The stage's registry ${stateId} no longer exists in Dokploy`);
8182
+ }
8183
+ const matches = serving(await api.listRegistries(), registry);
8184
+ if (matches.length > 1) throw new RegistryAmbiguous(registry, matches.map((r) => r.registryId));
8185
+ if (matches[0]) return matches[0];
8186
+ return options.create(registry);
8187
+ }
8188
+ //#endregion
8189
+ //#region src/deploy/secrets.ts
8190
+ /**
8191
+ * Filter secrets to only include the env vars that an app requires.
8192
+ *
8193
+ * @param stageSecrets - All secrets for the stage
8194
+ * @param sniffedEnv - The sniffed environment requirements for the app
7092
8195
  * @returns Filtered secrets with found/missing tracking
7093
8196
  */
7094
8197
  function filterSecretsForApp(stageSecrets, sniffedEnv) {
@@ -7796,6 +8899,7 @@ async function workspaceDeployCommand(configured, options) {
7796
8899
  apps: units
7797
8900
  } : configured;
7798
8901
  if (provider !== "dokploy") throw new Error(`Workspace deployment only supports Dokploy. Got: ${provider}`);
8902
+ const identity = deployIdentity(workspace, stage);
7799
8903
  logger$4.log(`\n🚀 Deploying workspace "${workspace.name}" to Dokploy...`);
7800
8904
  logger$4.log(` Stage: ${stage}`);
7801
8905
  const imageTag = tag ?? generateTag(stage);
@@ -7822,6 +8926,28 @@ async function workspaceDeployCommand(configured, options) {
7822
8926
  });
7823
8927
  if (dokployApps.length === 0) throw new Error("No apps to deploy. All selected apps have unsupported deploy targets.");
7824
8928
  appsToDeployNames = dokployApps;
8929
+ const store = await createStateStore({
8930
+ config: workspace.state,
8931
+ workspaceRoot: workspace.root,
8932
+ workspaceName: workspace.name
8933
+ });
8934
+ const lock = await store.lock(stage, { operation: "deploy" });
8935
+ try {
8936
+ return await deployLocked({
8937
+ workspace,
8938
+ manifest,
8939
+ stage,
8940
+ imageTag,
8941
+ appsToDeployNames,
8942
+ identity,
8943
+ store
8944
+ });
8945
+ } finally {
8946
+ await lock.release();
8947
+ }
8948
+ }
8949
+ /** Everything a deploy does while it holds the stage's lock. */
8950
+ async function deployLocked({ workspace, manifest, stage, imageTag, appsToDeployNames, identity, store }) {
7825
8951
  logger$4.log("\n🔐 Loading secrets and analyzing environment requirements...");
7826
8952
  const secretsStore = await secretsStoreFor(workspace, stage);
7827
8953
  const stored = await secretsStore.read(stage);
@@ -7862,79 +8988,71 @@ async function workspaceDeployCommand(configured, options) {
7862
8988
  baseUrl: creds.endpoint,
7863
8989
  token: creds.token
7864
8990
  });
8991
+ logger$4.log("\n📋 Loading deploy state...");
8992
+ const journal = await DeployJournal.open(store, stage, () => createEmptyState(stage, "", ""));
8993
+ const state = journal.state;
8994
+ if (journal.existed) logger$4.log(` Found existing state for stage "${stage}"`);
8995
+ else logger$4.log(` Creating new state for stage "${stage}"`);
8996
+ const unfinished = journal.unfinished();
8997
+ if (unfinished.length > 0) logger$4.log(` ⚠ A previous run stopped while creating: ${unfinished.join(", ")} — looking for them before creating anything`);
7865
8998
  logger$4.log("\n📁 Setting up Dokploy project...");
7866
- const projectName = workspace.name;
7867
- let project = (await api.listProjects()).find((p) => p.name.toLowerCase() === projectName.toLowerCase());
7868
- let environmentId;
7869
- if (project) {
7870
- logger$4.log(` Found existing project: ${project.name}`);
7871
- const matchingEnv = ((await api.getProject(project.projectId)).environments ?? []).find((e) => e.name.toLowerCase() === stage.toLowerCase());
7872
- if (matchingEnv) {
7873
- environmentId = matchingEnv.environmentId;
7874
- logger$4.log(` Using environment: ${matchingEnv.name}`);
7875
- } else {
7876
- logger$4.log(` Creating "${stage}" environment...`);
7877
- environmentId = (await api.createEnvironment(project.projectId, stage)).environmentId;
7878
- logger$4.log(` ✓ Created environment: ${stage}`);
8999
+ logger$4.log(` Identity: ${identity.key}`);
9000
+ const projectEntry = {
9001
+ key: "project",
9002
+ type: "project",
9003
+ data: {
9004
+ name: projectName(identity),
9005
+ identity: identity.key
7879
9006
  }
7880
- } else {
7881
- logger$4.log(` Creating project: ${projectName}`);
7882
- const result = await api.createProject(projectName);
7883
- project = result.project;
7884
- if (result.environment.name.toLowerCase() !== stage.toLowerCase()) {
9007
+ };
9008
+ const project = await resolveProject(api, identity, state.projectId || void 0, (message) => logger$4.log(message), { beforeCreate: () => journal.pending(projectEntry) });
9009
+ await journal.ready(projectEntry, project.projectId);
9010
+ if (project.via === "created") logger$4.log(` ✓ Created project: ${project.projectId}`);
9011
+ else logger$4.log(` Found existing project: ${project.name}`);
9012
+ const environmentEntry = {
9013
+ key: "environment",
9014
+ type: "environment",
9015
+ data: { name: stage }
9016
+ };
9017
+ const environment = await journal.ensure(environmentEntry, {
9018
+ find: async () => project.environments.find((e) => e.name.toLowerCase() === stage.toLowerCase()),
9019
+ create: () => {
7885
9020
  logger$4.log(` Creating "${stage}" environment...`);
7886
- environmentId = (await api.createEnvironment(project.projectId, stage)).environmentId;
7887
- } else environmentId = result.environment.environmentId;
7888
- logger$4.log(` ✓ Created project: ${project.projectId}`);
7889
- }
7890
- logger$4.log("\n📋 Loading deploy state...");
7891
- const stateProvider = await createStateProvider({
7892
- config: workspace.state,
7893
- workspaceRoot: workspace.root,
7894
- workspaceName: workspace.name
9021
+ return api.createEnvironment(project.projectId, stage);
9022
+ },
9023
+ id: (e) => e.environmentId
7895
9024
  });
7896
- let state = await stateProvider.read(stage);
7897
- if (state) {
7898
- logger$4.log(` Found existing state for stage "${stage}"`);
7899
- if (state.projectId !== project.projectId) {
7900
- logger$4.log(` ⚠ Project ID changed, updating state`);
7901
- state.projectId = project.projectId;
7902
- }
7903
- if (state.environmentId !== environmentId) {
7904
- logger$4.log(` ⚠ Environment ID changed, updating state`);
7905
- state.environmentId = environmentId;
7906
- }
7907
- } else {
7908
- logger$4.log(` Creating new state for stage "${stage}"`);
7909
- state = createEmptyState(stage, project.projectId, environmentId);
7910
- }
9025
+ const environmentId = environment.resource.environmentId;
9026
+ if (environment.via === "created") logger$4.log(` ✓ Created environment: ${stage}`);
9027
+ else logger$4.log(` Using environment: ${environment.resource.name}`);
9028
+ if (journal.existed) {
9029
+ if (state.projectId && state.projectId !== project.projectId) logger$4.log(` ⚠ Project ID changed, updating state`);
9030
+ if (state.environmentId && state.environmentId !== environmentId) logger$4.log(` ⚠ Environment ID changed, updating state`);
9031
+ }
9032
+ state.projectId = project.projectId;
9033
+ state.environmentId = environmentId;
9034
+ state.identity = identity.key;
7911
9035
  logger$4.log("\n🐳 Checking registry...");
7912
- let registryId = await getDokployRegistryId();
7913
9036
  const registry = workspace.deploy.dokploy?.registry;
7914
- if (registryId) try {
7915
- const reg = await api.getRegistry(registryId);
7916
- logger$4.log(` Using registry: ${reg.registryName}`);
7917
- } catch {
7918
- logger$4.log(" ⚠ Stored registry not found, clearing...");
7919
- registryId = void 0;
7920
- await storeDokployRegistryId("");
7921
- }
7922
- if (!registryId) {
7923
- const registries = await api.listRegistries();
7924
- if (registries.length > 0) {
7925
- registryId = registries[0].registryId;
7926
- await storeDokployRegistryId(registryId);
7927
- logger$4.log(` Using registry: ${registries[0].registryName}`);
7928
- } else if (registry) {
7929
- logger$4.log(" No registries found in Dokploy. Let's create one.");
7930
- logger$4.log(` Registry URL: ${registry}`);
9037
+ const dokployRegistry = await resolveRegistry(api, {
9038
+ stage,
9039
+ registry,
9040
+ configuredId: workspace.deploy.dokploy?.registryId,
9041
+ stateId: state.registryId,
9042
+ log: (message) => logger$4.log(message),
9043
+ create: async (url) => {
9044
+ logger$4.log(` Dokploy has no registry for ${url}. Let's create one.`);
7931
9045
  const username = await prompt("Registry username: ");
7932
9046
  const password = await prompt("Registry password/token: ", true);
7933
- registryId = (await api.createRegistry("Default Registry", registry, username, password)).registryId;
7934
- await storeDokployRegistryId(registryId);
7935
- logger$4.log(` ✓ Registry created: ${registryId}`);
7936
- } else logger$4.log(" ⚠ No registry configured. Set deploy.dokploy.registry in workspace config");
7937
- }
9047
+ const created = await api.createRegistry("Default Registry", url, username, password);
9048
+ logger$4.log(` ✓ Registry created: ${created.registryId}`);
9049
+ return created;
9050
+ }
9051
+ });
9052
+ const registryId = dokployRegistry.registryId;
9053
+ state.registryId = registryId;
9054
+ logger$4.log(` Using registry: ${dokployRegistry.registryName}`);
9055
+ await journal.save();
7938
9056
  const skippedMobileApps = appsToDeployNames.filter((name) => workspace.apps[name].type === "mobile");
7939
9057
  if (skippedMobileApps.length > 0) logger$4.log(`\n📱 Skipping ${skippedMobileApps.length} mobile app(s) — deploy via framework toolchain: ${skippedMobileApps.join(", ")}`);
7940
9058
  const backendApps = appsToDeployNames.filter((name) => workspace.apps[name].type === "backend");
@@ -7955,6 +9073,7 @@ async function workspaceDeployCommand(configured, options) {
7955
9073
  projectId: project.projectId,
7956
9074
  environmentId,
7957
9075
  stage,
9076
+ scope: identity.scope,
7958
9077
  appUrls,
7959
9078
  supplied: stageSecrets.custom,
7960
9079
  seed: stageSecrets.seed,
@@ -7980,7 +9099,7 @@ async function workspaceDeployCommand(configured, options) {
7980
9099
  const backupState = await provisionBackupDestination({
7981
9100
  api,
7982
9101
  projectId: project.projectId,
7983
- projectName: workspace.name,
9102
+ projectName: identity.scope,
7984
9103
  stage,
7985
9104
  config: workspace.deploy.backups,
7986
9105
  existingState: getBackupState(state),
@@ -7991,7 +9110,7 @@ async function workspaceDeployCommand(configured, options) {
7991
9110
  const backupSchedule = workspace.deploy.backups.schedule ?? "0 2 * * *";
7992
9111
  const backupRetention = workspace.deploy.backups.retention ?? 30;
7993
9112
  logger$4.log(" Creating postgres backup schedule...");
7994
- const backup = await api.createPostgresBackup({
9113
+ setPostgresBackupId(state, (await api.createPostgresBackup({
7995
9114
  schedule: backupSchedule,
7996
9115
  prefix: `${stage}/postgres`,
7997
9116
  destinationId: backupState.destinationId,
@@ -7999,8 +9118,7 @@ async function workspaceDeployCommand(configured, options) {
7999
9118
  postgresId: backupCluster.postgresId,
8000
9119
  enabled: true,
8001
9120
  keepLatestCount: backupRetention
8002
- });
8003
- setPostgresBackupId(state, backup.backupId);
9121
+ })).backupId);
8004
9122
  logger$4.log(` ✓ Postgres backup schedule created (${backupSchedule})`);
8005
9123
  } else logger$4.log(" ✓ Using existing postgres backup schedule");
8006
9124
  }
@@ -8021,42 +9139,31 @@ async function workspaceDeployCommand(configured, options) {
8021
9139
  const app = workspace.apps[appName];
8022
9140
  logger$4.log(`\n ⚙️ Deploying ${appName}...`);
8023
9141
  try {
8024
- const dokployAppName = applicationName(stage, workspace.name, appName);
8025
- let application = null;
8026
- const cachedAppId = getApplicationId(state, appName);
8027
- if (cachedAppId) {
8028
- logger$4.log(` Using cached ID: ${cachedAppId}`);
8029
- application = await api.getApplication(cachedAppId);
8030
- if (application) logger$4.log(` ✓ Application found: ${application.applicationId}`);
8031
- else logger$4.log(` ⚠ Cached ID invalid, will create new`);
8032
- }
8033
- if (!application) {
8034
- const result = await api.findOrCreateApplication(dokployAppName, project.projectId, environmentId);
8035
- application = result.application;
8036
- if (result.created) logger$4.log(` Created application: ${application.applicationId}`);
8037
- else logger$4.log(` Found existing application: ${application.applicationId}`);
8038
- }
8039
- setApplicationId(state, appName, application.applicationId);
9142
+ const application = await ensureApplication(api, journal, appName, applicationName(identity, appName), project.projectId, environmentId);
8040
9143
  const appSecrets = encryptedSecrets.get(appName);
8041
9144
  const credentials = appSecrets && appSecrets.secretCount > 0 ? {
8042
9145
  encrypted: appSecrets.payload.encrypted,
8043
9146
  iv: appSecrets.payload.iv
8044
9147
  } : void 0;
8045
9148
  if (appSecrets && credentials) logger$4.log(` Encrypted ${appSecrets.secretCount} secrets`);
8046
- const imageName = `${workspace.name}-${appName}`;
8047
- const imageRef = registry ? `${registry}/${imageName}:${imageTag}` : `${imageName}:${imageTag}`;
8048
- logger$4.log(` Building Docker image: ${imageRef}`);
8049
- await deployDocker({
9149
+ const imageRef$1 = imageRef(identity, appName, registry, imageTag);
9150
+ logger$4.log(` Building Docker image: ${imageRef$1}`);
9151
+ const built = await deployDocker({
8050
9152
  stage,
8051
9153
  tag: imageTag,
8052
9154
  skipPush: false,
8053
9155
  config: {
8054
9156
  registry,
8055
- imageName,
9157
+ imageName: imageName(identity, appName),
8056
9158
  appName
8057
9159
  },
8058
9160
  credentials
8059
9161
  });
9162
+ setDeployedImage(state, appName, {
9163
+ ref: built.imageRef ?? imageRef$1,
9164
+ ...built.digest ? { digest: built.digest } : {}
9165
+ });
9166
+ await journal.save();
8060
9167
  const backendHost = resolveHost(appName, app, stage, workspace.deploy?.domains, false);
8061
9168
  const dependencyUrls = {};
8062
9169
  if (app.dependencies) {
@@ -8087,40 +9194,21 @@ async function workspaceDeployCommand(configured, options) {
8087
9194
  };
8088
9195
  const envVars = Object.entries(withDeclared).map(([key, value]) => `${key}=${value}`);
8089
9196
  if (Object.keys(withDeclared).length > 0) logger$4.log(` Resolved ${Object.keys(withDeclared).length} env vars: ${Object.keys(withDeclared).sort().join(", ")}`);
8090
- await api.saveDockerProvider(application.applicationId, imageRef, { registryId });
9197
+ await api.saveDockerProvider(application.applicationId, imageRef$1, { registryId });
8091
9198
  await api.saveApplicationEnv(application.applicationId, envVars.join("\n"));
8092
9199
  logger$4.log(` Deploying to Dokploy...`);
8093
9200
  await api.deployApplication(application.applicationId);
8094
- const existingDomain = (await api.getDomainsByApplicationId(application.applicationId)).find((d) => d.host === backendHost);
8095
- if (existingDomain) {
8096
- appHostnames.set(appName, backendHost);
8097
- appDomainIds.set(appName, existingDomain.domainId);
8098
- publicUrls[appName] = `https://${backendHost}`;
8099
- logger$4.log(` ✓ Domain: https://${backendHost} (existing)`);
8100
- } else try {
8101
- const domain = await api.createDomain({
8102
- host: backendHost,
8103
- port: app.port,
8104
- https: true,
8105
- certificateType: "letsencrypt",
8106
- applicationId: application.applicationId
8107
- });
8108
- appHostnames.set(appName, backendHost);
8109
- appDomainIds.set(appName, domain.domainId);
8110
- publicUrls[appName] = `https://${backendHost}`;
8111
- logger$4.log(` ✓ Domain: https://${backendHost} (created)`);
8112
- } catch (domainError) {
8113
- const message = domainError instanceof Error ? domainError.message : "Unknown error";
8114
- logger$4.log(` ⚠ Domain creation failed: ${message}`);
8115
- appHostnames.set(appName, backendHost);
8116
- publicUrls[appName] = `https://${backendHost}`;
8117
- }
9201
+ const domainId = await ensureDomain(api, journal, backendHost, app.port, application.applicationId);
9202
+ appHostnames.set(appName, backendHost);
9203
+ if (domainId) appDomainIds.set(appName, domainId);
9204
+ publicUrls[appName] = `https://${backendHost}`;
8118
9205
  results.push({
8119
9206
  appName,
8120
9207
  type: app.type,
8121
9208
  success: true,
8122
9209
  applicationId: application.applicationId,
8123
- imageRef
9210
+ imageRef: imageRef$1,
9211
+ ...built.digest ? { digest: built.digest } : {}
8124
9212
  });
8125
9213
  logger$4.log(` ✓ ${appName} deployed successfully`);
8126
9214
  } catch (error) {
@@ -8142,22 +9230,7 @@ async function workspaceDeployCommand(configured, options) {
8142
9230
  const app = workspace.apps[appName];
8143
9231
  logger$4.log(`\n 🌐 Deploying ${appName}...`);
8144
9232
  try {
8145
- const dokployAppName = applicationName(stage, workspace.name, appName);
8146
- let application = null;
8147
- const cachedAppId = getApplicationId(state, appName);
8148
- if (cachedAppId) {
8149
- logger$4.log(` Using cached ID: ${cachedAppId}`);
8150
- application = await api.getApplication(cachedAppId);
8151
- if (application) logger$4.log(` ✓ Application found: ${application.applicationId}`);
8152
- else logger$4.log(` ⚠ Cached ID invalid, will create new`);
8153
- }
8154
- if (!application) {
8155
- const result = await api.findOrCreateApplication(dokployAppName, project.projectId, environmentId);
8156
- application = result.application;
8157
- if (result.created) logger$4.log(` Created application: ${application.applicationId}`);
8158
- else logger$4.log(` Found existing application: ${application.applicationId}`);
8159
- }
8160
- setApplicationId(state, appName, application.applicationId);
9233
+ const application = await ensureApplication(api, journal, appName, applicationName(identity, appName), project.projectId, environmentId);
8161
9234
  const dependencyUrls = {};
8162
9235
  if (app.dependencies) {
8163
9236
  for (const dep of app.dependencies) if (publicUrls[dep]) dependencyUrls[dep] = publicUrls[dep];
@@ -8187,61 +9260,46 @@ async function workspaceDeployCommand(configured, options) {
8187
9260
  }
8188
9261
  }
8189
9262
  if (buildArgs.length > 0) logger$4.log(` Build args: ${publicUrlArgNames.join(", ")}`);
8190
- const imageName = `${workspace.name}-${appName}`;
8191
- const imageRef = registry ? `${registry}/${imageName}:${imageTag}` : `${imageName}:${imageTag}`;
8192
- logger$4.log(` Building Docker image: ${imageRef}`);
8193
- await deployDocker({
9263
+ const imageRef$2 = imageRef(identity, appName, registry, imageTag);
9264
+ logger$4.log(` Building Docker image: ${imageRef$2}`);
9265
+ const built = await deployDocker({
8194
9266
  stage,
8195
9267
  tag: imageTag,
8196
9268
  skipPush: false,
8197
9269
  config: {
8198
9270
  registry,
8199
- imageName,
9271
+ imageName: imageName(identity, appName),
8200
9272
  appName
8201
9273
  },
8202
9274
  buildArgs,
8203
9275
  publicUrlArgs: publicUrlArgNames
8204
9276
  });
9277
+ setDeployedImage(state, appName, {
9278
+ ref: built.imageRef ?? imageRef$2,
9279
+ ...built.digest ? { digest: built.digest } : {}
9280
+ });
9281
+ await journal.save();
8205
9282
  const envVars = [
8206
9283
  `NODE_ENV=production`,
8207
9284
  `PORT=${app.port}`,
8208
9285
  `STAGE=${stage}`
8209
9286
  ];
8210
9287
  for (const [key, value] of Object.entries(resolved)) envVars.push(`${key}=${value}`);
8211
- await api.saveDockerProvider(application.applicationId, imageRef, { registryId });
9288
+ await api.saveDockerProvider(application.applicationId, imageRef$2, { registryId });
8212
9289
  await api.saveApplicationEnv(application.applicationId, envVars.join("\n"));
8213
9290
  logger$4.log(` Deploying to Dokploy...`);
8214
9291
  await api.deployApplication(application.applicationId);
8215
- const existingFrontendDomain = (await api.getDomainsByApplicationId(application.applicationId)).find((d) => d.host === frontendHost);
8216
- if (existingFrontendDomain) {
8217
- appHostnames.set(appName, frontendHost);
8218
- appDomainIds.set(appName, existingFrontendDomain.domainId);
8219
- publicUrls[appName] = `https://${frontendHost}`;
8220
- logger$4.log(` ✓ Domain: https://${frontendHost} (existing)`);
8221
- } else try {
8222
- const domain = await api.createDomain({
8223
- host: frontendHost,
8224
- port: app.port,
8225
- https: true,
8226
- certificateType: "letsencrypt",
8227
- applicationId: application.applicationId
8228
- });
8229
- appHostnames.set(appName, frontendHost);
8230
- appDomainIds.set(appName, domain.domainId);
8231
- publicUrls[appName] = `https://${frontendHost}`;
8232
- logger$4.log(` ✓ Domain: https://${frontendHost} (created)`);
8233
- } catch (domainError) {
8234
- const message = domainError instanceof Error ? domainError.message : "Unknown error";
8235
- logger$4.log(` ⚠ Domain creation failed: ${message}`);
8236
- appHostnames.set(appName, frontendHost);
8237
- publicUrls[appName] = `https://${frontendHost}`;
8238
- }
9292
+ const domainId = await ensureDomain(api, journal, frontendHost, app.port, application.applicationId);
9293
+ appHostnames.set(appName, frontendHost);
9294
+ if (domainId) appDomainIds.set(appName, domainId);
9295
+ publicUrls[appName] = `https://${frontendHost}`;
8239
9296
  results.push({
8240
9297
  appName,
8241
9298
  type: app.type,
8242
9299
  success: true,
8243
9300
  applicationId: application.applicationId,
8244
- imageRef
9301
+ imageRef: imageRef$2,
9302
+ ...built.digest ? { digest: built.digest } : {}
8245
9303
  });
8246
9304
  logger$4.log(` ✓ ${appName} deployed successfully`);
8247
9305
  } catch (error) {
@@ -8257,14 +9315,14 @@ async function workspaceDeployCommand(configured, options) {
8257
9315
  }
8258
9316
  }
8259
9317
  logger$4.log("\n📋 Saving deploy state...");
8260
- await stateProvider.write(stage, state);
9318
+ await journal.save();
8261
9319
  logger$4.log(" ✓ State saved");
8262
9320
  const dnsConfig = workspace.deploy.dns;
8263
9321
  if (dnsConfig && appHostnames.size > 0) {
8264
9322
  const dnsResult = await orchestrateDns(appHostnames, dnsConfig, creds.endpoint);
8265
9323
  if (dnsResult?.serverIp && appHostnames.size > 0) {
8266
9324
  await verifyDnsRecords(appHostnames, dnsResult.serverIp, state);
8267
- await stateProvider.write(stage, state);
9325
+ await journal.save();
8268
9326
  }
8269
9327
  if (dnsResult?.success && appHostnames.size > 0) {
8270
9328
  logger$4.log("\n🔒 Validating domains for SSL certificates...");
@@ -8297,6 +9355,71 @@ async function workspaceDeployCommand(configured, options) {
8297
9355
  };
8298
9356
  }
8299
9357
  /**
9358
+ * An app's Dokploy application, through the journal: by the id recorded for
9359
+ * it, else by its name in the stage's environment, else created. Its id goes
9360
+ * into `state.applications` too, which the env resolver and `state:show` read.
9361
+ */
9362
+ async function ensureApplication(api, journal, appName, dokployAppName, projectId, environmentId) {
9363
+ const recorded = journal.record(`application:${appName}`);
9364
+ if (recorded?.status === "ready" && recorded.id) logger$4.log(` Using cached ID: ${recorded.id}`);
9365
+ const { resource, via, staleId } = await journal.ensure({
9366
+ key: `application:${appName}`,
9367
+ type: "application",
9368
+ data: { name: dokployAppName }
9369
+ }, {
9370
+ get: (id) => api.getApplication(id),
9371
+ find: () => api.findApplicationByName(projectId, dokployAppName, environmentId),
9372
+ create: () => api.createApplication(dokployAppName, projectId, environmentId),
9373
+ id: (application) => application.applicationId
9374
+ });
9375
+ if (staleId) logger$4.log(` ⚠ Cached ID invalid, will create new`);
9376
+ logger$4.log(` ${{
9377
+ recorded: "✓ Application found",
9378
+ resumed: "Resumed application a stopped run created",
9379
+ found: "Found existing application",
9380
+ created: "Created application"
9381
+ }[via]}: ${resource.applicationId}`);
9382
+ setApplicationId(journal.state, appName, resource.applicationId);
9383
+ return resource;
9384
+ }
9385
+ /**
9386
+ * The domain `host` routes to an application, through the journal — found on
9387
+ * the application before it is created, so a re-run never adds a second.
9388
+ *
9389
+ * A domain that cannot be created is reported and skipped rather than
9390
+ * failing the app: the application is deployed and reachable on Dokploy's
9391
+ * own address, and DNS is fixed by hand more easily than a half deploy.
9392
+ */
9393
+ async function ensureDomain(api, journal, host, port, applicationId) {
9394
+ try {
9395
+ const { resource, via } = await journal.ensure({
9396
+ key: `domain:${host}`,
9397
+ type: "domain",
9398
+ data: {
9399
+ host,
9400
+ applicationId
9401
+ }
9402
+ }, {
9403
+ find: async () => (await api.getDomainsByApplicationId(applicationId)).find((d) => d.host === host),
9404
+ create: () => api.createDomain({
9405
+ host,
9406
+ port,
9407
+ https: true,
9408
+ certificateType: "letsencrypt",
9409
+ applicationId
9410
+ }),
9411
+ id: (domain) => domain.domainId
9412
+ });
9413
+ logger$4.log(` ✓ Domain: https://${host} (${via === "created" ? "created" : "existing"})`);
9414
+ return resource.domainId;
9415
+ } catch (domainError) {
9416
+ if (domainError instanceof StateVersionConflict || domainError instanceof StateStoreBusy) throw domainError;
9417
+ const message = domainError instanceof Error ? domainError.message : "Unknown error";
9418
+ logger$4.log(` ⚠ Domain creation failed: ${message}`);
9419
+ return;
9420
+ }
9421
+ }
9422
+ /**
8300
9423
  * Main deploy command
8301
9424
  */
8302
9425
  async function deployCommand(options) {
@@ -8433,453 +9556,160 @@ async function deployListCommand(options) {
8433
9556
  }
8434
9557
  }
8435
9558
  //#endregion
8436
- //#region src/deploy/CachedStateProvider.ts
8437
- var CachedStateProvider_exports = /* @__PURE__ */ __exportAll({ CachedStateProvider: () => CachedStateProvider });
8438
- /**
8439
- * Cached state provider that wraps a remote provider with local cache.
8440
- *
8441
- * @deprecated for deploys: a read prefers the local copy, so a stale one wins
8442
- * over the remote without warning, and a write overwrites the remote blindly.
8443
- * Deploy uses `createStateStore`, which reads SSM directly and writes
8444
- * conditionally. Kept for `gkm state:pull | push | diff`.
8445
- */
8446
- var CachedStateProvider = class {
8447
- remote;
8448
- local;
8449
- constructor(remote, local) {
8450
- this.remote = remote;
8451
- this.local = local;
8452
- }
8453
- async read(stage) {
8454
- const localState = await this.local.read(stage);
8455
- if (localState) return localState;
8456
- const remoteState = await this.remote.read(stage);
8457
- if (remoteState) await this.local.write(stage, remoteState);
8458
- return remoteState;
8459
- }
8460
- async write(stage, state) {
8461
- await this.remote.write(stage, state);
8462
- await this.local.write(stage, state);
8463
- }
8464
- /**
8465
- * Force pull from remote to local.
8466
- * Used by `gkm state pull` command.
8467
- */
8468
- async pull(stage) {
8469
- const remoteState = await this.remote.read(stage);
8470
- if (remoteState) await this.local.write(stage, remoteState);
8471
- return remoteState;
8472
- }
8473
- /**
8474
- * Force push from local to remote.
8475
- * Used by `gkm state push` command.
8476
- */
8477
- async push(stage) {
8478
- const localState = await this.local.read(stage);
8479
- if (localState) await this.remote.write(stage, localState);
8480
- return localState;
8481
- }
8482
- /**
8483
- * Get both local and remote state for comparison.
8484
- * Used by `gkm state diff` command.
8485
- */
8486
- async diff(stage) {
8487
- const [local, remote] = await Promise.all([this.local.read(stage), this.remote.read(stage)]);
8488
- return {
8489
- local,
8490
- remote
8491
- };
8492
- }
8493
- };
8494
- //#endregion
8495
- //#region src/deploy/StateStore.ts
9559
+ //#region src/deploy/LocalStateStore.ts
8496
9560
  /**
8497
- * Deploy state store.
8498
- *
8499
- * `StateProvider` reads and writes a whole stage at once, with nothing to stop
8500
- * two runs of the same stage from overwriting each other and nowhere to record
8501
- * a resource the moment it exists. A `StateStore` adds the three things a
8502
- * deploy needs to be safe against crashes and concurrent runs:
8503
- *
8504
- * - `lock(stage)`: one run per stage. A second gets `StateLocked`, naming the
8505
- * holder, instead of racing the first.
8506
- * - Versioned `read` and conditional `write`: a write names the version it was
8507
- * based on, and raises `StateVersionConflict` when the stored state moved on
8508
- * underneath it, so nothing is lost silently even without the lock.
8509
- * - Per-resource records: `putResource` journals one resource at a time, so a
8510
- * run that dies half way leaves behind the ids of everything it created.
9561
+ * Local filesystem state store.
8511
9562
  *
8512
- * Built-in stores: `LocalStateStore`, `SSMStateStore`, `S3StateStore`. A
8513
- * custom `StateProvider` is wrapped by `LegacyStateStore`, which keeps it
8514
- * working but cannot lock.
8515
- */
8516
- var StateStore_exports = /* @__PURE__ */ __exportAll({
8517
- DocumentStateStore: () => DocumentStateStore,
8518
- STATE_SCHEMA_VERSION: () => 2,
8519
- StageStateMissing: () => StageStateMissing,
8520
- StateLocked: () => StateLocked,
8521
- StateSchemaTooNew: () => StateSchemaTooNew,
8522
- StateStoreBusy: () => StateStoreBusy,
8523
- StateStoreNeedsWorkspaceName: () => StateStoreNeedsWorkspaceName,
8524
- StateStoreProvider: () => StateStoreProvider,
8525
- StateStoreWithoutLocking: () => StateStoreWithoutLocking,
8526
- StateUnreadable: () => StateUnreadable,
8527
- StateVersionConflict: () => StateVersionConflict,
8528
- UnknownStateProvider: () => UnknownStateProvider,
8529
- contentVersion: () => contentVersion,
8530
- createStateStore: () => createStateStore,
8531
- decodeDocument: () => decodeDocument,
8532
- encodeDocument: () => encodeDocument,
8533
- isStateStore: () => isStateStore,
8534
- migrateV1: () => migrateV1,
8535
- newLockHolder: () => newLockHolder
8536
- });
8537
- var StateLocked = class extends Error {
8538
- stage;
8539
- holder;
8540
- location;
8541
- constructor(stage, holder, location) {
8542
- const by = holder ? `${holder.owner}@${holder.host} (pid ${holder.pid}) since ${holder.acquiredAt}${holder.operation ? ` for '${holder.operation}'` : ""}` : "a run that did not record who it was";
8543
- super(`Deploy state for stage '${stage}' is locked by ${by} (${location}). Wait for that run to finish; if it crashed, release the lock with \`gkm state:unlock --stage ${stage}\`.`);
8544
- this.stage = stage;
8545
- this.holder = holder;
8546
- this.location = location;
8547
- this.name = "StateLocked";
8548
- }
8549
- };
8550
- var StateVersionConflict = class extends Error {
8551
- stage;
8552
- expectedVersion;
8553
- actualVersion;
8554
- constructor(stage, expectedVersion, actualVersion) {
8555
- super(`Deploy state for stage '${stage}' changed since it was read (expected ${expectedVersion ? `version ${expectedVersion}` : "no state"}, found ${actualVersion ? `version ${actualVersion}` : "no state"}). Another run wrote it: read the state again and re-apply the change, and hold the stage's lock so runs take turns.`);
8556
- this.stage = stage;
8557
- this.expectedVersion = expectedVersion;
8558
- this.actualVersion = actualVersion;
8559
- this.name = "StateVersionConflict";
8560
- }
8561
- };
8562
- var StageStateMissing = class extends Error {
8563
- stage;
8564
- constructor(stage) {
8565
- super(`There is no deploy state for stage '${stage}' to record a resource in. Write the stage's state first (write with expectedVersion: null).`);
8566
- this.stage = stage;
8567
- this.name = "StageStateMissing";
8568
- }
8569
- };
8570
- var StateUnreadable = class extends Error {
8571
- stage;
8572
- location;
8573
- constructor(stage, location, cause) {
8574
- super(`Deploy state for stage '${stage}' at ${location} is not valid JSON. Restore it from a backup (or the provider's history) rather than deploying without it — a deploy without state recreates every resource.`, { cause });
8575
- this.stage = stage;
8576
- this.location = location;
8577
- this.name = "StateUnreadable";
8578
- }
8579
- };
8580
- var StateSchemaTooNew = class extends Error {
8581
- stage;
8582
- schemaVersion;
8583
- constructor(stage, schemaVersion) {
8584
- super(`Deploy state for stage '${stage}' has schema version ${String(schemaVersion)}, newer than this @geekmidas/cli understands (2). Upgrade @geekmidas/cli to the version that wrote it.`);
8585
- this.stage = stage;
8586
- this.schemaVersion = schemaVersion;
8587
- this.name = "StateSchemaTooNew";
8588
- }
8589
- };
9563
+ * `.gkm/deploy-<stage>.json` holds the state, `.gkm/deploy-<stage>.lock` the
9564
+ * lock. Every file is mode 0600: state holds database passwords and IAM keys.
9565
+ */
9566
+ var LocalStateStore_exports = /* @__PURE__ */ __exportAll({ LocalStateStore: () => LocalStateStore });
9567
+ const FILE_MODE = 384;
9568
+ /** How long a write waits for another process's write to finish. */
9569
+ const MUTEX_TIMEOUT_MS = 5e3;
8590
9570
  /**
8591
- * Not thrown: emitted (once per store) when a custom `StateProvider` stands
8592
- * in for a store, so a deploy that cannot be protected from a concurrent one
8593
- * says so.
9571
+ * A write holds the mutex for milliseconds, so one this old was left by a
9572
+ * process that died mid-write and is safe to take over.
8594
9573
  */
8595
- var StateStoreWithoutLocking = class extends Error {
8596
- provider;
8597
- constructor(provider) {
8598
- super(`The custom state provider '${provider}' has no lock and no versions, so two deploys of the same stage can still overwrite each other. Implement StateStore (lock, versioned read, conditional write) or use the 'local', 'ssm' or 's3' state provider.`);
8599
- this.provider = provider;
8600
- this.name = "StateStoreWithoutLocking";
9574
+ const MUTEX_STALE_MS = 3e4;
9575
+ function isCode(error, code) {
9576
+ return error?.code === code;
9577
+ }
9578
+ var LocalStateStore = class extends DocumentStateStore {
9579
+ workspaceRoot;
9580
+ constructor(workspaceRoot) {
9581
+ super();
9582
+ this.workspaceRoot = workspaceRoot;
8601
9583
  }
8602
- };
8603
- var StateStoreNeedsWorkspaceName = class extends Error {
8604
- provider;
8605
- constructor(provider) {
8606
- super(`The '${provider}' state provider keys state by workspace name. Set "name" in gkm.config.ts.`);
8607
- this.provider = provider;
8608
- this.name = "StateStoreNeedsWorkspaceName";
9584
+ get dir() {
9585
+ return join(this.workspaceRoot, ".gkm");
8609
9586
  }
8610
- };
8611
- var UnknownStateProvider = class extends Error {
8612
- config;
8613
- constructor(config) {
8614
- super(`Unknown state provider ${JSON.stringify(config)}. Use 'local', 'ssm', 's3', or an object implementing StateStore or StateProvider.`);
8615
- this.config = config;
8616
- this.name = "UnknownStateProvider";
9587
+ statePath(stage) {
9588
+ return join(this.dir, `deploy-${stage}.json`);
8617
9589
  }
8618
- };
8619
- /** A lock or write that could not get at the state backend in time. */
8620
- var StateStoreBusy = class extends Error {
8621
- stage;
8622
- location;
8623
- constructor(stage, location) {
8624
- super(`Timed out waiting to write deploy state for stage '${stage}' (${location}). If no other gkm process is running, remove ${location}.`);
8625
- this.stage = stage;
8626
- this.location = location;
8627
- this.name = "StateStoreBusy";
9590
+ lockPath(stage) {
9591
+ return join(this.dir, `deploy-${stage}.lock`);
8628
9592
  }
8629
- };
8630
- function decodeDocument(stage, body, location) {
8631
- let parsed;
8632
- try {
8633
- parsed = JSON.parse(body);
8634
- } catch (error) {
8635
- throw new StateUnreadable(stage, location, error);
9593
+ backupPath(stage) {
9594
+ return join(this.dir, `deploy-${stage}.v1.json`);
8636
9595
  }
8637
- if (typeof parsed !== "object" || parsed === null) throw new StateUnreadable(stage, location, parsed);
8638
- const schemaVersion = parsed.schemaVersion;
8639
- if (schemaVersion === void 0) return {
8640
- schemaVersion: 1,
8641
- state: parsed
8642
- };
8643
- if (schemaVersion === 2) return {
8644
- schemaVersion: 2,
8645
- document: parsed
8646
- };
8647
- throw new StateSchemaTooNew(stage, schemaVersion);
8648
- }
8649
- function encodeDocument(document) {
8650
- return JSON.stringify(document, null, 2);
8651
- }
8652
- /**
8653
- * v1 state as a v2 document. The ids v1 kept are seeded as `ready` records,
8654
- * so the first journaled deploy after an upgrade adopts them rather than
8655
- * treating them as unknown.
8656
- */
8657
- function migrateV1(stage, state) {
8658
- const now = (/* @__PURE__ */ new Date()).toISOString();
8659
- const resources = {};
8660
- const seed = (type, name, id) => {
8661
- if (!id) return;
8662
- const key = name ? `${type}:${name}` : type;
8663
- resources[key] = {
8664
- key,
8665
- type,
8666
- id,
8667
- status: "ready",
8668
- updatedAt: now
8669
- };
8670
- };
8671
- seed("project", null, state.projectId);
8672
- seed("environment", null, state.environmentId);
8673
- for (const [app, id] of Object.entries(state.applications ?? {})) seed("application", app, id);
8674
- seed("postgres", null, state.services?.postgresId);
8675
- seed("redis", null, state.services?.redisId);
8676
- seed("backup-destination", null, state.backups?.destinationId);
8677
- return {
8678
- schemaVersion: 2,
8679
- stage,
8680
- serial: 1,
8681
- state,
8682
- resources,
8683
- updatedAt: now
8684
- };
8685
- }
8686
- function newLockHolder(options) {
8687
- let owner = "unknown";
8688
- try {
8689
- owner = userInfo().username;
8690
- } catch {}
8691
- return {
8692
- id: randomUUID(),
8693
- owner,
8694
- host: hostname(),
8695
- pid: process.pid,
8696
- acquiredAt: (/* @__PURE__ */ new Date()).toISOString(),
8697
- operation: options?.operation
8698
- };
8699
- }
8700
- function contentVersion(body) {
8701
- return createHash("sha256").update(body).digest("hex").slice(0, 16);
8702
- }
8703
- /**
8704
- * A store over a backend that can read and conditionally write one blob per
8705
- * stage, and create a lock object only if it does not exist. Migration and
8706
- * resource records are the same for every such backend, so they live here.
8707
- */
8708
- var DocumentStateStore = class {
8709
- async lock(stage, options) {
8710
- const holder = newLockHolder(options);
8711
- await this.createLock(stage, holder);
8712
- let released = false;
8713
- return {
8714
- stage,
8715
- holder,
8716
- release: async () => {
8717
- if (released) return;
8718
- released = true;
8719
- await this.removeLock(stage, holder);
8720
- }
8721
- };
9596
+ mutexPath(stage) {
9597
+ return join(this.dir, `deploy-${stage}.json.mutex`);
8722
9598
  }
8723
- async forceUnlock(stage) {
8724
- const holder = await this.readLock(stage);
8725
- await this.removeLock(stage, null);
8726
- return holder;
9599
+ location(stage) {
9600
+ return this.statePath(stage);
8727
9601
  }
8728
- async read(stage) {
8729
- const current = await this.readDocument(stage);
8730
- if (!current) return null;
8731
- return {
8732
- state: current.document.state,
8733
- resources: current.document.resources,
8734
- version: current.version
8735
- };
8736
- }
8737
- async write(stage, state, options) {
8738
- if (options.expectedVersion === null) {
8739
- const document = {
8740
- schemaVersion: 2,
8741
- stage,
8742
- serial: 1,
8743
- state,
8744
- resources: {},
8745
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
9602
+ async readRaw(stage) {
9603
+ try {
9604
+ const body = await readFile(this.statePath(stage), "utf-8");
9605
+ return {
9606
+ body,
9607
+ version: contentVersion(body)
8746
9608
  };
8747
- return this.writeRaw(stage, encodeDocument(document), null);
9609
+ } catch (error) {
9610
+ if (isCode(error, "ENOENT")) return null;
9611
+ throw error;
8748
9612
  }
8749
- return this.mutate(stage, options.expectedVersion, (document) => ({
8750
- ...document,
8751
- state
8752
- }));
8753
9613
  }
8754
- async putResource(stage, record, options) {
8755
- return this.mutate(stage, options?.expectedVersion, (document) => ({
8756
- ...document,
8757
- resources: {
8758
- ...document.resources,
8759
- [record.key]: {
8760
- ...record,
8761
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
9614
+ async writeRaw(stage, body, expectedVersion) {
9615
+ await this.ensureDir();
9616
+ return this.withMutex(stage, async () => {
9617
+ const actual = (await this.readRaw(stage))?.version ?? null;
9618
+ if (actual !== expectedVersion) throw new StateVersionConflict(stage, expectedVersion, actual);
9619
+ const target = this.statePath(stage);
9620
+ const temp = `${target}.${randomUUID()}.tmp`;
9621
+ try {
9622
+ const handle = await open(temp, "wx", FILE_MODE);
9623
+ try {
9624
+ await handle.writeFile(body);
9625
+ await handle.sync();
9626
+ } finally {
9627
+ await handle.close();
8762
9628
  }
9629
+ await rename(temp, target);
9630
+ } catch (error) {
9631
+ await rm(temp, { force: true });
9632
+ throw error;
8763
9633
  }
8764
- }));
8765
- }
8766
- async deleteResource(stage, key, options) {
8767
- return this.mutate(stage, options?.expectedVersion, (document) => {
8768
- const { [key]: _removed, ...resources } = document.resources;
8769
- return {
8770
- ...document,
8771
- resources
8772
- };
9634
+ return contentVersion(body);
8773
9635
  });
8774
9636
  }
8775
- async mutate(stage, expectedVersion, change) {
8776
- const current = await this.readDocument(stage);
8777
- if (!current) {
8778
- if (expectedVersion !== void 0) throw new StateVersionConflict(stage, expectedVersion, null);
8779
- throw new StageStateMissing(stage);
9637
+ async writeV1Backup(stage, body) {
9638
+ try {
9639
+ await writeFile(this.backupPath(stage), body, {
9640
+ flag: "wx",
9641
+ mode: FILE_MODE
9642
+ });
9643
+ } catch (error) {
9644
+ if (!isCode(error, "EEXIST")) throw error;
8780
9645
  }
8781
- if (expectedVersion !== void 0 && current.version !== expectedVersion) throw new StateVersionConflict(stage, expectedVersion, current.version);
8782
- const next = change(current.document);
8783
- next.serial = current.document.serial + 1;
8784
- next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
8785
- return this.writeRaw(stage, encodeDocument(next), current.version);
8786
9646
  }
8787
- /**
8788
- * Reads the stage's document, migrating v1 in place on the way. The
8789
- * migration is itself a conditional write: if another run migrated (or
8790
- * wrote) first, its result is read instead.
8791
- */
8792
- async readDocument(stage, attempt = 0) {
8793
- const raw = await this.readRaw(stage);
8794
- if (!raw) return null;
8795
- const decoded = decodeDocument(stage, raw.body, this.location(stage));
8796
- if (decoded.schemaVersion === 2) return {
8797
- document: decoded.document,
8798
- version: raw.version
8799
- };
8800
- await this.writeV1Backup(stage, raw.body);
8801
- const document = migrateV1(stage, decoded.state);
9647
+ async createLock(stage, holder) {
9648
+ await this.ensureDir();
9649
+ let handle;
8802
9650
  try {
8803
- return {
8804
- document,
8805
- version: await this.writeRaw(stage, encodeDocument(document), raw.version)
8806
- };
9651
+ handle = await open(this.lockPath(stage), "wx", FILE_MODE);
8807
9652
  } catch (error) {
8808
- if (error instanceof StateVersionConflict && attempt < 2) return this.readDocument(stage, attempt + 1);
9653
+ if (isCode(error, "EEXIST")) throw new StateLocked(stage, await this.readLock(stage), this.lockPath(stage));
8809
9654
  throw error;
8810
9655
  }
9656
+ try {
9657
+ await handle.writeFile(JSON.stringify(holder, null, 2));
9658
+ } finally {
9659
+ await handle.close();
9660
+ }
8811
9661
  }
8812
- };
8813
- function isStateStore(value) {
8814
- return typeof value === "object" && value !== null && typeof value.lock === "function" && typeof value.read === "function" && typeof value.write === "function" && typeof value.putResource === "function";
8815
- }
8816
- /**
8817
- * The store for a workspace's `state` config — the same config
8818
- * `createStateProvider` reads:
8819
- *
8820
- * - none / `'local'`: `LocalStateStore` under `.gkm/`
8821
- * - `'ssm'`: `SSMStateStore`, with no local cache — SSM is read directly, so
8822
- * a stale local copy can never win over it
8823
- * - `'s3'`: `S3StateStore`
8824
- * - a `StateStore` object: used as is
8825
- * - a `StateProvider` object: wrapped by `LegacyStateStore`
8826
- */
8827
- async function createStateStore(options) {
8828
- const { config, workspaceRoot, workspaceName } = options;
8829
- if (!config || config.provider === "local") {
8830
- const { LocalStateStore } = await import("./LocalStateStore-BupdV-VN.mjs");
8831
- return new LocalStateStore(workspaceRoot);
8832
- }
8833
- if (isStateStore(config.provider)) return config.provider;
8834
- if (isStateProvider(config.provider)) {
8835
- const { LegacyStateStore } = await import("./LegacyStateStore-ByRWQ9s1.mjs");
8836
- return new LegacyStateStore(config.provider, { warn: options.warn });
8837
- }
8838
- if (config.provider === "ssm") {
8839
- if (!workspaceName) throw new StateStoreNeedsWorkspaceName("ssm");
8840
- const { SSMStateStore } = await import("./SSMStateStore-7hPWW7v7.mjs");
8841
- const ssm = config;
8842
- return SSMStateStore.create({
8843
- workspaceName,
8844
- region: ssm.region,
8845
- profile: ssm.profile
8846
- });
9662
+ async readLock(stage) {
9663
+ try {
9664
+ return JSON.parse(await readFile(this.lockPath(stage), "utf-8"));
9665
+ } catch {
9666
+ return null;
9667
+ }
8847
9668
  }
8848
- if (config.provider === "s3") {
8849
- if (!workspaceName) throw new StateStoreNeedsWorkspaceName("s3");
8850
- const { S3StateStore } = await import("./S3StateStore-FhzyENrN.mjs");
8851
- const s3 = config;
8852
- return S3StateStore.create({
8853
- workspaceName,
8854
- bucket: s3.bucket,
8855
- prefix: s3.prefix,
8856
- region: s3.region,
8857
- profile: s3.profile
8858
- });
9669
+ async removeLock(stage, holder) {
9670
+ if (holder) {
9671
+ if ((await this.readLock(stage))?.id !== holder.id) return;
9672
+ }
9673
+ await rm(this.lockPath(stage), { force: true });
8859
9674
  }
8860
- throw new UnknownStateProvider(config);
8861
- }
8862
- /**
8863
- * A store behind the `StateProvider` interface, for the call sites that still
8864
- * read and write whole states (deploy, until it journals). A write is still
8865
- * conditional on the version this provider last read.
8866
- */
8867
- var StateStoreProvider = class {
8868
- store;
8869
- versions = /* @__PURE__ */ new Map();
8870
- constructor(store) {
8871
- this.store = store;
9675
+ async ensureDir() {
9676
+ await mkdir(this.dir, {
9677
+ recursive: true,
9678
+ mode: 448
9679
+ });
8872
9680
  }
8873
- async read(stage) {
8874
- const stored = await this.store.read(stage);
8875
- this.versions.set(stage, stored?.version ?? null);
8876
- return stored?.state ?? null;
9681
+ /**
9682
+ * Serialises the check-then-rename of a conditional write across
9683
+ * processes. The stage lock is for whole runs; this is for the instant of
9684
+ * a single write, so even writers that skip the lock never interleave.
9685
+ */
9686
+ async withMutex(stage, fn) {
9687
+ const path = this.mutexPath(stage);
9688
+ const deadline = Date.now() + MUTEX_TIMEOUT_MS;
9689
+ for (;;) try {
9690
+ await (await open(path, "wx", FILE_MODE)).close();
9691
+ break;
9692
+ } catch (error) {
9693
+ if (!isCode(error, "EEXIST")) throw error;
9694
+ if (await this.isStale(path)) {
9695
+ await unlink(path).catch(() => {});
9696
+ continue;
9697
+ }
9698
+ if (Date.now() > deadline) throw new StateStoreBusy(stage, path);
9699
+ await new Promise((resolve) => setTimeout(resolve, 10));
9700
+ }
9701
+ try {
9702
+ return await fn();
9703
+ } finally {
9704
+ await rm(path, { force: true });
9705
+ }
8877
9706
  }
8878
- async write(stage, state) {
8879
- const expectedVersion = this.versions.has(stage) ? this.versions.get(stage) ?? null : (await this.store.read(stage))?.version ?? null;
8880
- state.lastDeployedAt = (/* @__PURE__ */ new Date()).toISOString();
8881
- const version = await this.store.write(stage, state, { expectedVersion });
8882
- this.versions.set(stage, version);
9707
+ async isStale(path) {
9708
+ try {
9709
+ return Date.now() - (await stat(path)).mtimeMs > MUTEX_STALE_MS;
9710
+ } catch {
9711
+ return false;
9712
+ }
8883
9713
  }
8884
9714
  };
8885
9715
  //#endregion
@@ -8887,7 +9717,12 @@ var StateStoreProvider = class {
8887
9717
  /**
8888
9718
  * State Management CLI Commands
8889
9719
  *
8890
- * Commands for managing deployment state across local and remote providers.
9720
+ * Commands for managing deployment state across local and remote stores.
9721
+ *
9722
+ * Every command goes through a `StateStore`, the same one deploy writes
9723
+ * through: a v1 state is migrated on the way, resource records travel with
9724
+ * the state, and a copy is a conditional write — so `state:push` cannot
9725
+ * silently replace what a deploy wrote a moment earlier.
8891
9726
  */
8892
9727
  /** What `state:show` prints in place of a secret. */
8893
9728
  const MASKED = "********";
@@ -8913,26 +9748,12 @@ function maskStateSecrets(state) {
8913
9748
  */
8914
9749
  async function statePullCommand(options) {
8915
9750
  const { workspace } = await loadWorkspaceConfig();
8916
- if (!workspace.state || workspace.state.provider === "local") {
8917
- console.error("No remote state provider configured.");
8918
- console.error("Add a remote provider in gkm.config.ts:");
8919
- console.error(" state: { provider: \"ssm\", region: \"us-east-1\" }");
8920
- process.exit(1);
8921
- }
8922
- const provider = await createStateProvider({
8923
- config: workspace.state,
8924
- workspaceRoot: workspace.root,
8925
- workspaceName: workspace.name
8926
- });
8927
- if (!(provider instanceof CachedStateProvider)) {
8928
- console.error("State provider does not support pull operation.");
8929
- process.exit(1);
8930
- }
9751
+ const { local, remote } = await remoteAndLocal(workspace);
8931
9752
  console.log(`Pulling state for stage: ${options.stage}...`);
8932
- const state = await provider.pull(options.stage);
8933
- if (state) {
9753
+ const pulled = await copyUnderLock(remote, local, local, options.stage, "state:pull");
9754
+ if (pulled) {
8934
9755
  console.log("State pulled successfully.");
8935
- printStateSummary(state);
9756
+ printStateSummary(pulled.state);
8936
9757
  } else console.log("No remote state found for this stage.");
8937
9758
  }
8938
9759
  /**
@@ -8941,26 +9762,12 @@ async function statePullCommand(options) {
8941
9762
  */
8942
9763
  async function statePushCommand(options) {
8943
9764
  const { workspace } = await loadWorkspaceConfig();
8944
- if (!workspace.state || workspace.state.provider === "local") {
8945
- console.error("No remote state provider configured.");
8946
- console.error("Add a remote provider in gkm.config.ts:");
8947
- console.error(" state: { provider: \"ssm\", region: \"us-east-1\" }");
8948
- process.exit(1);
8949
- }
8950
- const provider = await createStateProvider({
8951
- config: workspace.state,
8952
- workspaceRoot: workspace.root,
8953
- workspaceName: workspace.name
8954
- });
8955
- if (!(provider instanceof CachedStateProvider)) {
8956
- console.error("State provider does not support push operation.");
8957
- process.exit(1);
8958
- }
9765
+ const { local, remote } = await remoteAndLocal(workspace);
8959
9766
  console.log(`Pushing state for stage: ${options.stage}...`);
8960
- const state = await provider.push(options.stage);
8961
- if (state) {
9767
+ const pushed = await copyUnderLock(local, remote, remote, options.stage, "state:push");
9768
+ if (pushed) {
8962
9769
  console.log("State pushed successfully.");
8963
- printStateSummary(state);
9770
+ printStateSummary(pushed.state);
8964
9771
  } else console.log("No local state found for this stage.");
8965
9772
  }
8966
9773
  /**
@@ -8969,7 +9776,7 @@ async function statePushCommand(options) {
8969
9776
  */
8970
9777
  async function stateShowCommand(options) {
8971
9778
  const { workspace } = await loadWorkspaceConfig();
8972
- const stored = await (await createStateProvider({
9779
+ const stored = await (await createStateStore({
8973
9780
  config: workspace.state,
8974
9781
  workspaceRoot: workspace.root,
8975
9782
  workspaceName: workspace.name
@@ -8978,9 +9785,12 @@ async function stateShowCommand(options) {
8978
9785
  console.log(`No state found for stage: ${options.stage}`);
8979
9786
  return;
8980
9787
  }
8981
- const state = maskStateSecrets(stored);
9788
+ const state = maskStateSecrets(stored.state);
8982
9789
  if (options.json) console.log(JSON.stringify(state, null, 2));
8983
- else printStateDetails(state);
9790
+ else {
9791
+ printStateDetails(state);
9792
+ printUnfinished(stored.resources);
9793
+ }
8984
9794
  }
8985
9795
  /**
8986
9796
  * Release a stage's deploy lock, whoever holds it.
@@ -9009,22 +9819,11 @@ async function stateUnlockCommand(options) {
9009
9819
  */
9010
9820
  async function stateDiffCommand(options) {
9011
9821
  const { workspace } = await loadWorkspaceConfig();
9012
- if (!workspace.state || workspace.state.provider === "local") {
9013
- console.error("No remote state provider configured.");
9014
- console.error("Diff requires a remote provider to compare against.");
9015
- process.exit(1);
9016
- }
9017
- const provider = await createStateProvider({
9018
- config: workspace.state,
9019
- workspaceRoot: workspace.root,
9020
- workspaceName: workspace.name
9021
- });
9022
- if (!(provider instanceof CachedStateProvider)) {
9023
- console.error("State provider does not support diff operation.");
9024
- process.exit(1);
9025
- }
9822
+ const stores = await remoteAndLocal(workspace, ["Diff requires a remote provider to compare against."]);
9026
9823
  console.log(`Comparing state for stage: ${options.stage}...\n`);
9027
- const { local, remote } = await provider.diff(options.stage);
9824
+ const [localStored, remoteStored] = await Promise.all([stores.local.read(options.stage), stores.remote.read(options.stage)]);
9825
+ const local = localStored?.state ?? null;
9826
+ const remote = remoteStored?.state ?? null;
9028
9827
  if (!local && !remote) {
9029
9828
  console.log("No state found (local or remote).");
9030
9829
  return;
@@ -9060,6 +9859,71 @@ async function stateDiffCommand(options) {
9060
9859
  else console.log(` ${key}: ${localVal ?? "(none)"} (local) != ${remoteVal ?? "(none)"} (remote)`);
9061
9860
  }
9062
9861
  }
9862
+ const localRecords = localStored?.resources ?? {};
9863
+ const remoteRecords = remoteStored?.resources ?? {};
9864
+ const differing = [.../* @__PURE__ */ new Set([...Object.keys(localRecords), ...Object.keys(remoteRecords)])].filter((key) => describeRecord(localRecords[key]) !== describeRecord(remoteRecords[key]));
9865
+ if (differing.length > 0) {
9866
+ console.log("\nResources:");
9867
+ for (const key of differing) console.log(` ${key}: ${describeRecord(localRecords[key])} (local) != ${describeRecord(remoteRecords[key])} (remote)`);
9868
+ }
9869
+ }
9870
+ /**
9871
+ * The stage's local store and its remote one — or, for a workspace that keeps
9872
+ * state only locally, the reason there is nothing to move it between.
9873
+ */
9874
+ async function remoteAndLocal(workspace, hint = ["Add a remote provider in gkm.config.ts:", " state: { provider: \"ssm\", region: \"us-east-1\" }"]) {
9875
+ if (!workspace.state || workspace.state.provider === "local") {
9876
+ console.error("No remote state provider configured.");
9877
+ for (const line of hint) console.error(line);
9878
+ process.exit(1);
9879
+ }
9880
+ return {
9881
+ local: new LocalStateStore(workspace.root),
9882
+ remote: await createStateStore({
9883
+ config: workspace.state,
9884
+ workspaceRoot: workspace.root,
9885
+ workspaceName: workspace.name
9886
+ })
9887
+ };
9888
+ }
9889
+ /**
9890
+ * Makes `to` hold exactly what `from` holds for `stage` — its state and its
9891
+ * resource records — while holding `locked`'s lock for the stage. Every
9892
+ * write is conditional on the version just read, so anything that changes
9893
+ * `to` mid-copy raises `StateVersionConflict` rather than being overwritten.
9894
+ */
9895
+ async function copyUnderLock(from, to, locked, stage, operation) {
9896
+ const lock = await locked.lock(stage, { operation });
9897
+ try {
9898
+ return await copyStage(from, to, stage);
9899
+ } finally {
9900
+ await lock.release();
9901
+ }
9902
+ }
9903
+ /** @internal Exported for testing */
9904
+ async function copyStage(from, to, stage) {
9905
+ const source = await from.read(stage);
9906
+ if (!source) return null;
9907
+ const target = await to.read(stage);
9908
+ let version = await to.write(stage, source.state, { expectedVersion: target?.version ?? null });
9909
+ for (const key of Object.keys(target?.resources ?? {})) {
9910
+ if (key in source.resources) continue;
9911
+ version = await to.deleteResource(stage, key, { expectedVersion: version });
9912
+ }
9913
+ for (const { updatedAt: _stamped, ...record } of Object.values(source.resources)) version = await to.putResource(stage, record, { expectedVersion: version });
9914
+ return source;
9915
+ }
9916
+ function describeRecord(record) {
9917
+ if (!record) return "(none)";
9918
+ return record.id ? `${record.status} ${record.id}` : record.status;
9919
+ }
9920
+ /** Resources a run marked `pending` and never saw created. */
9921
+ function printUnfinished(resources) {
9922
+ const pending = Object.values(resources).filter((record) => record.status === "pending");
9923
+ if (pending.length === 0) return;
9924
+ console.log("");
9925
+ console.log("Unfinished (a deploy stopped while creating these):");
9926
+ for (const record of pending) console.log(` ${record.key}`);
9063
9927
  }
9064
9928
  function printStateSummary(state) {
9065
9929
  const appCount = Object.keys(state.applications).length;
@@ -10107,25 +10971,25 @@ function loadPackageJson() {
10107
10971
  * Run: pnpm --filter @geekmidas/cli sync-versions
10108
10972
  */
10109
10973
  const GEEKMIDAS_VERSIONS = {
10110
- "@geekmidas/audit": "~10.0.0-alpha.57",
10111
- "@geekmidas/auth": "~10.0.0-alpha.57",
10112
- "@geekmidas/cache": "~10.0.0-alpha.57",
10113
- "@geekmidas/client": "~10.0.0-alpha.57",
10114
- "@geekmidas/cloud": "~10.0.0-alpha.57",
10115
- "@geekmidas/constructs": "~10.0.0-alpha.57",
10116
- "@geekmidas/db": "~10.0.0-alpha.57",
10117
- "@geekmidas/emailkit": "~10.0.0-alpha.57",
10118
- "@geekmidas/envkit": "~10.0.0-alpha.57",
10119
- "@geekmidas/errors": "~10.0.0-alpha.57",
10120
- "@geekmidas/events": "~10.0.0-alpha.57",
10121
- "@geekmidas/logger": "~10.0.0-alpha.57",
10122
- "@geekmidas/manifest": "~10.0.0-alpha.57",
10123
- "@geekmidas/rate-limit": "~10.0.0-alpha.57",
10124
- "@geekmidas/schema": "~10.0.0-alpha.57",
10125
- "@geekmidas/services": "~10.0.0-alpha.57",
10126
- "@geekmidas/storage": "~10.0.0-alpha.57",
10127
- "@geekmidas/telescope": "~10.0.0-alpha.57",
10128
- "@geekmidas/testkit": "~10.0.0-alpha.57",
10974
+ "@geekmidas/audit": "~10.0.0-alpha.59",
10975
+ "@geekmidas/auth": "~10.0.0-alpha.59",
10976
+ "@geekmidas/cache": "~10.0.0-alpha.59",
10977
+ "@geekmidas/client": "~10.0.0-alpha.59",
10978
+ "@geekmidas/cloud": "~10.0.0-alpha.59",
10979
+ "@geekmidas/constructs": "~10.0.0-alpha.59",
10980
+ "@geekmidas/db": "~10.0.0-alpha.59",
10981
+ "@geekmidas/emailkit": "~10.0.0-alpha.59",
10982
+ "@geekmidas/envkit": "~10.0.0-alpha.59",
10983
+ "@geekmidas/errors": "~10.0.0-alpha.59",
10984
+ "@geekmidas/events": "~10.0.0-alpha.59",
10985
+ "@geekmidas/logger": "~10.0.0-alpha.59",
10986
+ "@geekmidas/manifest": "~10.0.0-alpha.59",
10987
+ "@geekmidas/rate-limit": "~10.0.0-alpha.59",
10988
+ "@geekmidas/schema": "~10.0.0-alpha.59",
10989
+ "@geekmidas/services": "~10.0.0-alpha.59",
10990
+ "@geekmidas/storage": "~10.0.0-alpha.59",
10991
+ "@geekmidas/telescope": "~10.0.0-alpha.59",
10992
+ "@geekmidas/testkit": "~10.0.0-alpha.59",
10129
10993
  "@geekmidas/cli": `~${loadPackageJson().version}`
10130
10994
  };
10131
10995
  //#endregion
@@ -16007,7 +16871,7 @@ function testDatabases(reconciled) {
16007
16871
  * starts without the reference data the app needs.
16008
16872
  */
16009
16873
  async function migrateTestStage(workspace, env) {
16010
- const { migrateAndSeed } = await import("./migrate-CS3DkO6y.mjs");
16874
+ const { migrateAndSeed } = await import("./migrate-BrFREmgQ.mjs");
16011
16875
  const { discover } = await import("./discover-Cf2jAcgF.mjs").then((n) => n.n);
16012
16876
  const sources = {};
16013
16877
  const manifest = await discover({
@@ -16438,7 +17302,7 @@ program.command("migrate").description("Apply each database construct's migratio
16438
17302
  try {
16439
17303
  const globalOptions = program.opts();
16440
17304
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
16441
- const { migrateCommand } = await import("./migrate-CS3DkO6y.mjs");
17305
+ const { migrateCommand } = await import("./migrate-BrFREmgQ.mjs");
16442
17306
  await migrateCommand({
16443
17307
  ...options,
16444
17308
  ...construct ? { construct } : {}
@@ -16452,7 +17316,7 @@ program.command("seed").description("Migrate, then run each database construct's
16452
17316
  try {
16453
17317
  const globalOptions = program.opts();
16454
17318
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
16455
- const { seedCommand } = await import("./migrate-CS3DkO6y.mjs");
17319
+ const { seedCommand } = await import("./migrate-BrFREmgQ.mjs");
16456
17320
  await seedCommand({
16457
17321
  ...options,
16458
17322
  ...construct ? { construct } : {}
@@ -16466,7 +17330,7 @@ program.command("migration").description("Write a construct's next migration: an
16466
17330
  try {
16467
17331
  const globalOptions = program.opts();
16468
17332
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
16469
- const { migrationCommand } = await import("./migrate-CS3DkO6y.mjs");
17333
+ const { migrationCommand } = await import("./migrate-BrFREmgQ.mjs");
16470
17334
  await migrationCommand({
16471
17335
  construct,
16472
17336
  ...name ? { name } : {}
@@ -16564,7 +17428,7 @@ program.command("encryption:rotate").description("Add a new current key to an En
16564
17428
  try {
16565
17429
  const globalOptions = program.opts();
16566
17430
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
16567
- const { encryptionRotateCommand } = await import("./encryption-KQTYFEzU.mjs");
17431
+ const { encryptionRotateCommand } = await import("./encryption-BW7Gh51G.mjs");
16568
17432
  await encryptionRotateCommand(construct, options);
16569
17433
  } catch (error) {
16570
17434
  console.error(formatError(error));
@@ -16575,7 +17439,7 @@ program.command("encryption:retire").description("Remove an old key from an Encr
16575
17439
  try {
16576
17440
  const globalOptions = program.opts();
16577
17441
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
16578
- const { encryptionRetireCommand } = await import("./encryption-KQTYFEzU.mjs");
17442
+ const { encryptionRetireCommand } = await import("./encryption-BW7Gh51G.mjs");
16579
17443
  await encryptionRetireCommand(construct, key, options);
16580
17444
  } catch (error) {
16581
17445
  console.error(formatError(error));
@@ -16627,7 +17491,7 @@ program.command("secrets:reconcile").description("Backfill missing custom secret
16627
17491
  console.error(`No secrets found for stage "${stage}". Run "gkm secrets:init --stage ${stage}" first.`);
16628
17492
  process.exit(1);
16629
17493
  }
16630
- const { derivedContainers } = await import("./workspace-CgWcE_Yr.mjs").then((n) => n.o);
17494
+ const { derivedContainers } = await import("./workspace-BRZ14o-o.mjs").then((n) => n.o);
16631
17495
  const result = reconcileMissingSecrets(secrets, workspace, await derivedContainers(workspace, stage));
16632
17496
  if (!result) {
16633
17497
  console.log(`\n✓ Secrets for stage "${stage}" are up-to-date`);
@@ -16686,7 +17550,7 @@ program.command("deploy:github").description("Let GitHub Actions deploy a stage:
16686
17550
  try {
16687
17551
  const globalOptions = program.opts();
16688
17552
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
16689
- const { deployGithubCommand } = await import("./github-y7OEj59M.mjs");
17553
+ const { deployGithubCommand } = await import("./github-T_6sK3Rz.mjs");
16690
17554
  await deployGithubCommand(options);
16691
17555
  } catch (error) {
16692
17556
  console.error(formatError(error));
@@ -16821,6 +17685,6 @@ program.command("upgrade").description("Upgrade @geekmidas/cli (with --all, ever
16821
17685
  });
16822
17686
  program.parse();
16823
17687
  //#endregion
16824
- export { StageStateMissing as a, StateStoreWithoutLocking as c, newLockHolder as d, encryption_exports as f, DocumentStateStore as i, StateVersionConflict as l, getHostingerToken as m, slug as n, StateLocked as o, appPorts_exports as p, stamp as r, StateStoreBusy as s, generateFullstackCustomSecrets as t, contentVersion as u };
17688
+ export { encryption_exports as a, LocalStateStore_exports as i, slug as n, appPorts_exports as o, stamp as r, getHostingerToken as s, generateFullstackCustomSecrets as t };
16825
17689
 
16826
17690
  //# sourceMappingURL=index.mjs.map