@mesh-tech/mesh-cli 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,21 @@ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
13
13
  const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
14
14
  const REDIRECT_PORT = 9876;
15
15
  const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/callback`;
16
- const SCOPES = "openid email profile offline_access urn:zitadel:iam:org:project:id:zitadel:aud";
16
+ const BASE_SCOPES = "openid email profile offline_access urn:zitadel:iam:org:project:id:zitadel:aud";
17
+ export function loginScopes(config) {
18
+ return config.hubProjectId
19
+ ? `${BASE_SCOPES} urn:zitadel:iam:org:project:id:${config.hubProjectId}:aud`
20
+ : BASE_SCOPES;
21
+ }
22
+ export function withDiscoveredFields(config, data) {
23
+ const out = { ...config };
24
+ for (const key of ["vpn", "vpnJoinBroker", "registryBroker", "hubProjectId"]) {
25
+ const value = data[key];
26
+ if (typeof value === "string" && value)
27
+ out[key] = value;
28
+ }
29
+ return out;
30
+ }
17
31
  function readConfig() {
18
32
  if (!fs.existsSync(CONFIG_FILE))
19
33
  return {};
@@ -48,13 +62,14 @@ function parseTenantEnv(context) {
48
62
  return null;
49
63
  return { tenant: parts[0], env: parts[1] };
50
64
  }
51
- async function discoverConfigFromSsm(context) {
65
+ async function discoverConfigFromSsm(context, opts = {}) {
52
66
  const parsed = parseTenantEnv(context);
53
67
  if (!parsed)
54
68
  return null;
55
69
  const { tenant, env } = parsed;
56
70
  const ssmPath = `/mesh-platform/${tenant}/${env}/platform/zitadel`;
57
- logInfo(`Attempting SSM discovery from ${ssmPath}...`);
71
+ if (!opts.quiet)
72
+ logInfo(`Attempting SSM discovery from ${ssmPath}...`);
58
73
  try {
59
74
  const { SSMClient, GetParameterCommand } = await import("@aws-sdk/client-ssm");
60
75
  const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-2";
@@ -62,34 +77,36 @@ async function discoverConfigFromSsm(context) {
62
77
  const resp = await ssm.send(new GetParameterCommand({ Name: ssmPath }));
63
78
  const raw = resp.Parameter?.Value;
64
79
  if (!raw) {
65
- logWarn(`SSM parameter ${ssmPath} has no value`);
80
+ if (!opts.silent)
81
+ logWarn(`SSM parameter ${ssmPath} has no value`);
66
82
  return null;
67
83
  }
68
84
  const data = JSON.parse(raw);
69
85
  const issuer = data.endpoint;
70
86
  const clientId = data.cliClientId;
71
87
  if (typeof issuer !== "string" || !issuer) {
72
- logWarn("SSM zitadel entry missing 'endpoint' field");
88
+ if (!opts.silent)
89
+ logWarn("SSM zitadel entry missing 'endpoint' field");
73
90
  return null;
74
91
  }
75
92
  if (typeof clientId !== "string" || !clientId) {
76
- logWarn("SSM zitadel entry missing 'cliClientId' field.\n" +
77
- " The platform needs to be deployed with an updated ZitadelPlatformIdentity\n" +
78
- " that exports cliClientId. Until then, contact your platform admin.");
93
+ if (!opts.silent) {
94
+ logWarn("SSM zitadel entry missing 'cliClientId' field.\n" +
95
+ " The platform needs to be deployed with an updated ZitadelPlatformIdentity\n" +
96
+ " that exports cliClientId. Until then, contact your platform admin.");
97
+ }
79
98
  return null;
80
99
  }
81
- const config = { issuer, clientId };
82
- if (typeof data.vpn === "string")
83
- config.vpn = data.vpn;
84
- if (typeof data.vpnJoinBroker === "string")
85
- config.vpnJoinBroker = data.vpnJoinBroker;
86
- if (typeof data.registryBroker === "string")
87
- config.registryBroker = data.registryBroker;
100
+ const config = withDiscoveredFields({ issuer, clientId }, data);
101
+ if (opts.quiet)
102
+ return config;
88
103
  writeContextConfig(context, config);
89
104
  logSuccess(`Discovered platform configuration for ${context} via SSM`);
90
105
  return config;
91
106
  }
92
107
  catch (err) {
108
+ if (opts.silent)
109
+ return null;
93
110
  const message = err instanceof Error ? err.message : String(err);
94
111
  if (message.includes("ExpiredToken") || message.includes("credentials")) {
95
112
  logWarn(`SSM discovery failed: AWS credentials expired or unavailable.`);
@@ -121,23 +138,19 @@ async function discoverConfig(domain, contextKey = domain, opts = {}) {
121
138
  const resp = await fetch(url, { signal: controller.signal });
122
139
  clearTimeout(timeout);
123
140
  if (!resp.ok) {
124
- logWarn(`Discovery endpoint returned ${resp.status}`);
141
+ if (!opts.silent)
142
+ logWarn(`Discovery endpoint returned ${resp.status}`);
125
143
  return null;
126
144
  }
127
145
  const data = (await resp.json());
128
146
  const issuer = data.issuer;
129
147
  const clientId = data.clientId;
130
148
  if (typeof issuer !== "string" || !issuer || typeof clientId !== "string" || !clientId) {
131
- logWarn("Discovery endpoint returned invalid config (missing issuer or clientId)");
149
+ if (!opts.silent)
150
+ logWarn("Discovery endpoint returned invalid config (missing issuer or clientId)");
132
151
  return null;
133
152
  }
134
- const config = { issuer, clientId };
135
- if (typeof data.vpn === "string")
136
- config.vpn = data.vpn;
137
- if (typeof data.vpnJoinBroker === "string")
138
- config.vpnJoinBroker = data.vpnJoinBroker;
139
- if (typeof data.registryBroker === "string")
140
- config.registryBroker = data.registryBroker;
153
+ const config = withDiscoveredFields({ issuer, clientId }, data);
141
154
  if (!opts.quiet) {
142
155
  writeContextConfig(contextKey, config);
143
156
  logSuccess(`Discovered platform configuration for ${contextKey}`);
@@ -145,6 +158,8 @@ async function discoverConfig(domain, contextKey = domain, opts = {}) {
145
158
  return config;
146
159
  }
147
160
  catch (err) {
161
+ if (opts.silent)
162
+ return null;
148
163
  const message = err instanceof Error ? err.message : String(err);
149
164
  if (message.includes("abort")) {
150
165
  logWarn("Discovery timed out");
@@ -260,7 +275,7 @@ function login(context, config) {
260
275
  authUrl.searchParams.set("client_id", config.clientId);
261
276
  authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
262
277
  authUrl.searchParams.set("response_type", "code");
263
- authUrl.searchParams.set("scope", SCOPES);
278
+ authUrl.searchParams.set("scope", loginScopes(config));
264
279
  authUrl.searchParams.set("code_challenge", codeChallenge);
265
280
  authUrl.searchParams.set("code_challenge_method", "S256");
266
281
  authUrl.searchParams.set("state", state);
@@ -385,7 +400,7 @@ async function attemptDeviceCode(context, config) {
385
400
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
386
401
  body: new URLSearchParams({
387
402
  client_id: config.clientId,
388
- scope: SCOPES,
403
+ scope: loginScopes(config),
389
404
  }).toString(),
390
405
  });
391
406
  if (!resp.ok) {
@@ -606,9 +621,11 @@ async function showStatus(context, config) {
606
621
  return;
607
622
  }
608
623
  let tenants = [];
624
+ let audience = [];
609
625
  try {
610
626
  const accessPayload = decodeJwtPayload(creds.accessToken);
611
627
  tenants = accessPayload["urn:mesh:tenants"] ?? [];
628
+ audience = tokenAudience(accessPayload);
612
629
  }
613
630
  catch {
614
631
  }
@@ -618,6 +635,26 @@ async function showStatus(context, config) {
618
635
  if (tenants.length > 0) {
619
636
  logInfo(`Deployable tenants: ${tenants.join(", ")}`);
620
637
  }
638
+ if (audience.length > 0) {
639
+ logInfo(`Audience: ${audience.join(", ")}`);
640
+ if (config.hubProjectId && !audience.includes(config.hubProjectId)) {
641
+ logWarn(`This session does not carry the Hub project audience (${config.hubProjectId}); ` +
642
+ `a Hub that enforces its audience will refuse it. Re-run: mesh login ${context}`);
643
+ }
644
+ }
645
+ if (!config.hubProjectId) {
646
+ logInfo(`Hub project: not in this context's discovery record, so this session was requested without ` +
647
+ `a Hub audience — a Hub that enforces its audience would refuse it. ` +
648
+ `\`mesh login ${context}\` re-checks what the platform publishes.`);
649
+ }
650
+ }
651
+ export function tokenAudience(payload) {
652
+ const aud = payload.aud;
653
+ if (typeof aud === "string")
654
+ return [aud];
655
+ if (Array.isArray(aud))
656
+ return aud.filter((a) => typeof a === "string");
657
+ return [];
621
658
  }
