@jtl-software/create-cloud-app 0.0.14 → 0.0.16
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/dist/index.js +982 -47
- package/package.json +6 -5
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
|
}
|
|
@@ -155,39 +1077,40 @@ async function scaffold({
|
|
|
155
1077
|
dev: "turbo dev",
|
|
156
1078
|
build: "turbo build",
|
|
157
1079
|
lint: "turbo lint",
|
|
158
|
-
test: "turbo test"
|
|
1080
|
+
test: "turbo test",
|
|
1081
|
+
register: "npx -y @jtl-software/create-cloud-app@latest register"
|
|
159
1082
|
},
|
|
160
1083
|
packageManager: "npm@10.8.2",
|
|
161
1084
|
devDependencies: {
|
|
162
1085
|
turbo: "^2.5.5"
|
|
163
1086
|
}
|
|
164
1087
|
};
|
|
165
|
-
|
|
166
|
-
|
|
1088
|
+
fs2.writeFileSync(
|
|
1089
|
+
path2.join(targetDir, "package.json"),
|
|
167
1090
|
JSON.stringify(rootPackageJson, null, 2) + "\n"
|
|
168
1091
|
);
|
|
169
1092
|
}
|
|
170
1093
|
|
|
171
1094
|
// src/templates.ts
|
|
172
|
-
import
|
|
173
|
-
import
|
|
1095
|
+
import fs3 from "fs";
|
|
1096
|
+
import path3 from "path";
|
|
174
1097
|
import { createRequire } from "module";
|
|
175
1098
|
var require2 = createRequire(import.meta.url);
|
|
176
1099
|
function discoverTemplates() {
|
|
177
1100
|
const cliPkgPath = require2.resolve("@jtl-software/create-cloud-app/package.json");
|
|
178
|
-
const cliPkg = JSON.parse(
|
|
1101
|
+
const cliPkg = JSON.parse(fs3.readFileSync(cliPkgPath, "utf-8"));
|
|
179
1102
|
const deps = cliPkg.dependencies ?? {};
|
|
180
1103
|
const templates = [];
|
|
181
1104
|
for (const packageName of Object.keys(deps)) {
|
|
182
1105
|
if (!packageName.startsWith("@jtl-software/cloud-app-template-")) continue;
|
|
183
1106
|
try {
|
|
184
1107
|
const pkgJsonPath = require2.resolve(`${packageName}/package.json`);
|
|
185
|
-
const pkg = JSON.parse(
|
|
1108
|
+
const pkg = JSON.parse(fs3.readFileSync(pkgJsonPath, "utf-8"));
|
|
186
1109
|
const meta = pkg.cloudAppTemplate;
|
|
187
1110
|
if (!meta?.type) continue;
|
|
188
1111
|
templates.push({
|
|
189
1112
|
packageName,
|
|
190
|
-
dir:
|
|
1113
|
+
dir: path3.dirname(pkgJsonPath),
|
|
191
1114
|
meta
|
|
192
1115
|
});
|
|
193
1116
|
} catch {
|
|
@@ -200,20 +1123,20 @@ function getTemplatesByType(templates, type) {
|
|
|
200
1123
|
}
|
|
201
1124
|
|
|
202
1125
|
// src/index.ts
|
|
203
|
-
async function
|
|
1126
|
+
async function runScaffold() {
|
|
204
1127
|
console.log();
|
|
205
|
-
|
|
1128
|
+
p2.intro(pc2.bgCyan(pc2.black(" create-cloud-app ")));
|
|
206
1129
|
const templates = discoverTemplates();
|
|
207
1130
|
const backends = getTemplatesByType(templates, "backend");
|
|
208
1131
|
const frontends = getTemplatesByType(templates, "frontend");
|
|
209
1132
|
const shared = templates.find((t) => t.meta.type === "shared");
|
|
210
1133
|
if (!backends.length || !frontends.length || !shared) {
|
|
211
|
-
|
|
1134
|
+
p2.cancel("No templates found. Ensure template packages are installed.");
|
|
212
1135
|
process.exit(1);
|
|
213
1136
|
}
|
|
214
|
-
const answers = await
|
|
1137
|
+
const answers = await p2.group(
|
|
215
1138
|
{
|
|
216
|
-
appName: () =>
|
|
1139
|
+
appName: () => p2.text({
|
|
217
1140
|
message: "App name",
|
|
218
1141
|
placeholder: "my-jtl-app",
|
|
219
1142
|
validate: (value) => {
|
|
@@ -222,11 +1145,11 @@ async function main() {
|
|
|
222
1145
|
return "Use lowercase letters, numbers, and hyphens only";
|
|
223
1146
|
}
|
|
224
1147
|
}),
|
|
225
|
-
description: () =>
|
|
1148
|
+
description: () => p2.text({
|
|
226
1149
|
message: "Description",
|
|
227
1150
|
placeholder: "A JTL Platform app"
|
|
228
1151
|
}),
|
|
229
|
-
backend: () =>
|
|
1152
|
+
backend: () => p2.select({
|
|
230
1153
|
message: "Backend",
|
|
231
1154
|
options: backends.map((t) => ({
|
|
232
1155
|
value: t.packageName,
|
|
@@ -234,7 +1157,7 @@ async function main() {
|
|
|
234
1157
|
hint: t.meta.hint
|
|
235
1158
|
}))
|
|
236
1159
|
}),
|
|
237
|
-
frontend: () =>
|
|
1160
|
+
frontend: () => p2.select({
|
|
238
1161
|
message: "Frontend",
|
|
239
1162
|
options: frontends.map((t) => ({
|
|
240
1163
|
value: t.packageName,
|
|
@@ -245,7 +1168,7 @@ async function main() {
|
|
|
245
1168
|
},
|
|
246
1169
|
{
|
|
247
1170
|
onCancel: () => {
|
|
248
|
-
|
|
1171
|
+
p2.cancel("Cancelled.");
|
|
249
1172
|
process.exit(0);
|
|
250
1173
|
}
|
|
251
1174
|
}
|
|
@@ -256,7 +1179,7 @@ async function main() {
|
|
|
256
1179
|
const selectedFrontend = templates.find(
|
|
257
1180
|
(t) => t.packageName === answers.frontend
|
|
258
1181
|
);
|
|
259
|
-
const s =
|
|
1182
|
+
const s = p2.spinner();
|
|
260
1183
|
s.start("Scaffolding project");
|
|
261
1184
|
await scaffold({
|
|
262
1185
|
appName: answers.appName,
|
|
@@ -266,16 +1189,28 @@ async function main() {
|
|
|
266
1189
|
shared
|
|
267
1190
|
});
|
|
268
1191
|
s.stop("Project scaffolded");
|
|
269
|
-
|
|
1192
|
+
p2.note(
|
|
270
1193
|
[
|
|
271
|
-
`${
|
|
272
|
-
`${
|
|
273
|
-
`${
|
|
274
|
-
""
|
|
275
|
-
pc.dim("Then register your manifest in the Partner Portal.")
|
|
1194
|
+
`${pc2.cyan("cd")} ${answers.appName}`,
|
|
1195
|
+
`${pc2.cyan("npm install")}`,
|
|
1196
|
+
`${pc2.cyan("npm run register")}`,
|
|
1197
|
+
`${pc2.cyan("npm run dev")}`
|
|
276
1198
|
].join("\n"),
|
|
277
1199
|
"Next steps"
|
|
278
1200
|
);
|
|
279
|
-
|
|
1201
|
+
p2.outro(pc2.green("Done! Happy building."));
|
|
1202
|
+
}
|
|
1203
|
+
async function main() {
|
|
1204
|
+
if (process.argv[2] === "register") {
|
|
1205
|
+
await register(process.argv.slice(3));
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
await runScaffold();
|
|
280
1209
|
}
|
|
281
|
-
main().catch(
|
|
1210
|
+
main().catch((err) => {
|
|
1211
|
+
if (err instanceof BailError) {
|
|
1212
|
+
process.exit(1);
|
|
1213
|
+
}
|
|
1214
|
+
console.error(err);
|
|
1215
|
+
process.exit(1);
|
|
1216
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jtl-software/create-cloud-app",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.16",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "CLI tool for scaffolding JTL Platform cloud apps",
|
|
6
6
|
"bin": {
|
|
@@ -15,14 +15,15 @@
|
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@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.
|
|
18
|
+
"@jtl-software/cloud-app-template-backend-dotnet": "0.0.9",
|
|
19
|
+
"@jtl-software/cloud-app-template-backend-node": "0.0.11",
|
|
20
|
+
"@jtl-software/cloud-app-template-frontend-react": "0.0.13",
|
|
21
|
+
"@jtl-software/cloud-app-template-shared": "0.0.4",
|
|
22
22
|
"picocolors": "^1.1.1"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/node": "^22.14.0",
|
|
26
|
+
"msw": "^2.14.6",
|
|
26
27
|
"tsup": "^8.4.0",
|
|
27
28
|
"tsx": "^4.19.3",
|
|
28
29
|
"typescript": "^6.0.2"
|