@jtl-software/create-cloud-app 0.3.0 → 0.5.1

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 (2) hide show
  1. package/dist/index.js +531 -158
  2. package/package.json +9 -9
package/dist/index.js CHANGED
@@ -46,7 +46,6 @@ function defaultScopeFor(authBaseUrl) {
46
46
  const scopes = ["openid", "profile", "email", "offline_access"];
47
47
  if (projectId) {
48
48
  scopes.push(`urn:zitadel:iam:org:project:id:${projectId}:aud`);
49
- scopes.push("urn:zitadel:iam:org:project:id:zitadel:aud");
50
49
  }
51
50
  return scopes.join(" ");
52
51
  }
@@ -158,7 +157,10 @@ function captureAuthCode(authorizeUrl, expectedState, verifier, options = {}) {
158
157
  const closeAndSettle = (res, next) => {
159
158
  if (settled) return;
160
159
  settled = true;
161
- const finish = () => server.close(() => next());
160
+ const finish = () => {
161
+ server.closeAllConnections?.();
162
+ server.close(() => next());
163
+ };
162
164
  if (res.writableEnded) {
163
165
  finish();
164
166
  } else {
@@ -365,6 +367,9 @@ async function acquireAccessToken(opts) {
365
367
  authorizeUrl.searchParams.set("state", state);
366
368
  authorizeUrl.searchParams.set("code_challenge", pkce.challenge);
367
369
  authorizeUrl.searchParams.set("code_challenge_method", "S256");
370
+ if (reauth) {
371
+ authorizeUrl.searchParams.set("prompt", "select_account");
372
+ }
368
373
  spinner4?.start("Waiting for browser login");
369
374
  try {
370
375
  const { code, verifier } = await captureAuthCode(
@@ -472,6 +477,10 @@ function tenantsFromClaims(claims) {
472
477
  }
473
478
  return ordered;
474
479
  }
480
+ function activeTenantId(claims) {
481
+ const active = claims[TENANT_ID_CLAIM];
482
+ return typeof active === "string" && active ? active : void 0;
483
+ }
475
484
  function validateHost(input, flag) {
476
485
  let url;
477
486
  try {
@@ -681,6 +690,20 @@ function detectBackend(root) {
681
690
  }
682
691
  return { kind: "node", envFile: path2.join(root, ".env") };
683
692
  }
693
+ function detectFrontend(root) {
694
+ const frontendDir = path2.join(root, "packages", "frontend");
695
+ return fs2.existsSync(path2.join(frontendDir, "package.json")) ? frontendDir : null;
696
+ }
697
+ function writeFrontendPublicClient(root, authIssuer, publicClientId) {
698
+ const frontendDir = detectFrontend(root);
699
+ if (!frontendDir) return null;
700
+ const envFile = path2.join(frontendDir, ".env");
701
+ upsertEnv(envFile, {
702
+ VITE_JTL_CLIENT_ID: publicClientId,
703
+ VITE_JTL_ISSUER: authIssuer
704
+ });
705
+ return envFile;
706
+ }
684
707
  function readExistingCredentials(backend) {
685
708
  if (backend.kind === "node") {
686
709
  const values = readEnvFile(backend.envFile);
@@ -773,7 +796,8 @@ function resolveOptions(args) {
773
796
  ...COMMON_ARG_OPTIONS,
774
797
  "existing-app-id": { type: "string" },
775
798
  new: { type: "boolean" },
776
- "on-collision": { type: "string" }
799
+ "on-collision": { type: "string" },
800
+ "app-service-path": { type: "string" }
777
801
  },
778
802
  strict: true,
779
803
  allowPositionals: false
@@ -789,11 +813,13 @@ function resolveOptions(args) {
789
813
  }
790
814
  const onCollisionRaw = parsed.values["on-collision"];
791
815
  const onCollision = onCollisionRaw ? parseCollisionAction(onCollisionRaw) : void 0;
816
+ const appServicePath = parsed.values["app-service-path"] ?? "app-service";
792
817
  return {
793
818
  ...common,
794
819
  existingAppId,
795
820
  forceNew,
796
- onCollision
821
+ onCollision,
822
+ appServicePath
797
823
  };
798
824
  }
799
825
  async function selectExistingOrNew(matches, opts) {
@@ -848,9 +874,29 @@ async function resolveCollision(targetLabel, credentialPair, opts) {
848
874
  })
849
875
  );
850
876
  }
851
- async function listTenantApps(apiBaseUrl, jwt, tenantId) {
877
+ async function confirmRegister(opts, message, isUpdate) {
878
+ if (opts.yes) return "proceed";
879
+ return ensureContinue(
880
+ await p2.select({
881
+ message,
882
+ options: [
883
+ {
884
+ value: "proceed",
885
+ label: isUpdate ? "Update the app" : "Create the app"
886
+ },
887
+ {
888
+ value: "switch-org",
889
+ label: "Use a different organization",
890
+ hint: "sign in again and pick another org"
891
+ },
892
+ { value: "cancel", label: "Cancel" }
893
+ ]
894
+ })
895
+ );
896
+ }
897
+ async function listTenantApps(apiBaseUrl, jwt, tenantId, appServicePath = "app-service") {
852
898
  const res = await apiCall(
853
- `${apiBaseUrl}/app-service/tenants/apps?onlyAppsOfTenant=true&returnAll=true`,
899
+ `${apiBaseUrl}/${appServicePath}/tenants/apps?onlyAppsOfTenant=true&returnAll=true`,
854
900
  {
855
901
  headers: {
856
902
  Authorization: `Bearer ${jwt}`,
@@ -869,24 +915,28 @@ async function listTenantApps(apiBaseUrl, jwt, tenantId) {
869
915
  technicalName,
870
916
  clientId: entry.clientId,
871
917
  provisioningStatus: entry.activeVersion?.provisioningStatus,
872
- isInstalled: entry.isInstalled
918
+ isInstalled: entry.isInstalled,
919
+ publicClientId: entry.credentials?.publicClient?.clientId
873
920
  };
874
921
  }).filter((a) => a !== null);
875
922
  }
876
- async function updateAppManifest(apiBaseUrl, jwt, tenantId, appId, manifest) {
877
- await apiCall(`${apiBaseUrl}/app-service/store/apps/${appId}/manifest`, {
878
- method: "PUT",
879
- headers: {
880
- Authorization: `Bearer ${jwt}`,
881
- "Content-Type": "application/json",
882
- "X-Tenant-Id": tenantId
883
- },
884
- body: JSON.stringify({ manifest, tcAccepted: true })
885
- });
923
+ async function updateAppManifest(apiBaseUrl, jwt, tenantId, appId, manifest, appServicePath = "app-service") {
924
+ await apiCall(
925
+ `${apiBaseUrl}/${appServicePath}/store/apps/${appId}/manifest`,
926
+ {
927
+ method: "PUT",
928
+ headers: {
929
+ Authorization: `Bearer ${jwt}`,
930
+ "Content-Type": "application/json",
931
+ "X-Tenant-Id": tenantId
932
+ },
933
+ body: JSON.stringify({ manifest, tcAccepted: true })
934
+ }
935
+ );
886
936
  }
887
- async function createDraftApp(apiBaseUrl, jwt, tenantId, manifest) {
937
+ async function createDraftApp(apiBaseUrl, jwt, tenantId, manifest, appServicePath = "app-service") {
888
938
  const created = await apiCall(
889
- `${apiBaseUrl}/app-service/store/apps`,
939
+ `${apiBaseUrl}/${appServicePath}/store/apps`,
890
940
  {
891
941
  method: "POST",
892
942
  headers: {
@@ -907,9 +957,9 @@ async function createDraftApp(apiBaseUrl, jwt, tenantId, manifest) {
907
957
  }
908
958
  return created.id;
909
959
  }
910
- async function provisionApp(apiBaseUrl, jwt, tenantId, appId) {
960
+ async function provisionApp(apiBaseUrl, jwt, tenantId, appId, appServicePath = "app-service") {
911
961
  const provisioned = await apiCall(
912
- `${apiBaseUrl}/app-service/store/apps/${appId}/provision`,
962
+ `${apiBaseUrl}/${appServicePath}/store/apps/${appId}/provision`,
913
963
  {
914
964
  method: "PUT",
915
965
  headers: {
@@ -927,7 +977,11 @@ async function provisionApp(apiBaseUrl, jwt, tenantId, appId) {
927
977
  `provision response missing clientId or clientSecret: ${JSON.stringify(provisioned)}`
928
978
  );
929
979
  }
930
- return { clientId, clientSecret };
980
+ return {
981
+ clientId,
982
+ clientSecret,
983
+ publicClientId: provisioned.credentials?.publicClient?.clientId
984
+ };
931
985
  }
932
986
  function credentialLabels(backend) {
933
987
  if (backend.kind === "node") {
@@ -986,15 +1040,16 @@ async function register(argv = [], runOpts = {}) {
986
1040
  console.log();
987
1041
  p2.intro(pc2.bgCyan(pc2.black(" register cloud app ")));
988
1042
  const opts = resolveOptions(argv);
989
- const backend = detectBackend(root);
1043
+ const backend = fs2.existsSync(path2.join(root, "packages", "backend")) ? detectBackend(root) : null;
990
1044
  p2.note(
991
1045
  [
992
1046
  `${pc2.dim("API")} ${opts.api}`,
1047
+ opts.appServicePath !== "app-service" ? `${pc2.dim("App svc")} /${opts.appServicePath}` : null,
993
1048
  `${pc2.dim("Auth")} ${opts.auth}`,
994
1049
  `${pc2.dim("Hub")} ${opts.hub}`,
995
1050
  `${pc2.dim("Client ID")} ${opts.clientId}`,
996
1051
  `${pc2.dim("Scope")} ${opts.scope}`
997
- ].join("\n"),
1052
+ ].filter(Boolean).join("\n"),
998
1053
  "Target"
999
1054
  );
1000
1055
  const spinner4 = p2.spinner();
@@ -1011,114 +1066,196 @@ async function register(argv = [], runOpts = {}) {
1011
1066
  } catch (err) {
1012
1067
  bail(err.message);
1013
1068
  }
1014
- const identityJwt = jwt;
1015
- jwt = apiTokenFor(jwt);
1016
- spinner4.start("Fetching tenants");
1017
- let tenants;
1018
- try {
1019
- tenants = await fetchTenants(opts.api, identityJwt);
1020
- spinner4.stop(
1021
- `Found ${tenants.length} tenant${tenants.length === 1 ? "" : "s"}`
1022
- );
1023
- } catch (err) {
1024
- spinner4.stop("Failed to fetch tenants");
1025
- bail(err.message);
1026
- }
1027
- const tenant = await selectTenant(tenants, opts);
1028
1069
  const manifest = readManifest(path2.join(root, "app.json"));
1029
1070
  const summary = summarizeManifest(manifest);
1030
- spinner4.start("Looking for apps matching this manifest");
1031
- let matches;
1032
- try {
1033
- const apps = await listTenantApps(opts.api, jwt, tenant.id);
1034
- matches = apps.filter((a) => a.technicalName === summary.technicalName);
1035
- spinner4.stop(
1036
- `Found ${matches.length} app${matches.length === 1 ? "" : "s"} with technical name ${pc2.cyan(summary.technicalName)}`
1037
- );
1038
- } catch (err) {
1039
- spinner4.stop("Failed to look up tenant apps");
1040
- bail(err.message);
1041
- }
1042
- const existing = await selectExistingOrNew(matches, opts);
1043
- const reviewLines = [
1044
- `${pc2.dim("Technical name")} ${summary.technicalName}`,
1045
- `${pc2.dim("Version")} ${summary.version}`,
1046
- `${pc2.dim("Tenant")} ${tenant.name} (${tenant.slug})`,
1047
- existing ? `${pc2.dim("Updating")} ${existing.technicalName} (${existing.id}) [${existing.provisioningStatus ?? "?"}]` : `${pc2.dim("Action")} create new`
1048
- ].filter(Boolean);
1049
- p2.note(reviewLines.join("\n"), "Review");
1050
- const confirmMessage = existing ? `Update app "${existing.technicalName}" in tenant "${tenant.name}"?` : `Create app "${summary.technicalName}" in tenant "${tenant.name}"?`;
1051
- const confirm2 = await confirmAction(opts, confirmMessage, existing !== null);
1052
- if (!confirm2) bail("Cancelled.");
1053
- if (existing) {
1054
- spinner4.start("Updating manifest");
1071
+ for (; ; ) {
1072
+ const identityJwt = jwt;
1073
+ const apiJwt = apiTokenFor(jwt);
1074
+ spinner4.start("Fetching tenants");
1075
+ let tenants;
1055
1076
  try {
1056
- await updateAppManifest(opts.api, jwt, tenant.id, existing.id, manifest);
1057
- spinner4.stop("Manifest updated");
1077
+ tenants = await fetchTenants(opts.api, identityJwt);
1078
+ spinner4.stop(
1079
+ `Found ${tenants.length} tenant${tenants.length === 1 ? "" : "s"}`
1080
+ );
1058
1081
  } catch (err) {
1059
- spinner4.stop("Failed to update manifest");
1082
+ spinner4.stop("Failed to fetch tenants");
1060
1083
  bail(err.message);
1061
1084
  }
1062
- printHubLink(
1063
- opts.hub,
1064
- tenant.slug,
1065
- existing.id,
1066
- existing.isInstalled ?? false
1085
+ let tenant;
1086
+ const scopedTenantId = opts.tenant ? void 0 : activeTenantId(decodeJwtClaims(identityJwt));
1087
+ const scopedTenant = scopedTenantId ? tenants.find((t) => t.id === scopedTenantId) : void 0;
1088
+ if (scopedTenant) {
1089
+ tenant = scopedTenant;
1090
+ p2.log.info(
1091
+ `Organization ${pc2.cyan(tenant.name)} ${pc2.dim(`(${tenant.slug})`)} \u2014 selected at login`
1092
+ );
1093
+ } else {
1094
+ tenant = await selectTenant(tenants, opts);
1095
+ }
1096
+ spinner4.start("Looking for apps matching this manifest");
1097
+ let matches;
1098
+ try {
1099
+ const apps = await listTenantApps(
1100
+ opts.api,
1101
+ apiJwt,
1102
+ tenant.id,
1103
+ opts.appServicePath
1104
+ );
1105
+ matches = apps.filter((a) => a.technicalName === summary.technicalName);
1106
+ spinner4.stop(
1107
+ `Found ${matches.length} app${matches.length === 1 ? "" : "s"} with technical name ${pc2.cyan(summary.technicalName)}`
1108
+ );
1109
+ } catch (err) {
1110
+ spinner4.stop("Failed to look up tenant apps");
1111
+ bail(err.message);
1112
+ }
1113
+ const existing = await selectExistingOrNew(matches, opts);
1114
+ const reviewLines = [
1115
+ `${pc2.dim("Technical name")} ${summary.technicalName}`,
1116
+ `${pc2.dim("Version")} ${summary.version}`,
1117
+ `${pc2.dim("Tenant")} ${tenant.name} (${tenant.slug})`,
1118
+ existing ? `${pc2.dim("Updating")} ${existing.technicalName} (${existing.id}) [${existing.provisioningStatus ?? "?"}]` : `${pc2.dim("Action")} create new`
1119
+ ].filter(Boolean);
1120
+ p2.note(reviewLines.join("\n"), "Review");
1121
+ const confirmMessage = existing ? `Update app "${existing.technicalName}" in tenant "${tenant.name}"?` : `Create app "${summary.technicalName}" in tenant "${tenant.name}"?`;
1122
+ const decision = await confirmRegister(
1123
+ opts,
1124
+ confirmMessage,
1125
+ existing !== null
1067
1126
  );
1068
- if (existing.clientId) {
1069
- const labels = credentialLabels(backend);
1070
- const envFile = backend.kind === "node" ? backend.envFile : backend.settingsFile;
1071
- const localCreds = readExistingCredentials(backend);
1072
- const hasLocalCreds = !!(localCreds.clientId && localCreds.clientSecret);
1073
- if (!hasLocalCreds) {
1127
+ if (decision === "cancel") bail("Cancelled.");
1128
+ if (decision === "switch-org") {
1129
+ try {
1130
+ jwt = await acquireAccessToken({
1131
+ auth: opts.auth,
1132
+ clientId: opts.clientId,
1133
+ scope: opts.scope,
1134
+ reauth: true,
1135
+ // forces prompt=select_account so the user re-picks the organization
1136
+ spinner: spinner4,
1137
+ captureOptions: runOpts.captureOptions
1138
+ });
1139
+ } catch (err) {
1140
+ bail(err.message);
1141
+ }
1142
+ continue;
1143
+ }
1144
+ if (existing) {
1145
+ spinner4.start("Updating manifest");
1146
+ try {
1147
+ await updateAppManifest(
1148
+ opts.api,
1149
+ apiJwt,
1150
+ tenant.id,
1151
+ existing.id,
1152
+ manifest,
1153
+ opts.appServicePath
1154
+ );
1155
+ spinner4.stop("Manifest updated");
1156
+ } catch (err) {
1157
+ spinner4.stop("Failed to update manifest");
1158
+ bail(err.message);
1159
+ }
1160
+ if (existing.publicClientId) {
1161
+ const written = writeFrontendPublicClient(
1162
+ root,
1163
+ opts.auth,
1164
+ existing.publicClientId
1165
+ );
1166
+ if (written) {
1167
+ p2.log.success(
1168
+ `Wrote VITE_JTL_CLIENT_ID/VITE_JTL_ISSUER to ${pc2.cyan(path2.relative(root, written))}.`
1169
+ );
1170
+ }
1171
+ }
1172
+ printHubLink(
1173
+ opts.hub,
1174
+ tenant.slug,
1175
+ existing.id,
1176
+ existing.isInstalled ?? false
1177
+ );
1178
+ if (backend && existing.clientId) {
1179
+ const labels = credentialLabels(backend);
1180
+ const envFile = backend.kind === "node" ? backend.envFile : backend.settingsFile;
1181
+ const localCreds = readExistingCredentials(backend);
1182
+ const hasLocalCreds = !!(localCreds.clientId && localCreds.clientSecret);
1183
+ if (!hasLocalCreds) {
1184
+ p2.outro(
1185
+ pc2.yellow(
1186
+ `Manifest updated. Your backend config is missing ${labels.pair} \u2014 they can't be recovered. Re-create the app to get a fresh secret.`
1187
+ )
1188
+ );
1189
+ return;
1190
+ }
1074
1191
  p2.outro(
1075
- pc2.yellow(
1076
- `Manifest updated. Your backend config is missing ${labels.pair} \u2014 they can't be recovered. Re-create the app to get a fresh secret.`
1192
+ pc2.green(
1193
+ `Manifest updated. Existing ${labels.pair} in ${pc2.cyan(path2.relative(root, envFile))} kept as-is.`
1077
1194
  )
1078
1195
  );
1079
- return;
1196
+ } else {
1197
+ p2.outro(pc2.green("Manifest updated."));
1080
1198
  }
1081
- p2.outro(
1082
- pc2.green(
1083
- `Manifest updated. Existing ${labels.pair} in ${pc2.cyan(path2.relative(root, envFile))} kept as-is.`
1084
- )
1199
+ return;
1200
+ }
1201
+ spinner4.start("Creating draft app");
1202
+ let appId;
1203
+ try {
1204
+ appId = await createDraftApp(
1205
+ opts.api,
1206
+ apiJwt,
1207
+ tenant.id,
1208
+ manifest,
1209
+ opts.appServicePath
1085
1210
  );
1086
- } else {
1087
- p2.outro(pc2.green("Manifest updated."));
1211
+ spinner4.stop(`Created app ${pc2.cyan(appId)}`);
1212
+ } catch (err) {
1213
+ spinner4.stop("Failed to create app");
1214
+ bail(err.message);
1215
+ }
1216
+ spinner4.start("Provisioning app");
1217
+ let clientId;
1218
+ let clientSecret;
1219
+ let publicClientId;
1220
+ try {
1221
+ ({ clientId, clientSecret, publicClientId } = await provisionApp(
1222
+ opts.api,
1223
+ apiJwt,
1224
+ tenant.id,
1225
+ appId,
1226
+ opts.appServicePath
1227
+ ));
1228
+ spinner4.stop("Provisioned");
1229
+ } catch (err) {
1230
+ spinner4.stop("Failed to provision app");
1231
+ bail(err.message);
1232
+ }
1233
+ if (publicClientId) {
1234
+ const written = writeFrontendPublicClient(
1235
+ root,
1236
+ opts.auth,
1237
+ publicClientId
1238
+ );
1239
+ if (written) {
1240
+ p2.log.success(
1241
+ `Wrote VITE_JTL_CLIENT_ID/VITE_JTL_ISSUER to ${pc2.cyan(path2.relative(root, written))}.`
1242
+ );
1243
+ }
1088
1244
  }
1245
+ if (backend) {
1246
+ await persistCredentials(backend, clientId, clientSecret, root, opts, {
1247
+ hub: opts.hub,
1248
+ tenantSlug: tenant.slug,
1249
+ appId,
1250
+ // A freshly created app can't be installed yet.
1251
+ isInstalled: false
1252
+ });
1253
+ return;
1254
+ }
1255
+ printHubLink(opts.hub, tenant.slug, appId, false);
1256
+ p2.outro(pc2.green("Done. App registered."));
1089
1257
  return;
1090
1258
  }
1091
- spinner4.start("Creating draft app");
1092
- let appId;
1093
- try {
1094
- appId = await createDraftApp(opts.api, jwt, tenant.id, manifest);
1095
- spinner4.stop(`Created app ${pc2.cyan(appId)}`);
1096
- } catch (err) {
1097
- spinner4.stop("Failed to create app");
1098
- bail(err.message);
1099
- }
1100
- spinner4.start("Provisioning app");
1101
- let clientId;
1102
- let clientSecret;
1103
- try {
1104
- ({ clientId, clientSecret } = await provisionApp(
1105
- opts.api,
1106
- jwt,
1107
- tenant.id,
1108
- appId
1109
- ));
1110
- spinner4.stop("Provisioned");
1111
- } catch (err) {
1112
- spinner4.stop("Failed to provision app");
1113
- bail(err.message);
1114
- }
1115
- await persistCredentials(backend, clientId, clientSecret, root, opts, {
1116
- hub: opts.hub,
1117
- tenantSlug: tenant.slug,
1118
- appId,
1119
- // A freshly created app can't be installed yet.
1120
- isInstalled: false
1121
- });
1122
1259
  }
1123
1260
 
1124
1261
  // src/listing.ts
@@ -1391,7 +1528,25 @@ async function listing(argv = [], runOpts = {}) {
1391
1528
  // src/scaffold.ts
1392
1529
  import crypto2 from "crypto";
1393
1530
  import fs4 from "fs";
1531
+ import net from "net";
1394
1532
  import path4 from "path";
1533
+ var DEFAULT_FRONTEND_PORT = 3004;
1534
+ var BACKEND_PORT = 3005;
1535
+ function isPortFree(port) {
1536
+ return new Promise((resolve) => {
1537
+ const server = net.createServer();
1538
+ server.once("error", () => resolve(false));
1539
+ server.once("listening", () => server.close(() => resolve(true)));
1540
+ server.listen(port, "127.0.0.1");
1541
+ });
1542
+ }
1543
+ async function pickFrontendPort() {
1544
+ for (let port = DEFAULT_FRONTEND_PORT; port <= 3999; port++) {
1545
+ if (port === BACKEND_PORT) continue;
1546
+ if (await isPortFree(port)) return port;
1547
+ }
1548
+ return DEFAULT_FRONTEND_PORT;
1549
+ }
1395
1550
  function toPascalCase(kebab) {
1396
1551
  return kebab.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
1397
1552
  }
@@ -1413,7 +1568,8 @@ function buildPlaceholders(opts) {
1413
1568
  "{{APP_NAME_PASCAL}}": toPascalCase(opts.appName),
1414
1569
  "{{APP_TECHNICAL_NAME}}": buildTechnicalName(opts.appName),
1415
1570
  "{{APP_DESCRIPTION}}": opts.description,
1416
- "{{APP_DESCRIPTION_SHORT}}": buildShortDescription(opts.description)
1571
+ "{{APP_DESCRIPTION_SHORT}}": buildShortDescription(opts.description),
1572
+ "{{FRONTEND_PORT}}": String(opts.frontendPort)
1417
1573
  };
1418
1574
  }
1419
1575
  function applyPlaceholders(content, replacements) {
@@ -1449,9 +1605,11 @@ function transformPackageJson(content, appName, templateType, replacements) {
1449
1605
  const result = applyPlaceholders(JSON.stringify(pkg, null, 2), replacements);
1450
1606
  return result + "\n";
1451
1607
  }
1608
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".turbo"]);
1452
1609
  function copyDir(src, dest, replacements, templateType, appName) {
1453
1610
  fs4.mkdirSync(dest, { recursive: true });
1454
1611
  for (const entry of fs4.readdirSync(src, { withFileTypes: true })) {
1612
+ if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
1455
1613
  const srcPath = path4.join(src, entry.name);
1456
1614
  let destName = entry.name;
1457
1615
  if (destName.startsWith("_git")) {
@@ -1486,40 +1644,87 @@ function buildReplacements(base, template) {
1486
1644
  }
1487
1645
  return { ...base, ...resolved };
1488
1646
  }
1647
+ function applyManifestAuthentication(appJsonPath, opts) {
1648
+ const manifest = JSON.parse(fs4.readFileSync(appJsonPath, "utf-8"));
1649
+ const { frontend, backend } = opts.authentication;
1650
+ const authentication = {};
1651
+ if (frontend) {
1652
+ const origin = `http://localhost:${opts.frontendPort}`;
1653
+ authentication.publicClient = {
1654
+ redirectUris: [`${origin}/callback`],
1655
+ postLogoutRedirectUris: [`${origin}/`]
1656
+ };
1657
+ }
1658
+ if (backend) {
1659
+ authentication.serviceAccount = {
1660
+ clientCredentials: true,
1661
+ description: `${opts.appName} backend service account`
1662
+ };
1663
+ }
1664
+ manifest.authentication = authentication;
1665
+ if (!frontend) {
1666
+ delete manifest.lifecycle;
1667
+ if (manifest.capabilities) {
1668
+ delete manifest.capabilities.hub;
1669
+ if (manifest.capabilities.erp) {
1670
+ delete manifest.capabilities.erp.menuItems;
1671
+ delete manifest.capabilities.erp.pane;
1672
+ }
1673
+ }
1674
+ }
1675
+ fs4.writeFileSync(appJsonPath, JSON.stringify(manifest, null, 2) + "\n");
1676
+ }
1489
1677
  async function scaffold({
1490
1678
  appName,
1491
1679
  description,
1492
1680
  backend,
1493
1681
  frontend,
1494
1682
  shared,
1683
+ frontendPort = DEFAULT_FRONTEND_PORT,
1684
+ authentication,
1495
1685
  targetDir: customTargetDir
1496
1686
  }) {
1497
1687
  const targetDir = customTargetDir ?? path4.resolve(process.cwd(), appName);
1498
- const baseReplacements = buildPlaceholders({ appName, description });
1688
+ const baseReplacements = buildPlaceholders({
1689
+ appName,
1690
+ description,
1691
+ frontendPort
1692
+ });
1499
1693
  if (fs4.existsSync(targetDir)) {
1500
1694
  throw new Error(`Directory "${appName}" already exists`);
1501
1695
  }
1502
1696
  fs4.mkdirSync(targetDir, { recursive: true });
1503
1697
  copyDir(shared.dir, targetDir, baseReplacements);
1504
- const frontendDest = path4.join(targetDir, "packages", "frontend");
1505
- const frontendReplacements = buildReplacements(baseReplacements, frontend);
1506
- copyDir(
1507
- frontend.dir,
1508
- frontendDest,
1509
- frontendReplacements,
1510
- frontend.meta.type,
1511
- appName
1512
- );
1513
- const backendDest = path4.join(targetDir, "packages", "backend");
1514
- const backendReplacements = buildReplacements(baseReplacements, backend);
1515
- copyDir(
1516
- backend.dir,
1517
- backendDest,
1518
- backendReplacements,
1519
- backend.meta.type,
1520
- appName
1521
- );
1522
- const runtimeVersions = backend.meta.runtimeVersions ?? {};
1698
+ if (frontend) {
1699
+ const frontendDest = path4.join(targetDir, "packages", "frontend");
1700
+ const frontendReplacements = buildReplacements(baseReplacements, frontend);
1701
+ copyDir(
1702
+ frontend.dir,
1703
+ frontendDest,
1704
+ frontendReplacements,
1705
+ frontend.meta.type,
1706
+ appName
1707
+ );
1708
+ }
1709
+ if (backend) {
1710
+ const backendDest = path4.join(targetDir, "packages", "backend");
1711
+ const backendReplacements = buildReplacements(baseReplacements, backend);
1712
+ copyDir(
1713
+ backend.dir,
1714
+ backendDest,
1715
+ backendReplacements,
1716
+ backend.meta.type,
1717
+ appName
1718
+ );
1719
+ }
1720
+ if (authentication) {
1721
+ applyManifestAuthentication(path4.join(targetDir, "app.json"), {
1722
+ appName,
1723
+ frontendPort,
1724
+ authentication
1725
+ });
1726
+ }
1727
+ const runtimeVersions = backend?.meta.runtimeVersions ?? {};
1523
1728
  if (Object.keys(runtimeVersions).length > 0) {
1524
1729
  const toolVersions = Object.entries(runtimeVersions).map(([tool, version]) => `${tool} ${version}`).join("\n");
1525
1730
  fs4.writeFileSync(
@@ -1586,14 +1791,163 @@ function getTemplatesByType(templates, type) {
1586
1791
  }
1587
1792
 
1588
1793
  // src/posthog.ts
1794
+ import { randomUUID } from "crypto";
1795
+ import fs6 from "fs";
1796
+ import os2 from "os";
1797
+ import path6 from "path";
1589
1798
  import { PostHog } from "posthog-node";
1590
1799
  var TOKEN = "phc_SkFAxTIa4RUQxotci1Ftdw7xefZK2Ax0LEIGcl8zuD2";
1800
+ var CLI_VERSION = "0.5.1";
1591
1801
  var client = TOKEN ? new PostHog(TOKEN, { host: "https://e.jtl-cloud.com" }) : null;
1802
+ function telemetryDisabledByEnv() {
1803
+ const dnt = process.env.DO_NOT_TRACK;
1804
+ if (dnt && dnt !== "0" && dnt !== "false") return true;
1805
+ const optOut = process.env.JTL_CLI_TELEMETRY;
1806
+ if (optOut === "0" || optOut === "false") return true;
1807
+ return false;
1808
+ }
1809
+ function telemetryClient() {
1810
+ if (!client || telemetryDisabledByEnv()) return null;
1811
+ return client;
1812
+ }
1813
+ function telemetryIdFile() {
1814
+ return process.env.JTL_CLI_TELEMETRY_ID_FILE ?? path6.join(os2.homedir(), ".config", "jtl-cli", "telemetry.json");
1815
+ }
1816
+ var cachedId;
1817
+ function anonymousId() {
1818
+ if (cachedId) return cachedId;
1819
+ const file = telemetryIdFile();
1820
+ try {
1821
+ const parsed = JSON.parse(fs6.readFileSync(file, "utf8"));
1822
+ if (typeof parsed?.anonymous_id === "string" && parsed.anonymous_id) {
1823
+ cachedId = parsed.anonymous_id;
1824
+ return parsed.anonymous_id;
1825
+ }
1826
+ } catch {
1827
+ }
1828
+ const id = randomUUID();
1829
+ try {
1830
+ fs6.mkdirSync(path6.dirname(file), { recursive: true });
1831
+ fs6.writeFileSync(file, JSON.stringify({ anonymous_id: id }, null, 2));
1832
+ } catch {
1833
+ }
1834
+ cachedId = id;
1835
+ return id;
1836
+ }
1837
+ function baseProps() {
1838
+ return {
1839
+ cli_version: CLI_VERSION,
1840
+ node_version: process.version,
1841
+ os: process.platform,
1842
+ arch: process.arch,
1843
+ ci: Boolean(process.env.CI)
1844
+ };
1845
+ }
1846
+ function captureCommand(command, properties = {}) {
1847
+ const c = telemetryClient();
1848
+ if (!c) return;
1849
+ c.capture({
1850
+ distinctId: anonymousId(),
1851
+ event: "cli_command",
1852
+ properties: { command, ...baseProps(), ...properties }
1853
+ });
1854
+ }
1855
+ function captureError(error, properties = {}) {
1856
+ const c = telemetryClient();
1857
+ if (!c) return;
1858
+ try {
1859
+ c.captureException(error, anonymousId(), { ...baseProps(), ...properties });
1860
+ } catch {
1861
+ }
1862
+ }
1592
1863
  async function shutdownPostHog() {
1593
1864
  await client?.shutdown();
1594
1865
  }
1595
1866
 
1596
1867
  // src/index.ts
1868
+ var NONE = "__none__";
1869
+ function templateSelectOptions(templates) {
1870
+ return templates.map((t) => ({
1871
+ value: t.packageName,
1872
+ label: t.meta.label ?? t.packageName,
1873
+ hint: t.meta.hint
1874
+ }));
1875
+ }
1876
+ function halfSelectOptions(templates, noneHint) {
1877
+ return [
1878
+ ...templateSelectOptions(templates),
1879
+ { value: NONE, label: "None", hint: noneHint }
1880
+ ];
1881
+ }
1882
+ function cancelScaffold() {
1883
+ p4.cancel("Cancelled.");
1884
+ process.exit(0);
1885
+ }
1886
+ async function runNextScaffold(backends, frontends, shared) {
1887
+ const cancelOpts = { onCancel: cancelScaffold };
1888
+ const answers = await p4.group(
1889
+ {
1890
+ appName: () => p4.text({
1891
+ message: "App name",
1892
+ placeholder: "my-jtl-app",
1893
+ validate: (value) => {
1894
+ if (!value) return "App name is required";
1895
+ if (!/^[a-z0-9-]+$/.test(value))
1896
+ return "Use lowercase letters, numbers, and hyphens only";
1897
+ }
1898
+ }),
1899
+ description: () => p4.text({ message: "Description", placeholder: "A JTL Platform app" }),
1900
+ frontend: () => p4.select({
1901
+ message: "Frontend (adds a public client for browser login)",
1902
+ options: halfSelectOptions(frontends, "no frontend")
1903
+ }),
1904
+ backend: () => p4.select({
1905
+ message: "Backend (adds a service account for the JTL API)",
1906
+ options: halfSelectOptions(backends, "no backend")
1907
+ })
1908
+ },
1909
+ cancelOpts
1910
+ );
1911
+ const selectedFrontend = frontends.find(
1912
+ (t) => t.packageName === answers.frontend
1913
+ );
1914
+ const selectedBackend = backends.find(
1915
+ (t) => t.packageName === answers.backend
1916
+ );
1917
+ if (!selectedFrontend && !selectedBackend) {
1918
+ p4.cancel("Pick at least a frontend or a backend.");
1919
+ process.exit(1);
1920
+ }
1921
+ const s = p4.spinner();
1922
+ s.start("Scaffolding project");
1923
+ const frontendPort = selectedFrontend ? await pickFrontendPort() : void 0;
1924
+ await scaffold({
1925
+ appName: answers.appName,
1926
+ description: answers.description || "A JTL Platform app",
1927
+ backend: selectedBackend,
1928
+ frontend: selectedFrontend,
1929
+ shared,
1930
+ frontendPort,
1931
+ authentication: {
1932
+ frontend: Boolean(selectedFrontend),
1933
+ backend: Boolean(selectedBackend)
1934
+ }
1935
+ });
1936
+ s.stop("Project scaffolded");
1937
+ if (selectedFrontend) {
1938
+ p4.note(
1939
+ `The frontend dev server runs on ${pc4.cyan(`http://localhost:${frontendPort}`)}, and its ${pc4.cyan("/callback")} route is registered as the public client's redirect URI.`,
1940
+ "Frontend login"
1941
+ );
1942
+ }
1943
+ printNextSteps(answers.appName);
1944
+ p4.outro(pc4.green("Done! Happy building."));
1945
+ return {
1946
+ next: true,
1947
+ frontend: selectedFrontend ? selectedFrontend.meta.label ?? selectedFrontend.packageName : "none",
1948
+ backend: selectedBackend ? selectedBackend.meta.label ?? selectedBackend.packageName : "none"
1949
+ };
1950
+ }
1597
1951
  async function runScaffold() {
1598
1952
  console.log();
1599
1953
  p4.intro(pc4.bgCyan(pc4.black(" create-cloud-app ")));
@@ -1605,6 +1959,9 @@ async function runScaffold() {
1605
1959
  p4.cancel("No templates found. Ensure template packages are installed.");
1606
1960
  process.exit(1);
1607
1961
  }
1962
+ if (process.argv.includes("--next")) {
1963
+ return runNextScaffold(backends, frontends, shared);
1964
+ }
1608
1965
  const answers = await p4.group(
1609
1966
  {
1610
1967
  appName: () => p4.text({
@@ -1622,19 +1979,11 @@ async function runScaffold() {
1622
1979
  }),
1623
1980
  backend: () => p4.select({
1624
1981
  message: "Backend",
1625
- options: backends.map((t) => ({
1626
- value: t.packageName,
1627
- label: t.meta.label ?? t.packageName,
1628
- hint: t.meta.hint
1629
- }))
1982
+ options: templateSelectOptions(backends)
1630
1983
  }),
1631
1984
  frontend: () => p4.select({
1632
1985
  message: "Frontend",
1633
- options: frontends.map((t) => ({
1634
- value: t.packageName,
1635
- label: t.meta.label ?? t.packageName,
1636
- hint: t.meta.hint
1637
- }))
1986
+ options: templateSelectOptions(frontends)
1638
1987
  })
1639
1988
  },
1640
1989
  {
@@ -1660,9 +2009,17 @@ async function runScaffold() {
1660
2009
  shared
1661
2010
  });
1662
2011
  s.stop("Project scaffolded");
2012
+ printNextSteps(answers.appName);
2013
+ p4.outro(pc4.green("Done! Happy building."));
2014
+ return {
2015
+ backend: selectedBackend.meta.label ?? selectedBackend.packageName,
2016
+ frontend: selectedFrontend.meta.label ?? selectedFrontend.packageName
2017
+ };
2018
+ }
2019
+ function printNextSteps(appName) {
1663
2020
  p4.note(
1664
2021
  [
1665
- `${pc4.cyan("cd")} ${answers.appName}`,
2022
+ `${pc4.cyan("cd")} ${appName}`,
1666
2023
  `${pc4.cyan("npm install")}`,
1667
2024
  `${pc4.cyan("npm run register")}`,
1668
2025
  `${pc4.cyan("npm run dev")}`,
@@ -1670,18 +2027,34 @@ async function runScaffold() {
1670
2027
  ].join("\n"),
1671
2028
  "Next steps"
1672
2029
  );
1673
- p4.outro(pc4.green("Done! Happy building."));
1674
2030
  }
1675
2031
  async function main() {
1676
- if (process.argv[2] === "register") {
1677
- await register(process.argv.slice(3));
1678
- return;
1679
- }
1680
- if (process.argv[2] === "listing") {
1681
- await listing(process.argv.slice(3));
1682
- return;
2032
+ const command = process.argv[2] === "register" ? "register" : process.argv[2] === "listing" ? "listing" : "scaffold";
2033
+ const startedAt = Date.now();
2034
+ try {
2035
+ let detail = {};
2036
+ if (command === "register") {
2037
+ await register(process.argv.slice(3));
2038
+ } else if (command === "listing") {
2039
+ await listing(process.argv.slice(3));
2040
+ } else {
2041
+ detail = await runScaffold();
2042
+ }
2043
+ captureCommand(command, {
2044
+ success: true,
2045
+ duration_ms: Date.now() - startedAt,
2046
+ ...detail
2047
+ });
2048
+ } catch (err) {
2049
+ const bail2 = err instanceof BailError;
2050
+ captureCommand(command, {
2051
+ success: false,
2052
+ duration_ms: Date.now() - startedAt,
2053
+ outcome: bail2 ? "bailed" : "error"
2054
+ });
2055
+ if (!bail2) captureError(err, { command });
2056
+ throw err;
1683
2057
  }
1684
- await runScaffold();
1685
2058
  }
1686
2059
  main().then(() => shutdownPostHog()).catch(async (err) => {
1687
2060
  await shutdownPostHog();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jtl-software/create-cloud-app",
3
- "version": "0.3.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "description": "CLI tool for scaffolding JTL Platform cloud apps",
6
6
  "bin": {
@@ -17,20 +17,20 @@
17
17
  "dev": "tsx src/index.ts"
18
18
  },
19
19
  "dependencies": {
20
- "@clack/prompts": "^1.6.0",
20
+ "@clack/prompts": "^1.7.0",
21
21
  "@jtl-software/cloud-app-template-backend-dotnet": "0.0.12",
22
22
  "@jtl-software/cloud-app-template-backend-node": "0.0.15",
23
- "@jtl-software/cloud-app-template-backend-php": "0.1.1",
24
- "@jtl-software/cloud-app-template-frontend-react": "0.0.18",
25
- "@jtl-software/cloud-app-template-shared": "0.1.1",
23
+ "@jtl-software/cloud-app-template-backend-php": "0.1.2",
24
+ "@jtl-software/cloud-app-template-frontend-react": "0.1.1",
25
+ "@jtl-software/cloud-app-template-shared": "0.1.2",
26
26
  "picocolors": "^1.1.1",
27
- "posthog-node": "^5.45.2"
27
+ "posthog-node": "^5.48.1"
28
28
  },
29
29
  "devDependencies": {
30
- "@types/node": "^26.0.0",
31
- "msw": "^2.14.6",
30
+ "@types/node": "^26.2.0",
31
+ "msw": "^2.15.0",
32
32
  "tsup": "^8.5.1",
33
- "tsx": "^4.22.4",
33
+ "tsx": "^4.23.12",
34
34
  "typescript": "^6.0.3"
35
35
  },
36
36
  "publishConfig": {