@jtl-software/create-cloud-app 0.0.15 → 0.0.17

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 (3) hide show
  1. package/README.md +4 -0
  2. package/dist/index.js +985 -47
  3. package/package.json +9 -5
package/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  Scaffold a [JTL Platform](https://www.jtl-software.com/) cloud app in seconds.
4
4
 
5
+ ## Requirements
6
+
7
+ Node.js **24** (current LTS) or newer. The scaffolded project sets the same floor via `engines.node`.
8
+
5
9
  ## Quick start
6
10
 
7
11
  ```bash
package/dist/index.js CHANGED
@@ -1,13 +1,935 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import * as p from "@clack/prompts";
5
- import pc from "picocolors";
4
+ import * as p2 from "@clack/prompts";
5
+ import pc2 from "picocolors";
6
6
 
7
- // src/scaffold.ts
7
+ // src/register.ts
8
+ import * as p from "@clack/prompts";
9
+ import { spawn } from "child_process";
8
10
  import crypto from "crypto";
9
11
  import fs from "fs";
12
+ import http from "http";
13
+ import os from "os";
10
14
  import path from "path";
15
+ import { parseArgs } from "util";
16
+ import pc from "picocolors";
17
+ var LOGIN_TIMEOUT_MS = 5 * 60 * 1e3;
18
+ var CALLBACK_PORT = 9876;
19
+ var REDIRECT_URI = `http://localhost:${CALLBACK_PORT}/callback`;
20
+ var DEFAULT_SCOPE = "openid offline_access";
21
+ var DEFAULT_API_HOST = "https://api.jtl-cloud.com";
22
+ var DEFAULT_AUTH_HOST = "https://auth.jtl-cloud.com";
23
+ var CLIENT_IDS_BY_AUTH_HOST = {
24
+ "auth.jtl-cloud.com": "b6aca875-c9a3-4010-9fd7-7f8d2e0b0333",
25
+ "auth.dev.jtl-cloud.com": "9c230937-6a52-48d6-9a39-e97edd8ef511",
26
+ "auth.qa.jtl-cloud.com": "fdf8ec1d-b0d3-4f16-9f29-a2ebbbf62951"
27
+ };
28
+ var TOKEN_EXPIRY_MARGIN_S = 60;
29
+ function tokenCacheFile() {
30
+ return process.env.JTL_CLI_TOKEN_CACHE_FILE ?? path.join(os.homedir(), ".config", "jtl-cli", "tokens.json");
31
+ }
32
+ var BailError = class extends Error {
33
+ constructor(message) {
34
+ super(message);
35
+ this.name = "BailError";
36
+ }
37
+ };
38
+ function bail(message) {
39
+ p.cancel(message);
40
+ throw new BailError(message);
41
+ }
42
+ function base64url(buf) {
43
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
44
+ }
45
+ function generatePkce() {
46
+ const verifier = base64url(crypto.randomBytes(32));
47
+ const challenge = base64url(
48
+ crypto.createHash("sha256").update(verifier).digest()
49
+ );
50
+ return { verifier, challenge };
51
+ }
52
+ function parseEnv(content) {
53
+ const result = {};
54
+ for (const line of content.split(/\r?\n/)) {
55
+ const trimmed = line.trim();
56
+ if (!trimmed || trimmed.startsWith("#")) continue;
57
+ const eq = trimmed.indexOf("=");
58
+ if (eq === -1) continue;
59
+ const key = trimmed.slice(0, eq).trim();
60
+ let value = trimmed.slice(eq + 1).trim();
61
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
62
+ value = value.slice(1, -1);
63
+ }
64
+ result[key] = value;
65
+ }
66
+ return result;
67
+ }
68
+ function pickFirstLocaleEntry(entries) {
69
+ if (!entries || typeof entries !== "object") return void 0;
70
+ const keys = Object.keys(entries);
71
+ if (!keys.length) return void 0;
72
+ return entries[keys[0]];
73
+ }
74
+ function summarizeManifest(manifest) {
75
+ const m = manifest.manifest ?? {};
76
+ const listing = manifest.listing ?? {};
77
+ const nameEntry = pickFirstLocaleEntry(listing.name);
78
+ const descEntry = pickFirstLocaleEntry(listing.description);
79
+ return {
80
+ name: nameEntry?.short ?? nameEntry?.full ?? m.technicalName ?? "(unnamed)",
81
+ technicalName: m.technicalName ?? "(unset)",
82
+ version: m.version ?? "(unset)",
83
+ description: descEntry?.short ?? descEntry?.full
84
+ };
85
+ }
86
+ function readEnvFile(filePath) {
87
+ if (!fs.existsSync(filePath)) return {};
88
+ return parseEnv(fs.readFileSync(filePath, "utf-8"));
89
+ }
90
+ function upsertEnv(filePath, updates) {
91
+ const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : "";
92
+ const lines = existing.split(/\r?\n/);
93
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
94
+ const remaining = new Map(Object.entries(updates));
95
+ const next = lines.map((line) => {
96
+ const eq = line.indexOf("=");
97
+ if (eq === -1) return line;
98
+ const key = line.slice(0, eq).trim();
99
+ if (remaining.has(key)) {
100
+ const value = remaining.get(key);
101
+ remaining.delete(key);
102
+ return `${key}=${value}`;
103
+ }
104
+ return line;
105
+ });
106
+ for (const [key, value] of remaining) {
107
+ next.push(`${key}=${value}`);
108
+ }
109
+ fs.writeFileSync(filePath, next.join("\n") + "\n");
110
+ }
111
+ function findDotnetProject(backendDir) {
112
+ if (!fs.existsSync(backendDir)) return null;
113
+ for (const entry of fs.readdirSync(backendDir, { withFileTypes: true })) {
114
+ if (!entry.isDirectory()) continue;
115
+ const exampleFile = path.join(
116
+ backendDir,
117
+ entry.name,
118
+ "appsettings.Local.json.example"
119
+ );
120
+ const csproj = path.join(backendDir, entry.name, `${entry.name}.csproj`);
121
+ if (fs.existsSync(exampleFile) || fs.existsSync(csproj)) {
122
+ return path.join(backendDir, entry.name);
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+ function detectBackend(root) {
128
+ const backendDir = path.join(root, "packages", "backend");
129
+ const envFile = path.join(backendDir, ".env");
130
+ const envExample = path.join(backendDir, ".env.example");
131
+ if (fs.existsSync(envFile) || fs.existsSync(envExample)) {
132
+ return { kind: "node", envFile };
133
+ }
134
+ const dotnetDir = findDotnetProject(backendDir);
135
+ if (dotnetDir) {
136
+ return {
137
+ kind: "dotnet",
138
+ settingsFile: path.join(dotnetDir, "appsettings.Local.json")
139
+ };
140
+ }
141
+ return { kind: "node", envFile: path.join(root, ".env") };
142
+ }
143
+ function readExistingCredentials(backend) {
144
+ if (backend.kind === "node") {
145
+ const values = readEnvFile(backend.envFile);
146
+ return { clientId: values.CLIENT_ID, clientSecret: values.CLIENT_SECRET };
147
+ }
148
+ if (!fs.existsSync(backend.settingsFile)) return {};
149
+ try {
150
+ const settings = JSON.parse(fs.readFileSync(backend.settingsFile, "utf-8"));
151
+ return {
152
+ clientId: settings?.JtlPlatform?.ClientId,
153
+ clientSecret: settings?.JtlPlatform?.ClientSecret
154
+ };
155
+ } catch {
156
+ return {};
157
+ }
158
+ }
159
+ function writeBackendCredentials(backend, clientId, clientSecret) {
160
+ if (backend.kind === "node") {
161
+ upsertEnv(backend.envFile, {
162
+ CLIENT_ID: clientId,
163
+ CLIENT_SECRET: clientSecret
164
+ });
165
+ return backend.envFile;
166
+ }
167
+ let settings = {};
168
+ if (fs.existsSync(backend.settingsFile)) {
169
+ try {
170
+ settings = JSON.parse(fs.readFileSync(backend.settingsFile, "utf-8"));
171
+ } catch {
172
+ settings = {};
173
+ }
174
+ }
175
+ settings.JtlPlatform = {
176
+ ...settings.JtlPlatform ?? {},
177
+ ClientId: clientId,
178
+ ClientSecret: clientSecret
179
+ };
180
+ fs.writeFileSync(
181
+ backend.settingsFile,
182
+ JSON.stringify(settings, null, 2) + "\n"
183
+ );
184
+ return backend.settingsFile;
185
+ }
186
+ function readManifest(manifestPath) {
187
+ if (!fs.existsSync(manifestPath)) {
188
+ bail(
189
+ `manifest.json not found at ${manifestPath}. Run this from the project root.`
190
+ );
191
+ }
192
+ try {
193
+ return JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
194
+ } catch (err) {
195
+ bail(`manifest.json is not valid JSON: ${err.message}`);
196
+ }
197
+ }
198
+ function readTokenCache() {
199
+ const file = tokenCacheFile();
200
+ if (!fs.existsSync(file)) return {};
201
+ try {
202
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
203
+ } catch {
204
+ return {};
205
+ }
206
+ }
207
+ function writeTokenCache(cache) {
208
+ const file = tokenCacheFile();
209
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 448 });
210
+ fs.writeFileSync(file, JSON.stringify(cache, null, 2), { mode: 384 });
211
+ }
212
+ function cacheKey(authBaseUrl) {
213
+ return new URL(authBaseUrl).host;
214
+ }
215
+ function getCachedToken(authBaseUrl, clientId, scope) {
216
+ const entry = readTokenCache()[cacheKey(authBaseUrl)];
217
+ if (!entry || entry.client_id !== clientId) return null;
218
+ if (scope !== void 0 && entry.scope !== void 0 && entry.scope !== scope) {
219
+ return null;
220
+ }
221
+ return entry;
222
+ }
223
+ function saveCachedToken(authBaseUrl, token) {
224
+ const cache = readTokenCache();
225
+ cache[cacheKey(authBaseUrl)] = token;
226
+ writeTokenCache(cache);
227
+ }
228
+ function makeCachedToken(clientId, tokens, fallbackRefreshToken, scope) {
229
+ return {
230
+ client_id: clientId,
231
+ access_token: tokens.access_token,
232
+ expires_at: Math.floor(Date.now() / 1e3) + Math.max(60, tokens.expires_in ?? 3600),
233
+ refresh_token: tokens.refresh_token ?? fallbackRefreshToken,
234
+ scope
235
+ };
236
+ }
237
+ var defaultOpener = (url) => {
238
+ const platform = process.platform;
239
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
240
+ const args = platform === "win32" ? ["", url] : [url];
241
+ try {
242
+ spawn(cmd, args, {
243
+ stdio: "ignore",
244
+ detached: true,
245
+ shell: platform === "win32"
246
+ }).unref();
247
+ } catch {
248
+ }
249
+ };
250
+ function escapeHtml(value) {
251
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
252
+ }
253
+ function callbackPage(title, message, isError = false) {
254
+ return `<!doctype html>
255
+ <html lang="en">
256
+ <head>
257
+ <meta charset="utf-8" />
258
+ <title>JTL Cloud Apps CLI</title>
259
+ <style>
260
+ body { font-family: -apple-system, system-ui, sans-serif; max-width: 480px; margin: 80px auto; padding: 0 24px; color: #1f2937; }
261
+ h1 { font-size: 20px; margin-bottom: 8px; }
262
+ p { color: ${isError ? "#b91c1c" : "#6b7280"}; line-height: 1.5; }
263
+ </style>
264
+ </head>
265
+ <body>
266
+ <h1>${escapeHtml(title)}</h1>
267
+ <p>${escapeHtml(message)}</p>
268
+ </body>
269
+ </html>`;
270
+ }
271
+ function captureAuthCode(authorizeUrl, expectedState, verifier, options = {}) {
272
+ const opener = options.opener ?? defaultOpener;
273
+ const port = options.port ?? CALLBACK_PORT;
274
+ return new Promise((resolve, reject) => {
275
+ let settled = false;
276
+ const closeAndSettle = (res, next) => {
277
+ if (settled) return;
278
+ settled = true;
279
+ const finish = () => server.close(() => next());
280
+ if (res.writableEnded) {
281
+ finish();
282
+ } else {
283
+ res.once("finish", finish);
284
+ }
285
+ };
286
+ const server = http.createServer(
287
+ (req, res) => {
288
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
289
+ if (url.pathname !== "/callback" || req.method !== "GET") {
290
+ res.writeHead(404);
291
+ res.end();
292
+ return;
293
+ }
294
+ const error = url.searchParams.get("error");
295
+ const description = url.searchParams.get("error_description") ?? "";
296
+ const code = url.searchParams.get("code");
297
+ const state = url.searchParams.get("state");
298
+ if (error) {
299
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
300
+ res.end(
301
+ callbackPage("Sign-in failed", `${error}: ${description}`, true)
302
+ );
303
+ closeAndSettle(
304
+ res,
305
+ () => reject(new Error(`${error}: ${description || "no description"}`))
306
+ );
307
+ return;
308
+ }
309
+ if (state !== expectedState) {
310
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
311
+ res.end(callbackPage("Sign-in failed", "state mismatch", true));
312
+ closeAndSettle(res, () => reject(new Error("OAuth state mismatch")));
313
+ return;
314
+ }
315
+ if (!code) {
316
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
317
+ res.end(
318
+ callbackPage(
319
+ "Sign-in failed",
320
+ "no authorization code in response",
321
+ true
322
+ )
323
+ );
324
+ closeAndSettle(
325
+ res,
326
+ () => reject(new Error("OAuth callback missing code"))
327
+ );
328
+ return;
329
+ }
330
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
331
+ res.end(
332
+ callbackPage(
333
+ "Signed in",
334
+ "You can close this window and return to your terminal."
335
+ )
336
+ );
337
+ closeAndSettle(res, () => resolve({ code, verifier }));
338
+ }
339
+ );
340
+ const timer = setTimeout(() => {
341
+ if (settled) return;
342
+ settled = true;
343
+ server.close(
344
+ () => reject(new Error(`login timed out after ${LOGIN_TIMEOUT_MS / 1e3}s`))
345
+ );
346
+ }, LOGIN_TIMEOUT_MS);
347
+ timer.unref();
348
+ server.on("error", (err) => {
349
+ if (settled) return;
350
+ settled = true;
351
+ if (err.code === "EADDRINUSE") {
352
+ reject(
353
+ new Error(
354
+ `port ${port} is already in use \u2014 stop whatever is bound to it and try again`
355
+ )
356
+ );
357
+ return;
358
+ }
359
+ reject(err);
360
+ });
361
+ server.listen(port, () => {
362
+ if (!options.silent) {
363
+ console.log(`
364
+ Opening browser to sign in.`);
365
+ console.log(
366
+ `If it doesn't open, visit:
367
+ ${pc.cyan(authorizeUrl.toString())}
368
+ `
369
+ );
370
+ }
371
+ opener(authorizeUrl.toString());
372
+ });
373
+ });
374
+ }
375
+ async function postTokenRequest(authBaseUrl, body) {
376
+ const res = await fetch(`${authBaseUrl}/oauth2/token`, {
377
+ method: "POST",
378
+ headers: {
379
+ "Content-Type": "application/x-www-form-urlencoded",
380
+ Accept: "application/json"
381
+ },
382
+ body: body.toString()
383
+ });
384
+ const text2 = await res.text();
385
+ if (!res.ok) {
386
+ throw new Error(`token endpoint returned ${res.status}: ${text2}`);
387
+ }
388
+ let parsed;
389
+ try {
390
+ parsed = JSON.parse(text2);
391
+ } catch {
392
+ throw new Error(`token endpoint returned non-JSON: ${text2}`);
393
+ }
394
+ if (!parsed.access_token) {
395
+ throw new Error(`token endpoint response missing access_token: ${text2}`);
396
+ }
397
+ return parsed;
398
+ }
399
+ async function exchangeCodeForToken(authBaseUrl, clientId, code, verifier) {
400
+ return postTokenRequest(
401
+ authBaseUrl,
402
+ new URLSearchParams({
403
+ grant_type: "authorization_code",
404
+ code,
405
+ redirect_uri: REDIRECT_URI,
406
+ client_id: clientId,
407
+ code_verifier: verifier
408
+ })
409
+ );
410
+ }
411
+ async function refreshAccessToken(authBaseUrl, clientId, refreshToken) {
412
+ return postTokenRequest(
413
+ authBaseUrl,
414
+ new URLSearchParams({
415
+ grant_type: "refresh_token",
416
+ refresh_token: refreshToken,
417
+ client_id: clientId
418
+ })
419
+ );
420
+ }
421
+ async function acquireAccessToken(opts) {
422
+ const { auth, clientId, scope, reauth, spinner: spinner3, captureOptions } = opts;
423
+ const now = Math.floor(Date.now() / 1e3);
424
+ const cached = reauth ? null : getCachedToken(auth, clientId, scope);
425
+ if (cached && cached.expires_at > now + TOKEN_EXPIRY_MARGIN_S) {
426
+ spinner3?.start("Using cached session");
427
+ spinner3?.stop("Signed in (cached)");
428
+ return cached.access_token;
429
+ }
430
+ if (cached?.refresh_token) {
431
+ spinner3?.start("Refreshing session");
432
+ try {
433
+ const refreshed = await refreshAccessToken(
434
+ auth,
435
+ clientId,
436
+ cached.refresh_token
437
+ );
438
+ saveCachedToken(
439
+ auth,
440
+ makeCachedToken(clientId, refreshed, cached.refresh_token, scope)
441
+ );
442
+ spinner3?.stop("Signed in (refreshed)");
443
+ return refreshed.access_token;
444
+ } catch {
445
+ spinner3?.stop("Refresh failed, opening browser");
446
+ }
447
+ }
448
+ const pkce = generatePkce();
449
+ const state = base64url(crypto.randomBytes(16));
450
+ const authorizeUrl = new URL("/oauth2/auth", auth);
451
+ authorizeUrl.searchParams.set("response_type", "code");
452
+ authorizeUrl.searchParams.set("client_id", clientId);
453
+ authorizeUrl.searchParams.set("redirect_uri", REDIRECT_URI);
454
+ authorizeUrl.searchParams.set("scope", scope);
455
+ authorizeUrl.searchParams.set("state", state);
456
+ authorizeUrl.searchParams.set("code_challenge", pkce.challenge);
457
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
458
+ spinner3?.start("Waiting for browser login");
459
+ try {
460
+ const { code, verifier } = await captureAuthCode(
461
+ authorizeUrl,
462
+ state,
463
+ pkce.verifier,
464
+ captureOptions
465
+ );
466
+ spinner3?.message("Exchanging authorization code");
467
+ const tokens = await exchangeCodeForToken(auth, clientId, code, verifier);
468
+ saveCachedToken(auth, makeCachedToken(clientId, tokens, void 0, scope));
469
+ spinner3?.stop("Signed in");
470
+ return tokens.access_token;
471
+ } catch (err) {
472
+ spinner3?.stop("Login failed");
473
+ throw err;
474
+ }
475
+ }
476
+ async function apiCall(url, init) {
477
+ const res = await fetch(url, init);
478
+ const text2 = await res.text();
479
+ let body;
480
+ try {
481
+ body = text2 ? JSON.parse(text2) : {};
482
+ } catch {
483
+ body = text2;
484
+ }
485
+ if (!res.ok) {
486
+ const detail = typeof body === "string" ? body : JSON.stringify(body);
487
+ throw new Error(
488
+ `${init.method ?? "GET"} ${url} returned ${res.status}: ${detail}`
489
+ );
490
+ }
491
+ return body;
492
+ }
493
+ async function fetchTenants(apiBaseUrl, jwt) {
494
+ const tenants = await apiCall(`${apiBaseUrl}/account/tenants`, {
495
+ headers: { Authorization: `Bearer ${jwt}`, Accept: "application/json" }
496
+ });
497
+ if (!Array.isArray(tenants)) {
498
+ throw new Error(
499
+ `unexpected /account/tenants response: ${JSON.stringify(tenants)}`
500
+ );
501
+ }
502
+ return tenants;
503
+ }
504
+ async function listTenantApps(apiBaseUrl, jwt, tenantId) {
505
+ const res = await apiCall(
506
+ `${apiBaseUrl}/app-service/tenants/apps?onlyAppsOfTenant=true&returnAll=true`,
507
+ {
508
+ headers: {
509
+ Authorization: `Bearer ${jwt}`,
510
+ "X-Tenant-Id": tenantId,
511
+ Accept: "application/json"
512
+ }
513
+ }
514
+ );
515
+ const items = Array.isArray(res) ? res : res.items ?? [];
516
+ return items.map((entry) => {
517
+ const id = entry.appId ?? entry.app?.id;
518
+ const technicalName = entry.app?.technicalName;
519
+ if (!id || !technicalName) return null;
520
+ return {
521
+ id,
522
+ technicalName,
523
+ clientId: entry.app?.clientId,
524
+ provisioningStatus: entry.app?.activeVersion?.provisioningStatus
525
+ };
526
+ }).filter((a) => a !== null);
527
+ }
528
+ function validateHost(input, flag) {
529
+ let url;
530
+ try {
531
+ url = new URL(input);
532
+ } catch {
533
+ bail(`invalid --${flag} value: ${input}`);
534
+ }
535
+ if (!/^https?:$/.test(url.protocol)) {
536
+ bail(`--${flag} must use http(s): ${input}`);
537
+ }
538
+ return `${url.protocol}//${url.host}`;
539
+ }
540
+ function parseCollisionAction(input) {
541
+ if (input === "overwrite" || input === "print" || input === "cancel") {
542
+ return input;
543
+ }
544
+ bail(
545
+ `--on-collision must be one of: overwrite, print, cancel (got "${input}")`
546
+ );
547
+ }
548
+ function resolveOptions(args) {
549
+ let parsed;
550
+ try {
551
+ parsed = parseArgs({
552
+ args,
553
+ options: {
554
+ "api-host": { type: "string" },
555
+ "auth-host": { type: "string" },
556
+ "client-id": { type: "string" },
557
+ scope: { type: "string" },
558
+ reauth: { type: "boolean" },
559
+ yes: { type: "boolean", short: "y" },
560
+ tenant: { type: "string" },
561
+ "existing-app-id": { type: "string" },
562
+ new: { type: "boolean" },
563
+ "on-collision": { type: "string" }
564
+ },
565
+ strict: true,
566
+ allowPositionals: false
567
+ });
568
+ } catch (err) {
569
+ bail(err.message);
570
+ }
571
+ const api = parsed.values["api-host"] ? validateHost(parsed.values["api-host"], "api-host") : DEFAULT_API_HOST;
572
+ const auth = parsed.values["auth-host"] ? validateHost(parsed.values["auth-host"], "auth-host") : DEFAULT_AUTH_HOST;
573
+ const clientIdArg = parsed.values["client-id"];
574
+ const clientId = clientIdArg ?? CLIENT_IDS_BY_AUTH_HOST[new URL(auth).hostname] ?? "";
575
+ if (!clientId) {
576
+ bail(
577
+ `no OAuth client_id registered for ${auth}. Pass --client-id <id> or add an entry to CLIENT_IDS_BY_AUTH_HOST.`
578
+ );
579
+ }
580
+ const existingAppId = parsed.values["existing-app-id"];
581
+ const forceNew = parsed.values.new ?? false;
582
+ if (existingAppId && forceNew) {
583
+ bail("--existing-app-id and --new are mutually exclusive");
584
+ }
585
+ const onCollisionRaw = parsed.values["on-collision"];
586
+ const onCollision = onCollisionRaw ? parseCollisionAction(onCollisionRaw) : void 0;
587
+ return {
588
+ api,
589
+ auth,
590
+ clientId,
591
+ scope: parsed.values.scope ?? DEFAULT_SCOPE,
592
+ reauth: parsed.values.reauth ?? false,
593
+ yes: parsed.values.yes ?? false,
594
+ tenant: parsed.values.tenant,
595
+ existingAppId,
596
+ forceNew,
597
+ onCollision
598
+ };
599
+ }
600
+ function ensureContinue(value) {
601
+ if (p.isCancel(value)) {
602
+ bail("Cancelled.");
603
+ }
604
+ return value;
605
+ }
606
+ async function selectTenant(tenants, opts) {
607
+ if (tenants.length === 0) {
608
+ bail(
609
+ "your account has no tenants. Create one on the Partner Portal first."
610
+ );
611
+ }
612
+ if (opts.tenant) {
613
+ const needle = opts.tenant.toLowerCase();
614
+ const matches = tenants.filter(
615
+ (t) => t.id === opts.tenant || t.slug.toLowerCase() === needle
616
+ );
617
+ if (matches.length === 0) {
618
+ bail(`no tenant matched --tenant "${opts.tenant}"`);
619
+ }
620
+ if (matches.length > 1) {
621
+ bail(
622
+ `--tenant "${opts.tenant}" matched ${matches.length} tenants \u2014 be more specific`
623
+ );
624
+ }
625
+ return matches[0];
626
+ }
627
+ if (tenants.length === 1) {
628
+ return tenants[0];
629
+ }
630
+ if (opts.yes) {
631
+ bail(
632
+ `multiple tenants available; pass --tenant <id|slug> in non-interactive mode`
633
+ );
634
+ }
635
+ const tenantId = ensureContinue(
636
+ await p.select({
637
+ message: "Register the app in which tenant?",
638
+ options: tenants.map((t) => ({
639
+ value: t.id,
640
+ label: t.name,
641
+ hint: t.slug
642
+ }))
643
+ })
644
+ );
645
+ return tenants.find((t) => t.id === tenantId);
646
+ }
647
+ async function selectExistingOrNew(matches, opts) {
648
+ if (opts.existingAppId) {
649
+ const match = matches.find((a) => a.id === opts.existingAppId);
650
+ if (!match) {
651
+ bail(
652
+ `--existing-app-id "${opts.existingAppId}" not found among apps matching this manifest`
653
+ );
654
+ }
655
+ return match;
656
+ }
657
+ if (opts.forceNew) return null;
658
+ if (matches.length === 0) return null;
659
+ if (opts.yes) {
660
+ bail(
661
+ `${matches.length} existing app(s) match this manifest; pass --existing-app-id <id> or --new in non-interactive mode`
662
+ );
663
+ }
664
+ const CREATE_NEW = "__create_new__";
665
+ const choice = ensureContinue(
666
+ await p.select({
667
+ message: "Update an existing app or create a new one?",
668
+ options: [
669
+ ...matches.map((a) => ({
670
+ value: a.id,
671
+ label: `${a.technicalName} (${a.id})`,
672
+ hint: a.provisioningStatus
673
+ })),
674
+ { value: CREATE_NEW, label: "Create a new app" }
675
+ ]
676
+ })
677
+ );
678
+ if (choice === CREATE_NEW) return null;
679
+ return matches.find((a) => a.id === choice);
680
+ }
681
+ async function confirmAction(opts, message, defaultValue) {
682
+ if (opts.yes) return true;
683
+ return ensureContinue(
684
+ await p.confirm({ message, initialValue: defaultValue })
685
+ );
686
+ }
687
+ async function resolveCollision(targetLabel, credentialPair, opts) {
688
+ if (opts.onCollision) return opts.onCollision;
689
+ if (opts.yes) {
690
+ bail(
691
+ `${targetLabel} already has ${credentialPair}; pass --on-collision <overwrite|print|cancel> in non-interactive mode`
692
+ );
693
+ }
694
+ return ensureContinue(
695
+ await p.select({
696
+ message: `${targetLabel} already has ${credentialPair}. What now?`,
697
+ options: [
698
+ { value: "overwrite", label: "Overwrite the file" },
699
+ { value: "print", label: "Print to console, don't touch the file" },
700
+ { value: "cancel", label: "Cancel" }
701
+ ]
702
+ })
703
+ );
704
+ }
705
+ async function updateAppManifest(apiBaseUrl, jwt, tenantId, appId, manifest) {
706
+ await apiCall(`${apiBaseUrl}/app-service/store/apps/${appId}/manifest`, {
707
+ method: "PUT",
708
+ headers: {
709
+ Authorization: `Bearer ${jwt}`,
710
+ "Content-Type": "application/json",
711
+ "X-Tenant-Id": tenantId
712
+ },
713
+ body: JSON.stringify({ manifest: manifest.manifest, tcAccepted: true })
714
+ });
715
+ }
716
+ async function createDraftApp(apiBaseUrl, jwt, tenantId, manifest) {
717
+ const created = await apiCall(
718
+ `${apiBaseUrl}/app-service/store/apps`,
719
+ {
720
+ method: "POST",
721
+ headers: {
722
+ Authorization: `Bearer ${jwt}`,
723
+ "Content-Type": "application/json",
724
+ "X-Tenant-Id": tenantId
725
+ },
726
+ body: JSON.stringify({
727
+ manifest: manifest.manifest,
728
+ tcAccepted: true
729
+ })
730
+ }
731
+ );
732
+ if (!created.appId) {
733
+ throw new Error(
734
+ `/store/apps did not return appId: ${JSON.stringify(created)}`
735
+ );
736
+ }
737
+ return created.appId;
738
+ }
739
+ async function provisionApp(apiBaseUrl, jwt, tenantId, appId) {
740
+ const provisioned = await apiCall(
741
+ `${apiBaseUrl}/app-service/store/apps/${appId}/provision`,
742
+ {
743
+ method: "PUT",
744
+ headers: {
745
+ Authorization: `Bearer ${jwt}`,
746
+ "Content-Type": "application/json",
747
+ "X-Tenant-Id": tenantId
748
+ },
749
+ body: JSON.stringify({ appId })
750
+ }
751
+ );
752
+ const clientId = provisioned.app?.clientId;
753
+ const clientSecret = provisioned.clientSecret;
754
+ if (!clientId || !clientSecret) {
755
+ throw new Error(
756
+ `provision response missing app.clientId or clientSecret: ${JSON.stringify(provisioned)}`
757
+ );
758
+ }
759
+ return { clientId, clientSecret };
760
+ }
761
+ function credentialLabels(backend) {
762
+ if (backend.kind === "node") {
763
+ return {
764
+ id: "CLIENT_ID",
765
+ secret: "CLIENT_SECRET",
766
+ pair: "CLIENT_ID/CLIENT_SECRET"
767
+ };
768
+ }
769
+ return {
770
+ id: "JtlPlatform:ClientId",
771
+ secret: "JtlPlatform:ClientSecret",
772
+ pair: "JtlPlatform:ClientId/ClientSecret"
773
+ };
774
+ }
775
+ async function persistCredentials(backend, clientId, clientSecret, root, opts) {
776
+ const targetPath = backend.kind === "node" ? backend.envFile : backend.settingsFile;
777
+ const relPath = path.relative(root, targetPath) || targetPath;
778
+ const labels = credentialLabels(backend);
779
+ const existing = readExistingCredentials(backend);
780
+ const collision = !!(existing.clientId && existing.clientSecret);
781
+ let action = "overwrite";
782
+ if (collision) {
783
+ action = await resolveCollision(relPath, labels.pair, opts);
784
+ }
785
+ if (action === "cancel") bail("Cancelled.");
786
+ if (action === "print") {
787
+ console.log();
788
+ console.log(pc.cyan(labels.id) + " = " + clientId);
789
+ console.log(pc.cyan(labels.secret) + " = " + clientSecret);
790
+ console.log();
791
+ p.outro(pc.green("Done. Credentials printed above; file left untouched."));
792
+ return;
793
+ }
794
+ const written = writeBackendCredentials(backend, clientId, clientSecret);
795
+ p.outro(
796
+ pc.green(
797
+ `Done. Wrote ${labels.pair} to ${pc.cyan(path.relative(root, written) || written)}.`
798
+ )
799
+ );
800
+ }
801
+ async function register(argv = [], runOpts = {}) {
802
+ const root = runOpts.cwd ?? process.cwd();
803
+ const manifestPath = path.join(root, "manifest.json");
804
+ console.log();
805
+ p.intro(pc.bgCyan(pc.black(" register cloud app ")));
806
+ const opts = resolveOptions(argv);
807
+ const manifest = readManifest(manifestPath);
808
+ const summary = summarizeManifest(manifest);
809
+ const backend = detectBackend(root);
810
+ p.note(
811
+ [
812
+ `${pc.dim("API")} ${opts.api}`,
813
+ `${pc.dim("Auth")} ${opts.auth}`,
814
+ `${pc.dim("Client ID")} ${opts.clientId}`,
815
+ `${pc.dim("Scope")} ${opts.scope}`
816
+ ].join("\n"),
817
+ "Target"
818
+ );
819
+ const spinner3 = p.spinner();
820
+ let jwt;
821
+ try {
822
+ jwt = await acquireAccessToken({
823
+ auth: opts.auth,
824
+ clientId: opts.clientId,
825
+ scope: opts.scope,
826
+ reauth: opts.reauth,
827
+ spinner: spinner3,
828
+ captureOptions: runOpts.captureOptions
829
+ });
830
+ } catch (err) {
831
+ bail(err.message);
832
+ }
833
+ spinner3.start("Fetching tenants");
834
+ let tenants;
835
+ try {
836
+ tenants = await fetchTenants(opts.api, jwt);
837
+ spinner3.stop(
838
+ `Found ${tenants.length} tenant${tenants.length === 1 ? "" : "s"}`
839
+ );
840
+ } catch (err) {
841
+ spinner3.stop("Failed to fetch tenants");
842
+ bail(err.message);
843
+ }
844
+ const tenant = await selectTenant(tenants, opts);
845
+ spinner3.start("Looking for apps matching this manifest");
846
+ let matches;
847
+ try {
848
+ const apps = await listTenantApps(opts.api, jwt, tenant.id);
849
+ matches = apps.filter((a) => a.technicalName === summary.technicalName);
850
+ spinner3.stop(
851
+ `Found ${matches.length} app${matches.length === 1 ? "" : "s"} with technical name ${pc.cyan(summary.technicalName)}`
852
+ );
853
+ } catch (err) {
854
+ spinner3.stop("Failed to look up tenant apps");
855
+ bail(err.message);
856
+ }
857
+ const existing = await selectExistingOrNew(matches, opts);
858
+ const reviewLines = [
859
+ `${pc.dim("App name")} ${summary.name}`,
860
+ `${pc.dim("Technical name")} ${summary.technicalName}`,
861
+ `${pc.dim("Version")} ${summary.version}`,
862
+ summary.description ? `${pc.dim("Description")} ${summary.description}` : null,
863
+ `${pc.dim("Tenant")} ${tenant.name} (${tenant.slug})`,
864
+ existing ? `${pc.dim("Updating")} ${existing.technicalName} (${existing.id}) [${existing.provisioningStatus ?? "?"}]` : `${pc.dim("Action")} create new`
865
+ ].filter(Boolean);
866
+ p.note(reviewLines.join("\n"), "Review");
867
+ const confirmMessage = existing ? `Update app "${existing.technicalName}" in tenant "${tenant.name}"?` : `Create app "${summary.name}" in tenant "${tenant.name}"?`;
868
+ const confirm2 = await confirmAction(opts, confirmMessage, existing !== null);
869
+ if (!confirm2) bail("Cancelled.");
870
+ if (existing) {
871
+ spinner3.start("Updating manifest");
872
+ try {
873
+ await updateAppManifest(opts.api, jwt, tenant.id, existing.id, manifest);
874
+ spinner3.stop("Manifest updated");
875
+ } catch (err) {
876
+ spinner3.stop("Failed to update manifest");
877
+ bail(err.message);
878
+ }
879
+ if (existing.clientId) {
880
+ const labels = credentialLabels(backend);
881
+ const envFile = backend.kind === "node" ? backend.envFile : backend.settingsFile;
882
+ const localCreds = readExistingCredentials(backend);
883
+ const hasLocalCreds = !!(localCreds.clientId && localCreds.clientSecret);
884
+ if (!hasLocalCreds) {
885
+ p.outro(
886
+ pc.yellow(
887
+ `Manifest updated. Your backend config is missing ${labels.pair} \u2014 they can't be recovered. Re-create the app to get a fresh secret.`
888
+ )
889
+ );
890
+ return;
891
+ }
892
+ p.outro(
893
+ pc.green(
894
+ `Manifest updated. Existing ${labels.pair} in ${pc.cyan(path.relative(root, envFile))} kept as-is.`
895
+ )
896
+ );
897
+ } else {
898
+ p.outro(pc.green("Manifest updated."));
899
+ }
900
+ return;
901
+ }
902
+ spinner3.start("Creating draft app");
903
+ let appId;
904
+ try {
905
+ appId = await createDraftApp(opts.api, jwt, tenant.id, manifest);
906
+ spinner3.stop(`Created app ${pc.cyan(appId)}`);
907
+ } catch (err) {
908
+ spinner3.stop("Failed to create app");
909
+ bail(err.message);
910
+ }
911
+ spinner3.start("Provisioning app");
912
+ let clientId;
913
+ let clientSecret;
914
+ try {
915
+ ({ clientId, clientSecret } = await provisionApp(
916
+ opts.api,
917
+ jwt,
918
+ tenant.id,
919
+ appId
920
+ ));
921
+ spinner3.stop("Provisioned");
922
+ } catch (err) {
923
+ spinner3.stop("Failed to provision app");
924
+ bail(err.message);
925
+ }
926
+ await persistCredentials(backend, clientId, clientSecret, root, opts);
927
+ }
928
+
929
+ // src/scaffold.ts
930
+ import crypto2 from "crypto";
931
+ import fs2 from "fs";
932
+ import path2 from "path";
11
933
  function toPascalCase(kebab) {
12
934
  return kebab.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
13
935
  }
@@ -15,7 +937,7 @@ function slugify(input) {
15
937
  return input.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
16
938
  }
17
939
  function buildTechnicalName(appName) {
18
- const hash = crypto.randomBytes(3).toString("hex");
940
+ const hash = crypto2.randomBytes(3).toString("hex");
19
941
  return `${slugify(appName)}-${hash}`;
20
942
  }
21
943
  var SHORT_DESCRIPTION_MAX_LENGTH = 150;
@@ -53,9 +975,9 @@ function transformPackageJson(content, appName, templateType, replacements) {
53
975
  return result + "\n";
54
976
  }
55
977
  function copyDir(src, dest, replacements, templateType, appName) {
56
- fs.mkdirSync(dest, { recursive: true });
57
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
58
- const srcPath = path.join(src, entry.name);
978
+ fs2.mkdirSync(dest, { recursive: true });
979
+ for (const entry of fs2.readdirSync(src, { withFileTypes: true })) {
980
+ const srcPath = path2.join(src, entry.name);
59
981
  let destName = entry.name;
60
982
  if (destName.startsWith("_git")) {
61
983
  destName = `.${destName.slice(1)}`;
@@ -63,25 +985,25 @@ function copyDir(src, dest, replacements, templateType, appName) {
63
985
  for (const [key, value] of Object.entries(replacements)) {
64
986
  destName = destName.replaceAll(key, value);
65
987
  }
66
- const destPath = path.join(dest, destName);
988
+ const destPath = path2.join(dest, destName);
67
989
  if (entry.isDirectory()) {
68
990
  copyDir(srcPath, destPath, replacements);
69
991
  } else if (entry.name === "package.json" && templateType && appName) {
70
- const content = fs.readFileSync(srcPath, "utf-8");
71
- fs.writeFileSync(
992
+ const content = fs2.readFileSync(srcPath, "utf-8");
993
+ fs2.writeFileSync(
72
994
  destPath,
73
995
  transformPackageJson(content, appName, templateType, replacements)
74
996
  );
75
997
  } else {
76
- const ext = path.extname(entry.name).toLowerCase();
998
+ const ext = path2.extname(entry.name).toLowerCase();
77
999
  if (BINARY_EXTENSIONS.has(ext)) {
78
- fs.copyFileSync(srcPath, destPath);
1000
+ fs2.copyFileSync(srcPath, destPath);
79
1001
  } else {
80
- let content = fs.readFileSync(srcPath, "utf-8");
1002
+ let content = fs2.readFileSync(srcPath, "utf-8");
81
1003
  for (const [key, value] of Object.entries(replacements)) {
82
1004
  content = content.replaceAll(key, value);
83
1005
  }
84
- fs.writeFileSync(destPath, content);
1006
+ fs2.writeFileSync(destPath, content);
85
1007
  }
86
1008
  }
87
1009
  }
@@ -106,7 +1028,7 @@ async function scaffold({
106
1028
  shared,
107
1029
  targetDir: customTargetDir
108
1030
  }) {
109
- const targetDir = customTargetDir ?? path.resolve(process.cwd(), appName);
1031
+ const targetDir = customTargetDir ?? path2.resolve(process.cwd(), appName);
110
1032
  const pascalName = toPascalCase(appName);
111
1033
  const baseReplacements = {
112
1034
  "{{APP_NAME}}": appName,
@@ -115,12 +1037,12 @@ async function scaffold({
115
1037
  "{{APP_DESCRIPTION}}": description,
116
1038
  "{{APP_DESCRIPTION_SHORT}}": buildShortDescription(description)
117
1039
  };
118
- if (fs.existsSync(targetDir)) {
1040
+ if (fs2.existsSync(targetDir)) {
119
1041
  throw new Error(`Directory "${appName}" already exists`);
120
1042
  }
121
- fs.mkdirSync(targetDir, { recursive: true });
1043
+ fs2.mkdirSync(targetDir, { recursive: true });
122
1044
  copyDir(shared.dir, targetDir, baseReplacements);
123
- const frontendDest = path.join(targetDir, "packages", "frontend");
1045
+ const frontendDest = path2.join(targetDir, "packages", "frontend");
124
1046
  const frontendReplacements = buildReplacements(baseReplacements, frontend);
125
1047
  copyDir(
126
1048
  frontend.dir,
@@ -129,7 +1051,7 @@ async function scaffold({
129
1051
  frontend.meta.type,
130
1052
  appName
131
1053
  );
132
- const backendDest = path.join(targetDir, "packages", "backend");
1054
+ const backendDest = path2.join(targetDir, "packages", "backend");
133
1055
  const backendReplacements = buildReplacements(baseReplacements, backend);
134
1056
  copyDir(
135
1057
  backend.dir,
@@ -141,8 +1063,8 @@ async function scaffold({
141
1063
  const runtimeVersions = backend.meta.runtimeVersions ?? {};
142
1064
  if (Object.keys(runtimeVersions).length > 0) {
143
1065
  const toolVersions = Object.entries(runtimeVersions).map(([tool, version]) => `${tool} ${version}`).join("\n");
144
- fs.writeFileSync(
145
- path.join(targetDir, ".tool-versions"),
1066
+ fs2.writeFileSync(
1067
+ path2.join(targetDir, ".tool-versions"),
146
1068
  toolVersions + "\n"
147
1069
  );
148
1070
  }
@@ -150,44 +1072,48 @@ async function scaffold({
150
1072
  name: appName,
151
1073
  version: "1.0.0",
152
1074
  private: true,
1075
+ engines: {
1076
+ node: ">=24"
1077
+ },
153
1078
  workspaces: ["packages/*"],
154
1079
  scripts: {
155
1080
  dev: "turbo dev",
156
1081
  build: "turbo build",
157
1082
  lint: "turbo lint",
158
- test: "turbo test"
1083
+ test: "turbo test",
1084
+ register: "npx -y @jtl-software/create-cloud-app@latest register"
159
1085
  },
160
1086
  packageManager: "npm@10.8.2",
161
1087
  devDependencies: {
162
1088
  turbo: "^2.5.5"
163
1089
  }
164
1090
  };
165
- fs.writeFileSync(
166
- path.join(targetDir, "package.json"),
1091
+ fs2.writeFileSync(
1092
+ path2.join(targetDir, "package.json"),
167
1093
  JSON.stringify(rootPackageJson, null, 2) + "\n"
168
1094
  );
169
1095
  }
170
1096
 
171
1097
  // src/templates.ts
172
- import fs2 from "fs";
173
- import path2 from "path";
1098
+ import fs3 from "fs";
1099
+ import path3 from "path";
174
1100
  import { createRequire } from "module";
175
1101
  var require2 = createRequire(import.meta.url);
176
1102
  function discoverTemplates() {
177
1103
  const cliPkgPath = require2.resolve("@jtl-software/create-cloud-app/package.json");
178
- const cliPkg = JSON.parse(fs2.readFileSync(cliPkgPath, "utf-8"));
1104
+ const cliPkg = JSON.parse(fs3.readFileSync(cliPkgPath, "utf-8"));
179
1105
  const deps = cliPkg.dependencies ?? {};
180
1106
  const templates = [];
181
1107
  for (const packageName of Object.keys(deps)) {
182
1108
  if (!packageName.startsWith("@jtl-software/cloud-app-template-")) continue;
183
1109
  try {
184
1110
  const pkgJsonPath = require2.resolve(`${packageName}/package.json`);
185
- const pkg = JSON.parse(fs2.readFileSync(pkgJsonPath, "utf-8"));
1111
+ const pkg = JSON.parse(fs3.readFileSync(pkgJsonPath, "utf-8"));
186
1112
  const meta = pkg.cloudAppTemplate;
187
1113
  if (!meta?.type) continue;
188
1114
  templates.push({
189
1115
  packageName,
190
- dir: path2.dirname(pkgJsonPath),
1116
+ dir: path3.dirname(pkgJsonPath),
191
1117
  meta
192
1118
  });
193
1119
  } catch {
@@ -200,20 +1126,20 @@ function getTemplatesByType(templates, type) {
200
1126
  }
201
1127
 
202
1128
  // src/index.ts
203
- async function main() {
1129
+ async function runScaffold() {
204
1130
  console.log();
205
- p.intro(pc.bgCyan(pc.black(" create-cloud-app ")));
1131
+ p2.intro(pc2.bgCyan(pc2.black(" create-cloud-app ")));
206
1132
  const templates = discoverTemplates();
207
1133
  const backends = getTemplatesByType(templates, "backend");
208
1134
  const frontends = getTemplatesByType(templates, "frontend");
209
1135
  const shared = templates.find((t) => t.meta.type === "shared");
210
1136
  if (!backends.length || !frontends.length || !shared) {
211
- p.cancel("No templates found. Ensure template packages are installed.");
1137
+ p2.cancel("No templates found. Ensure template packages are installed.");
212
1138
  process.exit(1);
213
1139
  }
214
- const answers = await p.group(
1140
+ const answers = await p2.group(
215
1141
  {
216
- appName: () => p.text({
1142
+ appName: () => p2.text({
217
1143
  message: "App name",
218
1144
  placeholder: "my-jtl-app",
219
1145
  validate: (value) => {
@@ -222,11 +1148,11 @@ async function main() {
222
1148
  return "Use lowercase letters, numbers, and hyphens only";
223
1149
  }
224
1150
  }),
225
- description: () => p.text({
1151
+ description: () => p2.text({
226
1152
  message: "Description",
227
1153
  placeholder: "A JTL Platform app"
228
1154
  }),
229
- backend: () => p.select({
1155
+ backend: () => p2.select({
230
1156
  message: "Backend",
231
1157
  options: backends.map((t) => ({
232
1158
  value: t.packageName,
@@ -234,7 +1160,7 @@ async function main() {
234
1160
  hint: t.meta.hint
235
1161
  }))
236
1162
  }),
237
- frontend: () => p.select({
1163
+ frontend: () => p2.select({
238
1164
  message: "Frontend",
239
1165
  options: frontends.map((t) => ({
240
1166
  value: t.packageName,
@@ -245,7 +1171,7 @@ async function main() {
245
1171
  },
246
1172
  {
247
1173
  onCancel: () => {
248
- p.cancel("Cancelled.");
1174
+ p2.cancel("Cancelled.");
249
1175
  process.exit(0);
250
1176
  }
251
1177
  }
@@ -256,7 +1182,7 @@ async function main() {
256
1182
  const selectedFrontend = templates.find(
257
1183
  (t) => t.packageName === answers.frontend
258
1184
  );
259
- const s = p.spinner();
1185
+ const s = p2.spinner();
260
1186
  s.start("Scaffolding project");
261
1187
  await scaffold({
262
1188
  appName: answers.appName,
@@ -266,16 +1192,28 @@ async function main() {
266
1192
  shared
267
1193
  });
268
1194
  s.stop("Project scaffolded");
269
- p.note(
1195
+ p2.note(
270
1196
  [
271
- `${pc.cyan("cd")} ${answers.appName}`,
272
- `${pc.cyan("npm install")}`,
273
- `${pc.cyan("npm run dev")}`,
274
- "",
275
- pc.dim("Then register your manifest in the Partner Portal.")
1197
+ `${pc2.cyan("cd")} ${answers.appName}`,
1198
+ `${pc2.cyan("npm install")}`,
1199
+ `${pc2.cyan("npm run register")}`,
1200
+ `${pc2.cyan("npm run dev")}`
276
1201
  ].join("\n"),
277
1202
  "Next steps"
278
1203
  );
279
- p.outro(pc.green("Done! Happy building."));
1204
+ p2.outro(pc2.green("Done! Happy building."));
1205
+ }
1206
+ async function main() {
1207
+ if (process.argv[2] === "register") {
1208
+ await register(process.argv.slice(3));
1209
+ return;
1210
+ }
1211
+ await runScaffold();
280
1212
  }
281
- main().catch(console.error);
1213
+ main().catch((err) => {
1214
+ if (err instanceof BailError) {
1215
+ process.exit(1);
1216
+ }
1217
+ console.error(err);
1218
+ process.exit(1);
1219
+ });
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@jtl-software/create-cloud-app",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "type": "module",
5
5
  "description": "CLI tool for scaffolding JTL Platform cloud apps",
6
6
  "bin": {
7
7
  "create-cloud-app": "./dist/index.js"
8
8
  },
9
+ "engines": {
10
+ "node": ">=24"
11
+ },
9
12
  "files": [
10
13
  "dist"
11
14
  ],
@@ -15,14 +18,15 @@
15
18
  },
16
19
  "dependencies": {
17
20
  "@clack/prompts": "^1.2.0",
18
- "@jtl-software/cloud-app-template-frontend-react": "0.0.12",
19
- "@jtl-software/cloud-app-template-backend-node": "0.0.10",
20
- "@jtl-software/cloud-app-template-backend-dotnet": "0.0.8",
21
- "@jtl-software/cloud-app-template-shared": "0.0.3",
21
+ "@jtl-software/cloud-app-template-backend-dotnet": "0.0.10",
22
+ "@jtl-software/cloud-app-template-backend-node": "0.0.12",
23
+ "@jtl-software/cloud-app-template-frontend-react": "0.0.14",
24
+ "@jtl-software/cloud-app-template-shared": "0.0.5",
22
25
  "picocolors": "^1.1.1"
23
26
  },
24
27
  "devDependencies": {
25
28
  "@types/node": "^22.14.0",
29
+ "msw": "^2.14.6",
26
30
  "tsup": "^8.4.0",
27
31
  "tsx": "^4.19.3",
28
32
  "typescript": "^6.0.2"