@jtl-software/create-cloud-app 0.3.1 → 0.5.2

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 +561 -161
  2. package/package.json +6 -6
package/dist/index.js CHANGED
@@ -157,7 +157,10 @@ function captureAuthCode(authorizeUrl, expectedState, verifier, options = {}) {
157
157
  const closeAndSettle = (res, next) => {
158
158
  if (settled) return;
159
159
  settled = true;
160
- const finish = () => server.close(() => next());
160
+ const finish = () => {
161
+ server.closeAllConnections?.();
162
+ server.close(() => next());
163
+ };
161
164
  if (res.writableEnded) {
162
165
  finish();
163
166
  } else {
@@ -364,6 +367,9 @@ async function acquireAccessToken(opts) {
364
367
  authorizeUrl.searchParams.set("state", state);
365
368
  authorizeUrl.searchParams.set("code_challenge", pkce.challenge);
366
369
  authorizeUrl.searchParams.set("code_challenge_method", "S256");
370
+ if (reauth) {
371
+ authorizeUrl.searchParams.set("prompt", "select_account");
372
+ }
367
373
  spinner4?.start("Waiting for browser login");
368
374
  try {
369
375
  const { code, verifier } = await captureAuthCode(
@@ -471,6 +477,10 @@ function tenantsFromClaims(claims) {
471
477
  }
472
478
  return ordered;
473
479
  }
480
+ function activeTenantId(claims) {
481
+ const active = claims[TENANT_ID_CLAIM];
482
+ return typeof active === "string" && active ? active : void 0;
483
+ }
474
484
  function validateHost(input, flag) {
475
485
  let url;
476
486
  try {
@@ -680,6 +690,37 @@ function detectBackend(root) {
680
690
  }
681
691
  return { kind: "node", envFile: path2.join(root, ".env") };
682
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
+ }
707
+ function readBackendPort(backend) {
708
+ if (backend.kind === "node") {
709
+ for (const file of [backend.envFile, `${backend.envFile}.example`]) {
710
+ const value = readEnvFile(file).PORT;
711
+ if (value && /^\d+$/.test(value)) return Number(value);
712
+ }
713
+ }
714
+ return 3005;
715
+ }
716
+ function writeVerifyDemoEnv(root, backend) {
717
+ if (!backend) return;
718
+ const frontendDir = detectFrontend(root);
719
+ if (!frontendDir) return;
720
+ upsertEnv(path2.join(frontendDir, ".env"), {
721
+ VITE_BACKEND_URL: `http://localhost:${readBackendPort(backend)}`
722
+ });
723
+ }
683
724
  function readExistingCredentials(backend) {
684
725
  if (backend.kind === "node") {
685
726
  const values = readEnvFile(backend.envFile);
@@ -696,11 +737,12 @@ function readExistingCredentials(backend) {
696
737
  return {};
697
738
  }
698
739
  }
699
- function writeBackendCredentials(backend, clientId, clientSecret) {
740
+ function writeBackendCredentials(backend, clientId, clientSecret, issuer) {
700
741
  if (backend.kind === "node") {
701
742
  upsertEnv(backend.envFile, {
702
743
  CLIENT_ID: clientId,
703
- CLIENT_SECRET: clientSecret
744
+ CLIENT_SECRET: clientSecret,
745
+ ...issuer ? { JTL_ISSUER: issuer } : {}
704
746
  });
705
747
  return backend.envFile;
706
748
  }
@@ -715,7 +757,8 @@ function writeBackendCredentials(backend, clientId, clientSecret) {
715
757
  settings.JtlPlatform = {
716
758
  ...settings.JtlPlatform ?? {},
717
759
  ClientId: clientId,
718
- ClientSecret: clientSecret
760
+ ClientSecret: clientSecret,
761
+ ...issuer ? { Issuer: issuer } : {}
719
762
  };
720
763
  fs2.writeFileSync(
721
764
  backend.settingsFile,
@@ -772,7 +815,8 @@ function resolveOptions(args) {
772
815
  ...COMMON_ARG_OPTIONS,
773
816
  "existing-app-id": { type: "string" },
774
817
  new: { type: "boolean" },
775
- "on-collision": { type: "string" }
818
+ "on-collision": { type: "string" },
819
+ "app-service-path": { type: "string" }
776
820
  },
777
821
  strict: true,
778
822
  allowPositionals: false
@@ -788,11 +832,13 @@ function resolveOptions(args) {
788
832
  }
789
833
  const onCollisionRaw = parsed.values["on-collision"];
790
834
  const onCollision = onCollisionRaw ? parseCollisionAction(onCollisionRaw) : void 0;
835
+ const appServicePath = parsed.values["app-service-path"] ?? "app-service";
791
836
  return {
792
837
  ...common,
793
838
  existingAppId,
794
839
  forceNew,
795
- onCollision
840
+ onCollision,
841
+ appServicePath
796
842
  };
797
843
  }
798
844
  async function selectExistingOrNew(matches, opts) {
@@ -847,9 +893,29 @@ async function resolveCollision(targetLabel, credentialPair, opts) {
847
893
  })
848
894
  );
849
895
  }
850
- async function listTenantApps(apiBaseUrl, jwt, tenantId) {
896
+ async function confirmRegister(opts, message, isUpdate) {
897
+ if (opts.yes) return "proceed";
898
+ return ensureContinue(
899
+ await p2.select({
900
+ message,
901
+ options: [
902
+ {
903
+ value: "proceed",
904
+ label: isUpdate ? "Update the app" : "Create the app"
905
+ },
906
+ {
907
+ value: "switch-org",
908
+ label: "Use a different organization",
909
+ hint: "sign in again and pick another org"
910
+ },
911
+ { value: "cancel", label: "Cancel" }
912
+ ]
913
+ })
914
+ );
915
+ }
916
+ async function listTenantApps(apiBaseUrl, jwt, tenantId, appServicePath = "app-service") {
851
917
  const res = await apiCall(
852
- `${apiBaseUrl}/app-service/tenants/apps?onlyAppsOfTenant=true&returnAll=true`,
918
+ `${apiBaseUrl}/${appServicePath}/tenants/apps?onlyAppsOfTenant=true&returnAll=true`,
853
919
  {
854
920
  headers: {
855
921
  Authorization: `Bearer ${jwt}`,
@@ -868,24 +934,28 @@ async function listTenantApps(apiBaseUrl, jwt, tenantId) {
868
934
  technicalName,
869
935
  clientId: entry.clientId,
870
936
  provisioningStatus: entry.activeVersion?.provisioningStatus,
871
- isInstalled: entry.isInstalled
937
+ isInstalled: entry.isInstalled,
938
+ publicClientId: entry.credentials?.publicClient?.clientId
872
939
  };
873
940
  }).filter((a) => a !== null);
874
941
  }
875
- async function updateAppManifest(apiBaseUrl, jwt, tenantId, appId, manifest) {
876
- await apiCall(`${apiBaseUrl}/app-service/store/apps/${appId}/manifest`, {
877
- method: "PUT",
878
- headers: {
879
- Authorization: `Bearer ${jwt}`,
880
- "Content-Type": "application/json",
881
- "X-Tenant-Id": tenantId
882
- },
883
- body: JSON.stringify({ manifest, tcAccepted: true })
884
- });
942
+ async function updateAppManifest(apiBaseUrl, jwt, tenantId, appId, manifest, appServicePath = "app-service") {
943
+ await apiCall(
944
+ `${apiBaseUrl}/${appServicePath}/store/apps/${appId}/manifest`,
945
+ {
946
+ method: "PUT",
947
+ headers: {
948
+ Authorization: `Bearer ${jwt}`,
949
+ "Content-Type": "application/json",
950
+ "X-Tenant-Id": tenantId
951
+ },
952
+ body: JSON.stringify({ manifest, tcAccepted: true })
953
+ }
954
+ );
885
955
  }
886
- async function createDraftApp(apiBaseUrl, jwt, tenantId, manifest) {
956
+ async function createDraftApp(apiBaseUrl, jwt, tenantId, manifest, appServicePath = "app-service") {
887
957
  const created = await apiCall(
888
- `${apiBaseUrl}/app-service/store/apps`,
958
+ `${apiBaseUrl}/${appServicePath}/store/apps`,
889
959
  {
890
960
  method: "POST",
891
961
  headers: {
@@ -906,9 +976,9 @@ async function createDraftApp(apiBaseUrl, jwt, tenantId, manifest) {
906
976
  }
907
977
  return created.id;
908
978
  }
909
- async function provisionApp(apiBaseUrl, jwt, tenantId, appId) {
979
+ async function provisionApp(apiBaseUrl, jwt, tenantId, appId, appServicePath = "app-service") {
910
980
  const provisioned = await apiCall(
911
- `${apiBaseUrl}/app-service/store/apps/${appId}/provision`,
981
+ `${apiBaseUrl}/${appServicePath}/store/apps/${appId}/provision`,
912
982
  {
913
983
  method: "PUT",
914
984
  headers: {
@@ -926,7 +996,11 @@ async function provisionApp(apiBaseUrl, jwt, tenantId, appId) {
926
996
  `provision response missing clientId or clientSecret: ${JSON.stringify(provisioned)}`
927
997
  );
928
998
  }
929
- return { clientId, clientSecret };
999
+ return {
1000
+ clientId,
1001
+ clientSecret,
1002
+ publicClientId: provisioned.credentials?.publicClient?.clientId
1003
+ };
930
1004
  }
931
1005
  function credentialLabels(backend) {
932
1006
  if (backend.kind === "node") {
@@ -967,7 +1041,12 @@ async function persistCredentials(backend, clientId, clientSecret, root, opts, c
967
1041
  p2.outro(pc2.green("Done. Credentials printed above; file left untouched."));
968
1042
  return;
969
1043
  }
970
- const written = writeBackendCredentials(backend, clientId, clientSecret);
1044
+ const written = writeBackendCredentials(
1045
+ backend,
1046
+ clientId,
1047
+ clientSecret,
1048
+ opts.auth
1049
+ );
971
1050
  printHubLink(
972
1051
  context.hub,
973
1052
  context.tenantSlug,
@@ -985,15 +1064,16 @@ async function register(argv = [], runOpts = {}) {
985
1064
  console.log();
986
1065
  p2.intro(pc2.bgCyan(pc2.black(" register cloud app ")));
987
1066
  const opts = resolveOptions(argv);
988
- const backend = detectBackend(root);
1067
+ const backend = fs2.existsSync(path2.join(root, "packages", "backend")) ? detectBackend(root) : null;
989
1068
  p2.note(
990
1069
  [
991
1070
  `${pc2.dim("API")} ${opts.api}`,
1071
+ opts.appServicePath !== "app-service" ? `${pc2.dim("App svc")} /${opts.appServicePath}` : null,
992
1072
  `${pc2.dim("Auth")} ${opts.auth}`,
993
1073
  `${pc2.dim("Hub")} ${opts.hub}`,
994
1074
  `${pc2.dim("Client ID")} ${opts.clientId}`,
995
1075
  `${pc2.dim("Scope")} ${opts.scope}`
996
- ].join("\n"),
1076
+ ].filter(Boolean).join("\n"),
997
1077
  "Target"
998
1078
  );
999
1079
  const spinner4 = p2.spinner();
@@ -1010,114 +1090,198 @@ async function register(argv = [], runOpts = {}) {
1010
1090
  } catch (err) {
1011
1091
  bail(err.message);
1012
1092
  }
1013
- const identityJwt = jwt;
1014
- jwt = apiTokenFor(jwt);
1015
- spinner4.start("Fetching tenants");
1016
- let tenants;
1017
- try {
1018
- tenants = await fetchTenants(opts.api, identityJwt);
1019
- spinner4.stop(
1020
- `Found ${tenants.length} tenant${tenants.length === 1 ? "" : "s"}`
1021
- );
1022
- } catch (err) {
1023
- spinner4.stop("Failed to fetch tenants");
1024
- bail(err.message);
1025
- }
1026
- const tenant = await selectTenant(tenants, opts);
1027
1093
  const manifest = readManifest(path2.join(root, "app.json"));
1028
1094
  const summary = summarizeManifest(manifest);
1029
- spinner4.start("Looking for apps matching this manifest");
1030
- let matches;
1031
- try {
1032
- const apps = await listTenantApps(opts.api, jwt, tenant.id);
1033
- matches = apps.filter((a) => a.technicalName === summary.technicalName);
1034
- spinner4.stop(
1035
- `Found ${matches.length} app${matches.length === 1 ? "" : "s"} with technical name ${pc2.cyan(summary.technicalName)}`
1036
- );
1037
- } catch (err) {
1038
- spinner4.stop("Failed to look up tenant apps");
1039
- bail(err.message);
1040
- }
1041
- const existing = await selectExistingOrNew(matches, opts);
1042
- const reviewLines = [
1043
- `${pc2.dim("Technical name")} ${summary.technicalName}`,
1044
- `${pc2.dim("Version")} ${summary.version}`,
1045
- `${pc2.dim("Tenant")} ${tenant.name} (${tenant.slug})`,
1046
- existing ? `${pc2.dim("Updating")} ${existing.technicalName} (${existing.id}) [${existing.provisioningStatus ?? "?"}]` : `${pc2.dim("Action")} create new`
1047
- ].filter(Boolean);
1048
- p2.note(reviewLines.join("\n"), "Review");
1049
- const confirmMessage = existing ? `Update app "${existing.technicalName}" in tenant "${tenant.name}"?` : `Create app "${summary.technicalName}" in tenant "${tenant.name}"?`;
1050
- const confirm2 = await confirmAction(opts, confirmMessage, existing !== null);
1051
- if (!confirm2) bail("Cancelled.");
1052
- if (existing) {
1053
- spinner4.start("Updating manifest");
1095
+ for (; ; ) {
1096
+ const identityJwt = jwt;
1097
+ const apiJwt = apiTokenFor(jwt);
1098
+ spinner4.start("Fetching tenants");
1099
+ let tenants;
1054
1100
  try {
1055
- await updateAppManifest(opts.api, jwt, tenant.id, existing.id, manifest);
1056
- spinner4.stop("Manifest updated");
1101
+ tenants = await fetchTenants(opts.api, identityJwt);
1102
+ spinner4.stop(
1103
+ `Found ${tenants.length} tenant${tenants.length === 1 ? "" : "s"}`
1104
+ );
1057
1105
  } catch (err) {
1058
- spinner4.stop("Failed to update manifest");
1106
+ spinner4.stop("Failed to fetch tenants");
1059
1107
  bail(err.message);
1060
1108
  }
1061
- printHubLink(
1062
- opts.hub,
1063
- tenant.slug,
1064
- existing.id,
1065
- existing.isInstalled ?? false
1109
+ let tenant;
1110
+ const scopedTenantId = opts.tenant ? void 0 : activeTenantId(decodeJwtClaims(identityJwt));
1111
+ const scopedTenant = scopedTenantId ? tenants.find((t) => t.id === scopedTenantId) : void 0;
1112
+ if (scopedTenant) {
1113
+ tenant = scopedTenant;
1114
+ p2.log.info(
1115
+ `Organization ${pc2.cyan(tenant.name)} ${pc2.dim(`(${tenant.slug})`)} \u2014 selected at login`
1116
+ );
1117
+ } else {
1118
+ tenant = await selectTenant(tenants, opts);
1119
+ }
1120
+ spinner4.start("Looking for apps matching this manifest");
1121
+ let matches;
1122
+ try {
1123
+ const apps = await listTenantApps(
1124
+ opts.api,
1125
+ apiJwt,
1126
+ tenant.id,
1127
+ opts.appServicePath
1128
+ );
1129
+ matches = apps.filter((a) => a.technicalName === summary.technicalName);
1130
+ spinner4.stop(
1131
+ `Found ${matches.length} app${matches.length === 1 ? "" : "s"} with technical name ${pc2.cyan(summary.technicalName)}`
1132
+ );
1133
+ } catch (err) {
1134
+ spinner4.stop("Failed to look up tenant apps");
1135
+ bail(err.message);
1136
+ }
1137
+ const existing = await selectExistingOrNew(matches, opts);
1138
+ const reviewLines = [
1139
+ `${pc2.dim("Technical name")} ${summary.technicalName}`,
1140
+ `${pc2.dim("Version")} ${summary.version}`,
1141
+ `${pc2.dim("Tenant")} ${tenant.name} (${tenant.slug})`,
1142
+ existing ? `${pc2.dim("Updating")} ${existing.technicalName} (${existing.id}) [${existing.provisioningStatus ?? "?"}]` : `${pc2.dim("Action")} create new`
1143
+ ].filter(Boolean);
1144
+ p2.note(reviewLines.join("\n"), "Review");
1145
+ const confirmMessage = existing ? `Update app "${existing.technicalName}" in tenant "${tenant.name}"?` : `Create app "${summary.technicalName}" in tenant "${tenant.name}"?`;
1146
+ const decision = await confirmRegister(
1147
+ opts,
1148
+ confirmMessage,
1149
+ existing !== null
1066
1150
  );
1067
- if (existing.clientId) {
1068
- const labels = credentialLabels(backend);
1069
- const envFile = backend.kind === "node" ? backend.envFile : backend.settingsFile;
1070
- const localCreds = readExistingCredentials(backend);
1071
- const hasLocalCreds = !!(localCreds.clientId && localCreds.clientSecret);
1072
- if (!hasLocalCreds) {
1151
+ if (decision === "cancel") bail("Cancelled.");
1152
+ if (decision === "switch-org") {
1153
+ try {
1154
+ jwt = await acquireAccessToken({
1155
+ auth: opts.auth,
1156
+ clientId: opts.clientId,
1157
+ scope: opts.scope,
1158
+ reauth: true,
1159
+ // forces prompt=select_account so the user re-picks the organization
1160
+ spinner: spinner4,
1161
+ captureOptions: runOpts.captureOptions
1162
+ });
1163
+ } catch (err) {
1164
+ bail(err.message);
1165
+ }
1166
+ continue;
1167
+ }
1168
+ if (existing) {
1169
+ spinner4.start("Updating manifest");
1170
+ try {
1171
+ await updateAppManifest(
1172
+ opts.api,
1173
+ apiJwt,
1174
+ tenant.id,
1175
+ existing.id,
1176
+ manifest,
1177
+ opts.appServicePath
1178
+ );
1179
+ spinner4.stop("Manifest updated");
1180
+ } catch (err) {
1181
+ spinner4.stop("Failed to update manifest");
1182
+ bail(err.message);
1183
+ }
1184
+ if (existing.publicClientId) {
1185
+ const written = writeFrontendPublicClient(
1186
+ root,
1187
+ opts.auth,
1188
+ existing.publicClientId
1189
+ );
1190
+ if (written) {
1191
+ p2.log.success(
1192
+ `Wrote VITE_JTL_CLIENT_ID/VITE_JTL_ISSUER to ${pc2.cyan(path2.relative(root, written))}.`
1193
+ );
1194
+ }
1195
+ }
1196
+ writeVerifyDemoEnv(root, backend);
1197
+ printHubLink(
1198
+ opts.hub,
1199
+ tenant.slug,
1200
+ existing.id,
1201
+ existing.isInstalled ?? false
1202
+ );
1203
+ if (backend && existing.clientId) {
1204
+ const labels = credentialLabels(backend);
1205
+ const envFile = backend.kind === "node" ? backend.envFile : backend.settingsFile;
1206
+ const localCreds = readExistingCredentials(backend);
1207
+ const hasLocalCreds = !!(localCreds.clientId && localCreds.clientSecret);
1208
+ if (!hasLocalCreds) {
1209
+ p2.outro(
1210
+ pc2.yellow(
1211
+ `Manifest updated. Your backend config is missing ${labels.pair} \u2014 they can't be recovered. Re-create the app to get a fresh secret.`
1212
+ )
1213
+ );
1214
+ return;
1215
+ }
1073
1216
  p2.outro(
1074
- pc2.yellow(
1075
- `Manifest updated. Your backend config is missing ${labels.pair} \u2014 they can't be recovered. Re-create the app to get a fresh secret.`
1217
+ pc2.green(
1218
+ `Manifest updated. Existing ${labels.pair} in ${pc2.cyan(path2.relative(root, envFile))} kept as-is.`
1076
1219
  )
1077
1220
  );
1078
- return;
1221
+ } else {
1222
+ p2.outro(pc2.green("Manifest updated."));
1079
1223
  }
1080
- p2.outro(
1081
- pc2.green(
1082
- `Manifest updated. Existing ${labels.pair} in ${pc2.cyan(path2.relative(root, envFile))} kept as-is.`
1083
- )
1224
+ return;
1225
+ }
1226
+ spinner4.start("Creating draft app");
1227
+ let appId;
1228
+ try {
1229
+ appId = await createDraftApp(
1230
+ opts.api,
1231
+ apiJwt,
1232
+ tenant.id,
1233
+ manifest,
1234
+ opts.appServicePath
1084
1235
  );
1085
- } else {
1086
- p2.outro(pc2.green("Manifest updated."));
1236
+ spinner4.stop(`Created app ${pc2.cyan(appId)}`);
1237
+ } catch (err) {
1238
+ spinner4.stop("Failed to create app");
1239
+ bail(err.message);
1240
+ }
1241
+ spinner4.start("Provisioning app");
1242
+ let clientId;
1243
+ let clientSecret;
1244
+ let publicClientId;
1245
+ try {
1246
+ ({ clientId, clientSecret, publicClientId } = await provisionApp(
1247
+ opts.api,
1248
+ apiJwt,
1249
+ tenant.id,
1250
+ appId,
1251
+ opts.appServicePath
1252
+ ));
1253
+ spinner4.stop("Provisioned");
1254
+ } catch (err) {
1255
+ spinner4.stop("Failed to provision app");
1256
+ bail(err.message);
1257
+ }
1258
+ if (publicClientId) {
1259
+ const written = writeFrontendPublicClient(
1260
+ root,
1261
+ opts.auth,
1262
+ publicClientId
1263
+ );
1264
+ if (written) {
1265
+ p2.log.success(
1266
+ `Wrote VITE_JTL_CLIENT_ID/VITE_JTL_ISSUER to ${pc2.cyan(path2.relative(root, written))}.`
1267
+ );
1268
+ }
1269
+ }
1270
+ writeVerifyDemoEnv(root, backend);
1271
+ if (backend) {
1272
+ await persistCredentials(backend, clientId, clientSecret, root, opts, {
1273
+ hub: opts.hub,
1274
+ tenantSlug: tenant.slug,
1275
+ appId,
1276
+ // A freshly created app can't be installed yet.
1277
+ isInstalled: false
1278
+ });
1279
+ return;
1087
1280
  }
1281
+ printHubLink(opts.hub, tenant.slug, appId, false);
1282
+ p2.outro(pc2.green("Done. App registered."));
1088
1283
  return;
1089
1284
  }
1090
- spinner4.start("Creating draft app");
1091
- let appId;
1092
- try {
1093
- appId = await createDraftApp(opts.api, jwt, tenant.id, manifest);
1094
- spinner4.stop(`Created app ${pc2.cyan(appId)}`);
1095
- } catch (err) {
1096
- spinner4.stop("Failed to create app");
1097
- bail(err.message);
1098
- }
1099
- spinner4.start("Provisioning app");
1100
- let clientId;
1101
- let clientSecret;
1102
- try {
1103
- ({ clientId, clientSecret } = await provisionApp(
1104
- opts.api,
1105
- jwt,
1106
- tenant.id,
1107
- appId
1108
- ));
1109
- spinner4.stop("Provisioned");
1110
- } catch (err) {
1111
- spinner4.stop("Failed to provision app");
1112
- bail(err.message);
1113
- }
1114
- await persistCredentials(backend, clientId, clientSecret, root, opts, {
1115
- hub: opts.hub,
1116
- tenantSlug: tenant.slug,
1117
- appId,
1118
- // A freshly created app can't be installed yet.
1119
- isInstalled: false
1120
- });
1121
1285
  }
1122
1286
 
1123
1287
  // src/listing.ts
@@ -1390,7 +1554,25 @@ async function listing(argv = [], runOpts = {}) {
1390
1554
  // src/scaffold.ts
1391
1555
  import crypto2 from "crypto";
1392
1556
  import fs4 from "fs";
1557
+ import net from "net";
1393
1558
  import path4 from "path";
1559
+ var DEFAULT_FRONTEND_PORT = 3004;
1560
+ var BACKEND_PORT = 3005;
1561
+ function isPortFree(port) {
1562
+ return new Promise((resolve) => {
1563
+ const server = net.createServer();
1564
+ server.once("error", () => resolve(false));
1565
+ server.once("listening", () => server.close(() => resolve(true)));
1566
+ server.listen(port, "127.0.0.1");
1567
+ });
1568
+ }
1569
+ async function pickFrontendPort() {
1570
+ for (let port = DEFAULT_FRONTEND_PORT; port <= 3999; port++) {
1571
+ if (port === BACKEND_PORT) continue;
1572
+ if (await isPortFree(port)) return port;
1573
+ }
1574
+ return DEFAULT_FRONTEND_PORT;
1575
+ }
1394
1576
  function toPascalCase(kebab) {
1395
1577
  return kebab.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
1396
1578
  }
@@ -1412,7 +1594,8 @@ function buildPlaceholders(opts) {
1412
1594
  "{{APP_NAME_PASCAL}}": toPascalCase(opts.appName),
1413
1595
  "{{APP_TECHNICAL_NAME}}": buildTechnicalName(opts.appName),
1414
1596
  "{{APP_DESCRIPTION}}": opts.description,
1415
- "{{APP_DESCRIPTION_SHORT}}": buildShortDescription(opts.description)
1597
+ "{{APP_DESCRIPTION_SHORT}}": buildShortDescription(opts.description),
1598
+ "{{FRONTEND_PORT}}": String(opts.frontendPort)
1416
1599
  };
1417
1600
  }
1418
1601
  function applyPlaceholders(content, replacements) {
@@ -1448,9 +1631,11 @@ function transformPackageJson(content, appName, templateType, replacements) {
1448
1631
  const result = applyPlaceholders(JSON.stringify(pkg, null, 2), replacements);
1449
1632
  return result + "\n";
1450
1633
  }
1634
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".turbo"]);
1451
1635
  function copyDir(src, dest, replacements, templateType, appName) {
1452
1636
  fs4.mkdirSync(dest, { recursive: true });
1453
1637
  for (const entry of fs4.readdirSync(src, { withFileTypes: true })) {
1638
+ if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
1454
1639
  const srcPath = path4.join(src, entry.name);
1455
1640
  let destName = entry.name;
1456
1641
  if (destName.startsWith("_git")) {
@@ -1485,40 +1670,87 @@ function buildReplacements(base, template) {
1485
1670
  }
1486
1671
  return { ...base, ...resolved };
1487
1672
  }
1673
+ function applyManifestAuthentication(appJsonPath, opts) {
1674
+ const manifest = JSON.parse(fs4.readFileSync(appJsonPath, "utf-8"));
1675
+ const { frontend, backend } = opts.authentication;
1676
+ const authentication = {};
1677
+ if (frontend) {
1678
+ const origin = `http://localhost:${opts.frontendPort}`;
1679
+ authentication.publicClient = {
1680
+ redirectUris: [`${origin}/callback`],
1681
+ postLogoutRedirectUris: [`${origin}/`]
1682
+ };
1683
+ }
1684
+ if (backend) {
1685
+ authentication.serviceAccount = {
1686
+ clientCredentials: true,
1687
+ description: `${opts.appName} backend service account`
1688
+ };
1689
+ }
1690
+ manifest.authentication = authentication;
1691
+ if (!frontend) {
1692
+ delete manifest.lifecycle;
1693
+ if (manifest.capabilities) {
1694
+ delete manifest.capabilities.hub;
1695
+ if (manifest.capabilities.erp) {
1696
+ delete manifest.capabilities.erp.menuItems;
1697
+ delete manifest.capabilities.erp.pane;
1698
+ }
1699
+ }
1700
+ }
1701
+ fs4.writeFileSync(appJsonPath, JSON.stringify(manifest, null, 2) + "\n");
1702
+ }
1488
1703
  async function scaffold({
1489
1704
  appName,
1490
1705
  description,
1491
1706
  backend,
1492
1707
  frontend,
1493
1708
  shared,
1709
+ frontendPort = DEFAULT_FRONTEND_PORT,
1710
+ authentication,
1494
1711
  targetDir: customTargetDir
1495
1712
  }) {
1496
1713
  const targetDir = customTargetDir ?? path4.resolve(process.cwd(), appName);
1497
- const baseReplacements = buildPlaceholders({ appName, description });
1714
+ const baseReplacements = buildPlaceholders({
1715
+ appName,
1716
+ description,
1717
+ frontendPort
1718
+ });
1498
1719
  if (fs4.existsSync(targetDir)) {
1499
1720
  throw new Error(`Directory "${appName}" already exists`);
1500
1721
  }
1501
1722
  fs4.mkdirSync(targetDir, { recursive: true });
1502
1723
  copyDir(shared.dir, targetDir, baseReplacements);
1503
- const frontendDest = path4.join(targetDir, "packages", "frontend");
1504
- const frontendReplacements = buildReplacements(baseReplacements, frontend);
1505
- copyDir(
1506
- frontend.dir,
1507
- frontendDest,
1508
- frontendReplacements,
1509
- frontend.meta.type,
1510
- appName
1511
- );
1512
- const backendDest = path4.join(targetDir, "packages", "backend");
1513
- const backendReplacements = buildReplacements(baseReplacements, backend);
1514
- copyDir(
1515
- backend.dir,
1516
- backendDest,
1517
- backendReplacements,
1518
- backend.meta.type,
1519
- appName
1520
- );
1521
- const runtimeVersions = backend.meta.runtimeVersions ?? {};
1724
+ if (frontend) {
1725
+ const frontendDest = path4.join(targetDir, "packages", "frontend");
1726
+ const frontendReplacements = buildReplacements(baseReplacements, frontend);
1727
+ copyDir(
1728
+ frontend.dir,
1729
+ frontendDest,
1730
+ frontendReplacements,
1731
+ frontend.meta.type,
1732
+ appName
1733
+ );
1734
+ }
1735
+ if (backend) {
1736
+ const backendDest = path4.join(targetDir, "packages", "backend");
1737
+ const backendReplacements = buildReplacements(baseReplacements, backend);
1738
+ copyDir(
1739
+ backend.dir,
1740
+ backendDest,
1741
+ backendReplacements,
1742
+ backend.meta.type,
1743
+ appName
1744
+ );
1745
+ }
1746
+ if (authentication) {
1747
+ applyManifestAuthentication(path4.join(targetDir, "app.json"), {
1748
+ appName,
1749
+ frontendPort,
1750
+ authentication
1751
+ });
1752
+ }
1753
+ const runtimeVersions = backend?.meta.runtimeVersions ?? {};
1522
1754
  if (Object.keys(runtimeVersions).length > 0) {
1523
1755
  const toolVersions = Object.entries(runtimeVersions).map(([tool, version]) => `${tool} ${version}`).join("\n");
1524
1756
  fs4.writeFileSync(
@@ -1585,14 +1817,163 @@ function getTemplatesByType(templates, type) {
1585
1817
  }
1586
1818
 
1587
1819
  // src/posthog.ts
1820
+ import { randomUUID } from "crypto";
1821
+ import fs6 from "fs";
1822
+ import os2 from "os";
1823
+ import path6 from "path";
1588
1824
  import { PostHog } from "posthog-node";
1589
1825
  var TOKEN = "phc_SkFAxTIa4RUQxotci1Ftdw7xefZK2Ax0LEIGcl8zuD2";
1826
+ var CLI_VERSION = "0.5.2";
1590
1827
  var client = TOKEN ? new PostHog(TOKEN, { host: "https://e.jtl-cloud.com" }) : null;
1828
+ function telemetryDisabledByEnv() {
1829
+ const dnt = process.env.DO_NOT_TRACK;
1830
+ if (dnt && dnt !== "0" && dnt !== "false") return true;
1831
+ const optOut = process.env.JTL_CLI_TELEMETRY;
1832
+ if (optOut === "0" || optOut === "false") return true;
1833
+ return false;
1834
+ }
1835
+ function telemetryClient() {
1836
+ if (!client || telemetryDisabledByEnv()) return null;
1837
+ return client;
1838
+ }
1839
+ function telemetryIdFile() {
1840
+ return process.env.JTL_CLI_TELEMETRY_ID_FILE ?? path6.join(os2.homedir(), ".config", "jtl-cli", "telemetry.json");
1841
+ }
1842
+ var cachedId;
1843
+ function anonymousId() {
1844
+ if (cachedId) return cachedId;
1845
+ const file = telemetryIdFile();
1846
+ try {
1847
+ const parsed = JSON.parse(fs6.readFileSync(file, "utf8"));
1848
+ if (typeof parsed?.anonymous_id === "string" && parsed.anonymous_id) {
1849
+ cachedId = parsed.anonymous_id;
1850
+ return parsed.anonymous_id;
1851
+ }
1852
+ } catch {
1853
+ }
1854
+ const id = randomUUID();
1855
+ try {
1856
+ fs6.mkdirSync(path6.dirname(file), { recursive: true });
1857
+ fs6.writeFileSync(file, JSON.stringify({ anonymous_id: id }, null, 2));
1858
+ } catch {
1859
+ }
1860
+ cachedId = id;
1861
+ return id;
1862
+ }
1863
+ function baseProps() {
1864
+ return {
1865
+ cli_version: CLI_VERSION,
1866
+ node_version: process.version,
1867
+ os: process.platform,
1868
+ arch: process.arch,
1869
+ ci: Boolean(process.env.CI)
1870
+ };
1871
+ }
1872
+ function captureCommand(command, properties = {}) {
1873
+ const c = telemetryClient();
1874
+ if (!c) return;
1875
+ c.capture({
1876
+ distinctId: anonymousId(),
1877
+ event: "cli_command",
1878
+ properties: { command, ...baseProps(), ...properties }
1879
+ });
1880
+ }
1881
+ function captureError(error, properties = {}) {
1882
+ const c = telemetryClient();
1883
+ if (!c) return;
1884
+ try {
1885
+ c.captureException(error, anonymousId(), { ...baseProps(), ...properties });
1886
+ } catch {
1887
+ }
1888
+ }
1591
1889
  async function shutdownPostHog() {
1592
1890
  await client?.shutdown();
1593
1891
  }
1594
1892
 
1595
1893
  // src/index.ts
1894
+ var NONE = "__none__";
1895
+ function templateSelectOptions(templates) {
1896
+ return templates.map((t) => ({
1897
+ value: t.packageName,
1898
+ label: t.meta.label ?? t.packageName,
1899
+ hint: t.meta.hint
1900
+ }));
1901
+ }
1902
+ function halfSelectOptions(templates, noneHint) {
1903
+ return [
1904
+ ...templateSelectOptions(templates),
1905
+ { value: NONE, label: "None", hint: noneHint }
1906
+ ];
1907
+ }
1908
+ function cancelScaffold() {
1909
+ p4.cancel("Cancelled.");
1910
+ process.exit(0);
1911
+ }
1912
+ async function runNextScaffold(backends, frontends, shared) {
1913
+ const cancelOpts = { onCancel: cancelScaffold };
1914
+ const answers = await p4.group(
1915
+ {
1916
+ appName: () => p4.text({
1917
+ message: "App name",
1918
+ placeholder: "my-jtl-app",
1919
+ validate: (value) => {
1920
+ if (!value) return "App name is required";
1921
+ if (!/^[a-z0-9-]+$/.test(value))
1922
+ return "Use lowercase letters, numbers, and hyphens only";
1923
+ }
1924
+ }),
1925
+ description: () => p4.text({ message: "Description", placeholder: "A JTL Platform app" }),
1926
+ frontend: () => p4.select({
1927
+ message: "Frontend (adds a public client for browser login)",
1928
+ options: halfSelectOptions(frontends, "no frontend")
1929
+ }),
1930
+ backend: () => p4.select({
1931
+ message: "Backend (adds a service account for the JTL API)",
1932
+ options: halfSelectOptions(backends, "no backend")
1933
+ })
1934
+ },
1935
+ cancelOpts
1936
+ );
1937
+ const selectedFrontend = frontends.find(
1938
+ (t) => t.packageName === answers.frontend
1939
+ );
1940
+ const selectedBackend = backends.find(
1941
+ (t) => t.packageName === answers.backend
1942
+ );
1943
+ if (!selectedFrontend && !selectedBackend) {
1944
+ p4.cancel("Pick at least a frontend or a backend.");
1945
+ process.exit(1);
1946
+ }
1947
+ const s = p4.spinner();
1948
+ s.start("Scaffolding project");
1949
+ const frontendPort = selectedFrontend ? await pickFrontendPort() : void 0;
1950
+ await scaffold({
1951
+ appName: answers.appName,
1952
+ description: answers.description || "A JTL Platform app",
1953
+ backend: selectedBackend,
1954
+ frontend: selectedFrontend,
1955
+ shared,
1956
+ frontendPort,
1957
+ authentication: {
1958
+ frontend: Boolean(selectedFrontend),
1959
+ backend: Boolean(selectedBackend)
1960
+ }
1961
+ });
1962
+ s.stop("Project scaffolded");
1963
+ if (selectedFrontend) {
1964
+ p4.note(
1965
+ `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.`,
1966
+ "Frontend login"
1967
+ );
1968
+ }
1969
+ printNextSteps(answers.appName);
1970
+ p4.outro(pc4.green("Done! Happy building."));
1971
+ return {
1972
+ next: true,
1973
+ frontend: selectedFrontend ? selectedFrontend.meta.label ?? selectedFrontend.packageName : "none",
1974
+ backend: selectedBackend ? selectedBackend.meta.label ?? selectedBackend.packageName : "none"
1975
+ };
1976
+ }
1596
1977
  async function runScaffold() {
1597
1978
  console.log();
1598
1979
  p4.intro(pc4.bgCyan(pc4.black(" create-cloud-app ")));
@@ -1604,6 +1985,9 @@ async function runScaffold() {
1604
1985
  p4.cancel("No templates found. Ensure template packages are installed.");
1605
1986
  process.exit(1);
1606
1987
  }
1988
+ if (process.argv.includes("--next")) {
1989
+ return runNextScaffold(backends, frontends, shared);
1990
+ }
1607
1991
  const answers = await p4.group(
1608
1992
  {
1609
1993
  appName: () => p4.text({
@@ -1621,19 +2005,11 @@ async function runScaffold() {
1621
2005
  }),
1622
2006
  backend: () => p4.select({
1623
2007
  message: "Backend",
1624
- options: backends.map((t) => ({
1625
- value: t.packageName,
1626
- label: t.meta.label ?? t.packageName,
1627
- hint: t.meta.hint
1628
- }))
2008
+ options: templateSelectOptions(backends)
1629
2009
  }),
1630
2010
  frontend: () => p4.select({
1631
2011
  message: "Frontend",
1632
- options: frontends.map((t) => ({
1633
- value: t.packageName,
1634
- label: t.meta.label ?? t.packageName,
1635
- hint: t.meta.hint
1636
- }))
2012
+ options: templateSelectOptions(frontends)
1637
2013
  })
1638
2014
  },
1639
2015
  {
@@ -1659,9 +2035,17 @@ async function runScaffold() {
1659
2035
  shared
1660
2036
  });
1661
2037
  s.stop("Project scaffolded");
2038
+ printNextSteps(answers.appName);
2039
+ p4.outro(pc4.green("Done! Happy building."));
2040
+ return {
2041
+ backend: selectedBackend.meta.label ?? selectedBackend.packageName,
2042
+ frontend: selectedFrontend.meta.label ?? selectedFrontend.packageName
2043
+ };
2044
+ }
2045
+ function printNextSteps(appName) {
1662
2046
  p4.note(
1663
2047
  [
1664
- `${pc4.cyan("cd")} ${answers.appName}`,
2048
+ `${pc4.cyan("cd")} ${appName}`,
1665
2049
  `${pc4.cyan("npm install")}`,
1666
2050
  `${pc4.cyan("npm run register")}`,
1667
2051
  `${pc4.cyan("npm run dev")}`,
@@ -1669,18 +2053,34 @@ async function runScaffold() {
1669
2053
  ].join("\n"),
1670
2054
  "Next steps"
1671
2055
  );
1672
- p4.outro(pc4.green("Done! Happy building."));
1673
2056
  }
1674
2057
  async function main() {
1675
- if (process.argv[2] === "register") {
1676
- await register(process.argv.slice(3));
1677
- return;
1678
- }
1679
- if (process.argv[2] === "listing") {
1680
- await listing(process.argv.slice(3));
1681
- return;
2058
+ const command = process.argv[2] === "register" ? "register" : process.argv[2] === "listing" ? "listing" : "scaffold";
2059
+ const startedAt = Date.now();
2060
+ try {
2061
+ let detail = {};
2062
+ if (command === "register") {
2063
+ await register(process.argv.slice(3));
2064
+ } else if (command === "listing") {
2065
+ await listing(process.argv.slice(3));
2066
+ } else {
2067
+ detail = await runScaffold();
2068
+ }
2069
+ captureCommand(command, {
2070
+ success: true,
2071
+ duration_ms: Date.now() - startedAt,
2072
+ ...detail
2073
+ });
2074
+ } catch (err) {
2075
+ const bail2 = err instanceof BailError;
2076
+ captureCommand(command, {
2077
+ success: false,
2078
+ duration_ms: Date.now() - startedAt,
2079
+ outcome: bail2 ? "bailed" : "error"
2080
+ });
2081
+ if (!bail2) captureError(err, { command });
2082
+ throw err;
1682
2083
  }
1683
- await runScaffold();
1684
2084
  }
1685
2085
  main().then(() => shutdownPostHog()).catch(async (err) => {
1686
2086
  await shutdownPostHog();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jtl-software/create-cloud-app",
3
- "version": "0.3.1",
3
+ "version": "0.5.2",
4
4
  "type": "module",
5
5
  "description": "CLI tool for scaffolding JTL Platform cloud apps",
6
6
  "bin": {
@@ -18,11 +18,11 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@clack/prompts": "^1.7.0",
21
- "@jtl-software/cloud-app-template-backend-dotnet": "0.0.12",
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",
21
+ "@jtl-software/cloud-app-template-backend-dotnet": "0.0.13",
22
+ "@jtl-software/cloud-app-template-backend-node": "0.0.16",
23
+ "@jtl-software/cloud-app-template-backend-php": "0.1.3",
24
+ "@jtl-software/cloud-app-template-frontend-react": "0.1.2",
25
+ "@jtl-software/cloud-app-template-shared": "0.1.2",
26
26
  "picocolors": "^1.1.1",
27
27
  "posthog-node": "^5.48.1"
28
28
  },