manage-tuurio-id 0.1.0
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 +47 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1298 -0
- package/package.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# manage-tuurio-id
|
|
2
|
+
|
|
3
|
+
Interactive CLI wizard for scaffolding projects and provisioning Tuurio ID setup through short-lived tokens.
|
|
4
|
+
|
|
5
|
+
## Run
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx manage-tuurio-id@latest
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Dev-only host override
|
|
12
|
+
|
|
13
|
+
For local development only (source runtime), you can override the Tuurio host:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm --dir create-tuurio-app dev -- --tuurio-host id.localhost:8080
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The wizard asks for `tenantId` first and opens:
|
|
20
|
+
`http://<tenantId>.id.localhost:8080/admin/provisioning/cli`
|
|
21
|
+
(for localhost-style hosts).
|
|
22
|
+
|
|
23
|
+
`--tuurio-host` is intentionally disabled in published builds.
|
|
24
|
+
|
|
25
|
+
Wizard start flow:
|
|
26
|
+
- `Do you already have a tenant ID?`
|
|
27
|
+
- `yes` -> asks for tenant ID
|
|
28
|
+
- `no` -> asks for organization/name/email/password/subdomain and uses subdomain as tenant host
|
|
29
|
+
- For `no`, CLI runs a server-side signup preflight (`/cli/v1/provisioning/signup/preflight`) before opening browser
|
|
30
|
+
- Browser entry uses `/login?sso=manual&return=/admin/provisioning/cli?mode=...&tenantId=...`
|
|
31
|
+
- After login, the provisioning page auto-generates and displays the bootstrap token directly for copy
|
|
32
|
+
|
|
33
|
+
## Generated credentials
|
|
34
|
+
|
|
35
|
+
- SPA provisioning creates a new client and prints generated credentials (`client ID` and `client secret`).
|
|
36
|
+
- After creation, the CLI asks if it should download the matching sample from:
|
|
37
|
+
- `https://github.com/Tuurio/auth_samples`
|
|
38
|
+
- If you confirm, it downloads the framework sample (`React`, `Vue`, or `Angular`) and writes a local `.env`
|
|
39
|
+
populated with the values that were just generated.
|
|
40
|
+
- Treat generated `.env` files as secrets and move values to your secret manager before production.
|
|
41
|
+
|
|
42
|
+
## Security model
|
|
43
|
+
|
|
44
|
+
- Provisioning requires a short-lived bootstrap token generated from the authenticated admin portal.
|
|
45
|
+
- Bootstrap tokens are one-time and exchanged for short-lived session tokens.
|
|
46
|
+
- Create operations require `Idempotency-Key` and are processed idempotently server-side.
|
|
47
|
+
- CLI uses retry with exponential backoff for transient network and HTTP failures.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1298 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { randomUUID } from "crypto";
|
|
5
|
+
import { spawn } from "child_process";
|
|
6
|
+
import { cp, mkdir, mkdtemp, readFile, readdir, rm, unlink, writeFile } from "fs/promises";
|
|
7
|
+
import { homedir, tmpdir } from "os";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import kleur2 from "kleur";
|
|
10
|
+
import open from "open";
|
|
11
|
+
import ora from "ora";
|
|
12
|
+
import prompts from "prompts";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
|
|
15
|
+
// src/http.ts
|
|
16
|
+
import { fetch } from "undici";
|
|
17
|
+
import kleur from "kleur";
|
|
18
|
+
var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 409, 425, 429, 500, 502, 503, 504]);
|
|
19
|
+
var RequestError = class extends Error {
|
|
20
|
+
retryable;
|
|
21
|
+
constructor(message, retryable = false) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.retryable = retryable;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
async function requestJson(opts) {
|
|
27
|
+
const retries = opts.retries ?? 4;
|
|
28
|
+
const headers = {
|
|
29
|
+
"content-type": "application/json",
|
|
30
|
+
...opts.headers ?? {}
|
|
31
|
+
};
|
|
32
|
+
let attempt = 0;
|
|
33
|
+
while (true) {
|
|
34
|
+
attempt += 1;
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(opts.url, {
|
|
37
|
+
method: opts.method,
|
|
38
|
+
headers,
|
|
39
|
+
body: opts.body === void 0 ? void 0 : JSON.stringify(opts.body)
|
|
40
|
+
});
|
|
41
|
+
const text = await res.text();
|
|
42
|
+
const parsed = text ? tryParseJson(text) : void 0;
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
const message = extractErrorMessage(parsed) ?? `${res.status} ${res.statusText}`;
|
|
45
|
+
const retryable = RETRYABLE_STATUSES.has(res.status);
|
|
46
|
+
if (attempt <= retries && retryable) {
|
|
47
|
+
await sleep(backoffDelayMs(attempt));
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
throw new RequestError(message, retryable);
|
|
51
|
+
}
|
|
52
|
+
if (parsed === void 0) {
|
|
53
|
+
throw new Error("Empty response payload");
|
|
54
|
+
}
|
|
55
|
+
const envelope = parsed;
|
|
56
|
+
return envelope.data ?? parsed;
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (err instanceof RequestError && !err.retryable) {
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
if (attempt > retries) {
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
65
|
+
process.stderr.write(kleur.yellow(`Retrying request (${attempt}/${retries}) due to: ${message}
|
|
66
|
+
`));
|
|
67
|
+
await sleep(backoffDelayMs(attempt));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function tryParseJson(raw) {
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(raw);
|
|
74
|
+
} catch {
|
|
75
|
+
return void 0;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function extractErrorMessage(parsed) {
|
|
79
|
+
if (!parsed || typeof parsed !== "object") return void 0;
|
|
80
|
+
const rec = parsed;
|
|
81
|
+
if (typeof rec.message === "string" && rec.message.trim()) return rec.message;
|
|
82
|
+
if (typeof rec.error === "string" && rec.error.trim()) return rec.error;
|
|
83
|
+
const data = rec.data;
|
|
84
|
+
if (data && typeof data.message === "string" && data.message.trim()) return data.message;
|
|
85
|
+
return void 0;
|
|
86
|
+
}
|
|
87
|
+
function backoffDelayMs(attempt) {
|
|
88
|
+
const base = 300 * Math.pow(2, attempt - 1);
|
|
89
|
+
const jitter = Math.floor(Math.random() * 120);
|
|
90
|
+
return Math.min(5e3, base + jitter);
|
|
91
|
+
}
|
|
92
|
+
function sleep(ms) {
|
|
93
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/index.ts
|
|
97
|
+
var tokenSchema = z.string().min(12);
|
|
98
|
+
var colorSchema = z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, "Expected a valid hex color like #2563EB");
|
|
99
|
+
var urlSchema = z.string().url();
|
|
100
|
+
var tenantIdSchema = z.string().regex(/^[a-z0-9-]{6,30}$/, "Use 6-30 lowercase letters, numbers, or hyphens");
|
|
101
|
+
var SESSION_FILE = path.join(homedir(), ".tuurio", "manage-tuurio-id-session.json");
|
|
102
|
+
var AUTH_SAMPLES_REPO_URL = "https://github.com/Tuurio/auth_samples.git";
|
|
103
|
+
async function main() {
|
|
104
|
+
printHeader();
|
|
105
|
+
const cliArgs = parseCliArgs(process.argv.slice(2));
|
|
106
|
+
const portalBaseUrl = cliArgs.tuurioHost ? buildPortalBaseUrlFromHost(cliArgs.tuurioHost) : "https://id.tuurio.com";
|
|
107
|
+
const apiBaseUrl = deriveApiBaseUrl(portalBaseUrl);
|
|
108
|
+
if (cliArgs.tuurioHost) {
|
|
109
|
+
process.stdout.write(kleur2.gray(`Using dev host override: ${portalBaseUrl}
|
|
110
|
+
`));
|
|
111
|
+
}
|
|
112
|
+
const reusedSession = await resolveSavedSession(apiBaseUrl);
|
|
113
|
+
const activeSession = reusedSession ?? await runBootstrapStartMenu({
|
|
114
|
+
apiBaseUrl,
|
|
115
|
+
portalBaseUrl
|
|
116
|
+
});
|
|
117
|
+
if (!activeSession) {
|
|
118
|
+
process.stdout.write(kleur2.gray("Bye.\n"));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
await runMainMenu({
|
|
122
|
+
apiBaseUrl,
|
|
123
|
+
portalBaseUrl,
|
|
124
|
+
session: activeSession
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
async function runBootstrapStartMenu(args) {
|
|
128
|
+
for (; ; ) {
|
|
129
|
+
const { action } = await prompts(
|
|
130
|
+
{
|
|
131
|
+
type: "select",
|
|
132
|
+
name: "action",
|
|
133
|
+
message: "Get started",
|
|
134
|
+
choices: [
|
|
135
|
+
{ title: "Login", value: "login" },
|
|
136
|
+
{ title: "New", value: "new" },
|
|
137
|
+
{ title: "Website", value: "website" },
|
|
138
|
+
{ title: "Exit", value: "exit" }
|
|
139
|
+
]
|
|
140
|
+
},
|
|
141
|
+
{ onCancel: () => process.exit(1) }
|
|
142
|
+
);
|
|
143
|
+
if (action === "login") {
|
|
144
|
+
return performLoginFlow(args);
|
|
145
|
+
}
|
|
146
|
+
if (action === "new") {
|
|
147
|
+
return performNewTenantSignupFlow(args);
|
|
148
|
+
}
|
|
149
|
+
if (action === "website") {
|
|
150
|
+
await openPortal(args.portalBaseUrl);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (action === "exit") {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function runMainMenu(args) {
|
|
159
|
+
let activeSession = args.session;
|
|
160
|
+
for (; ; ) {
|
|
161
|
+
printSessionInfo(activeSession.status);
|
|
162
|
+
const { action } = await prompts(
|
|
163
|
+
{
|
|
164
|
+
type: "select",
|
|
165
|
+
name: "action",
|
|
166
|
+
message: "Choose an action",
|
|
167
|
+
choices: [
|
|
168
|
+
{ title: "Create new SPA client", value: "createSpa" },
|
|
169
|
+
{ title: "Create server-side web app client", value: "createServerWeb" },
|
|
170
|
+
{
|
|
171
|
+
title: "Edit tenant settings",
|
|
172
|
+
value: "editTenantSettings",
|
|
173
|
+
disabled: !activeSession.status.canEditTenantSettings ? "Missing org:admin permission" : void 0
|
|
174
|
+
},
|
|
175
|
+
{ title: "More settings (open admin portal)", value: "moreSettings" },
|
|
176
|
+
{ title: "Refresh session status", value: "refreshStatus" },
|
|
177
|
+
{ title: "Logout", value: "logout" },
|
|
178
|
+
{ title: "Exit", value: "exit" }
|
|
179
|
+
]
|
|
180
|
+
},
|
|
181
|
+
{ onCancel: () => process.exit(1) }
|
|
182
|
+
);
|
|
183
|
+
if (action === "createSpa") {
|
|
184
|
+
await createSpaClient(activeSession, args.apiBaseUrl);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (action === "createServerWeb") {
|
|
188
|
+
await createServerWebAppClient(activeSession, args.apiBaseUrl);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (action === "editTenantSettings") {
|
|
192
|
+
await editTenantSettings(activeSession, args.apiBaseUrl);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (action === "moreSettings") {
|
|
196
|
+
await openMoreSettings(activeSession.status.tenantAdminEditUrl);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (action === "refreshStatus") {
|
|
200
|
+
activeSession = await refreshActiveSession(args.apiBaseUrl, activeSession.sessionToken);
|
|
201
|
+
await saveSession({
|
|
202
|
+
sessionToken: activeSession.sessionToken,
|
|
203
|
+
apiBaseUrl: args.apiBaseUrl,
|
|
204
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
205
|
+
lastKnownExpiresAt: activeSession.status.expiresAt,
|
|
206
|
+
tenantId: activeSession.status.tenantId
|
|
207
|
+
});
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (action === "logout") {
|
|
211
|
+
await logout(args.apiBaseUrl, activeSession.sessionToken);
|
|
212
|
+
await clearSavedSession();
|
|
213
|
+
process.stdout.write(kleur2.green("Logged out. Local session removed.\n"));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (action === "exit") {
|
|
217
|
+
process.stdout.write(kleur2.gray("Bye.\n"));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async function createSpaClient(activeSession, apiBaseUrl) {
|
|
223
|
+
const { targetSpa } = await prompts(
|
|
224
|
+
{
|
|
225
|
+
type: "select",
|
|
226
|
+
name: "targetSpa",
|
|
227
|
+
message: "Which SPA sample do you want to create?",
|
|
228
|
+
choices: [
|
|
229
|
+
{ title: "Angular SPA", value: "ANGULAR_SPA" },
|
|
230
|
+
{ title: "Vue SPA", value: "VUE_SPA" },
|
|
231
|
+
{ title: "React SPA", value: "REACT_SPA" }
|
|
232
|
+
]
|
|
233
|
+
},
|
|
234
|
+
{ onCancel: () => process.exit(1) }
|
|
235
|
+
);
|
|
236
|
+
const framework = targetSpa;
|
|
237
|
+
const frameworkSlug = frameworkSlugFor(framework);
|
|
238
|
+
const { projectName } = await prompts(
|
|
239
|
+
{
|
|
240
|
+
type: "text",
|
|
241
|
+
name: "projectName",
|
|
242
|
+
message: "Project name for generated sample",
|
|
243
|
+
initial: `${activeSession.status.tenantId}-${frameworkSlug}-sample`,
|
|
244
|
+
validate: (value) => value.trim().length >= 2 ? true : "Project name is required"
|
|
245
|
+
},
|
|
246
|
+
{ onCancel: () => process.exit(1) }
|
|
247
|
+
);
|
|
248
|
+
const spinner = ora(`Creating ${frameworkSlug} client + sample scaffold...`).start();
|
|
249
|
+
let scaffold;
|
|
250
|
+
try {
|
|
251
|
+
scaffold = await requestJson({
|
|
252
|
+
method: "POST",
|
|
253
|
+
url: `${apiBaseUrl}/cli/v1/provisioning/scaffold`,
|
|
254
|
+
headers: {
|
|
255
|
+
Authorization: `Bearer ${activeSession.sessionToken}`,
|
|
256
|
+
"Idempotency-Key": randomUUID()
|
|
257
|
+
},
|
|
258
|
+
body: {
|
|
259
|
+
projectName: projectName.trim(),
|
|
260
|
+
frameworks: [framework],
|
|
261
|
+
enableWebhooks: false,
|
|
262
|
+
addApiIntegration: false
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
spinner.succeed("Client created.");
|
|
266
|
+
} catch (err) {
|
|
267
|
+
spinner.fail("SPA client creation failed.");
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
const envPath = `apps/${frameworkSlug}/.env`;
|
|
271
|
+
const envFile = scaffold.files.find((file) => file.path === envPath);
|
|
272
|
+
const envMap = parseEnvFile(envFile?.content ?? "");
|
|
273
|
+
const clientId = envMap.TUURIO_CLIENT_ID ?? "";
|
|
274
|
+
process.stdout.write(`
|
|
275
|
+
${kleur2.bold("Generated SPA client")}
|
|
276
|
+
`);
|
|
277
|
+
process.stdout.write(`Client ID: ${kleur2.cyan(clientId || "(not returned)")}
|
|
278
|
+
`);
|
|
279
|
+
process.stdout.write(kleur2.gray("Client secret is intentionally not shown for SPA templates.\n"));
|
|
280
|
+
const { shouldWriteSample } = await prompts(
|
|
281
|
+
{
|
|
282
|
+
type: "toggle",
|
|
283
|
+
name: "shouldWriteSample",
|
|
284
|
+
message: "Download matching auth_samples template from GitHub and configure .env?",
|
|
285
|
+
initial: true,
|
|
286
|
+
active: "yes",
|
|
287
|
+
inactive: "no"
|
|
288
|
+
},
|
|
289
|
+
{ onCancel: () => process.exit(1) }
|
|
290
|
+
);
|
|
291
|
+
if (!shouldWriteSample) {
|
|
292
|
+
process.stdout.write(kleur2.gray("Skipped sample download.\n"));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const { outputDir } = await prompts(
|
|
296
|
+
{
|
|
297
|
+
type: "text",
|
|
298
|
+
name: "outputDir",
|
|
299
|
+
message: "Output directory",
|
|
300
|
+
initial: `./${activeSession.status.tenantId}-${frameworkSlug}-sample`
|
|
301
|
+
},
|
|
302
|
+
{ onCancel: () => process.exit(1) }
|
|
303
|
+
);
|
|
304
|
+
const outputRoot = path.resolve(process.cwd(), outputDir.trim());
|
|
305
|
+
await downloadSampleFromGithub(framework, outputRoot);
|
|
306
|
+
await writeSpaSampleEnvFile(outputRoot, framework, envMap);
|
|
307
|
+
process.stdout.write(kleur2.green(`Sample downloaded and configured at ${outputRoot}
|
|
308
|
+
`));
|
|
309
|
+
}
|
|
310
|
+
async function createServerWebAppClient(activeSession, apiBaseUrl) {
|
|
311
|
+
const { targetServer } = await prompts(
|
|
312
|
+
{
|
|
313
|
+
type: "select",
|
|
314
|
+
name: "targetServer",
|
|
315
|
+
message: "Which server-side web sample do you want to create?",
|
|
316
|
+
choices: [
|
|
317
|
+
{ title: "Node.js", value: "NODE_JS_WEB" },
|
|
318
|
+
{ title: "Python", value: "PYTHON_WEB" },
|
|
319
|
+
{ title: "Java / Spring", value: "SPRING_WEB" },
|
|
320
|
+
{ title: "Go", value: "GO_WEB" },
|
|
321
|
+
{ title: "PHP", value: "PHP_WEB" }
|
|
322
|
+
]
|
|
323
|
+
},
|
|
324
|
+
{ onCancel: () => process.exit(1) }
|
|
325
|
+
);
|
|
326
|
+
const framework = targetServer;
|
|
327
|
+
const frameworkSlug = frameworkSlugFor(framework);
|
|
328
|
+
const { projectName } = await prompts(
|
|
329
|
+
{
|
|
330
|
+
type: "text",
|
|
331
|
+
name: "projectName",
|
|
332
|
+
message: "Project name for generated sample",
|
|
333
|
+
initial: `${activeSession.status.tenantId}-${frameworkSlug}-sample`,
|
|
334
|
+
validate: (value) => value.trim().length >= 2 ? true : "Project name is required"
|
|
335
|
+
},
|
|
336
|
+
{ onCancel: () => process.exit(1) }
|
|
337
|
+
);
|
|
338
|
+
const { enableWebhooks } = await prompts(
|
|
339
|
+
{
|
|
340
|
+
type: "toggle",
|
|
341
|
+
name: "enableWebhooks",
|
|
342
|
+
message: "Create active webhook (API key header auth + all events)?",
|
|
343
|
+
initial: true,
|
|
344
|
+
active: "yes",
|
|
345
|
+
inactive: "no"
|
|
346
|
+
},
|
|
347
|
+
{ onCancel: () => process.exit(1) }
|
|
348
|
+
);
|
|
349
|
+
const spinner = ora(`Creating ${frameworkSlug} client${enableWebhooks ? " + webhook" : ""}...`).start();
|
|
350
|
+
let scaffold;
|
|
351
|
+
try {
|
|
352
|
+
scaffold = await requestJson({
|
|
353
|
+
method: "POST",
|
|
354
|
+
url: `${apiBaseUrl}/cli/v1/provisioning/scaffold`,
|
|
355
|
+
headers: {
|
|
356
|
+
Authorization: `Bearer ${activeSession.sessionToken}`,
|
|
357
|
+
"Idempotency-Key": randomUUID()
|
|
358
|
+
},
|
|
359
|
+
body: {
|
|
360
|
+
projectName: projectName.trim(),
|
|
361
|
+
frameworks: [framework],
|
|
362
|
+
enableWebhooks: Boolean(enableWebhooks),
|
|
363
|
+
addApiIntegration: false
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
spinner.succeed("Server-side web app client created.");
|
|
367
|
+
} catch (err) {
|
|
368
|
+
spinner.fail("Server-side web app client creation failed.");
|
|
369
|
+
throw err;
|
|
370
|
+
}
|
|
371
|
+
const envPath = `apps/${frameworkSlug}/.env`;
|
|
372
|
+
const envFile = scaffold.files.find((file) => file.path === envPath);
|
|
373
|
+
const envMap = parseEnvFile(envFile?.content ?? "");
|
|
374
|
+
const clientId = envMap.TUURIO_CLIENT_ID ?? "";
|
|
375
|
+
const clientSecret = envMap.TUURIO_CLIENT_SECRET ?? "";
|
|
376
|
+
process.stdout.write(`
|
|
377
|
+
${kleur2.bold("Generated client credentials")}
|
|
378
|
+
`);
|
|
379
|
+
process.stdout.write(`Client ID: ${kleur2.cyan(clientId || "(not returned)")}
|
|
380
|
+
`);
|
|
381
|
+
process.stdout.write(`Client Secret: ${kleur2.cyan(clientSecret || "(not returned)")}
|
|
382
|
+
`);
|
|
383
|
+
if (enableWebhooks) {
|
|
384
|
+
process.stdout.write(`
|
|
385
|
+
${kleur2.bold("Generated webhook configuration")}
|
|
386
|
+
`);
|
|
387
|
+
process.stdout.write(`Webhook ID: ${kleur2.cyan(envMap.TUURIO_WEBHOOK_ID ?? "(not returned)")}
|
|
388
|
+
`);
|
|
389
|
+
process.stdout.write(`Auth header: ${kleur2.cyan(envMap.TUURIO_WEBHOOK_API_KEY_HEADER ?? "(not returned)")}
|
|
390
|
+
`);
|
|
391
|
+
process.stdout.write(`Auth value: ${kleur2.cyan(envMap.TUURIO_WEBHOOK_API_KEY ?? "(not returned)")}
|
|
392
|
+
`);
|
|
393
|
+
process.stdout.write(`Edit URL: ${kleur2.cyan(envMap.TUURIO_WEBHOOK_EDIT_URL ?? "(not returned)")}
|
|
394
|
+
`);
|
|
395
|
+
process.stdout.write(
|
|
396
|
+
kleur2.yellow(
|
|
397
|
+
"Webhook endpoint URL must be updated after deployment in the admin UI (edit URL above).\n"
|
|
398
|
+
)
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
const { shouldWriteSample } = await prompts(
|
|
402
|
+
{
|
|
403
|
+
type: "toggle",
|
|
404
|
+
name: "shouldWriteSample",
|
|
405
|
+
message: "Download matching auth_samples template from GitHub and configure .env?",
|
|
406
|
+
initial: true,
|
|
407
|
+
active: "yes",
|
|
408
|
+
inactive: "no"
|
|
409
|
+
},
|
|
410
|
+
{ onCancel: () => process.exit(1) }
|
|
411
|
+
);
|
|
412
|
+
if (!shouldWriteSample) {
|
|
413
|
+
process.stdout.write(kleur2.gray("Skipped sample download.\n"));
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const { outputDir } = await prompts(
|
|
417
|
+
{
|
|
418
|
+
type: "text",
|
|
419
|
+
name: "outputDir",
|
|
420
|
+
message: "Output directory",
|
|
421
|
+
initial: `./${activeSession.status.tenantId}-${frameworkSlug}-sample`
|
|
422
|
+
},
|
|
423
|
+
{ onCancel: () => process.exit(1) }
|
|
424
|
+
);
|
|
425
|
+
const outputRoot = path.resolve(process.cwd(), outputDir.trim());
|
|
426
|
+
await downloadSampleFromGithub(framework, outputRoot);
|
|
427
|
+
await writeServerSampleEnvFile(outputRoot, framework, envMap);
|
|
428
|
+
process.stdout.write(kleur2.green(`Sample downloaded and configured at ${outputRoot}
|
|
429
|
+
`));
|
|
430
|
+
}
|
|
431
|
+
async function editTenantSettings(activeSession, apiBaseUrl) {
|
|
432
|
+
const current = await requestJson({
|
|
433
|
+
method: "GET",
|
|
434
|
+
url: `${apiBaseUrl}/cli/v1/provisioning/tenant/settings`,
|
|
435
|
+
headers: {
|
|
436
|
+
Authorization: `Bearer ${activeSession.sessionToken}`
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
const answers = await prompts(
|
|
440
|
+
[
|
|
441
|
+
{
|
|
442
|
+
type: "text",
|
|
443
|
+
name: "name",
|
|
444
|
+
message: "Tenant name",
|
|
445
|
+
initial: current.name ?? ""
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
type: "text",
|
|
449
|
+
name: "headline",
|
|
450
|
+
message: "Login headline",
|
|
451
|
+
initial: current.headline ?? ""
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
type: "text",
|
|
455
|
+
name: "logoUrl",
|
|
456
|
+
message: "Logo URL (optional)",
|
|
457
|
+
initial: current.logoUrl ?? "",
|
|
458
|
+
validate: (value) => {
|
|
459
|
+
const trimmed = value.trim();
|
|
460
|
+
if (!trimmed) return true;
|
|
461
|
+
return urlSchema.safeParse(trimmed).success ? true : "Enter a valid URL";
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
type: "text",
|
|
466
|
+
name: "primaryColor",
|
|
467
|
+
message: "Primary color (hex)",
|
|
468
|
+
initial: current.primaryColor,
|
|
469
|
+
validate: (value) => colorSchema.safeParse(value.trim()).success ? true : "Expected #RRGGBB or #RGB"
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
type: "toggle",
|
|
473
|
+
name: "registrationAllowed",
|
|
474
|
+
message: "Allow public registration?",
|
|
475
|
+
initial: current.registrationAllowed,
|
|
476
|
+
active: "yes",
|
|
477
|
+
inactive: "no"
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
type: "toggle",
|
|
481
|
+
name: "fourEyesPrinciple",
|
|
482
|
+
message: "Enable 4-eyes principle (admin approval required)?",
|
|
483
|
+
initial: current.fourEyesPrinciple,
|
|
484
|
+
active: "yes",
|
|
485
|
+
inactive: "no"
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
type: "number",
|
|
489
|
+
name: "passwordMinLength",
|
|
490
|
+
message: "Minimum password length",
|
|
491
|
+
initial: current.passwordMinLength,
|
|
492
|
+
validate: (value) => {
|
|
493
|
+
if (!Number.isFinite(value)) return "Enter a valid number";
|
|
494
|
+
if (value < 8 || value > 128) return "Value must be between 8 and 128";
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
],
|
|
499
|
+
{ onCancel: () => process.exit(1) }
|
|
500
|
+
);
|
|
501
|
+
const payload = {
|
|
502
|
+
name: toNullableString(answers.name),
|
|
503
|
+
headline: toNullableString(answers.headline),
|
|
504
|
+
logoUrl: toNullableString(answers.logoUrl),
|
|
505
|
+
primaryColor: String(answers.primaryColor).trim(),
|
|
506
|
+
registrationAllowed: Boolean(answers.registrationAllowed),
|
|
507
|
+
fourEyesPrinciple: Boolean(answers.fourEyesPrinciple),
|
|
508
|
+
passwordMinLength: Number(answers.passwordMinLength)
|
|
509
|
+
};
|
|
510
|
+
const spinner = ora("Saving tenant settings...").start();
|
|
511
|
+
let updated;
|
|
512
|
+
try {
|
|
513
|
+
updated = await requestJson({
|
|
514
|
+
method: "PUT",
|
|
515
|
+
url: `${apiBaseUrl}/cli/v1/provisioning/tenant/settings`,
|
|
516
|
+
headers: {
|
|
517
|
+
Authorization: `Bearer ${activeSession.sessionToken}`
|
|
518
|
+
},
|
|
519
|
+
body: payload
|
|
520
|
+
});
|
|
521
|
+
spinner.succeed("Tenant settings updated.");
|
|
522
|
+
} catch (err) {
|
|
523
|
+
spinner.fail("Updating tenant settings failed.");
|
|
524
|
+
throw err;
|
|
525
|
+
}
|
|
526
|
+
process.stdout.write(`Updated tenant: ${kleur2.bold(updated.tenantId)}
|
|
527
|
+
`);
|
|
528
|
+
}
|
|
529
|
+
async function resolveSavedSession(apiBaseUrl) {
|
|
530
|
+
const stored = await loadSession();
|
|
531
|
+
if (!stored) {
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
if (normalizeBaseUrl(stored.apiBaseUrl) !== normalizeBaseUrl(apiBaseUrl)) {
|
|
535
|
+
process.stdout.write(kleur2.gray("Stored session belongs to a different Tuurio host. Ignoring it.\n"));
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
const active = await refreshActiveSession(apiBaseUrl, stored.sessionToken);
|
|
540
|
+
process.stdout.write(
|
|
541
|
+
kleur2.green(
|
|
542
|
+
`Reused saved session for tenant '${active.status.tenantId}'. Expires: ${active.status.expiresAt}
|
|
543
|
+
`
|
|
544
|
+
)
|
|
545
|
+
);
|
|
546
|
+
return active;
|
|
547
|
+
} catch (err) {
|
|
548
|
+
await clearSavedSession();
|
|
549
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
550
|
+
process.stdout.write(kleur2.yellow(`Saved session is no longer valid: ${message}
|
|
551
|
+
`));
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
async function performLoginFlow(args) {
|
|
556
|
+
process.stdout.write(kleur2.gray("Generate a bootstrap token for an existing tenant.\n"));
|
|
557
|
+
const { tenantId } = await prompts(
|
|
558
|
+
{
|
|
559
|
+
type: "text",
|
|
560
|
+
name: "tenantId",
|
|
561
|
+
message: "Tenant ID (subdomain)",
|
|
562
|
+
validate: (value) => tenantIdSchema.safeParse(normalizeTenantId(value)).success ? true : "Use 6-30 lowercase letters, numbers, or hyphens"
|
|
563
|
+
},
|
|
564
|
+
{ onCancel: () => process.exit(1) }
|
|
565
|
+
);
|
|
566
|
+
const normalizedTenantId = normalizeTenantId(tenantId);
|
|
567
|
+
const loginUrl = buildProvisioningLoginUrl(args.portalBaseUrl, "EXISTING", normalizedTenantId);
|
|
568
|
+
await openPortal(loginUrl);
|
|
569
|
+
const bootstrapToken = await promptBootstrapTokenFromBrowser();
|
|
570
|
+
return establishSessionFromBootstrap(args.apiBaseUrl, bootstrapToken);
|
|
571
|
+
}
|
|
572
|
+
async function performNewTenantSignupFlow(args) {
|
|
573
|
+
const profileAnswers = await prompts(
|
|
574
|
+
[
|
|
575
|
+
{
|
|
576
|
+
type: "text",
|
|
577
|
+
name: "organizationName",
|
|
578
|
+
message: "Company name",
|
|
579
|
+
validate: (value) => value.trim().length >= 2 ? true : "Company name must be at least 2 characters"
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
type: "text",
|
|
583
|
+
name: "firstName",
|
|
584
|
+
message: "First name",
|
|
585
|
+
validate: (value) => value.trim().length >= 2 ? true : "First name must be at least 2 characters"
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
type: "text",
|
|
589
|
+
name: "lastName",
|
|
590
|
+
message: "Last name",
|
|
591
|
+
validate: (value) => value.trim().length >= 2 ? true : "Last name must be at least 2 characters"
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
type: "text",
|
|
595
|
+
name: "email",
|
|
596
|
+
message: "Email",
|
|
597
|
+
validate: (value) => z.string().email().safeParse(value.trim()).success ? true : "Enter a valid email"
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
type: "password",
|
|
601
|
+
name: "password",
|
|
602
|
+
message: "Password",
|
|
603
|
+
validate: (value) => value.trim().length >= 8 ? true : "Password must be at least 8 characters"
|
|
604
|
+
}
|
|
605
|
+
],
|
|
606
|
+
{ onCancel: () => process.exit(1) }
|
|
607
|
+
);
|
|
608
|
+
const { passwordRepeat } = await prompts(
|
|
609
|
+
{
|
|
610
|
+
type: "password",
|
|
611
|
+
name: "passwordRepeat",
|
|
612
|
+
message: "Repeat password",
|
|
613
|
+
validate: (value) => String(profileAnswers.password ?? "") === value ? true : "Passwords do not match"
|
|
614
|
+
},
|
|
615
|
+
{ onCancel: () => process.exit(1) }
|
|
616
|
+
);
|
|
617
|
+
const accountEmail = String(profileAnswers.email).trim().toLowerCase();
|
|
618
|
+
const tenantSetupAnswers = await prompts(
|
|
619
|
+
[
|
|
620
|
+
{
|
|
621
|
+
type: "text",
|
|
622
|
+
name: "primaryColor",
|
|
623
|
+
message: "Primary color (hex)",
|
|
624
|
+
initial: "#3b82f6",
|
|
625
|
+
validate: (value) => colorSchema.safeParse(value.trim()).success ? true : "Expected #RRGGBB or #RGB"
|
|
626
|
+
},
|
|
627
|
+
{
|
|
628
|
+
type: "toggle",
|
|
629
|
+
name: "registrationAllowed",
|
|
630
|
+
message: "Registration available?",
|
|
631
|
+
initial: false,
|
|
632
|
+
active: "yes",
|
|
633
|
+
inactive: "no"
|
|
634
|
+
},
|
|
635
|
+
{
|
|
636
|
+
type: (_prev, values) => values.registrationAllowed ? "toggle" : null,
|
|
637
|
+
name: "fourEyesPrinciple",
|
|
638
|
+
message: "Admin approval required?",
|
|
639
|
+
initial: true,
|
|
640
|
+
active: "yes",
|
|
641
|
+
inactive: "no"
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
type: "text",
|
|
645
|
+
name: "imprintUrl",
|
|
646
|
+
message: "Imprint URL (optional)",
|
|
647
|
+
validate: validateOptionalUrlPrompt
|
|
648
|
+
},
|
|
649
|
+
{
|
|
650
|
+
type: "text",
|
|
651
|
+
name: "privacyUrl",
|
|
652
|
+
message: "Privacy URL (optional)",
|
|
653
|
+
validate: validateOptionalUrlPrompt
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
type: (_prev, values) => toNullableString(values.privacyUrl) ? "text" : null,
|
|
657
|
+
name: "privacyVersion",
|
|
658
|
+
message: "Privacy version (optional)",
|
|
659
|
+
validate: (value) => value.trim().length <= 100 ? true : "Privacy version must be <= 100 characters"
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
type: "text",
|
|
663
|
+
name: "termsUrl",
|
|
664
|
+
message: "Terms URL (optional)",
|
|
665
|
+
validate: validateOptionalUrlPrompt
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
type: (_prev, values) => toNullableString(values.termsUrl) ? "text" : null,
|
|
669
|
+
name: "termsVersion",
|
|
670
|
+
message: "Terms version (optional)",
|
|
671
|
+
validate: (value) => value.trim().length <= 100 ? true : "Terms version must be <= 100 characters"
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
type: "text",
|
|
675
|
+
name: "supportEmail",
|
|
676
|
+
message: "Contact support email (leave empty to use account email)",
|
|
677
|
+
initial: accountEmail,
|
|
678
|
+
validate: (value) => {
|
|
679
|
+
const normalized = value.trim();
|
|
680
|
+
if (!normalized) return true;
|
|
681
|
+
return z.string().email().safeParse(normalized).success ? true : "Enter a valid email";
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
],
|
|
685
|
+
{ onCancel: () => process.exit(1) }
|
|
686
|
+
);
|
|
687
|
+
const tenantSetup = {
|
|
688
|
+
name: String(profileAnswers.organizationName).trim(),
|
|
689
|
+
primaryColor: String(tenantSetupAnswers.primaryColor).trim(),
|
|
690
|
+
registrationAllowed: Boolean(tenantSetupAnswers.registrationAllowed),
|
|
691
|
+
fourEyesPrinciple: Boolean(tenantSetupAnswers.registrationAllowed) && Boolean(tenantSetupAnswers.fourEyesPrinciple),
|
|
692
|
+
imprintUrl: toNullableString(tenantSetupAnswers.imprintUrl),
|
|
693
|
+
privacyUrl: toNullableString(tenantSetupAnswers.privacyUrl),
|
|
694
|
+
privacyVersion: toNullableString(tenantSetupAnswers.privacyVersion),
|
|
695
|
+
termsUrl: toNullableString(tenantSetupAnswers.termsUrl),
|
|
696
|
+
termsVersion: toNullableString(tenantSetupAnswers.termsVersion),
|
|
697
|
+
supportEmail: toNullableString(tenantSetupAnswers.supportEmail) ?? accountEmail
|
|
698
|
+
};
|
|
699
|
+
if (!tenantSetup.privacyUrl) {
|
|
700
|
+
tenantSetup.privacyVersion = null;
|
|
701
|
+
}
|
|
702
|
+
if (!tenantSetup.termsUrl) {
|
|
703
|
+
tenantSetup.termsVersion = null;
|
|
704
|
+
}
|
|
705
|
+
for (; ; ) {
|
|
706
|
+
const { tenantId } = await prompts(
|
|
707
|
+
{
|
|
708
|
+
type: "text",
|
|
709
|
+
name: "tenantId",
|
|
710
|
+
message: "Subdomain / tenant ID",
|
|
711
|
+
validate: (value) => tenantIdSchema.safeParse(normalizeTenantId(value)).success ? true : "Use 6-30 lowercase letters, numbers, or hyphens"
|
|
712
|
+
},
|
|
713
|
+
{ onCancel: () => process.exit(1) }
|
|
714
|
+
);
|
|
715
|
+
const normalizedTenantId = normalizeTenantId(String(tenantId));
|
|
716
|
+
const availability = await requestJson({
|
|
717
|
+
method: "POST",
|
|
718
|
+
url: `${args.apiBaseUrl}/cli/v1/provisioning/tenant/check`,
|
|
719
|
+
body: { tenantId: normalizedTenantId }
|
|
720
|
+
});
|
|
721
|
+
if (!availability.available) {
|
|
722
|
+
process.stdout.write(
|
|
723
|
+
kleur2.yellow(`Subdomain '${normalizedTenantId}' is already taken. Please choose another one.
|
|
724
|
+
`)
|
|
725
|
+
);
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
const spinner = ora("Creating tenant and admin account...").start();
|
|
729
|
+
let signup;
|
|
730
|
+
try {
|
|
731
|
+
signup = await requestJson({
|
|
732
|
+
method: "POST",
|
|
733
|
+
url: `${args.apiBaseUrl}/cli/v1/provisioning/signup`,
|
|
734
|
+
body: {
|
|
735
|
+
tenantId: normalizedTenantId,
|
|
736
|
+
organizationName: String(profileAnswers.organizationName).trim(),
|
|
737
|
+
firstName: String(profileAnswers.firstName).trim(),
|
|
738
|
+
lastName: String(profileAnswers.lastName).trim(),
|
|
739
|
+
email: String(profileAnswers.email).trim().toLowerCase(),
|
|
740
|
+
password: String(profileAnswers.password),
|
|
741
|
+
passwordRepeat: String(passwordRepeat)
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
spinner.succeed("Tenant created.");
|
|
745
|
+
} catch (err) {
|
|
746
|
+
spinner.fail("Tenant creation failed.");
|
|
747
|
+
throw err;
|
|
748
|
+
}
|
|
749
|
+
const activeSession = await establishSessionFromBootstrap(args.apiBaseUrl, signup.bootstrapToken);
|
|
750
|
+
await applyNewFlowTenantSettings(activeSession, args.apiBaseUrl, tenantSetup);
|
|
751
|
+
process.stdout.write(kleur2.gray("Bootstrap token exchanged automatically. No web login required.\n"));
|
|
752
|
+
return activeSession;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
async function applyNewFlowTenantSettings(activeSession, apiBaseUrl, tenantSetup) {
|
|
756
|
+
const current = await requestJson({
|
|
757
|
+
method: "GET",
|
|
758
|
+
url: `${apiBaseUrl}/cli/v1/provisioning/tenant/settings`,
|
|
759
|
+
headers: {
|
|
760
|
+
Authorization: `Bearer ${activeSession.sessionToken}`
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
const payload = {
|
|
764
|
+
name: tenantSetup.name,
|
|
765
|
+
headline: current.headline ?? null,
|
|
766
|
+
logoUrl: current.logoUrl ?? null,
|
|
767
|
+
imprintUrl: tenantSetup.imprintUrl,
|
|
768
|
+
privacyUrl: tenantSetup.privacyUrl,
|
|
769
|
+
privacyVersion: tenantSetup.privacyUrl ? tenantSetup.privacyVersion : null,
|
|
770
|
+
termsUrl: tenantSetup.termsUrl,
|
|
771
|
+
termsVersion: tenantSetup.termsUrl ? tenantSetup.termsVersion : null,
|
|
772
|
+
supportEmail: tenantSetup.supportEmail,
|
|
773
|
+
primaryColor: tenantSetup.primaryColor,
|
|
774
|
+
registrationAllowed: tenantSetup.registrationAllowed,
|
|
775
|
+
fourEyesPrinciple: tenantSetup.registrationAllowed ? tenantSetup.fourEyesPrinciple : false,
|
|
776
|
+
passwordMinLength: current.passwordMinLength
|
|
777
|
+
};
|
|
778
|
+
const spinner = ora("Applying tenant setup...").start();
|
|
779
|
+
try {
|
|
780
|
+
await requestJson({
|
|
781
|
+
method: "PUT",
|
|
782
|
+
url: `${apiBaseUrl}/cli/v1/provisioning/tenant/settings`,
|
|
783
|
+
headers: {
|
|
784
|
+
Authorization: `Bearer ${activeSession.sessionToken}`
|
|
785
|
+
},
|
|
786
|
+
body: payload
|
|
787
|
+
});
|
|
788
|
+
spinner.succeed("Tenant setup applied.");
|
|
789
|
+
} catch (err) {
|
|
790
|
+
spinner.fail("Applying tenant setup failed.");
|
|
791
|
+
throw err;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
async function promptBootstrapTokenFromBrowser() {
|
|
795
|
+
const { bootstrapToken } = await prompts(
|
|
796
|
+
{
|
|
797
|
+
type: "password",
|
|
798
|
+
name: "bootstrapToken",
|
|
799
|
+
message: "Paste bootstrap token from browser",
|
|
800
|
+
validate: (value) => tokenSchema.safeParse(value.trim()).success ? true : "Token looks too short"
|
|
801
|
+
},
|
|
802
|
+
{ onCancel: () => process.exit(1) }
|
|
803
|
+
);
|
|
804
|
+
return String(bootstrapToken).trim();
|
|
805
|
+
}
|
|
806
|
+
async function establishSessionFromBootstrap(apiBaseUrl, bootstrapToken) {
|
|
807
|
+
const exchange = await exchangeBootstrap(apiBaseUrl, bootstrapToken.trim());
|
|
808
|
+
const activeSession = await refreshActiveSession(apiBaseUrl, exchange.sessionToken);
|
|
809
|
+
await saveSession({
|
|
810
|
+
sessionToken: exchange.sessionToken,
|
|
811
|
+
apiBaseUrl,
|
|
812
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
813
|
+
lastKnownExpiresAt: activeSession.status.expiresAt,
|
|
814
|
+
tenantId: activeSession.status.tenantId
|
|
815
|
+
});
|
|
816
|
+
process.stdout.write(
|
|
817
|
+
kleur2.green(
|
|
818
|
+
`Session established for tenant '${activeSession.status.tenantId}'. Expires: ${activeSession.status.expiresAt}
|
|
819
|
+
`
|
|
820
|
+
)
|
|
821
|
+
);
|
|
822
|
+
return activeSession;
|
|
823
|
+
}
|
|
824
|
+
async function refreshActiveSession(apiBaseUrl, sessionToken) {
|
|
825
|
+
const status = await requestJson({
|
|
826
|
+
method: "GET",
|
|
827
|
+
url: `${apiBaseUrl}/cli/v1/provisioning/session/status`,
|
|
828
|
+
headers: {
|
|
829
|
+
Authorization: `Bearer ${sessionToken}`
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
return { sessionToken, status };
|
|
833
|
+
}
|
|
834
|
+
async function exchangeBootstrap(baseUrl, bootstrapToken) {
|
|
835
|
+
const spinner = ora("Exchanging bootstrap token...").start();
|
|
836
|
+
try {
|
|
837
|
+
const response = await requestJson({
|
|
838
|
+
method: "POST",
|
|
839
|
+
url: `${baseUrl}/cli/v1/provisioning/session/exchange`,
|
|
840
|
+
body: { bootstrapToken }
|
|
841
|
+
});
|
|
842
|
+
spinner.succeed("Bootstrap token exchanged.");
|
|
843
|
+
return response;
|
|
844
|
+
} catch (err) {
|
|
845
|
+
spinner.fail("Bootstrap token exchange failed.");
|
|
846
|
+
throw err;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
async function openMoreSettings(url) {
|
|
850
|
+
const spinner = ora("Opening tenant settings in browser...").start();
|
|
851
|
+
try {
|
|
852
|
+
await open(url);
|
|
853
|
+
spinner.succeed("Browser opened.");
|
|
854
|
+
} catch {
|
|
855
|
+
spinner.warn(`Could not open browser automatically. Open manually: ${url}`);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
async function openPortal(portalUrl) {
|
|
859
|
+
const spinner = ora("Opening browser...").start();
|
|
860
|
+
try {
|
|
861
|
+
await open(portalUrl);
|
|
862
|
+
spinner.succeed("Browser opened. Login and generate your bootstrap token.");
|
|
863
|
+
} catch {
|
|
864
|
+
spinner.warn(`Could not open browser automatically. Open this URL manually: ${portalUrl}`);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
async function logout(apiBaseUrl, sessionToken) {
|
|
868
|
+
try {
|
|
869
|
+
const response = await requestJson({
|
|
870
|
+
method: "POST",
|
|
871
|
+
url: `${apiBaseUrl}/cli/v1/provisioning/session/logout`,
|
|
872
|
+
headers: {
|
|
873
|
+
Authorization: `Bearer ${sessionToken}`
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
if (response.revoked) {
|
|
877
|
+
process.stdout.write(kleur2.gray("Server-side session revoked.\n"));
|
|
878
|
+
}
|
|
879
|
+
} catch (err) {
|
|
880
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
881
|
+
process.stdout.write(kleur2.yellow(`Logout request failed: ${message}
|
|
882
|
+
`));
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
function printSessionInfo(status) {
|
|
886
|
+
process.stdout.write(`
|
|
887
|
+
${kleur2.bold("Active tenant")}: ${status.tenantId}
|
|
888
|
+
`);
|
|
889
|
+
process.stdout.write(`Tier: ${status.paymentTier}
|
|
890
|
+
`);
|
|
891
|
+
process.stdout.write(`Session expires: ${status.expiresAt}
|
|
892
|
+
`);
|
|
893
|
+
process.stdout.write(`Existing clients: ${status.existingClients.length}
|
|
894
|
+
`);
|
|
895
|
+
}
|
|
896
|
+
function buildProvisioningLoginUrl(portalBaseUrl, mode, tenantId, options) {
|
|
897
|
+
const normalizedTenantId = normalizeTenantId(tenantId);
|
|
898
|
+
const tenantPortalBaseUrl = buildTenantPortalBaseUrl(portalBaseUrl, normalizedTenantId);
|
|
899
|
+
const returnUrl = new URL("/admin/provisioning/cli", tenantPortalBaseUrl);
|
|
900
|
+
returnUrl.searchParams.set("mode", mode);
|
|
901
|
+
returnUrl.searchParams.set("tenantId", normalizedTenantId);
|
|
902
|
+
const loginUrl = new URL("/login", tenantPortalBaseUrl);
|
|
903
|
+
loginUrl.searchParams.set("sso", "manual");
|
|
904
|
+
loginUrl.searchParams.set("tenantId", normalizedTenantId);
|
|
905
|
+
const returnPathAndQuery = options?.returnPathAndQuery ?? `${returnUrl.pathname}${returnUrl.search}`;
|
|
906
|
+
loginUrl.searchParams.set("return", returnPathAndQuery);
|
|
907
|
+
const prefillEmail = options?.prefillEmail?.trim();
|
|
908
|
+
if (prefillEmail) {
|
|
909
|
+
loginUrl.searchParams.set("email", prefillEmail);
|
|
910
|
+
}
|
|
911
|
+
return loginUrl.toString();
|
|
912
|
+
}
|
|
913
|
+
function normalizeTenantId(value) {
|
|
914
|
+
return value.trim().toLowerCase();
|
|
915
|
+
}
|
|
916
|
+
function buildTenantPortalBaseUrl(portalBaseUrl, tenantId) {
|
|
917
|
+
const parsed = new URL(normalizeBaseUrl(portalBaseUrl));
|
|
918
|
+
const hostname = parsed.hostname.trim();
|
|
919
|
+
if (!hostname) {
|
|
920
|
+
return normalizeBaseUrl(portalBaseUrl);
|
|
921
|
+
}
|
|
922
|
+
const isIpAddress = /^(?:\d{1,3}\.){3}\d{1,3}$/.test(hostname) || hostname === "localhost" || hostname.startsWith("127.") || hostname === "0.0.0.0";
|
|
923
|
+
const hostWithTenant = isIpAddress ? hostname : `${tenantId}.${hostname}`;
|
|
924
|
+
const host = parsed.port ? `${hostWithTenant}:${parsed.port}` : hostWithTenant;
|
|
925
|
+
return `${parsed.protocol}//${host}`;
|
|
926
|
+
}
|
|
927
|
+
function parseCliArgs(argv) {
|
|
928
|
+
let tuurioHost;
|
|
929
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
930
|
+
const arg = argv[index];
|
|
931
|
+
if (arg === "--tuurio-host") {
|
|
932
|
+
const value = argv[index + 1];
|
|
933
|
+
if (!value || value.startsWith("--")) {
|
|
934
|
+
throw new Error("Missing value for --tuurio-host");
|
|
935
|
+
}
|
|
936
|
+
tuurioHost = value.trim();
|
|
937
|
+
index += 1;
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
if (arg.startsWith("--tuurio-host=")) {
|
|
941
|
+
tuurioHost = arg.slice("--tuurio-host=".length).trim();
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return { tuurioHost };
|
|
945
|
+
}
|
|
946
|
+
function buildPortalBaseUrlFromHost(hostOverride) {
|
|
947
|
+
if (!isDevRuntime()) {
|
|
948
|
+
throw new Error("--tuurio-host is only available in local dev runtime and disabled in published builds.");
|
|
949
|
+
}
|
|
950
|
+
const raw = hostOverride.trim();
|
|
951
|
+
if (!raw) {
|
|
952
|
+
throw new Error("Host override must not be empty.");
|
|
953
|
+
}
|
|
954
|
+
const hasScheme = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(raw);
|
|
955
|
+
const useHttp = /(localhost|127\.0\.0\.1|0\.0\.0\.0)/i.test(raw);
|
|
956
|
+
const candidate = hasScheme ? raw : `${useHttp ? "http" : "https"}://${raw}`;
|
|
957
|
+
if (!urlSchema.safeParse(candidate).success) {
|
|
958
|
+
throw new Error(`Invalid --tuurio-host value: '${hostOverride}'`);
|
|
959
|
+
}
|
|
960
|
+
const parsed = new URL(candidate);
|
|
961
|
+
const hostWithPort = parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname;
|
|
962
|
+
return `${parsed.protocol}//${hostWithPort}`;
|
|
963
|
+
}
|
|
964
|
+
function isDevRuntime() {
|
|
965
|
+
if (process.env.TUURIO_CLI_DEV === "1") return true;
|
|
966
|
+
const argvEntry = process.argv[1] ?? "";
|
|
967
|
+
const fromSrcPath = /(^|[\\/])src([\\/]|$)/.test(argvEntry);
|
|
968
|
+
const fromSrcUrl = /(^|[\\/])src([\\/]|$)/.test(import.meta.url);
|
|
969
|
+
return fromSrcPath || fromSrcUrl;
|
|
970
|
+
}
|
|
971
|
+
function frameworkSlugFor(framework) {
|
|
972
|
+
switch (framework) {
|
|
973
|
+
case "ANGULAR_SPA":
|
|
974
|
+
return "angular-spa";
|
|
975
|
+
case "REACT_SPA":
|
|
976
|
+
return "react-spa";
|
|
977
|
+
case "VUE_SPA":
|
|
978
|
+
return "vue-spa";
|
|
979
|
+
default:
|
|
980
|
+
return framework.toLowerCase().replaceAll("_", "-");
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
function parseEnvFile(content) {
|
|
984
|
+
const parsed = {};
|
|
985
|
+
for (const line of content.split(/\r?\n/)) {
|
|
986
|
+
const trimmed = line.trim();
|
|
987
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
988
|
+
const separator = trimmed.indexOf("=");
|
|
989
|
+
if (separator <= 0) continue;
|
|
990
|
+
const key = trimmed.slice(0, separator).trim();
|
|
991
|
+
const value = trimmed.slice(separator + 1).trim();
|
|
992
|
+
parsed[key] = value;
|
|
993
|
+
}
|
|
994
|
+
return parsed;
|
|
995
|
+
}
|
|
996
|
+
function authSamplesDirectoryFor(framework) {
|
|
997
|
+
switch (framework) {
|
|
998
|
+
case "ANGULAR_SPA":
|
|
999
|
+
return "auth_samples_angular";
|
|
1000
|
+
case "REACT_SPA":
|
|
1001
|
+
return "auth_samples_react";
|
|
1002
|
+
case "VUE_SPA":
|
|
1003
|
+
return "auth_samples_vue3";
|
|
1004
|
+
case "NODE_JS_WEB":
|
|
1005
|
+
return "auth_samples_node";
|
|
1006
|
+
case "PYTHON_WEB":
|
|
1007
|
+
return "auth_samples_python";
|
|
1008
|
+
case "SPRING_WEB":
|
|
1009
|
+
return "auth_samples_java";
|
|
1010
|
+
case "GO_WEB":
|
|
1011
|
+
return "auth_samples_go";
|
|
1012
|
+
case "PHP_WEB":
|
|
1013
|
+
return "auth_samples_php";
|
|
1014
|
+
default:
|
|
1015
|
+
throw new Error(`Downloading auth_samples is not supported for framework ${framework}`);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
function spaEnvKeysFor(framework) {
|
|
1019
|
+
switch (framework) {
|
|
1020
|
+
case "ANGULAR_SPA":
|
|
1021
|
+
return {
|
|
1022
|
+
issuer: "TUURIO_ISSUER",
|
|
1023
|
+
clientId: "TUURIO_CLIENT_ID",
|
|
1024
|
+
redirectUri: "TUURIO_REDIRECT_URI",
|
|
1025
|
+
postLogoutRedirectUri: "TUURIO_POST_LOGOUT_REDIRECT_URI",
|
|
1026
|
+
scope: "TUURIO_SCOPE"
|
|
1027
|
+
};
|
|
1028
|
+
case "REACT_SPA":
|
|
1029
|
+
case "VUE_SPA":
|
|
1030
|
+
return {
|
|
1031
|
+
issuer: "VITE_TUURIO_ISSUER",
|
|
1032
|
+
clientId: "VITE_TUURIO_CLIENT_ID",
|
|
1033
|
+
redirectUri: "VITE_TUURIO_REDIRECT_URI",
|
|
1034
|
+
postLogoutRedirectUri: "VITE_TUURIO_POST_LOGOUT_REDIRECT_URI",
|
|
1035
|
+
scope: "VITE_TUURIO_SCOPE"
|
|
1036
|
+
};
|
|
1037
|
+
default:
|
|
1038
|
+
throw new Error(`Unsupported SPA framework for env mapping: ${framework}`);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
async function downloadSampleFromGithub(framework, outputRoot) {
|
|
1042
|
+
const sampleDir = authSamplesDirectoryFor(framework);
|
|
1043
|
+
const spinner = ora(`Downloading ${sampleDir} from GitHub...`).start();
|
|
1044
|
+
const tempRoot = await mkdtemp(path.join(tmpdir(), "manage-tuurio-id-"));
|
|
1045
|
+
try {
|
|
1046
|
+
await runCommand("git", [
|
|
1047
|
+
"clone",
|
|
1048
|
+
"--depth",
|
|
1049
|
+
"1",
|
|
1050
|
+
"--filter=blob:none",
|
|
1051
|
+
"--sparse",
|
|
1052
|
+
AUTH_SAMPLES_REPO_URL,
|
|
1053
|
+
tempRoot
|
|
1054
|
+
]);
|
|
1055
|
+
await runCommand("git", ["-C", tempRoot, "sparse-checkout", "set", sampleDir]);
|
|
1056
|
+
const sourceDir = path.join(tempRoot, sampleDir);
|
|
1057
|
+
await mkdir(outputRoot, { recursive: true });
|
|
1058
|
+
await copyDirectoryContents(sourceDir, outputRoot);
|
|
1059
|
+
spinner.succeed(`Downloaded ${sampleDir}.`);
|
|
1060
|
+
} catch (err) {
|
|
1061
|
+
spinner.fail("Downloading sample from GitHub failed.");
|
|
1062
|
+
throw err;
|
|
1063
|
+
} finally {
|
|
1064
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
async function copyDirectoryContents(sourceDir, targetDir) {
|
|
1068
|
+
const entries = await readdir(sourceDir);
|
|
1069
|
+
for (const entry of entries) {
|
|
1070
|
+
await cp(path.join(sourceDir, entry), path.join(targetDir, entry), {
|
|
1071
|
+
recursive: true,
|
|
1072
|
+
force: true
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
async function writeSpaSampleEnvFile(outputRoot, framework, generatedEnv) {
|
|
1077
|
+
const keys = spaEnvKeysFor(framework);
|
|
1078
|
+
const issuer = generatedEnv.TUURIO_ISSUER ?? "";
|
|
1079
|
+
const clientId = generatedEnv.TUURIO_CLIENT_ID ?? "";
|
|
1080
|
+
const redirectUri = generatedEnv.TUURIO_REDIRECT_URI ?? defaultRedirectUriFor(framework);
|
|
1081
|
+
const postLogoutRedirectUri = generatedEnv.TUURIO_POST_LOGOUT_REDIRECT_URI ?? derivePostLogoutRedirectUri(redirectUri, defaultPostLogoutRedirectUriFor(framework));
|
|
1082
|
+
const scope = generatedEnv.TUURIO_SCOPE ?? "openid profile email";
|
|
1083
|
+
const envContent = [
|
|
1084
|
+
`${keys.issuer}=${issuer}`,
|
|
1085
|
+
`${keys.clientId}=${clientId}`,
|
|
1086
|
+
`${keys.redirectUri}=${redirectUri}`,
|
|
1087
|
+
`${keys.postLogoutRedirectUri}=${postLogoutRedirectUri}`,
|
|
1088
|
+
`${keys.scope}=${scope}`
|
|
1089
|
+
].join("\n");
|
|
1090
|
+
await writeFile(path.join(outputRoot, ".env"), envContent + "\n", { mode: 384 });
|
|
1091
|
+
}
|
|
1092
|
+
async function writeServerSampleEnvFile(outputRoot, framework, generatedEnv) {
|
|
1093
|
+
const redirectUri = generatedEnv.TUURIO_REDIRECT_URI ?? defaultServerRedirectUriFor(framework);
|
|
1094
|
+
const postLogoutRedirectUri = generatedEnv.TUURIO_POST_LOGOUT_REDIRECT_URI ?? derivePostLogoutRedirectUri(redirectUri, defaultServerPostLogoutRedirectUriFor(framework));
|
|
1095
|
+
const envValues = {
|
|
1096
|
+
TUURIO_ISSUER: generatedEnv.TUURIO_ISSUER ?? "",
|
|
1097
|
+
TUURIO_CLIENT_ID: generatedEnv.TUURIO_CLIENT_ID ?? "",
|
|
1098
|
+
TUURIO_CLIENT_SECRET: generatedEnv.TUURIO_CLIENT_SECRET ?? "",
|
|
1099
|
+
TUURIO_REDIRECT_URI: redirectUri,
|
|
1100
|
+
TUURIO_POST_LOGOUT_REDIRECT_URI: postLogoutRedirectUri,
|
|
1101
|
+
TUURIO_SCOPE: generatedEnv.TUURIO_SCOPE ?? defaultServerScopeFor(framework),
|
|
1102
|
+
TUURIO_WEBHOOK_ID: generatedEnv.TUURIO_WEBHOOK_ID ?? "",
|
|
1103
|
+
TUURIO_WEBHOOK_SIGNING_SECRET: generatedEnv.TUURIO_WEBHOOK_SIGNING_SECRET ?? "",
|
|
1104
|
+
TUURIO_WEBHOOK_URL: generatedEnv.TUURIO_WEBHOOK_URL ?? "",
|
|
1105
|
+
TUURIO_WEBHOOK_EDIT_URL: generatedEnv.TUURIO_WEBHOOK_EDIT_URL ?? "",
|
|
1106
|
+
TUURIO_WEBHOOK_LISTEN_PATH: generatedEnv.TUURIO_WEBHOOK_LISTEN_PATH ?? "/webhooks/tuurio",
|
|
1107
|
+
TUURIO_WEBHOOK_API_KEY_HEADER: generatedEnv.TUURIO_WEBHOOK_API_KEY_HEADER ?? "X-Tuurio-Webhook-Key",
|
|
1108
|
+
TUURIO_WEBHOOK_API_KEY: generatedEnv.TUURIO_WEBHOOK_API_KEY ?? ""
|
|
1109
|
+
};
|
|
1110
|
+
await writeEnvFileFromTemplate(outputRoot, envValues);
|
|
1111
|
+
}
|
|
1112
|
+
function defaultServerRedirectUriFor(framework) {
|
|
1113
|
+
switch (framework) {
|
|
1114
|
+
case "NODE_JS_WEB":
|
|
1115
|
+
return "http://localhost:8082/auth/callback";
|
|
1116
|
+
case "PYTHON_WEB":
|
|
1117
|
+
return "http://localhost:8083/auth/callback";
|
|
1118
|
+
case "GO_WEB":
|
|
1119
|
+
return "http://localhost:8084/auth/callback";
|
|
1120
|
+
case "SPRING_WEB":
|
|
1121
|
+
return "http://localhost:8085/auth/callback";
|
|
1122
|
+
case "PHP_WEB":
|
|
1123
|
+
return "http://localhost:8080/auth/callback";
|
|
1124
|
+
default:
|
|
1125
|
+
return "http://localhost:8082/auth/callback";
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
function defaultServerPostLogoutRedirectUriFor(framework) {
|
|
1129
|
+
switch (framework) {
|
|
1130
|
+
case "NODE_JS_WEB":
|
|
1131
|
+
return "http://localhost:8082/";
|
|
1132
|
+
case "PYTHON_WEB":
|
|
1133
|
+
return "http://localhost:8083/";
|
|
1134
|
+
case "GO_WEB":
|
|
1135
|
+
return "http://localhost:8084/";
|
|
1136
|
+
case "SPRING_WEB":
|
|
1137
|
+
return "http://localhost:8085/";
|
|
1138
|
+
case "PHP_WEB":
|
|
1139
|
+
return "http://localhost:8080/";
|
|
1140
|
+
default:
|
|
1141
|
+
return "http://localhost:8082/";
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
function defaultServerScopeFor(framework) {
|
|
1145
|
+
if (framework === "SPRING_WEB") {
|
|
1146
|
+
return "openid,profile,email";
|
|
1147
|
+
}
|
|
1148
|
+
return "openid profile email";
|
|
1149
|
+
}
|
|
1150
|
+
async function writeEnvFileFromTemplate(outputRoot, values) {
|
|
1151
|
+
const templatePath = path.join(outputRoot, ".env.example");
|
|
1152
|
+
let template = "";
|
|
1153
|
+
try {
|
|
1154
|
+
template = await readFile(templatePath, "utf8");
|
|
1155
|
+
} catch {
|
|
1156
|
+
template = "";
|
|
1157
|
+
}
|
|
1158
|
+
if (!template) {
|
|
1159
|
+
const content = Object.entries(values).map(([key, value]) => `${key}=${value}`).join("\n");
|
|
1160
|
+
await writeFile(path.join(outputRoot, ".env"), content + "\n", { mode: 384 });
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1164
|
+
const lines = template.split(/\r?\n/);
|
|
1165
|
+
const out = [];
|
|
1166
|
+
for (const line of lines) {
|
|
1167
|
+
const trimmed = line.trim();
|
|
1168
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
1169
|
+
out.push(line);
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
const separator = trimmed.indexOf("=");
|
|
1173
|
+
if (separator <= 0) {
|
|
1174
|
+
out.push(line);
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
const key = trimmed.slice(0, separator).trim();
|
|
1178
|
+
if (Object.hasOwn(values, key)) {
|
|
1179
|
+
out.push(`${key}=${values[key]}`);
|
|
1180
|
+
seen.add(key);
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
out.push(line);
|
|
1184
|
+
}
|
|
1185
|
+
for (const [key, value] of Object.entries(values)) {
|
|
1186
|
+
if (seen.has(key)) continue;
|
|
1187
|
+
out.push(`${key}=${value}`);
|
|
1188
|
+
}
|
|
1189
|
+
const normalized = out.join("\n").replace(/\n*$/, "\n");
|
|
1190
|
+
await writeFile(path.join(outputRoot, ".env"), normalized, { mode: 384 });
|
|
1191
|
+
}
|
|
1192
|
+
function defaultRedirectUriFor(framework) {
|
|
1193
|
+
switch (framework) {
|
|
1194
|
+
case "ANGULAR_SPA":
|
|
1195
|
+
return "http://localhost:4200/auth/callback";
|
|
1196
|
+
case "REACT_SPA":
|
|
1197
|
+
case "VUE_SPA":
|
|
1198
|
+
return "http://localhost:5173/auth/callback";
|
|
1199
|
+
default:
|
|
1200
|
+
return "http://localhost:5173/auth/callback";
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
function defaultPostLogoutRedirectUriFor(framework) {
|
|
1204
|
+
switch (framework) {
|
|
1205
|
+
case "ANGULAR_SPA":
|
|
1206
|
+
return "http://localhost:4200/";
|
|
1207
|
+
case "REACT_SPA":
|
|
1208
|
+
case "VUE_SPA":
|
|
1209
|
+
return "http://localhost:5173/";
|
|
1210
|
+
default:
|
|
1211
|
+
return "http://localhost:5173/";
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
function derivePostLogoutRedirectUri(redirectUri, fallback) {
|
|
1215
|
+
try {
|
|
1216
|
+
const parsed = new URL(redirectUri);
|
|
1217
|
+
parsed.pathname = "/";
|
|
1218
|
+
parsed.search = "";
|
|
1219
|
+
parsed.hash = "";
|
|
1220
|
+
return parsed.toString();
|
|
1221
|
+
} catch {
|
|
1222
|
+
return fallback;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
async function runCommand(command, args) {
|
|
1226
|
+
await new Promise((resolve, reject) => {
|
|
1227
|
+
const child = spawn(command, args, {
|
|
1228
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1229
|
+
});
|
|
1230
|
+
let stderr = "";
|
|
1231
|
+
child.stderr.on("data", (chunk) => {
|
|
1232
|
+
stderr += String(chunk);
|
|
1233
|
+
});
|
|
1234
|
+
child.on("error", (err) => {
|
|
1235
|
+
reject(err);
|
|
1236
|
+
});
|
|
1237
|
+
child.on("close", (code) => {
|
|
1238
|
+
if (code === 0) {
|
|
1239
|
+
resolve();
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
const suffix = stderr.trim() ? `: ${stderr.trim()}` : "";
|
|
1243
|
+
reject(new Error(`${command} ${args.join(" ")} exited with code ${code}${suffix}`));
|
|
1244
|
+
});
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
async function loadSession() {
|
|
1248
|
+
try {
|
|
1249
|
+
const raw = await readFile(SESSION_FILE, "utf8");
|
|
1250
|
+
const parsed = JSON.parse(raw);
|
|
1251
|
+
if (!parsed.sessionToken || !parsed.apiBaseUrl) {
|
|
1252
|
+
return null;
|
|
1253
|
+
}
|
|
1254
|
+
return parsed;
|
|
1255
|
+
} catch {
|
|
1256
|
+
return null;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
async function saveSession(session) {
|
|
1260
|
+
await mkdir(path.dirname(SESSION_FILE), { recursive: true });
|
|
1261
|
+
await writeFile(SESSION_FILE, JSON.stringify(session, null, 2) + "\n", {
|
|
1262
|
+
mode: 384
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
async function clearSavedSession() {
|
|
1266
|
+
try {
|
|
1267
|
+
await unlink(SESSION_FILE);
|
|
1268
|
+
} catch {
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
function toNullableString(value) {
|
|
1272
|
+
const normalized = String(value ?? "").trim();
|
|
1273
|
+
return normalized.length > 0 ? normalized : null;
|
|
1274
|
+
}
|
|
1275
|
+
function validateOptionalUrlPrompt(value) {
|
|
1276
|
+
const normalized = value.trim();
|
|
1277
|
+
if (!normalized) return true;
|
|
1278
|
+
return urlSchema.safeParse(normalized).success ? true : "Enter a valid URL";
|
|
1279
|
+
}
|
|
1280
|
+
function normalizeBaseUrl(value) {
|
|
1281
|
+
return value.trim().replace(/\/+$/, "");
|
|
1282
|
+
}
|
|
1283
|
+
function deriveApiBaseUrl(portalBaseUrl) {
|
|
1284
|
+
const parsed = new URL(normalizeBaseUrl(portalBaseUrl));
|
|
1285
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
1286
|
+
}
|
|
1287
|
+
function printHeader() {
|
|
1288
|
+
process.stdout.write(kleur2.bold().cyan("manage-tuurio-id\n"));
|
|
1289
|
+
process.stdout.write(
|
|
1290
|
+
kleur2.gray("Choose Login, New, or Website to start provisioning with Tuurio ID.\n\n")
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
main().catch((err) => {
|
|
1294
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1295
|
+
process.stderr.write(kleur2.red(`Error: ${message}
|
|
1296
|
+
`));
|
|
1297
|
+
process.exit(1);
|
|
1298
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "manage-tuurio-id",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold apps and provision Tuurio ID configuration via secure short-lived tokens.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"homepage": "https://id.tuurio.com",
|
|
7
|
+
"bin": {
|
|
8
|
+
"manage-tuurio-id": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsup src/index.ts --format esm --dts --clean --target node18",
|
|
19
|
+
"dev": "tsx src/index.ts",
|
|
20
|
+
"dev:localhost": "tsx src/index.ts --tuurio-host id.localhost:8080",
|
|
21
|
+
"typecheck": "tsc --noEmit"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"tuurio",
|
|
25
|
+
"oidc",
|
|
26
|
+
"scaffold",
|
|
27
|
+
"cli"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"kleur": "^4.1.5",
|
|
32
|
+
"open": "^10.2.0",
|
|
33
|
+
"ora": "^8.1.0",
|
|
34
|
+
"prompts": "^2.4.2",
|
|
35
|
+
"undici": "^7.13.0",
|
|
36
|
+
"zod": "^3.25.67"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^22.15.3",
|
|
40
|
+
"tsup": "^8.5.0",
|
|
41
|
+
"tsx": "^4.20.3",
|
|
42
|
+
"typescript": "^5.8.3"
|
|
43
|
+
}
|
|
44
|
+
}
|