@jtl-software/create-cloud-app 0.1.0 → 0.2.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 (3) hide show
  1. package/README.md +60 -15
  2. package/dist/index.js +708 -394
  3. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,10 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import * as p2 from "@clack/prompts";
5
- import pc2 from "picocolors";
4
+ import * as p4 from "@clack/prompts";
5
+ import pc4 from "picocolors";
6
6
 
7
- // src/register.ts
7
+ // src/listing.ts
8
+ import * as p3 from "@clack/prompts";
9
+ import fs3 from "fs";
10
+ import path3 from "path";
11
+ import { parseArgs as parseArgs2 } from "util";
12
+ import pc3 from "picocolors";
13
+
14
+ // src/platform-client.ts
8
15
  import * as p from "@clack/prompts";
9
16
  import { spawn } from "child_process";
10
17
  import crypto from "crypto";
@@ -12,7 +19,6 @@ import fs from "fs";
12
19
  import http from "http";
13
20
  import os from "os";
14
21
  import path from "path";
15
- import { parseArgs } from "util";
16
22
  import pc from "picocolors";
17
23
  var LOGIN_TIMEOUT_MS = 5 * 60 * 1e3;
18
24
  var CALLBACK_PORT = 9876;
@@ -21,6 +27,7 @@ var DEFAULT_SCOPE = "openid offline_access";
21
27
  var DEFAULT_API_HOST = "https://api.jtl-cloud.com";
22
28
  var DEFAULT_AUTH_HOST = "https://auth.jtl-cloud.com";
23
29
  var DEFAULT_HUB_HOST = "https://hub.jtl-cloud.com";
30
+ var DEFAULT_PARTNER_HOST = "https://partner.jtl-cloud.com";
24
31
  var CLIENT_IDS_BY_AUTH_HOST = {
25
32
  "auth.jtl-cloud.com": "b6aca875-c9a3-4010-9fd7-7f8d2e0b0333",
26
33
  "auth.dev.jtl-cloud.com": "9c230937-6a52-48d6-9a39-e97edd8ef511",
@@ -50,152 +57,6 @@ function generatePkce() {
50
57
  );
51
58
  return { verifier, challenge };
52
59
  }
53
- function parseEnv(content) {
54
- const result = {};
55
- for (const line of content.split(/\r?\n/)) {
56
- const trimmed = line.trim();
57
- if (!trimmed || trimmed.startsWith("#")) continue;
58
- const eq = trimmed.indexOf("=");
59
- if (eq === -1) continue;
60
- const key = trimmed.slice(0, eq).trim();
61
- let value = trimmed.slice(eq + 1).trim();
62
- if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
63
- value = value.slice(1, -1);
64
- }
65
- result[key] = value;
66
- }
67
- return result;
68
- }
69
- function pickFirstLocaleEntry(entries) {
70
- if (!entries || typeof entries !== "object") return void 0;
71
- const keys = Object.keys(entries);
72
- if (!keys.length) return void 0;
73
- return entries[keys[0]];
74
- }
75
- function summarizeManifest(manifest) {
76
- const m = manifest.manifest ?? {};
77
- const listing = manifest.listing ?? {};
78
- const nameEntry = pickFirstLocaleEntry(listing.name);
79
- const descEntry = pickFirstLocaleEntry(listing.description);
80
- return {
81
- name: nameEntry?.short ?? nameEntry?.full ?? m.technicalName ?? "(unnamed)",
82
- technicalName: m.technicalName ?? "(unset)",
83
- version: m.version ?? "(unset)",
84
- description: descEntry?.short ?? descEntry?.full
85
- };
86
- }
87
- function readEnvFile(filePath) {
88
- if (!fs.existsSync(filePath)) return {};
89
- return parseEnv(fs.readFileSync(filePath, "utf-8"));
90
- }
91
- function upsertEnv(filePath, updates) {
92
- const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : "";
93
- const lines = existing.split(/\r?\n/);
94
- if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
95
- const remaining = new Map(Object.entries(updates));
96
- const next = lines.map((line) => {
97
- const eq = line.indexOf("=");
98
- if (eq === -1) return line;
99
- const key = line.slice(0, eq).trim();
100
- if (remaining.has(key)) {
101
- const value = remaining.get(key);
102
- remaining.delete(key);
103
- return `${key}=${value}`;
104
- }
105
- return line;
106
- });
107
- for (const [key, value] of remaining) {
108
- next.push(`${key}=${value}`);
109
- }
110
- fs.writeFileSync(filePath, next.join("\n") + "\n");
111
- }
112
- function findDotnetProject(backendDir) {
113
- if (!fs.existsSync(backendDir)) return null;
114
- for (const entry of fs.readdirSync(backendDir, { withFileTypes: true })) {
115
- if (!entry.isDirectory()) continue;
116
- const exampleFile = path.join(
117
- backendDir,
118
- entry.name,
119
- "appsettings.Local.json.example"
120
- );
121
- const csproj = path.join(backendDir, entry.name, `${entry.name}.csproj`);
122
- if (fs.existsSync(exampleFile) || fs.existsSync(csproj)) {
123
- return path.join(backendDir, entry.name);
124
- }
125
- }
126
- return null;
127
- }
128
- function detectBackend(root) {
129
- const backendDir = path.join(root, "packages", "backend");
130
- const envFile = path.join(backendDir, ".env");
131
- const envExample = path.join(backendDir, ".env.example");
132
- if (fs.existsSync(envFile) || fs.existsSync(envExample)) {
133
- return { kind: "node", envFile };
134
- }
135
- const dotnetDir = findDotnetProject(backendDir);
136
- if (dotnetDir) {
137
- return {
138
- kind: "dotnet",
139
- settingsFile: path.join(dotnetDir, "appsettings.Local.json")
140
- };
141
- }
142
- return { kind: "node", envFile: path.join(root, ".env") };
143
- }
144
- function readExistingCredentials(backend) {
145
- if (backend.kind === "node") {
146
- const values = readEnvFile(backend.envFile);
147
- return { clientId: values.CLIENT_ID, clientSecret: values.CLIENT_SECRET };
148
- }
149
- if (!fs.existsSync(backend.settingsFile)) return {};
150
- try {
151
- const settings = JSON.parse(fs.readFileSync(backend.settingsFile, "utf-8"));
152
- return {
153
- clientId: settings?.JtlPlatform?.ClientId,
154
- clientSecret: settings?.JtlPlatform?.ClientSecret
155
- };
156
- } catch {
157
- return {};
158
- }
159
- }
160
- function writeBackendCredentials(backend, clientId, clientSecret) {
161
- if (backend.kind === "node") {
162
- upsertEnv(backend.envFile, {
163
- CLIENT_ID: clientId,
164
- CLIENT_SECRET: clientSecret
165
- });
166
- return backend.envFile;
167
- }
168
- let settings = {};
169
- if (fs.existsSync(backend.settingsFile)) {
170
- try {
171
- settings = JSON.parse(fs.readFileSync(backend.settingsFile, "utf-8"));
172
- } catch {
173
- settings = {};
174
- }
175
- }
176
- settings.JtlPlatform = {
177
- ...settings.JtlPlatform ?? {},
178
- ClientId: clientId,
179
- ClientSecret: clientSecret
180
- };
181
- fs.writeFileSync(
182
- backend.settingsFile,
183
- JSON.stringify(settings, null, 2) + "\n"
184
- );
185
- return backend.settingsFile;
186
- }
187
- function readManifest(manifestPath) {
188
- if (!fs.existsSync(manifestPath)) {
189
- bail(
190
- `manifest.json not found at ${manifestPath}. Run this from the project root.`
191
- );
192
- }
193
- try {
194
- return JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
195
- } catch (err) {
196
- bail(`manifest.json is not valid JSON: ${err.message}`);
197
- }
198
- }
199
60
  function readTokenCache() {
200
61
  const file = tokenCacheFile();
201
62
  if (!fs.existsSync(file)) return {};
@@ -385,18 +246,18 @@ async function postTokenRequest(authBaseUrl, body) {
385
246
  },
386
247
  body: body.toString()
387
248
  });
388
- const text2 = await res.text();
249
+ const text3 = await res.text();
389
250
  if (!res.ok) {
390
- throw new Error(`token endpoint returned ${res.status}: ${text2}`);
251
+ throw new Error(`token endpoint returned ${res.status}: ${text3}`);
391
252
  }
392
253
  let parsed;
393
254
  try {
394
- parsed = JSON.parse(text2);
255
+ parsed = JSON.parse(text3);
395
256
  } catch {
396
- throw new Error(`token endpoint returned non-JSON: ${text2}`);
257
+ throw new Error(`token endpoint returned non-JSON: ${text3}`);
397
258
  }
398
259
  if (!parsed.access_token) {
399
- throw new Error(`token endpoint response missing access_token: ${text2}`);
260
+ throw new Error(`token endpoint response missing access_token: ${text3}`);
400
261
  }
401
262
  return parsed;
402
263
  }
