@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.
- package/README.md +60 -15
- package/dist/index.js +708 -394
- 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
|
|
5
|
-
import
|
|
4
|
+
import * as p4 from "@clack/prompts";
|
|
5
|
+
import pc4 from "picocolors";
|
|
6
6
|
|
|
7
|
-
// src/
|
|
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
|
|
249
|
+
const text3 = await res.text();
|
|
389
250
|
if (!res.ok) {
|
|
390
|
-
throw new Error(`token endpoint returned ${res.status}: ${
|
|
251
|
+
throw new Error(`token endpoint returned ${res.status}: ${text3}`);
|
|
391
252
|
}
|
|
392
253
|
let parsed;
|
|
393
254
|
try {
|
|
394
|
-
parsed = JSON.parse(
|
|
255
|
+
parsed = JSON.parse(text3);
|
|
395
256
|
} catch {
|
|
396
|
-
throw new Error(`token endpoint returned non-JSON: ${
|
|
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: ${
|
|
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:
|
|
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
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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
|
-
|
|
307
|
+
spinner4?.stop("Signed in (refreshed)");
|
|
447
308
|
return refreshed.access_token;
|
|
448
309
|
} catch {
|
|
449
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
+
spinner4?.stop("Signed in");
|
|
475
336
|
return tokens.access_token;
|
|
476
337
|
} catch (err) {
|
|
477
|
-
|
|
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
|
|
344
|
+
const text3 = await res.text();
|
|
484
345
|
let body;
|
|
485
346
|
try {
|
|
486
|
-
body =
|
|
347
|
+
body = text3 ? JSON.parse(text3) : {};
|
|
487
348
|
} catch {
|
|
488
|
-
body =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
793
|
+
manifest,
|
|
737
794
|
tcAccepted: true
|
|
738
795
|
})
|
|
739
796
|
}
|
|
740
797
|
);
|
|
741
|
-
if (!created.
|
|
798
|
+
if (!created.id) {
|
|
742
799
|
throw new Error(
|
|
743
|
-
`/store/apps did not return
|
|
800
|
+
`/store/apps did not return id: ${JSON.stringify(created)}`
|
|
744
801
|
);
|
|
745
802
|
}
|
|
746
|
-
return created.
|
|
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.
|
|
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
|
|
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 =
|
|
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(
|
|
818
|
-
console.log(
|
|
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
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
`Done. Wrote ${labels.pair} to ${
|
|
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
|
-
|
|
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
|
-
|
|
885
|
+
p2.note(
|
|
852
886
|
[
|
|
853
|
-
`${
|
|
854
|
-
`${
|
|
855
|
-
`${
|
|
856
|
-
`${
|
|
857
|
-
`${
|
|
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
|
|
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:
|
|
903
|
+
spinner: spinner4,
|
|
870
904
|
captureOptions: runOpts.captureOptions
|
|
871
905
|
});
|
|
872
906
|
} catch (err) {
|
|
873
907
|
bail(err.message);
|
|
874
908
|
}
|
|
875
|
-
|
|
909
|
+
spinner4.start("Fetching tenants");
|
|
876
910
|
let tenants;
|
|
877
911
|
try {
|
|
878
912
|
tenants = await fetchTenants(opts.api, jwt);
|
|
879
|
-
|
|
913
|
+
spinner4.stop(
|
|
880
914
|
`Found ${tenants.length} tenant${tenants.length === 1 ? "" : "s"}`
|
|
881
915
|
);
|
|
882
916
|
} catch (err) {
|
|
883
|
-
|
|
917
|
+
spinner4.stop("Failed to fetch tenants");
|
|
884
918
|
bail(err.message);
|
|
885
919
|
}
|
|
886
920
|
const tenant = await selectTenant(tenants, opts);
|
|
887
|
-
|
|
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
|
-
|
|
893
|
-
`Found ${matches.length} app${matches.length === 1 ? "" : "s"} with technical name ${
|
|
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
|
-
|
|
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
|
-
`${
|
|
902
|
-
`${
|
|
903
|
-
`${
|
|
904
|
-
|
|
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
|
-
|
|
909
|
-
const confirmMessage = existing ? `Update app "${existing.technicalName}" in tenant "${tenant.name}"?` : `Create app "${summary.
|
|
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
|
-
|
|
947
|
+
spinner4.start("Updating manifest");
|
|
914
948
|
try {
|
|
915
949
|
await updateAppManifest(opts.api, jwt, tenant.id, existing.id, manifest);
|
|
916
|
-
|
|
950
|
+
spinner4.stop("Manifest updated");
|
|
917
951
|
} catch (err) {
|
|
918
|
-
|
|
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
|
-
|
|
934
|
-
|
|
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
|
-
|
|
941
|
-
|
|
942
|
-
`Manifest updated. Existing ${labels.pair} in ${
|
|
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
|
-
|
|
980
|
+
p2.outro(pc2.green("Manifest updated."));
|
|
947
981
|
}
|
|
948
982
|
return;
|
|
949
983
|
}
|
|
950
|
-
|
|
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
|
-
|
|
988
|
+
spinner4.stop(`Created app ${pc2.cyan(appId)}`);
|
|
955
989
|
} catch (err) {
|
|
956
|
-
|
|
990
|
+
spinner4.stop("Failed to create app");
|
|
957
991
|
bail(err.message);
|
|
958
992
|
}
|
|
959
|
-
|
|
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
|
-
|
|
1003
|
+
spinner4.stop("Provisioned");
|
|
970
1004
|
} catch (err) {
|
|
971
|
-
|
|
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
|
|
986
|
-
import
|
|
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
|
-
|
|
1046
|
-
for (const entry of
|
|
1047
|
-
const srcPath =
|
|
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 =
|
|
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 =
|
|
1058
|
-
|
|
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 =
|
|
1362
|
+
const ext = path4.extname(entry.name).toLowerCase();
|
|
1064
1363
|
if (BINARY_EXTENSIONS.has(ext)) {
|
|
1065
|
-
|
|
1364
|
+
fs4.copyFileSync(srcPath, destPath);
|
|
1066
1365
|
} else {
|
|
1067
|
-
const content =
|
|
1068
|
-
|
|
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 ??
|
|
1388
|
+
const targetDir = customTargetDir ?? path4.resolve(process.cwd(), appName);
|
|
1090
1389
|
const baseReplacements = buildPlaceholders({ appName, description });
|
|
1091
|
-
if (
|
|
1390
|
+
if (fs4.existsSync(targetDir)) {
|
|
1092
1391
|
throw new Error(`Directory "${appName}" already exists`);
|
|
1093
1392
|
}
|
|
1094
|
-
|
|
1393
|
+
fs4.mkdirSync(targetDir, { recursive: true });
|
|
1095
1394
|
copyDir(shared.dir, targetDir, baseReplacements);
|
|
1096
|
-
const frontendDest =
|
|
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 =
|
|
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
|
-
|
|
1118
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
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
|
|
1150
|
-
import
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1496
|
+
p4.cancel("No templates found. Ensure template packages are installed.");
|
|
1189
1497
|
process.exit(1);
|
|
1190
1498
|
}
|
|
1191
|
-
const answers = await
|
|
1499
|
+
const answers = await p4.group(
|
|
1192
1500
|
{
|
|
1193
|
-
appName: () =>
|
|
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: () =>
|
|
1510
|
+
description: () => p4.text({
|
|
1203
1511
|
message: "Description",
|
|
1204
1512
|
placeholder: "A JTL Platform app"
|
|
1205
1513
|
}),
|
|
1206
|
-
backend: () =>
|
|
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: () =>
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1554
|
+
p4.note(
|
|
1247
1555
|
[
|
|
1248
|
-
`${
|
|
1249
|
-
`${
|
|
1250
|
-
`${
|
|
1251
|
-
`${
|
|
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
|
-
|
|
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
|
}
|