622
659
  export function registerLoginCommand(program) {
623
660
  program
@@ -657,6 +694,7 @@ export function registerLoginCommand(program) {
657
694
  if (opts.registry === false) {
658
695
  logWarn(NO_REGISTRY_DEPRECATED);
659
696
  }
697
+ config = await adoptPublishedHubProject(context, config);
660
698
  try {
661
699
  await runLoginFlow(context, config, opts);
662
700
  }
@@ -872,8 +910,9 @@ async function ensureValidToken(context, config, opts) {
872
910
  if (token)
873
911
  return token;
874
912
  logInfo(`No valid Zitadel session for ${context} — running login flow`);
913
+ const adopted = await adoptPublishedHubProject(context, config);
875
914
  try {
876
- await runLoginFlow(context, config, opts);
915
+ await runLoginFlow(context, adopted, opts);
877
916
  }
878
917
  catch (err) {
879
918
  logError(`Login failed: ${err.message}`);
@@ -897,23 +936,36 @@ export async function ensureLogin(context, opts = {}) {
897
936
  return null;
898
937
  return readCredentials(context);
899
938
  }
900
- async function discoverConfigGuarded(context) {
939
+ async function discoverConfigGuarded(context, opts = {}) {
901
940
  let config = null;
902
941
  if (parseTenantEnv(context)) {
903
- config = await discoverConfigFromSsm(context);
942
+ config = await discoverConfigFromSsm(context, opts);
904
943
  }
905
944
  if (!config) {
906
945
  const pinned = firstPartyDomainFor(context);
907
946
  if (pinned) {
908
- logInfo(`"${context}" is a known Mesh platform — resolving via ${pinned} (no AWS needed)`);
909
- config = await discoverConfig(pinned, context);
947
+ if (!opts.quiet) {
948
+ logInfo(`"${context}" is a known Mesh platform — resolving via ${pinned} (no AWS needed)`);
949
+ }
950
+ config = await discoverConfig(pinned, context, opts);
910
951
  }
911
952
  }
912
953
  if (!config && context.split(".").length - 1 >= 2) {
913
- config = await discoverConfig(context);
954
+ config = await discoverConfig(context, context, opts);
914
955
  }
915
956
  return config;
916
957
  }
958
+ export async function adoptPublishedHubProject(context, config, discover = discoverConfigGuarded, write = writeContextConfig) {
959
+ if (config.hubProjectId)
960
+ return config;
961
+ const fresh = await discover(context, { quiet: true, silent: true });
962
+ if (!fresh?.hubProjectId)
963
+ return config;
964
+ const merged = { ...config, hubProjectId: fresh.hubProjectId };
965
+ write(context, merged);
966
+ logInfo(`${context} publishes its Hub project (${fresh.hubProjectId}) — requesting the Hub audience for this login`);
967
+ return merged;
968
+ }
917
969
  async function exportAwsCredentials(context, config, opts) {
918
970
  const token = await ensureValidToken(context, config, opts);
919
971
  if (!token) {
@@ -816,6 +816,17 @@ import * as crypto from "crypto";
816
816
  import * as fs2 from "fs";
817
817
  import * as path2 from "path";
818
818
  import { execFileSync as execFileSync4 } from "child_process";
819
+ function loginScopes(config) {
820
+ return config.hubProjectId ? `${BASE_SCOPES} urn:zitadel:iam:org:project:id:${config.hubProjectId}:aud` : BASE_SCOPES;
821
+ }
822
+ function withDiscoveredFields(config, data) {
823
+ const out = { ...config };
824
+ for (const key of ["vpn", "vpnJoinBroker", "registryBroker", "hubProjectId"]) {
825
+ const value = data[key];
826
+ if (typeof value === "string" && value) out[key] = value;
827
+ }
828
+ return out;
829
+ }
819
830
  function readConfig() {
820
831
  if (!fs2.existsSync(CONFIG_FILE)) return {};
821
832
  try {
@@ -839,12 +850,12 @@ function parseTenantEnv(context) {
839
850
  if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
840
851
  return { tenant: parts[0], env: parts[1] };
841
852
  }
842
- async function discoverConfigFromSsm(context) {
853
+ async function discoverConfigFromSsm(context, opts = {}) {
843
854
  const parsed = parseTenantEnv(context);
844
855
  if (!parsed) return null;
845
856
  const { tenant, env } = parsed;
846
857
  const ssmPath = `/mesh-platform/${tenant}/${env}/platform/zitadel`;
847
- logInfo(`Attempting SSM discovery from ${ssmPath}...`);
858
+ if (!opts.quiet) logInfo(`Attempting SSM discovery from ${ssmPath}...`);
848
859
  try {
849
860
  const { SSMClient: SSMClient2, GetParameterCommand: GetParameterCommand2 } = await import("@aws-sdk/client-ssm");
850
861
  const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-2";
@@ -852,30 +863,31 @@ async function discoverConfigFromSsm(context) {
852
863
  const resp = await ssm.send(new GetParameterCommand2({ Name: ssmPath }));
853
864
  const raw = resp.Parameter?.Value;
854
865
  if (!raw) {
855
- logWarn(`SSM parameter ${ssmPath} has no value`);
866
+ if (!opts.silent) logWarn(`SSM parameter ${ssmPath} has no value`);
856
867
  return null;
857
868
  }
858
869
  const data = JSON.parse(raw);
859
870
  const issuer = data.endpoint;
860
871
  const clientId = data.cliClientId;
861
872
  if (typeof issuer !== "string" || !issuer) {
862
- logWarn("SSM zitadel entry missing 'endpoint' field");
873
+ if (!opts.silent) logWarn("SSM zitadel entry missing 'endpoint' field");
863
874
  return null;
864
875
  }
865
876
  if (typeof clientId !== "string" || !clientId) {
866
- logWarn(
867
- "SSM zitadel entry missing 'cliClientId' field.\n The platform needs to be deployed with an updated ZitadelPlatformIdentity\n that exports cliClientId. Until then, contact your platform admin."
868
- );
877
+ if (!opts.silent) {
878
+ logWarn(
879
+ "SSM zitadel entry missing 'cliClientId' field.\n The platform needs to be deployed with an updated ZitadelPlatformIdentity\n that exports cliClientId. Until then, contact your platform admin."
880
+ );
881
+ }
869
882
  return null;
870
883
  }
871
- const config = { issuer, clientId };
872
- if (typeof data.vpn === "string") config.vpn = data.vpn;
873
- if (typeof data.vpnJoinBroker === "string") config.vpnJoinBroker = data.vpnJoinBroker;
874
- if (typeof data.registryBroker === "string") config.registryBroker = data.registryBroker;
884
+ const config = withDiscoveredFields({ issuer, clientId }, data);
885
+ if (opts.quiet) return config;
875
886
  writeContextConfig(context, config);
876
887
  logSuccess(`Discovered platform configuration for ${context} via SSM`);
877
888
  return config;
878
889
  } catch (err) {
890
+ if (opts.silent) return null;
879
891
  const message = err instanceof Error ? err.message : String(err);
880
892
  if (message.includes("ExpiredToken") || message.includes("credentials")) {
881
893
  logWarn(`SSM discovery failed: AWS credentials expired or unavailable.`);
@@ -903,26 +915,24 @@ async function discoverConfig(domain, contextKey = domain, opts = {}) {
903
915
  const resp = await fetch(url, { signal: controller.signal });
904
916
  clearTimeout(timeout);
905
917
  if (!resp.ok) {
906
- logWarn(`Discovery endpoint returned ${resp.status}`);
918
+ if (!opts.silent) logWarn(`Discovery endpoint returned ${resp.status}`);
907
919
  return null;
908
920
  }
909
921
  const data = await resp.json();
910
922
  const issuer = data.issuer;
911
923
  const clientId = data.clientId;
912
924
  if (typeof issuer !== "string" || !issuer || typeof clientId !== "string" || !clientId) {
913
- logWarn("Discovery endpoint returned invalid config (missing issuer or clientId)");
925
+ if (!opts.silent) logWarn("Discovery endpoint returned invalid config (missing issuer or clientId)");
914
926
  return null;
915
927
  }
916
- const config = { issuer, clientId };
917
- if (typeof data.vpn === "string") config.vpn = data.vpn;
918
- if (typeof data.vpnJoinBroker === "string") config.vpnJoinBroker = data.vpnJoinBroker;
919
- if (typeof data.registryBroker === "string") config.registryBroker = data.registryBroker;
928
+ const config = withDiscoveredFields({ issuer, clientId }, data);
920
929
  if (!opts.quiet) {
921
930
  writeContextConfig(contextKey, config);
922
931
  logSuccess(`Discovered platform configuration for ${contextKey}`);
923
932
  }
924
933
  return config;
925
934
  } catch (err) {
935
+ if (opts.silent) return null;
926
936
  const message = err instanceof Error ? err.message : String(err);
927
937
  if (message.includes("abort")) {
928
938
  logWarn("Discovery timed out");
@@ -1020,7 +1030,7 @@ function login(context, config) {
1020
1030
  authUrl.searchParams.set("client_id", config.clientId);
1021
1031
  authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
1022
1032
  authUrl.searchParams.set("response_type", "code");
1023
- authUrl.searchParams.set("scope", SCOPES);
1033
+ authUrl.searchParams.set("scope", loginScopes(config));
1024
1034
  authUrl.searchParams.set("code_challenge", codeChallenge);
1025
1035
  authUrl.searchParams.set("code_challenge_method", "S256");
1026
1036
  authUrl.searchParams.set("state", state);
@@ -1148,7 +1158,7 @@ async function attemptDeviceCode(context, config) {
1148
1158
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
1149
1159
  body: new URLSearchParams({
1150
1160
  client_id: config.clientId,
1151
- scope: SCOPES
1161
+ scope: loginScopes(config)
1152
1162
  }).toString()
1153
1163
  });
1154
1164
  if (!resp.ok) {
@@ -1309,8 +1319,9 @@ async function ensureValidToken(context, config, opts) {
1309
1319
  let token = await getValidToken(context);
1310
1320
  if (token) return token;
1311
1321
  logInfo(`No valid Zitadel session for ${context} \u2014 running login flow`);
1322
+ const adopted = await adoptPublishedHubProject(context, config);
1312
1323
  try {
1313
- await runLoginFlow(context, config, opts);
1324
+ await runLoginFlow(context, adopted, opts);
1314
1325
  } catch (err) {
1315
1326
  logError(`Login failed: ${err.message}`);
1316
1327
  return null;
@@ -1330,24 +1341,35 @@ async function ensureLogin(context, opts = {}) {
1330
1341
  if (!token) return null;
1331
1342
  return readCredentials(context);
1332
1343
  }
1333
- async function discoverConfigGuarded(context) {
1344
+ async function discoverConfigGuarded(context, opts = {}) {
1334
1345
  let config = null;
1335
1346
  if (parseTenantEnv(context)) {
1336
- config = await discoverConfigFromSsm(context);
1347
+ config = await discoverConfigFromSsm(context, opts);
1337
1348
  }
1338
1349
  if (!config) {
1339
1350
  const pinned = firstPartyDomainFor(context);
1340
1351
  if (pinned) {
1341
- logInfo(`"${context}" is a known Mesh platform \u2014 resolving via ${pinned} (no AWS needed)`);
1342
- config = await discoverConfig(pinned, context);
1352
+ if (!opts.quiet) {
1353
+ logInfo(`"${context}" is a known Mesh platform \u2014 resolving via ${pinned} (no AWS needed)`);
1354
+ }
1355
+ config = await discoverConfig(pinned, context, opts);
1343
1356
  }
1344
1357
  }
1345
1358
  if (!config && context.split(".").length - 1 >= 2) {
1346
- config = await discoverConfig(context);
1359
+ config = await discoverConfig(context, context, opts);
1347
1360
  }
1348
1361
  return config;
1349
1362
  }
1350
- var CONFIG_DIR, CONFIG_FILE, CREDENTIALS_FILE, REDIRECT_PORT, REDIRECT_URI, SCOPES, atomicWriteCounter, MAX_DEVICE_CODES, RESERVED_REGISTRY_CONTEXT;
1363
+ async function adoptPublishedHubProject(context, config, discover = discoverConfigGuarded, write = writeContextConfig) {
1364
+ if (config.hubProjectId) return config;
1365
+ const fresh = await discover(context, { quiet: true, silent: true });
1366
+ if (!fresh?.hubProjectId) return config;
1367
+ const merged = { ...config, hubProjectId: fresh.hubProjectId };
1368
+ write(context, merged);
1369
+ logInfo(`${context} publishes its Hub project (${fresh.hubProjectId}) \u2014 requesting the Hub audience for this login`);
1370
+ return merged;
1371
+ }
1372
+ var CONFIG_DIR, CONFIG_FILE, CREDENTIALS_FILE, REDIRECT_PORT, REDIRECT_URI, BASE_SCOPES, atomicWriteCounter, MAX_DEVICE_CODES, RESERVED_REGISTRY_CONTEXT;
1351
1373
  var init_login = __esm({
1352
1374
  "libs/mesh-cli/src/commands/login.ts"() {
1353
1375
  "use strict";
@@ -1364,7 +1386,7 @@ var init_login = __esm({
1364
1386
  CREDENTIALS_FILE = path2.join(CONFIG_DIR, "credentials.json");
1365
1387
  REDIRECT_PORT = 9876;
1366
1388
  REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/callback`;
1367
- SCOPES = "openid email profile offline_access urn:zitadel:iam:org:project:id:zitadel:aud";
1389
+ BASE_SCOPES = "openid email profile offline_access urn:zitadel:iam:org:project:id:zitadel:aud";
1368
1390
  atomicWriteCounter = 0;
1369
1391
  MAX_DEVICE_CODES = 3;
1370
1392
  RESERVED_REGISTRY_CONTEXT = "registry";