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