@jtl-software/create-cloud-app 0.3.1 → 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 -157
  2. package/package.json +4 -4
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,20 @@ 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
+ }
683
707
  function readExistingCredentials(backend) {
684
708
  if (backend.kind === "node") {
685
709
  const values = readEnvFile(backend.envFile);
@@ -772,7 +796,8 @@ function resolveOptions(args) {
772
796
  ...COMMON_ARG_OPTIONS,
773
797
  "existing-app-id": { type: "string" },
774
798
  new: { type: "boolean" },
775
- "on-collision": { type: "string" }
799
+ "on-collision": { type: "string" },
800
+ "app-service-path": { type: "string" }
776
801
  },
777
802
  strict: true,
778
803
  allowPositionals: false
@@ -788,11 +813,13 @@ function resolveOptions(args) {
788
813
  }
789
814
  const onCollisionRaw = parsed.values["on-collision"];
790
815
  const onCollision = onCollisionRaw ? parseCollisionAction(onCollisionRaw) : void 0;
816
+ const appServicePath = parsed.values["app-service-path"] ?? "app-service";
791
817
  return {
792
818
  ...common,
793
819
  existingAppId,
794
820
  forceNew,
795
- onCollision
821
+ onCollision,
822
+ appServicePath
796
823
  };
797
824
  }
798
825
  async function selectExistingOrNew(matches, opts) {
@@ -847,9 +874,29 @@ async function resolveCollision(targetLabel, credentialPair, opts) {
847
874
  })
848
875
  );
849
876
  }
850
- 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") {
851
898
  const res = await apiCall(
852
- `${apiBaseUrl}/app-service/tenants/apps?onlyAppsOfTenant=true&returnAll=true`,
899
+ `${apiBaseUrl}/${appServicePath}/tenants/apps?onlyAppsOfTenant=true&returnAll=true`,
853
900
  {
854
901
  headers: {
855
902
  Authorization: `Bearer ${jwt}`,
@@ -868,24 +915,28 @@ async function listTenantApps(apiBaseUrl, jwt, tenantId) {
868
915
  technicalName,
869
916
  clientId: entry.clientId,
870
917
  provisioningStatus: entry.activeVersion?.provisioningStatus,
871
- isInstalled: entry.isInstalled
918
+ isInstalled: entry.isInstalled,
919
+ publicClientId: entry.credentials?.publicClient?.clientId
872
920
  };
873
921
  }).filter((a) => a !== null);
874
922
  }
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
- });
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
+ );
885
936
  }