@@ -423,16 +284,16 @@ async function refreshAccessToken(authBaseUrl, clientId, refreshToken) {
423
284
  );
424
285
  }
425
286
  async function acquireAccessToken(opts) {
426
- const { auth, clientId, scope, reauth, spinner: spinner3, captureOptions } = opts;
287
+ const { auth, clientId, scope, reauth, spinner: spinner4, captureOptions } = opts;
427
288
  const now = Math.floor(Date.now() / 1e3);
428
289
  const cached = reauth ? null : getCachedToken(auth, clientId, scope);
429
290
  if (cached && cached.expires_at > now + TOKEN_EXPIRY_MARGIN_S) {
430
- spinner3?.start("Using cached session");
431
- spinner3?.stop("Signed in (cached)");
291
+ spinner4?.start("Using cached session");
292
+ spinner4?.stop("Signed in (cached)");
432
293
  return cached.access_token;
433
294
  }
434
295
  if (cached?.refresh_token) {
435
- spinner3?.start("Refreshing session");
296
+ spinner4?.start("Refreshing session");
436
297
  try {
437
298
  const refreshed = await refreshAccessToken(
438
299
  auth,
@@ -443,10 +304,10 @@ async function acquireAccessToken(opts) {
443
304
  auth,
444
305
  makeCachedToken(clientId, refreshed, cached.refresh_token, scope)
445
306
  );
446
- spinner3?.stop("Signed in (refreshed)");
307
+ spinner4?.stop("Signed in (refreshed)");
447
308
  return refreshed.access_token;
448
309
  } catch {
449
- spinner3?.stop("Refresh failed, opening browser");
310
+ spinner4?.stop("Refresh failed, opening browser");
450
311
  }
451
312
  }
452
313
  const pkce = generatePkce();
@@ -460,7 +321,7 @@ async function acquireAccessToken(opts) {
460
321
  authorizeUrl.searchParams.set("code_challenge", pkce.challenge);
461
322
  authorizeUrl.searchParams.set("code_challenge_method", "S256");
462
323
  authorizeUrl.searchParams.set("prompt", "login");
463
- spinner3?.start("Waiting for browser login");
324
+ spinner4?.start("Waiting for browser login");
464
325
  try {
465
326
  const { code, verifier } = await captureAuthCode(
466
327
  authorizeUrl,
@@ -468,24 +329,24 @@ async function acquireAccessToken(opts) {
468
329
  pkce.verifier,
469
330
  captureOptions
470
331
  );
471
- spinner3?.message("Exchanging authorization code");
332
+ spinner4?.message("Exchanging authorization code");
472
333
  const tokens = await exchangeCodeForToken(auth, clientId, code, verifier);
473
334
  saveCachedToken(auth, makeCachedToken(clientId, tokens, void 0, scope));
474
- spinner3?.stop("Signed in");
335
+ spinner4?.stop("Signed in");
475
336
  return tokens.access_token;
476
337
  } catch (err) {
477
- spinner3?.stop("Login failed");
338
+ spinner4?.stop("Login failed");
478
339
  throw err;
479
340
  }
480
341
  }
481
342
  async function apiCall(url, init) {
482
343
  const res = await fetch(url, init);
483
- const text2 = await res.text();
344
+ const text3 = await res.text();
484
345
  let body;
485
346
  try {
486
- body = text2 ? JSON.parse(text2) : {};
347
+ body = text3 ? JSON.parse(text3) : {};
487
348
  } catch {
488
- body = text2;
349
+ body = text3;
489
350
  }
490
351
  if (!res.ok) {
491
352
  const detail = typeof body === "string" ? body : JSON.stringify(body);
@@ -506,31 +367,6 @@ async function fetchTenants(apiBaseUrl, jwt) {
506
367
  }
507
368
  return tenants;
508
369
  }
509
- async function listTenantApps(apiBaseUrl, jwt, tenantId) {
510
- const res = await apiCall(
511
- `${apiBaseUrl}/app-service/tenants/apps?onlyAppsOfTenant=true&returnAll=true`,
512
- {
513
- headers: {
514
- Authorization: `Bearer ${jwt}`,
515
- "X-Tenant-Id": tenantId,
516
- Accept: "application/json"
517
- }
518
- }
519
- );
520
- const items = Array.isArray(res) ? res : res.items ?? [];
521
- return items.map((entry) => {
522
- const id = entry.appId ?? entry.app?.id;
523
- const technicalName = entry.app?.technicalName;
524
- if (!id || !technicalName) return null;
525
- return {
526
- id,
527
- technicalName,
528
- clientId: entry.app?.clientId,
529
- provisioningStatus: entry.app?.activeVersion?.provisioningStatus,
530
- isInstalled: entry.isInstalled
531
- };
532
- }).filter((a) => a !== null);
533
- }
534
370
  function validateHost(input, flag) {
535
371
  let url;
536
372
  try {
@@ -541,7 +377,279 @@ function validateHost(input, flag) {
541
377
  if (!/^https?:$/.test(url.protocol)) {
542
378
  bail(`--${flag} must use http(s): ${input}`);
543
379
  }
544
- return `${url.protocol}//${url.host}`;
380
+ return `${url.protocol}//${url.host}`;
381
+ }
382
+ var COMMON_ARG_OPTIONS = {
383
+ "api-host": { type: "string" },
384
+ "auth-host": { type: "string" },
385
+ "hub-host": { type: "string" },
386
+ "partner-host": { type: "string" },
387
+ "client-id": { type: "string" },
388
+ scope: { type: "string" },
389
+ reauth: { type: "boolean" },
390
+ yes: { type: "boolean", short: "y" },
391
+ tenant: { type: "string" }
392
+ };
393
+ function resolveCommonOptions(values) {
394
+ const api = values["api-host"] ? validateHost(values["api-host"], "api-host") : DEFAULT_API_HOST;
395
+ const auth = values["auth-host"] ? validateHost(values["auth-host"], "auth-host") : DEFAULT_AUTH_HOST;
396
+ const hub = values["hub-host"] ? validateHost(values["hub-host"], "hub-host") : DEFAULT_HUB_HOST;
397
+ const partner = values["partner-host"] ? validateHost(values["partner-host"], "partner-host") : DEFAULT_PARTNER_HOST;
398
+ const clientIdArg = values["client-id"];
399
+ const clientId = clientIdArg ?? CLIENT_IDS_BY_AUTH_HOST[new URL(auth).hostname] ?? "";
400
+ if (!clientId) {
401
+ bail(
402
+ `no OAuth client_id registered for ${auth}. Pass --client-id <id> or add an entry to CLIENT_IDS_BY_AUTH_HOST.`
403
+ );
404
+ }
405
+ return {
406
+ api,
407
+ auth,
408
+ hub,
409
+ partner,
410
+ clientId,
411
+ scope: values.scope ?? DEFAULT_SCOPE,
412
+ reauth: values.reauth ?? false,
413
+ yes: values.yes ?? false,
414
+ tenant: values.tenant
415
+ };
416
+ }
417
+ function ensureContinue(value) {
418
+ if (p.isCancel(value)) {
419
+ bail("Cancelled.");
420
+ }
421
+ return value;
422
+ }
423
+ async function selectTenant(tenants, opts) {
424
+ if (tenants.length === 0) {
425
+ bail(
426
+ "your account has no tenants. Create one on the Partner Portal first."
427
+ );
428
+ }
429
+ if (opts.tenant) {
430
+ const needle = opts.tenant.toLowerCase();
431
+ const matches = tenants.filter(
432
+ (t) => t.id === opts.tenant || t.slug.toLowerCase() === needle
433
+ );
434
+ if (matches.length === 0) {
435
+ bail(`no tenant matched --tenant "${opts.tenant}"`);
436
+ }
437
+ if (matches.length > 1) {
438
+ bail(
439
+ `--tenant "${opts.tenant}" matched ${matches.length} tenants \u2014 be more specific`
440
+ );
441
+ }
442
+ return matches[0];
443
+ }
444
+ if (tenants.length === 1) {
445
+ return tenants[0];
446
+ }
447
+ if (opts.yes) {
448
+ bail(
449
+ `multiple tenants available; pass --tenant <id|slug> in non-interactive mode`
450
+ );
451
+ }
452
+ const tenantId = ensureContinue(
453
+ await p.select({
454
+ message: "Select a tenant",
455
+ options: tenants.map((t) => ({
456
+ value: t.id,
457
+ label: t.name,
458
+ hint: t.slug
459
+ }))
460
+ })
461
+ );
462
+ return tenants.find((t) => t.id === tenantId);
463
+ }
464
+ async function confirmAction(opts, message, defaultValue) {
465
+ if (opts.yes) return true;
466
+ return ensureContinue(
467
+ await p.confirm({ message, initialValue: defaultValue })
468
+ );
469
+ }
470
+ function buildHubInstallUrl(hubHost, tenantSlug, appId) {
471
+ return `${hubHost}/${tenantSlug}/admin/app-overview/manage/${appId}/grants`;
472
+ }
473
+ function buildHubAppOverviewUrl(hubHost, tenantSlug) {
474
+ return `${hubHost}/${tenantSlug}/admin/app-overview?tab=development-apps`;
475
+ }
476
+ function buildPartnerPortalPublishUrl(partnerHost, tenantSlug, publisherId) {
477
+ return `${partnerHost}/${tenantSlug}/${publisherId}/apps/publish`;
478
+ }
479
+ function terminalLink(url, text3 = url) {
480
+ const OSC = "\x1B]";
481
+ const ST = "\x1B\\";
482
+ return `${OSC}8;;${url}${ST}${text3}${OSC}8;;${ST}`;
483
+ }
484
+ function printHubLink(hubHost, tenantSlug, appId, isInstalled) {
485
+ if (isInstalled) {
486
+ const url = buildHubAppOverviewUrl(hubHost, tenantSlug);
487
+ p.note(pc.cyan(terminalLink(url)), "Already installed \u2014 open in Hub");
488
+ } else {
489
+ const url = buildHubInstallUrl(hubHost, tenantSlug, appId);
490
+ p.note(pc.cyan(terminalLink(url)), "Install in Hub");
491
+ }
492
+ }
493
+
494
+ // src/register.ts
495
+ import * as p2 from "@clack/prompts";
496
+ import fs2 from "fs";
497
+ import path2 from "path";
498
+ import { parseArgs } from "util";
499
+ import pc2 from "picocolors";
500
+ function parseEnv(content) {
501
+ const result = {};
502
+ for (const line of content.split(/\r?\n/)) {
503
+ const trimmed = line.trim();
504
+ if (!trimmed || trimmed.startsWith("#")) continue;
505
+ const eq = trimmed.indexOf("=");
506
+ if (eq === -1) continue;
507
+ const key = trimmed.slice(0, eq).trim();
508
+ let value = trimmed.slice(eq + 1).trim();
509
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
510
+ value = value.slice(1, -1);
511
+ }
512
+ result[key] = value;
513
+ }
514
+ return result;
515
+ }
516
+ function summarizeManifest(manifest) {
517
+ return {
518
+ technicalName: manifest.technicalName ?? "(unset)",
519
+ version: manifest.version ?? "(unset)"
520
+ };
521
+ }
522
+ function readEnvFile(filePath) {
523
+ if (!fs2.existsSync(filePath)) return {};
524
+ return parseEnv(fs2.readFileSync(filePath, "utf-8"));
525
+ }
526
+ function upsertEnv(filePath, updates) {
527
+ const existing = fs2.existsSync(filePath) ? fs2.readFileSync(filePath, "utf-8") : "";
528
+ const lines = existing.split(/\r?\n/);
529
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
530
+ const remaining = new Map(Object.entries(updates));
531
+ const next = lines.map((line) => {
532
+ const eq = line.indexOf("=");
533
+ if (eq === -1) return line;
534
+ const key = line.slice(0, eq).trim();
535
+ if (remaining.has(key)) {
536
+ const value = remaining.get(key);
537
+ remaining.delete(key);
538
+ return `${key}=${value}`;
539
+ }
540
+ return line;
541
+ });
542
+ for (const [key, value] of remaining) {
543
+ next.push(`${key}=${value}`);
544
+ }
545
+ fs2.writeFileSync(filePath, next.join("\n") + "\n");
546
+ }
547
+ function findDotnetProject(backendDir) {
548
+ if (!fs2.existsSync(backendDir)) return null;
549
+ for (const entry of fs2.readdirSync(backendDir, { withFileTypes: true })) {
550
+ if (!entry.isDirectory()) continue;
551
+ const exampleFile = path2.join(
552
+ backendDir,
553
+ entry.name,
554
+ "appsettings.Local.json.example"
555
+ );
556
+ const csproj = path2.join(backendDir, entry.name, `${entry.name}.csproj`);
557
+ if (fs2.existsSync(exampleFile) || fs2.existsSync(csproj)) {
558
+ return path2.join(backendDir, entry.name);
559
+ }
560
+ }
561
+ return null;
562
+ }
563
+ function detectBackend(root) {
564
+ const backendDir = path2.join(root, "packages", "backend");
565
+ const envFile = path2.join(backendDir, ".env");
566
+ const envExample = path2.join(backendDir, ".env.example");
567
+ if (fs2.existsSync(envFile) || fs2.existsSync(envExample)) {
568
+ return { kind: "node", envFile };
569
+ }
570
+ const dotnetDir = findDotnetProject(backendDir);
571
+ if (dotnetDir) {
572
+ return {
573
+ kind: "dotnet",
574
+ settingsFile: path2.join(dotnetDir, "appsettings.Local.json")
575
+ };
576
+ }
577
+ return { kind: "node", envFile: path2.join(root, ".env") };
578
+ }
579
+ function readExistingCredentials(backend) {
580
+ if (backend.kind === "node") {
581
+ const values = readEnvFile(backend.envFile);
582
+ return { clientId: values.CLIENT_ID, clientSecret: values.CLIENT_SECRET };
583
+ }
584
+ if (!fs2.existsSync(backend.settingsFile)) return {};
585
+ try {
586
+ const settings = JSON.parse(fs2.readFileSync(backend.settingsFile, "utf-8"));
587
+ return {
588
+ clientId: settings?.JtlPlatform?.ClientId,
589
+ clientSecret: settings?.JtlPlatform?.ClientSecret
590
+ };
591
+ } catch {
592
+ return {};
593
+ }
594
+ }
595
+ function writeBackendCredentials(backend, clientId, clientSecret) {
596
+ if (backend.kind === "node") {
597
+ upsertEnv(backend.envFile, {
598
+ CLIENT_ID: clientId,
599
+ CLIENT_SECRET: clientSecret
600
+ });
601
+ return backend.envFile;
602
+ }
603
+ let settings = {};
604
+ if (fs2.existsSync(backend.settingsFile)) {
605
+ try {
606
+ settings = JSON.parse(fs2.readFileSync(backend.settingsFile, "utf-8"));
607
+ } catch {
608
+ settings = {};
609
+ }
610
+ }
611
+ settings.JtlPlatform = {
612
+ ...settings.JtlPlatform ?? {},
613
+ ClientId: clientId,
614
+ ClientSecret: clientSecret
615
+ };
616
+ fs2.writeFileSync(
617
+ backend.settingsFile,
618
+ JSON.stringify(settings, null, 2) + "\n"
619
+ );
620
+ return backend.settingsFile;
621
+ }
622
+ function readManifest(appJsonPath) {
623
+ const dir = path2.dirname(appJsonPath);
624
+ const legacyPath = path2.join(dir, "manifest.json");
625
+ let filePath;
626
+ if (fs2.existsSync(appJsonPath)) {
627
+ filePath = appJsonPath;
628
+ } else if (fs2.existsSync(legacyPath)) {
629
+ p2.log.warn(
630
+ "manifest.json is deprecated. Rename it to app.json. See the doc for more details: \nhttps://developer.jtl-software.com/guides/cloud-apps/app-manifest."
631
+ );
632
+ filePath = legacyPath;
633
+ } else {
634
+ bail(
635
+ `app.json not found at ${appJsonPath}. Run this from the project root.`
636
+ );
637
+ }
638
+ let parsed;
639
+ try {
640
+ parsed = JSON.parse(fs2.readFileSync(filePath, "utf-8"));
641
+ } catch (err) {
642
+ bail(
643
+ `${path2.basename(filePath)} is not valid JSON: ${err.message}`
644
+ );
645
+ }
646
+ if (parsed && typeof parsed === "object" && "manifest" in parsed && typeof parsed.manifest === "object") {
647
+ p2.log.warn(
648
+ "manifest.json uses the old combined format. Split it into app.json (version, technicalName, lifecycle, capabilities) and listing.json (defaultLocale, distributionType, name, ...). See the doc for more details: \nhttps://developer.jtl-software.com/guides/cloud-apps/app-manifest."
649
+ );
650
+ return parsed.manifest;
651
+ }
652
+ return parsed;
545
653
  }
546
654
  function parseCollisionAction(input) {
547
655
  if (input === "overwrite" || input === "print" || input === "cancel") {
@@ -557,14 +665,7 @@ function resolveOptions(args) {
557
665
  parsed = parseArgs({
558
666
  args,
559
667
  options: {
560
- "api-host": { type: "string" },
561
- "auth-host": { type: "string" },
562
- "hub-host": { type: "string" },
563
- "client-id": { type: "string" },
564
- scope: { type: "string" },
565
- reauth: { type: "boolean" },
566
- yes: { type: "boolean", short: "y" },
567
- tenant: { type: "string" },
668
+ ...COMMON_ARG_OPTIONS,
568
669
  "existing-app-id": { type: "string" },
569
670
  new: { type: "boolean" },
570
671
  "on-collision": { type: "string" }
@@ -575,16 +676,7 @@ function resolveOptions(args) {
575
676
  } catch (err) {
576
677
  bail(err.message);
577
678
  }
578
- const api = parsed.values["api-host"] ? validateHost(parsed.values["api-host"], "api-host") : DEFAULT_API_HOST;
579
- const auth = parsed.values["auth-host"] ? validateHost(parsed.values["auth-host"], "auth-host") : DEFAULT_AUTH_HOST;
580
- const hub = parsed.values["hub-host"] ? validateHost(parsed.values["hub-host"], "hub-host") : DEFAULT_HUB_HOST;
581
- const clientIdArg = parsed.values["client-id"];
582
- const clientId = clientIdArg ?? CLIENT_IDS_BY_AUTH_HOST[new URL(auth).hostname] ?? "";
583
- if (!clientId) {
584
- bail(
585
- `no OAuth client_id registered for ${auth}. Pass --client-id <id> or add an entry to CLIENT_IDS_BY_AUTH_HOST.`
586
- );
587
- }
679
+ const common = resolveCommonOptions(parsed.values);
588
680
  const existingAppId = parsed.values["existing-app-id"];
589
681
  const forceNew = parsed.values.new ?? false;
590
682
  if (existingAppId && forceNew) {
@@ -593,66 +685,12 @@ function resolveOptions(args) {
593
685
  const onCollisionRaw = parsed.values["on-collision"];
594
686
  const onCollision = onCollisionRaw ? parseCollisionAction(onCollisionRaw) : void 0;
595
687
  return {
596
- api,
597
- auth,
598
- hub,
599
- clientId,
600
- scope: parsed.values.scope ?? DEFAULT_SCOPE,
601
- reauth: parsed.values.reauth ?? false,
602
- yes: parsed.values.yes ?? false,
603
- tenant: parsed.values.tenant,
688
+ ...common,
604
689
  existingAppId,
605
690
  forceNew,
606
691
  onCollision
607
692
  };
608
693
  }
609
- function ensureContinue(value) {
610
- if (p.isCancel(value)) {
611
- bail("Cancelled.");
612
- }
613
- return value;
614
- }
615
- async function selectTenant(tenants, opts) {
616
- if (tenants.length === 0) {
617
- bail(
618
- "your account has no tenants. Create one on the Partner Portal first."
619
- );
620
- }
621
- if (opts.tenant) {
622
- const needle = opts.tenant.toLowerCase();
623
- const matches = tenants.filter(
624
- (t) => t.id === opts.tenant || t.slug.toLowerCase() === needle
625
- );
626
- if (matches.length === 0) {
627
- bail(`no tenant matched --tenant "${opts.tenant}"`);
628
- }
629
- if (matches.length > 1) {
630
- bail(
631
- `--tenant "${opts.tenant}" matched ${matches.length} tenants \u2014 be more specific`
632
- );
633
- }
634
- return matches[0];
635
- }
636
- if (tenants.length === 1) {
637
- return tenants[0];
638
- }
639
- if (opts.yes) {
640
- bail(
641
- `multiple tenants available; pass --tenant <id|slug> in non-interactive mode`
642
- );
643
- }
644
- const tenantId = ensureContinue(
645
- await p.select({
646
- message: "Register the app in which tenant?",
647
- options: tenants.map((t) => ({
648
- value: t.id,
649
- label: t.name,
650
- hint: t.slug
651
- }))
652
- })
653
- );
654
- return tenants.find((t) => t.id === tenantId);
655
- }
656
694
  async function selectExistingOrNew(matches, opts) {
657
695
  if (opts.existingAppId) {
658
696
  const match = matches.find((a) => a.id === opts.existingAppId);
@@ -672,7 +710,7 @@ async function selectExistingOrNew(matches, opts) {
672
710
  }
673
711
  const CREATE_NEW = "__create_new__";
674
712
  const choice = ensureContinue(
675
- await p.select({
713
+ await p2.select({
676
714
  message: "Update an existing app or create a new one?",
677
715
  options: [
678
716
  ...matches.map((a) => ({
@@ -687,12 +725,6 @@ async function selectExistingOrNew(matches, opts) {
687
725
  if (choice === CREATE_NEW) return null;
688
726
  return matches.find((a) => a.id === choice);
689
727
  }
690
- async function confirmAction(opts, message, defaultValue) {
691
- if (opts.yes) return true;
692
- return ensureContinue(
693
- await p.confirm({ message, initialValue: defaultValue })
694
- );
695
- }
696
728
  async function resolveCollision(targetLabel, credentialPair, opts) {
697
729
  if (opts.onCollision) return opts.onCollision;
698
730
  if (opts.yes) {
@@ -701,7 +733,7 @@ async function resolveCollision(targetLabel, credentialPair, opts) {
701
733
  );
702
734
  }
703
735
  return ensureContinue(
704
- await p.select({
736
+ await p2.select({
705
737
  message: `${targetLabel} already has ${credentialPair}. What now?`,
706
738
  options: [
707
739
  { value: "overwrite", label: "Overwrite the file" },
@@ -711,6 +743,31 @@ async function resolveCollision(targetLabel, credentialPair, opts) {
711
743
  })
712
744
  );
713
745
  }
746
+ async function listTenantApps(apiBaseUrl, jwt, tenantId) {
747
+ const res = await apiCall(
748
+ `${apiBaseUrl}/app-service/tenants/apps?onlyAppsOfTenant=true&returnAll=true`,
749
+ {
750
+ headers: {
751
+ Authorization: `Bearer ${jwt}`,
752
+ "X-Tenant-Id": tenantId,
753
+ Accept: "application/json"
754
+ }
755
+ }
756
+ );
757
+ const items = Array.isArray(res) ? res : res.items ?? [];
758
+ return items.map((entry) => {
759
+ const id = entry.id;
760
+ const technicalName = entry.technicalName;
761
+ if (!id || !technicalName) return null;
762
+ return {
763
+ id,
764
+ technicalName,
765
+ clientId: entry.clientId,
766
+ provisioningStatus: entry.activeVersion?.provisioningStatus,
767
+ isInstalled: entry.isInstalled
768
+ };
769
+ }).filter((a) => a !== null);
770
+ }
714
771
  async function updateAppManifest(apiBaseUrl, jwt, tenantId, appId, manifest) {
715
772
  await apiCall(`${apiBaseUrl}/app-service/store/apps/${appId}/manifest`, {
716
773
  method: "PUT",
@@ -719,7 +776,7 @@ async function updateAppManifest(apiBaseUrl, jwt, tenantId, appId, manifest) {
719
776
  "Content-Type": "application/json",
720
777
  "X-Tenant-Id": tenantId
721
778
  },
722
- body: JSON.stringify({ manifest: manifest.manifest, tcAccepted: true })
779
+ body: JSON.stringify({ manifest, tcAccepted: true })
723
780
  });
724
781
  }
725
782
  async function createDraftApp(apiBaseUrl, jwt, tenantId, manifest) {
@@ -733,17 +790,17 @@ async function createDraftApp(apiBaseUrl, jwt, tenantId, manifest) {
733
790
  "X-Tenant-Id": tenantId
734
791
  },
735
792
  body: JSON.stringify({
736
- manifest: manifest.manifest,
793
+ manifest,
737
794
  tcAccepted: true
738
795
  })
739
796
  }
740
797
  );
741
- if (!created.appId) {
798
+ if (!created.id) {
742
799
  throw new Error(
743
- `/store/apps did not return appId: ${JSON.stringify(created)}`
800
+ `/store/apps did not return id: ${JSON.stringify(created)}`
744
801
  );
745
802
  }
746
- return created.appId;
803
+ return created.id;
747
804
  }
748
805
  async function provisionApp(apiBaseUrl, jwt, tenantId, appId) {
749
806
  const provisioned = await apiCall(
@@ -758,35 +815,15 @@ async function provisionApp(apiBaseUrl, jwt, tenantId, appId) {
758
815
  body: JSON.stringify({ appId })
759
816
  }
760
817
  );
761
- const clientId = provisioned.app?.clientId;
818
+ const clientId = provisioned.clientId;
762
819
  const clientSecret = provisioned.clientSecret;
763
820
  if (!clientId || !clientSecret) {
764
821
  throw new Error(
765
- `provision response missing app.clientId or clientSecret: ${JSON.stringify(provisioned)}`
822
+ `provision response missing clientId or clientSecret: ${JSON.stringify(provisioned)}`
766
823
  );
767
824
  }
768
825
  return { clientId, clientSecret };
769
826
  }
770
- function buildHubInstallUrl(hubHost, tenantSlug, appId) {
771
- return `${hubHost}/${tenantSlug}/admin/app-overview/manage/${appId}/grants`;
772
- }
773
- function buildHubAppOverviewUrl(hubHost, tenantSlug) {
774
- return `${hubHost}/${tenantSlug}/admin/app-overview?tab=development-apps`;
775
- }
776
- function terminalLink(url, text2 = url) {
777
- const OSC = "\x1B]";
778
- const ST = "\x1B\\";
779
- return `${OSC}8;;${url}${ST}${text2}${OSC}8;;${ST}`;
780
- }
781
- function printHubLink(hubHost, tenantSlug, appId, isInstalled) {
782
- if (isInstalled) {
783
- const url = buildHubAppOverviewUrl(hubHost, tenantSlug);
784
- p.note(pc.cyan(terminalLink(url)), "Already installed \u2014 open in Hub");
785
- } else {
786
- const url = buildHubInstallUrl(hubHost, tenantSlug, appId);
787
- p.note(pc.cyan(terminalLink(url)), "Install in Hub");
788
- }
789
- }
790
827
  function credentialLabels(backend) {
791
828
  if (backend.kind === "node") {
792
829
  return {
@@ -803,7 +840,7 @@ function credentialLabels(backend) {
803
840
  }
804
841
  async function persistCredentials(backend, clientId, clientSecret, root, opts, context) {
805
842
  const targetPath = backend.kind === "node" ? backend.envFile : backend.settingsFile;
806
- const relPath = path.relative(root, targetPath) || targetPath;
843
+ const relPath = path2.relative(root, targetPath) || targetPath;
807
844
  const labels = credentialLabels(backend);
808
845
  const existing = readExistingCredentials(backend);
809
846
  const collision = !!(existing.clientId && existing.clientSecret);
@@ -814,8 +851,8 @@ async function persistCredentials(backend, clientId, clientSecret, root, opts, c
814
851
  if (action === "cancel") bail("Cancelled.");
815
852
  if (action === "print") {
816
853
  console.log();
817
- console.log(pc.cyan(labels.id) + " = " + clientId);
818
- console.log(pc.cyan(labels.secret) + " = " + clientSecret);
854
+ console.log(pc2.cyan(labels.id) + " = " + clientId);
855
+ console.log(pc2.cyan(labels.secret) + " = " + clientSecret);
819
856
  console.log();
820
857
  printHubLink(
821
858
  context.hub,
@@ -823,7 +860,7 @@ async function persistCredentials(backend, clientId, clientSecret, root, opts, c
823
860
  context.appId,
824
861
  context.isInstalled
825
862
  );
826
- p.outro(pc.green("Done. Credentials printed above; file left untouched."));
863
+ p2.outro(pc2.green("Done. Credentials printed above; file left untouched."));
827
864
  return;
828
865
  }
829
866
  const written = writeBackendCredentials(backend, clientId, clientSecret);
@@ -833,32 +870,29 @@ async function persistCredentials(backend, clientId, clientSecret, root, opts, c
833
870
  context.appId,
834
871
  context.isInstalled
835
872
  );
836
- p.outro(
837
- pc.green(
838
- `Done. Wrote ${labels.pair} to ${pc.cyan(path.relative(root, written) || written)}.`
873
+ p2.outro(
874
+ pc2.green(
875
+ `Done. Wrote ${labels.pair} to ${pc2.cyan(path2.relative(root, written) || written)}.`
839
876
  )
840
877
  );
841
878
  }
842
879
  async function register(argv = [], runOpts = {}) {
843
880
  const root = runOpts.cwd ?? process.cwd();
844
- const manifestPath = path.join(root, "manifest.json");
845
881
  console.log();
846
- p.intro(pc.bgCyan(pc.black(" register cloud app ")));
882
+ p2.intro(pc2.bgCyan(pc2.black(" register cloud app ")));
847
883
  const opts = resolveOptions(argv);
848
- const manifest = readManifest(manifestPath);
849
- const summary = summarizeManifest(manifest);
850
884
  const backend = detectBackend(root);
851
- p.note(
885
+ p2.note(
852
886
  [
853
- `${pc.dim("API")} ${opts.api}`,
854
- `${pc.dim("Auth")} ${opts.auth}`,
855
- `${pc.dim("Hub")} ${opts.hub}`,
856
- `${pc.dim("Client ID")} ${opts.clientId}`,
857
- `${pc.dim("Scope")} ${opts.scope}`
887
+ `${pc2.dim("API")} ${opts.api}`,
888
+ `${pc2.dim("Auth")} ${opts.auth}`,
889
+ `${pc2.dim("Hub")} ${opts.hub}`,
890
+ `${pc2.dim("Client ID")} ${opts.clientId}`,
891
+ `${pc2.dim("Scope")} ${opts.scope}`
858
892
  ].join("\n"),
859
893
  "Target"
860
894
  );
861
- const spinner3 = p.spinner();
895
+ const spinner4 = p2.spinner();
862
896
  let jwt;
863
897
  try {
864
898
  jwt = await acquireAccessToken({
@@ -866,56 +900,56 @@ async function register(argv = [], runOpts = {}) {
866
900
  clientId: opts.clientId,
867
901
  scope: opts.scope,
868
902
  reauth: opts.reauth,
869
- spinner: spinner3,
903
+ spinner: spinner4,
870
904
  captureOptions: runOpts.captureOptions
871
905
  });
872
906
  } catch (err) {
873
907
  bail(err.message);
874
908
  }
875
- spinner3.start("Fetching tenants");
909
+ spinner4.start("Fetching tenants");
876
910
  let tenants;
877
911
  try {
878
912
  tenants = await fetchTenants(opts.api, jwt);
879
- spinner3.stop(
913
+ spinner4.stop(
880
914
  `Found ${tenants.length} tenant${tenants.length === 1 ? "" : "s"}`
881
915
  );
882
916
  } catch (err) {
883
- spinner3.stop("Failed to fetch tenants");
917
+ spinner4.stop("Failed to fetch tenants");
884
918
  bail(err.message);
885
919
  }
886
920
  const tenant = await selectTenant(tenants, opts);
887
- spinner3.start("Looking for apps matching this manifest");
921
+ const manifest = readManifest(path2.join(root, "app.json"));
922
+ const summary = summarizeManifest(manifest);
923
+ spinner4.start("Looking for apps matching this manifest");
888
924
  let matches;
889
925
  try {
890
926
  const apps = await listTenantApps(opts.api, jwt, tenant.id);
891
927
  matches = apps.filter((a) => a.technicalName === summary.technicalName);
892
- spinner3.stop(
893
- `Found ${matches.length} app${matches.length === 1 ? "" : "s"} with technical name ${pc.cyan(summary.technicalName)}`
928
+ spinner4.stop(
929
+ `Found ${matches.length} app${matches.length === 1 ? "" : "s"} with technical name ${pc2.cyan(summary.technicalName)}`
894
930
  );
895
931
  } catch (err) {
896
- spinner3.stop("Failed to look up tenant apps");
932
+ spinner4.stop("Failed to look up tenant apps");
897
933
  bail(err.message);
898
934
  }
899
935
  const existing = await selectExistingOrNew(matches, opts);
900
936
  const reviewLines = [
901
- `${pc.dim("App name")} ${summary.name}`,
902
- `${pc.dim("Technical name")} ${summary.technicalName}`,
903
- `${pc.dim("Version")} ${summary.version}`,
904
- summary.description ? `${pc.dim("Description")} ${summary.description}` : null,
905
- `${pc.dim("Tenant")} ${tenant.name} (${tenant.slug})`,
906
- existing ? `${pc.dim("Updating")} ${existing.technicalName} (${existing.id}) [${existing.provisioningStatus ?? "?"}]` : `${pc.dim("Action")} create new`
937
+ `${pc2.dim("Technical name")} ${summary.technicalName}`,
938
+ `${pc2.dim("Version")} ${summary.version}`,
939
+ `${pc2.dim("Tenant")} ${tenant.name} (${tenant.slug})`,
940
+ existing ? `${pc2.dim("Updating")} ${existing.technicalName} (${existing.id}) [${existing.provisioningStatus ?? "?"}]` : `${pc2.dim("Action")} create new`
907
941
  ].filter(Boolean);
908
- p.note(reviewLines.join("\n"), "Review");
909
- const confirmMessage = existing ? `Update app "${existing.technicalName}" in tenant "${tenant.name}"?` : `Create app "${summary.name}" in tenant "${tenant.name}"?`;
942
+ p2.note(reviewLines.join("\n"), "Review");
943
+ const confirmMessage = existing ? `Update app "${existing.technicalName}" in tenant "${tenant.name}"?` : `Create app "${summary.technicalName}" in tenant "${tenant.name}"?`;
910
944
  const confirm2 = await confirmAction(opts, confirmMessage, existing !== null);
911
945
  if (!confirm2) bail("Cancelled.");
912
946
  if (existing) {
913
- spinner3.start("Updating manifest");
947
+ spinner4.start("Updating manifest");
914
948
  try {
915
949
  await updateAppManifest(opts.api, jwt, tenant.id, existing.id, manifest);
916
- spinner3.stop("Manifest updated");
950
+ spinner4.stop("Manifest updated");
917
951
  } catch (err) {
918
- spinner3.stop("Failed to update manifest");
952
+ spinner4.stop("Failed to update manifest");
919
953
  bail(err.message);
920
954
  }
921
955
  printHubLink(
@@ -930,33 +964,33 @@ async function register(argv = [], runOpts = {}) {
930
964
  const localCreds = readExistingCredentials(backend);
931
965
  const hasLocalCreds = !!(localCreds.clientId && localCreds.clientSecret);
932
966
  if (!hasLocalCreds) {
933
- p.outro(
934
- pc.yellow(
967
+ p2.outro(
968
+ pc2.yellow(
935
969
  `Manifest updated. Your backend config is missing ${labels.pair} \u2014 they can't be recovered. Re-create the app to get a fresh secret.`
936
970
  )
937
971
  );
938
972
  return;
939
973
  }
940
- p.outro(
941
- pc.green(
942
- `Manifest updated. Existing ${labels.pair} in ${pc.cyan(path.relative(root, envFile))} kept as-is.`
974
+ p2.outro(
975
+ pc2.green(
976
+ `Manifest updated. Existing ${labels.pair} in ${pc2.cyan(path2.relative(root, envFile))} kept as-is.`
943
977
  )
944
978
  );
945
979
  } else {
946
- p.outro(pc.green("Manifest updated."));
980
+ p2.outro(pc2.green("Manifest updated."));
947
981
  }
948
982
  return;
949
983
  }
950
- spinner3.start("Creating draft app");
984
+ spinner4.start("Creating draft app");
951
985
  let appId;
952
986
  try {
953
987
  appId = await createDraftApp(opts.api, jwt, tenant.id, manifest);
954
- spinner3.stop(`Created app ${pc.cyan(appId)}`);
988
+ spinner4.stop(`Created app ${pc2.cyan(appId)}`);
955
989
  } catch (err) {
956
- spinner3.stop("Failed to create app");
990
+ spinner4.stop("Failed to create app");
957
991
  bail(err.message);
958
992
  }
959
- spinner3.start("Provisioning app");
993
+ spinner4.start("Provisioning app");
960
994
  let clientId;
961
995
  let clientSecret;
962
996
  try {
@@ -966,9 +1000,9 @@ async function register(argv = [], runOpts = {}) {
966
1000
  tenant.id,
967
1001
  appId
968
1002
  ));
969
- spinner3.stop("Provisioned");
1003
+ spinner4.stop("Provisioned");
970
1004
  } catch (err) {
971
- spinner3.stop("Failed to provision app");
1005
+ spinner4.stop("Failed to provision app");
972
1006
  bail(err.message);
973
1007
  }
974
1008
  await persistCredentials(backend, clientId, clientSecret, root, opts, {
@@ -980,10 +1014,275 @@ async function register(argv = [], runOpts = {}) {
980
1014
  });
981
1015
  }
982
1016
 
1017
+ // src/listing.ts
1018
+ function pickFirstLocaleEntry(entries) {
1019
+ if (!entries || typeof entries !== "object") return void 0;
1020
+ const keys = Object.keys(entries);
1021
+ if (!keys.length) return void 0;
1022
+ return entries[keys[0]];
1023
+ }
1024
+ function summarizeListing(listing2) {
1025
+ const nameEntry = pickFirstLocaleEntry(listing2.name);
1026
+ return {
1027
+ name: nameEntry?.short ?? nameEntry?.full ?? "(unnamed)",
1028
+ distributionType: listing2.distributionType ?? "(unset)"
1029
+ };
1030
+ }
1031
+ function readListing(listingPath) {
1032
+ if (!fs3.existsSync(listingPath)) {
1033
+ bail(
1034
+ `listing.json not found at ${listingPath}. Run this from the project root.`
1035
+ );
1036
+ }
1037
+ try {
1038
+ return JSON.parse(fs3.readFileSync(listingPath, "utf-8"));
1039
+ } catch (err) {
1040
+ bail(`listing.json is not valid JSON: ${err.message}`);
1041
+ }
1042
+ }
1043
+ function writeListing(listingPath, listing2) {
1044
+ fs3.writeFileSync(listingPath, JSON.stringify(listing2, null, 2) + "\n");
1045
+ }
1046
+ function readTechnicalName(root) {
1047
+ const manifestPath = path3.join(root, "app.json");
1048
+ const manifest = readManifest(manifestPath);
1049
+ if (!manifest.technicalName) {
1050
+ bail(
1051
+ `app.json at ${manifestPath} has no technicalName \u2014 run \`npm run register\` first.`
1052
+ );
1053
+ }
1054
+ return manifest.technicalName;
1055
+ }
1056
+ async function resolveAppId(apiBaseUrl, jwt, tenantId, technicalName) {
1057
+ const apps = await listTenantApps(apiBaseUrl, jwt, tenantId);
1058
+ const matches = apps.filter(
1059
+ (a) => a.technicalName === technicalName
1060
+ );
1061
+ if (matches.length === 0) {
1062
+ bail(
1063
+ `No app with technicalName "${technicalName}" found in this tenant \u2014 run \`npm run register\` first.`
1064
+ );
1065
+ }
1066
+ if (matches.length > 1) {
1067
+ bail(
1068
+ `${matches.length} apps with technicalName "${technicalName}" found in this tenant \u2014 resolve the duplicate in the Partner Portal before pushing a listing.`
1069
+ );
1070
+ }
1071
+ return matches[0].id;
1072
+ }
1073
+ function resolveListingOptions(args) {
1074
+ let parsed;
1075
+ try {
1076
+ parsed = parseArgs2({
1077
+ args,
1078
+ options: { ...COMMON_ARG_OPTIONS },
1079
+ strict: true,
1080
+ allowPositionals: false
1081
+ });
1082
+ } catch (err) {
1083
+ bail(err.message);
1084
+ }
1085
+ return resolveCommonOptions(parsed.values);
1086
+ }
1087
+ async function createAppListing(apiBaseUrl, jwt, tenantId, appId, listing2) {
1088
+ const created = await apiCall(
1089
+ `${apiBaseUrl}/app-service/app-listings`,
1090
+ {
1091
+ method: "POST",
1092
+ headers: {
1093
+ Authorization: `Bearer ${jwt}`,
1094
+ "Content-Type": "application/json",
1095
+ "X-Tenant-Id": tenantId
1096
+ },
1097
+ body: JSON.stringify({
1098
+ appId,
1099
+ defaultLocale: listing2.defaultLocale,
1100
+ name: listing2.name
1101
+ })
1102
+ }
1103
+ );
1104
+ if (!created.slug) {
1105
+ throw new Error(
1106
+ `/app-listings did not return a slug: ${JSON.stringify(created)}`
1107
+ );
1108
+ }
1109
+ return { slug: created.slug, publisherId: created.publisherId };
1110
+ }
1111
+ async function updateAppListing(apiBaseUrl, jwt, tenantId, slug, appId, listing2) {
1112
+ await apiCall(
1113
+ `${apiBaseUrl}/app-service/app-listings/${encodeURIComponent(slug)}`,
1114
+ {
1115
+ method: "PUT",
1116
+ headers: {
1117
+ Authorization: `Bearer ${jwt}`,
1118
+ "Content-Type": "application/json",
1119
+ "X-Tenant-Id": tenantId
1120
+ },
1121
+ body: JSON.stringify({ ...listing2, appId })
1122
+ }
1123
+ );
1124
+ }
1125
+ async function resolveDistributionType(existing, opts) {
1126
+ const fallback = existing === "PUBLIC" ? "PUBLIC" : "PRIVATE";
1127
+ if (opts.yes) return fallback;
1128
+ return ensureContinue(
1129
+ await p3.select({
1130
+ message: "Distribution type",
1131
+ initialValue: fallback,
1132
+ options: [
1133
+ {
1134
+ value: "PRIVATE",
1135
+ label: "Private",
1136
+ hint: "shared with merchants via activation codes, not listed in the App Store"
1137
+ },
1138
+ {
1139
+ value: "PUBLIC",
1140
+ label: "Public",
1141
+ hint: "listed in the App Store, goes through review"
1142
+ }
1143
+ ]
1144
+ })
1145
+ );
1146
+ }
1147
+ async function resolveDefaultLocale(existing, opts) {
1148
+ const fallback = existing ?? "de-DE";
1149
+ if (opts.yes) return fallback;
1150
+ return ensureContinue(
1151
+ await p3.text({
1152
+ message: "Default locale",
1153
+ initialValue: fallback,
1154
+ validate: (value) => {
1155
+ if (!value) return "Default locale is required";
1156
+ }
1157
+ })
1158
+ );
1159
+ }
1160
+ async function listing(argv = [], runOpts = {}) {
1161
+ const root = runOpts.cwd ?? process.cwd();
1162
+ const listingPath = path3.join(root, "listing.json");
1163
+ console.log();
1164
+ p3.intro(pc3.bgCyan(pc3.black(" push app listing ")));
1165
+ const opts = resolveListingOptions(argv);
1166
+ const technicalName = readTechnicalName(root);
1167
+ const listingData = readListing(listingPath);
1168
+ p3.note(
1169
+ [
1170
+ `${pc3.dim("API")} ${opts.api}`,
1171
+ `${pc3.dim("Auth")} ${opts.auth}`,
1172
+ `${pc3.dim("Hub")} ${opts.hub}`,
1173
+ `${pc3.dim("Partner")} ${opts.partner}`
1174
+ ].join("\n"),
1175
+ "Target"
1176
+ );
1177
+ const spinner4 = p3.spinner();
1178
+ let jwt;
1179
+ try {
1180
+ jwt = await acquireAccessToken({
1181
+ auth: opts.auth,
1182
+ clientId: opts.clientId,
1183
+ scope: opts.scope,
1184
+ reauth: opts.reauth,
1185
+ spinner: spinner4,
1186
+ captureOptions: runOpts.captureOptions
1187
+ });
1188
+ } catch (err) {
1189
+ bail(err.message);
1190
+ }
1191
+ spinner4.start("Fetching tenants");
1192
+ let tenants;
1193
+ try {
1194
+ tenants = await fetchTenants(opts.api, jwt);
1195
+ spinner4.stop(
1196
+ `Found ${tenants.length} tenant${tenants.length === 1 ? "" : "s"}`
1197
+ );
1198
+ } catch (err) {
1199
+ spinner4.stop("Failed to fetch tenants");
1200
+ bail(err.message);
1201
+ }
1202
+ const tenant = await selectTenant(tenants, opts);
1203
+ listingData.distributionType = await resolveDistributionType(
1204
+ listingData.distributionType,
1205
+ opts
1206
+ );
1207
+ listingData.defaultLocale = await resolveDefaultLocale(
1208
+ listingData.defaultLocale,
1209
+ opts
1210
+ );
1211
+ writeListing(listingPath, listingData);
1212
+ const summary = summarizeListing(listingData);
1213
+ if (listingData.distributionType === "PUBLIC" && !listingData.pricing?.subscriptionTiers?.length) {
1214
+ bail(
1215
+ `listing.json has distributionType "PUBLIC" but no pricing.subscriptionTiers \u2014 public listings require at least one tier. Add one, or set distributionType to "PRIVATE" if this app isn't ready for App Store review yet.`
1216
+ );
1217
+ }
1218
+ spinner4.start("Looking up the registered app");
1219
+ let appId;
1220
+ try {
1221
+ appId = await resolveAppId(opts.api, jwt, tenant.id, technicalName);
1222
+ spinner4.stop(`Found app ${pc3.cyan(appId)}`);
1223
+ } catch (err) {
1224
+ spinner4.stop("Failed to look up the app");
1225
+ bail(err.message);
1226
+ }
1227
+ const reviewLines = [
1228
+ `${pc3.dim("Technical name")} ${technicalName}`,
1229
+ `${pc3.dim("App ID")} ${appId}`,
1230
+ `${pc3.dim("Listing name")} ${summary.name}`,
1231
+ `${pc3.dim("Distribution")} ${summary.distributionType}`,
1232
+ `${pc3.dim("Tenant")} ${tenant.name} (${tenant.slug})`
1233
+ ].join("\n");
1234
+ p3.note(reviewLines, "Review");
1235
+ const confirm2 = await confirmAction(
1236
+ opts,
1237
+ `Push listing for "${technicalName}" in tenant "${tenant.name}"?`,
1238
+ true
1239
+ );
1240
+ if (!confirm2) bail("Cancelled.");
1241
+ spinner4.start("Creating listing");
1242
+ let slug;
1243
+ let publisherId;
1244
+ try {
1245
+ ({ slug, publisherId } = await createAppListing(
1246
+ opts.api,
1247
+ jwt,
1248
+ tenant.id,
1249
+ appId,
1250
+ listingData
1251
+ ));
1252
+ spinner4.stop(`Listing created (${pc3.cyan(slug)})`);
1253
+ } catch (err) {
1254
+ spinner4.stop("Failed to create listing");
1255
+ const message = err.message;
1256
+ if (/AppListingAlreadyExists/.test(message)) {
1257
+ bail(
1258
+ `App "${technicalName}" already has a listing. This CLI can't update an existing listing yet (no confirmed lookup for its slug) \u2014 edit it in the Partner Portal's "Publish apps" section instead: ${opts.partner}`
1259
+ );
1260
+ }
1261
+ bail(message);
1262
+ }
1263
+ spinner4.start("Pushing listing details");
1264
+ try {
1265
+ await updateAppListing(opts.api, jwt, tenant.id, slug, appId, listingData);
1266
+ spinner4.stop("Listing updated");
1267
+ } catch (err) {
1268
+ spinner4.stop("Failed to push listing details");
1269
+ bail(err.message);
1270
+ }
1271
+ const hubUrl = buildHubAppOverviewUrl(opts.hub, tenant.slug);
1272
+ p3.note(pc3.cyan(terminalLink(hubUrl)), "View in Hub");
1273
+ const publishUrl = buildPartnerPortalPublishUrl(
1274
+ opts.partner,
1275
+ tenant.slug,
1276
+ publisherId
1277
+ );
1278
+ p3.note(pc3.cyan(terminalLink(publishUrl)), "Manage in Partner Portal");
1279
+ p3.outro(pc3.green("Done. Listing pushed."));
1280
+ }
1281
+
983
1282
  // src/scaffold.ts
984
1283
  import crypto2 from "crypto";
985
- import fs2 from "fs";
986
- import path2 from "path";
1284
+ import fs4 from "fs";
1285
+ import path4 from "path";
987
1286
  function toPascalCase(kebab) {
988
1287
  return kebab.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
989
1288
  }
@@ -1042,30 +1341,30 @@ function transformPackageJson(content, appName, templateType, replacements) {
1042
1341
  return result + "\n";
1043
1342
  }
1044
1343
  function copyDir(src, dest, replacements, templateType, appName) {
1045
- fs2.mkdirSync(dest, { recursive: true });
1046
- for (const entry of fs2.readdirSync(src, { withFileTypes: true })) {
1047
- const srcPath = path2.join(src, entry.name);
1344
+ fs4.mkdirSync(dest, { recursive: true });
1345
+ for (const entry of fs4.readdirSync(src, { withFileTypes: true })) {
1346
+ const srcPath = path4.join(src, entry.name);
1048
1347
  let destName = entry.name;
1049
1348
  if (destName.startsWith("_git")) {
1050
1349
  destName = `.${destName.slice(1)}`;
1051
1350
  }
1052
1351
  destName = applyPlaceholders(destName, replacements);
1053
- const destPath = path2.join(dest, destName);
1352
+ const destPath = path4.join(dest, destName);
1054
1353
  if (entry.isDirectory()) {
1055
1354
  copyDir(srcPath, destPath, replacements);
1056
1355
  } else if (entry.name === "package.json" && templateType && appName) {
1057
- const content = fs2.readFileSync(srcPath, "utf-8");
1058
- fs2.writeFileSync(
1356
+ const content = fs4.readFileSync(srcPath, "utf-8");
1357
+ fs4.writeFileSync(
1059
1358
  destPath,
1060
1359
  transformPackageJson(content, appName, templateType, replacements)
1061
1360
  );
1062
1361
  } else {
1063
- const ext = path2.extname(entry.name).toLowerCase();
1362
+ const ext = path4.extname(entry.name).toLowerCase();
1064
1363
  if (BINARY_EXTENSIONS.has(ext)) {
1065
- fs2.copyFileSync(srcPath, destPath);
1364
+ fs4.copyFileSync(srcPath, destPath);
1066
1365
  } else {
1067
- const content = fs2.readFileSync(srcPath, "utf-8");
1068
- fs2.writeFileSync(destPath, applyPlaceholders(content, replacements));
1366
+ const content = fs4.readFileSync(srcPath, "utf-8");
1367
+ fs4.writeFileSync(destPath, applyPlaceholders(content, replacements));
1069
1368
  }
1070
1369
  }
1071
1370
  }
@@ -1086,14 +1385,14 @@ async function scaffold({
1086
1385
  shared,
1087
1386
  targetDir: customTargetDir
1088
1387
  }) {
1089
- const targetDir = customTargetDir ?? path2.resolve(process.cwd(), appName);
1388
+ const targetDir = customTargetDir ?? path4.resolve(process.cwd(), appName);
1090
1389
  const baseReplacements = buildPlaceholders({ appName, description });
1091
- if (fs2.existsSync(targetDir)) {
1390
+ if (fs4.existsSync(targetDir)) {
1092
1391
  throw new Error(`Directory "${appName}" already exists`);
1093
1392
  }
1094
- fs2.mkdirSync(targetDir, { recursive: true });
1393
+ fs4.mkdirSync(targetDir, { recursive: true });
1095
1394
  copyDir(shared.dir, targetDir, baseReplacements);
1096
- const frontendDest = path2.join(targetDir, "packages", "frontend");
1395
+ const frontendDest = path4.join(targetDir, "packages", "frontend");
1097
1396
  const frontendReplacements = buildReplacements(baseReplacements, frontend);
1098
1397
  copyDir(
1099
1398
  frontend.dir,
@@ -1102,7 +1401,7 @@ async function scaffold({
1102
1401
  frontend.meta.type,
1103
1402
  appName
1104
1403
  );
1105
- const backendDest = path2.join(targetDir, "packages", "backend");
1404
+ const backendDest = path4.join(targetDir, "packages", "backend");
1106
1405
  const backendReplacements = buildReplacements(baseReplacements, backend);
1107
1406
  copyDir(
1108
1407
  backend.dir,
@@ -1114,8 +1413,8 @@ async function scaffold({
1114
1413
  const runtimeVersions = backend.meta.runtimeVersions ?? {};
1115
1414
  if (Object.keys(runtimeVersions).length > 0) {
1116
1415
  const toolVersions = Object.entries(runtimeVersions).map(([tool, version]) => `${tool} ${version}`).join("\n");
1117
- fs2.writeFileSync(
1118
- path2.join(targetDir, ".tool-versions"),
1416
+ fs4.writeFileSync(
1417
+ path4.join(targetDir, ".tool-versions"),
1119
1418
  toolVersions + "\n"
1120
1419
  );
1121
1420
  }
@@ -1132,39 +1431,40 @@ async function scaffold({
1132
1431
  build: "turbo build",
1133
1432
  lint: "turbo lint",
1134
1433
  test: "turbo test",
1135
- register: "npx -y @jtl-software/create-cloud-app@latest register"
1434
+ register: "npx -y @jtl-software/create-cloud-app@latest register",
1435
+ listing: "npx -y @jtl-software/create-cloud-app@latest listing"
1136
1436
  },
1137
1437
  packageManager: "npm@10.8.2",
1138
1438
  devDependencies: {
1139
1439
  turbo: "^2.5.5"
1140
1440
  }
1141
1441
  };
1142
- fs2.writeFileSync(
1143
- path2.join(targetDir, "package.json"),
1442
+ fs4.writeFileSync(
1443
+ path4.join(targetDir, "package.json"),
1144
1444
  JSON.stringify(rootPackageJson, null, 2) + "\n"
1145
1445
  );
1146
1446
  }
1147
1447
 
1148
1448
  // src/templates.ts
1149
- import fs3 from "fs";
1150
- import path3 from "path";
1449
+ import fs5 from "fs";
1450
+ import path5 from "path";
1151
1451
  import { createRequire } from "module";
1152
1452
  var require2 = createRequire(import.meta.url);
1153
1453
  function discoverTemplates() {
1154
1454
  const cliPkgPath = require2.resolve("@jtl-software/create-cloud-app/package.json");
1155
- const cliPkg = JSON.parse(fs3.readFileSync(cliPkgPath, "utf-8"));
1455
+ const cliPkg = JSON.parse(fs5.readFileSync(cliPkgPath, "utf-8"));
1156
1456
  const deps = cliPkg.dependencies ?? {};
1157
1457
  const templates = [];
1158
1458
  for (const packageName of Object.keys(deps)) {
1159
1459
  if (!packageName.startsWith("@jtl-software/cloud-app-template-")) continue;
1160
1460
  try {
1161
1461
  const pkgJsonPath = require2.resolve(`${packageName}/package.json`);
1162
- const pkg = JSON.parse(fs3.readFileSync(pkgJsonPath, "utf-8"));
1462
+ const pkg = JSON.parse(fs5.readFileSync(pkgJsonPath, "utf-8"));
1163
1463
  const meta = pkg.cloudAppTemplate;
1164
1464
  if (!meta?.type) continue;
1165
1465
  templates.push({
1166
1466
  packageName,
1167
- dir: path3.dirname(pkgJsonPath),
1467
+ dir: path5.dirname(pkgJsonPath),
1168
1468
  meta
1169
1469
  });
1170
1470
  } catch {
@@ -1176,21 +1476,29 @@ function getTemplatesByType(templates, type) {
1176
1476
  return templates.filter((t) => t.meta.type === type);
1177
1477
  }
1178
1478
 
1479
+ // src/posthog.ts
1480
+ import { PostHog } from "posthog-node";
1481
+ var TOKEN = "phc_SkFAxTIa4RUQxotci1Ftdw7xefZK2Ax0LEIGcl8zuD2";
1482
+ var client = TOKEN ? new PostHog(TOKEN, { host: "https://e.jtl-cloud.com" }) : null;
1483
+ async function shutdownPostHog() {
1484
+ await client?.shutdown();
1485
+ }
1486
+
1179
1487
  // src/index.ts
1180
1488
  async function runScaffold() {
1181
1489
  console.log();
1182
- p2.intro(pc2.bgCyan(pc2.black(" create-cloud-app ")));
1490
+ p4.intro(pc4.bgCyan(pc4.black(" create-cloud-app ")));
1183
1491
  const templates = discoverTemplates();
1184
1492
  const backends = getTemplatesByType(templates, "backend");
1185
1493
  const frontends = getTemplatesByType(templates, "frontend");
1186
1494
  const shared = templates.find((t) => t.meta.type === "shared");
1187
1495
  if (!backends.length || !frontends.length || !shared) {
1188
- p2.cancel("No templates found. Ensure template packages are installed.");
1496
+ p4.cancel("No templates found. Ensure template packages are installed.");
1189
1497
  process.exit(1);
1190
1498
  }
1191
- const answers = await p2.group(
1499
+ const answers = await p4.group(
1192
1500
  {
1193
- appName: () => p2.text({
1501
+ appName: () => p4.text({
1194
1502
  message: "App name",
1195
1503
  placeholder: "my-jtl-app",
1196
1504
  validate: (value) => {
@@ -1199,11 +1507,11 @@ async function runScaffold() {
1199
1507
  return "Use lowercase letters, numbers, and hyphens only";
1200
1508
  }
1201
1509
  }),
1202
- description: () => p2.text({
1510
+ description: () => p4.text({
1203
1511
  message: "Description",
1204
1512
  placeholder: "A JTL Platform app"
1205
1513
  }),
1206
- backend: () => p2.select({
1514
+ backend: () => p4.select({
1207
1515
  message: "Backend",
1208
1516
  options: backends.map((t) => ({
1209
1517
  value: t.packageName,
@@ -1211,7 +1519,7 @@ async function runScaffold() {
1211
1519
  hint: t.meta.hint
1212
1520
  }))
1213
1521
  }),
1214
- frontend: () => p2.select({
1522
+ frontend: () => p4.select({
1215
1523
  message: "Frontend",
1216
1524
  options: frontends.map((t) => ({
1217
1525
  value: t.packageName,
@@ -1222,7 +1530,7 @@ async function runScaffold() {
1222
1530
  },
1223
1531
  {
1224
1532
  onCancel: () => {
1225
- p2.cancel("Cancelled.");
1533
+ p4.cancel("Cancelled.");
1226
1534
  process.exit(0);
1227
1535
  }
1228
1536
  }
@@ -1233,7 +1541,7 @@ async function runScaffold() {
1233
1541
  const selectedFrontend = templates.find(
1234
1542
  (t) => t.packageName === answers.frontend
1235
1543
  );
1236
- const s = p2.spinner();
1544
+ const s = p4.spinner();
1237
1545
  s.start("Scaffolding project");
1238
1546
  await scaffold({
1239
1547
  appName: answers.appName,
@@ -1243,25 +1551,31 @@ async function runScaffold() {
1243
1551
  shared
1244
1552
  });
1245
1553
  s.stop("Project scaffolded");
1246
- p2.note(
1554
+ p4.note(
1247
1555
  [
1248
- `${pc2.cyan("cd")} ${answers.appName}`,
1249
- `${pc2.cyan("npm install")}`,
1250
- `${pc2.cyan("npm run register")}`,
1251
- `${pc2.cyan("npm run dev")}`
1556
+ `${pc4.cyan("cd")} ${answers.appName}`,
1557
+ `${pc4.cyan("npm install")}`,
1558
+ `${pc4.cyan("npm run register")}`,
1559
+ `${pc4.cyan("npm run dev")}`,
1560
+ `${pc4.cyan("npm run listing")} ${pc4.dim("(once you're ready to publish)")}`
1252
1561
  ].join("\n"),
1253
1562
  "Next steps"
1254
1563
  );
1255
- p2.outro(pc2.green("Done! Happy building."));
1564
+ p4.outro(pc4.green("Done! Happy building."));
1256
1565
  }
1257
1566
  async function main() {
1258
1567
  if (process.argv[2] === "register") {
1259
1568
  await register(process.argv.slice(3));
1260
1569
  return;
1261
1570
  }
1571
+ if (process.argv[2] === "listing") {
1572
+ await listing(process.argv.slice(3));
1573
+ return;
1574
+ }
1262
1575
  await runScaffold();
1263
1576
  }
1264
- main().catch((err) => {
1577
+ main().then(() => shutdownPostHog()).catch(async (err) => {
1578
+ await shutdownPostHog();
1265
1579
  if (err instanceof BailError) {
1266
1580
  process.exit(1);
1267
1581
  }