886
- async function createDraftApp(apiBaseUrl, jwt, tenantId, manifest) {
937
+ async function createDraftApp(apiBaseUrl, jwt, tenantId, manifest, appServicePath = "app-service") {
887
938
  const created = await apiCall(
888
- `${apiBaseUrl}/app-service/store/apps`,
939
+ `${apiBaseUrl}/${appServicePath}/store/apps`,
889
940
  {
890
941
  method: "POST",
891
942
  headers: {
@@ -906,9 +957,9 @@ async function createDraftApp(apiBaseUrl, jwt, tenantId, manifest) {
906
957
  }
907
958
  return created.id;
908
959
  }
909
- async function provisionApp(apiBaseUrl, jwt, tenantId, appId) {
960
+ async function provisionApp(apiBaseUrl, jwt, tenantId, appId, appServicePath = "app-service") {
910
961
  const provisioned = await apiCall(
911
- `${apiBaseUrl}/app-service/store/apps/${appId}/provision`,
962
+ `${apiBaseUrl}/${appServicePath}/store/apps/${appId}/provision`,
912
963
  {
913
964
  method: "PUT",
914
965
  headers: {
@@ -926,7 +977,11 @@ async function provisionApp(apiBaseUrl, jwt, tenantId, appId) {
926
977
  `provision response missing clientId or clientSecret: ${JSON.stringify(provisioned)}`
927
978
  );
928
979
  }
929
- return { clientId, clientSecret };
980
+ return {
981
+ clientId,
982
+ clientSecret,
983
+ publicClientId: provisioned.credentials?.publicClient?.clientId
984
+ };
930
985
  }
931
986
  function credentialLabels(backend) {
932
987
  if (backend.kind === "node") {
@@ -985,15 +1040,16 @@ async function register(argv = [], runOpts = {}) {
985
1040
  console.log();
986
1041
  p2.intro(pc2.bgCyan(pc2.black(" register cloud app ")));
987
1042
  const opts = resolveOptions(argv);
988
- const backend = detectBackend(root);
1043
+ const backend = fs2.existsSync(path2.join(root, "packages", "backend")) ? detectBackend(root) : null;
989
1044
  p2.note(
990
1045
  [
991
1046
  `${pc2.dim("API")} ${opts.api}`,
1047
+ opts.appServicePath !== "app-service" ? `${pc2.dim("App svc")} /${opts.appServicePath}` : null,
992
1048
  `${pc2.dim("Auth")} ${opts.auth}`,
993
1049
  `${pc2.dim("Hub")} ${opts.hub}`,
994
1050
  `${pc2.dim("Client ID")} ${opts.clientId}`,
995
1051
  `${pc2.dim("Scope")} ${opts.scope}`
996
- ].join("\n"),
1052
+ ].filter(Boolean).join("\n"),
997
1053
  "Target"
998
1054
  );
999
1055
  const spinner4 = p2.spinner();
@@ -1010,114 +1066,196 @@ async function register(argv = [], runOpts = {}) {
1010
1066
  } catch (err) {
1011
1067
  bail(err.message);
1012
1068
  }
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
1069
  const manifest = readManifest(path2.join(root, "app.json"));
1028
1070
  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");
1071
+ for (; ; ) {
1072
+ const identityJwt = jwt;
1073
+ const apiJwt = apiTokenFor(jwt);
1074
+ spinner4.start("Fetching tenants");
1075
+ let tenants;
1054
1076
  try {
1055
- await updateAppManifest(opts.api, jwt, tenant.id, existing.id, manifest);
1056
- spinner4.stop("Manifest updated");
1077
+ tenants = await fetchTenants(opts.api, identityJwt);
1078
+ spinner4.stop(
1079
+ `Found ${tenants.length} tenant${tenants.length === 1 ? "" : "s"}`
1080
+ );
1057
1081
  } catch (err) {
1058
- spinner4.stop("Failed to update manifest");
1082
+ spinner4.stop("Failed to fetch tenants");
1059
1083
  bail(err.message);
1060
1084
  }
1061
- printHubLink(
1062
- opts.hub,
1063
- tenant.slug,
1064
- existing.id,
1065
- 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
1066
1126
  );
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) {
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
+ }
1073
1191
  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.`
1192
+ pc2.green(
1193
+ `Manifest updated. Existing ${labels.pair} in ${pc2.cyan(path2.relative(root, envFile))} kept as-is.`
1076
1194
  )
1077
1195
  );
1078
- return;
1196
+ } else {
1197
+ p2.outro(pc2.green("Manifest updated."));
1079
1198
  }
1080
- p2.outro(
1081
- pc2.green(
1082
- `Manifest updated. Existing ${labels.pair} in ${pc2.cyan(path2.relative(root, envFile))} kept as-is.`
1083
- )
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
1084
1210
  );
1085
- } else {
1086
- 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
+ }
1087
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."));
1088
1257
  return;
1089
1258
  }
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
1259
  }
1122
1260
 
1123
1261
  // src/listing.ts
@@ -1390,7 +1528,25 @@ async function listing(argv = [], runOpts = {}) {
1390
1528
  // src/scaffold.ts
1391
1529
  import crypto2 from "crypto";
1392
1530
  import fs4 from "fs";
1531
+ import net from "net";
1393
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
+ }
1394
1550
  function toPascalCase(kebab) {
1395
1551
  return kebab.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
1396
1552
  }
@@ -1412,7 +1568,8 @@ function buildPlaceholders(opts) {
1412
1568
  "{{APP_NAME_PASCAL}}": toPascalCase(opts.appName),
1413
1569
  "{{APP_TECHNICAL_NAME}}": buildTechnicalName(opts.appName),
1414
1570
  "{{APP_DESCRIPTION}}": opts.description,
1415
- "{{APP_DESCRIPTION_SHORT}}": buildShortDescription(opts.description)
1571
+ "{{APP_DESCRIPTION_SHORT}}": buildShortDescription(opts.description),
1572
+ "{{FRONTEND_PORT}}": String(opts.frontendPort)
1416
1573
  };
1417
1574
  }
1418
1575
  function applyPlaceholders(content, replacements) {
@@ -1448,9 +1605,11 @@ function transformPackageJson(content, appName, templateType, replacements) {
1448
1605
  const result = applyPlaceholders(JSON.stringify(pkg, null, 2), replacements);
1449
1606
  return result + "\n";
1450
1607
  }
1608
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".turbo"]);
1451
1609
  function copyDir(src, dest, replacements, templateType, appName) {
1452
1610
  fs4.mkdirSync(dest, { recursive: true });
1453
1611
  for (const entry of fs4.readdirSync(src, { withFileTypes: true })) {
1612
+ if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
1454
1613
  const srcPath = path4.join(src, entry.name);
1455
1614
  let destName = entry.name;
1456
1615
  if (destName.startsWith("_git")) {
@@ -1485,40 +1644,87 @@ function buildReplacements(base, template) {
1485
1644
  }
1486
1645
  return { ...base, ...resolved };
1487
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
+ }
1488
1677
  async function scaffold({
1489
1678
  appName,
1490
1679
  description,
1491
1680
  backend,
1492
1681
  frontend,
1493
1682
  shared,
1683
+ frontendPort = DEFAULT_FRONTEND_PORT,
1684
+ authentication,
1494
1685
  targetDir: customTargetDir
1495
1686
  }) {
1496
1687
  const targetDir = customTargetDir ?? path4.resolve(process.cwd(), appName);
1497
- const baseReplacements = buildPlaceholders({ appName, description });
1688
+ const baseReplacements = buildPlaceholders({
1689
+ appName,
1690
+ description,
1691
+ frontendPort
1692
+ });
1498
1693
  if (fs4.existsSync(targetDir)) {
1499
1694
  throw new Error(`Directory "${appName}" already exists`);
1500
1695
  }
1501
1696
  fs4.mkdirSync(targetDir, { recursive: true });
1502
1697
  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 ?? {};
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 ?? {};
1522
1728
  if (Object.keys(runtimeVersions).length > 0) {
1523
1729
  const toolVersions = Object.entries(runtimeVersions).map(([tool, version]) => `${tool} ${version}`).join("\n");
1524
1730
  fs4.writeFileSync(
@@ -1585,14 +1791,163 @@ function getTemplatesByType(templates, type) {
1585
1791
  }
1586
1792
 
1587
1793
  // src/posthog.ts
1794
+ import { randomUUID } from "crypto";
1795
+ import fs6 from "fs";
1796
+ import os2 from "os";
1797
+ import path6 from "path";
1588
1798
  import { PostHog } from "posthog-node";
1589
1799
  var TOKEN = "phc_SkFAxTIa4RUQxotci1Ftdw7xefZK2Ax0LEIGcl8zuD2";
1800
+ var CLI_VERSION = "0.5.1";
1590
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
+ }
1591
1863
  async function shutdownPostHog() {
1592
1864
  await client?.shutdown();
1593
1865
  }
1594
1866
 
1595
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
+ }
1596
1951
  async function runScaffold() {
1597
1952
  console.log();
1598
1953
  p4.intro(pc4.bgCyan(pc4.black(" create-cloud-app ")));
@@ -1604,6 +1959,9 @@ async function runScaffold() {
1604
1959
  p4.cancel("No templates found. Ensure template packages are installed.");
1605
1960
  process.exit(1);
1606
1961
  }
1962
+ if (process.argv.includes("--next")) {
1963
+ return runNextScaffold(backends, frontends, shared);
1964
+ }
1607
1965
  const answers = await p4.group(
1608
1966
  {
1609
1967
  appName: () => p4.text({
@@ -1621,19 +1979,11 @@ async function runScaffold() {
1621
1979
  }),
1622
1980
  backend: () => p4.select({
1623
1981
  message: "Backend",
1624
- options: backends.map((t) => ({
1625
- value: t.packageName,
1626
- label: t.meta.label ?? t.packageName,
1627
- hint: t.meta.hint
1628
- }))
1982
+ options: templateSelectOptions(backends)
1629
1983
  }),
1630
1984
  frontend: () => p4.select({
1631
1985
  message: "Frontend",
1632
- options: frontends.map((t) => ({
1633
- value: t.packageName,
1634
- label: t.meta.label ?? t.packageName,
1635
- hint: t.meta.hint
1636
- }))
1986
+ options: templateSelectOptions(frontends)
1637
1987
  })
1638
1988
  },
1639
1989
  {
@@ -1659,9 +2009,17 @@ async function runScaffold() {
1659
2009
  shared
1660
2010
  });
1661
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) {
1662
2020
  p4.note(
1663
2021
  [
1664
- `${pc4.cyan("cd")} ${answers.appName}`,
2022
+ `${pc4.cyan("cd")} ${appName}`,
1665
2023
  `${pc4.cyan("npm install")}`,
1666
2024
  `${pc4.cyan("npm run register")}`,
1667
2025
  `${pc4.cyan("npm run dev")}`,
@@ -1669,18 +2027,34 @@ async function runScaffold() {
1669
2027
  ].join("\n"),
1670
2028
  "Next steps"
1671
2029
  );
1672
- p4.outro(pc4.green("Done! Happy building."));
1673
2030
  }
1674
2031
  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;
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;
1682
2057
  }
1683
- await runScaffold();
1684
2058
  }
1685
2059
  main().then(() => shutdownPostHog()).catch(async (err) => {
1686
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.1",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "description": "CLI tool for scaffolding JTL Platform cloud apps",
6
6
  "bin": {
@@ -20,9 +20,9 @@
20
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
27
  "posthog-node": "^5.48.1"
28
28
  },