@zitadel/cli 0.1.0-alpha.9 → 1.0.0-alpha.20
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 +250 -62
- package/SKILLS.md +221 -37
- package/dist/branding-cygSBPkV.mjs +79 -0
- package/dist/branding-cygSBPkV.mjs.map +1 -0
- package/dist/claim-state-DlfD6Res.mjs +66 -0
- package/dist/claim-state-DlfD6Res.mjs.map +1 -0
- package/dist/commands/apply.mjs +27 -8
- package/dist/commands/apply.mjs.map +1 -1
- package/dist/commands/branding/eject.mjs +104 -0
- package/dist/commands/branding/eject.mjs.map +1 -0
- package/dist/commands/claim.mjs +402 -0
- package/dist/commands/claim.mjs.map +1 -0
- package/dist/commands/doctor.mjs +594 -41
- package/dist/commands/doctor.mjs.map +1 -1
- package/dist/commands/eject.mjs +18 -6
- package/dist/commands/eject.mjs.map +1 -1
- package/dist/commands/logs.mjs +2 -2
- package/dist/commands/plan.mjs +19 -6
- package/dist/commands/plan.mjs.map +1 -1
- package/dist/commands/reset.mjs +2 -2
- package/dist/commands/schemas/list.mjs +146 -0
- package/dist/commands/schemas/list.mjs.map +1 -0
- package/dist/commands/setup.mjs +629 -214
- package/dist/commands/setup.mjs.map +1 -1
- package/dist/commands/start.mjs +4 -4
- package/dist/commands/start.mjs.map +1 -1
- package/dist/commands/status.mjs +52 -13
- package/dist/commands/status.mjs.map +1 -1
- package/dist/commands/stop.mjs +3 -3
- package/dist/commands/stop.mjs.map +1 -1
- package/dist/designs-Ckz18Dpo.mjs +38 -0
- package/dist/designs-Ckz18Dpo.mjs.map +1 -0
- package/dist/{docker-CnGQK3ZK.mjs → docker-DcRGTOJa.mjs} +5 -4
- package/dist/docker-DcRGTOJa.mjs.map +1 -0
- package/dist/environment-rjRVkJjW.mjs +17 -0
- package/dist/environment-rjRVkJjW.mjs.map +1 -0
- package/dist/journey-guidance-BGrOX_gT.mjs +40 -0
- package/dist/journey-guidance-BGrOX_gT.mjs.map +1 -0
- package/dist/oclif-CanO3zdt.mjs +1769 -0
- package/dist/oclif-CanO3zdt.mjs.map +1 -0
- package/dist/{orca-U142Wrau.mjs → orca-BzQIQqzt.mjs} +1663 -303
- package/dist/orca-BzQIQqzt.mjs.map +1 -0
- package/dist/package-manager-DFdLcDx1.mjs +194 -0
- package/dist/package-manager-DFdLcDx1.mjs.map +1 -0
- package/dist/{ports-B09RjuHx.mjs → ports-BM20XIZb.mjs} +25 -20
- package/dist/ports-BM20XIZb.mjs.map +1 -0
- package/dist/{processes-Cw8TO1SY.mjs → processes-DKHPkU8O.mjs} +1 -1
- package/dist/{processes-Cw8TO1SY.mjs.map → processes-DKHPkU8O.mjs.map} +1 -1
- package/dist/{project-Cd0L3PtM.mjs → project-CHhot85s.mjs} +57 -4
- package/dist/project-CHhot85s.mjs.map +1 -0
- package/dist/sync-BBlACKzT.mjs +1633 -0
- package/dist/sync-BBlACKzT.mjs.map +1 -0
- package/dist/user-schema-DTuOsdKE.mjs +91 -0
- package/dist/user-schema-DTuOsdKE.mjs.map +1 -0
- package/oclif.manifest.json +406 -5
- package/package.json +10 -5
- package/dist/docker-CnGQK3ZK.mjs.map +0 -1
- package/dist/docker-guidance-ypN3IM3o.mjs +0 -21
- package/dist/docker-guidance-ypN3IM3o.mjs.map +0 -1
- package/dist/oclif-B7lBzh3R.mjs +0 -808
- package/dist/oclif-B7lBzh3R.mjs.map +0 -1
- package/dist/orca-U142Wrau.mjs.map +0 -1
- package/dist/ports-B09RjuHx.mjs.map +0 -1
- package/dist/project-Cd0L3PtM.mjs.map +0 -1
- package/dist/sync-BojoQm2P.mjs +0 -733
- package/dist/sync-BojoQm2P.mjs.map +0 -1
|
@@ -0,0 +1,1769 @@
|
|
|
1
|
+
import { access, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { Command, Flags } from "@oclif/core";
|
|
4
|
+
import consola from "consola";
|
|
5
|
+
import { ApiError, apiErrorMessage } from "@zitadel/api/runtime/fetch";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { stringify } from "safe-stable-stringify";
|
|
8
|
+
import { constants, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
10
|
+
import mixpanelLib from "mixpanel";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
//#region src/lib/oclif/server-kind.ts
|
|
13
|
+
/**
|
|
14
|
+
* Buckets the resolved backend `source` into a coarse kind. The raw URL is never
|
|
15
|
+
* emitted — it can carry an internal/self-hosted hostname — only which kind of
|
|
16
|
+
* backend the command targeted.
|
|
17
|
+
*/
|
|
18
|
+
var ServerKind = class {
|
|
19
|
+
value(source) {
|
|
20
|
+
if (source === "mock") return "local";
|
|
21
|
+
if (!URL.canParse(source)) return "unknown";
|
|
22
|
+
const { hostname } = new URL(source);
|
|
23
|
+
if (hostname === "zitadel.cloud" || hostname.endsWith(".zitadel.cloud")) return "cloud";
|
|
24
|
+
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return "local";
|
|
25
|
+
return "self_hosted";
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const serverKind = new ServerKind();
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/lib/public-cli.ts
|
|
31
|
+
const CLI_PACKAGE_NAME = "@zitadel/cli";
|
|
32
|
+
function npmDistTagForCliVersion(cliVersion) {
|
|
33
|
+
return cliVersion.trim().replace(/^v/, "").match(/^\d+\.\d+\.\d+-([0-9A-Za-z][0-9A-Za-z-]*)/)?.[1] ?? "latest";
|
|
34
|
+
}
|
|
35
|
+
function npmSelectorForCliVersion(cliVersion) {
|
|
36
|
+
const normalized = cliVersion.trim().replace(/^v/, "");
|
|
37
|
+
if (/^\d+\.\d+\.\d+-alpha\.\d+$/.test(normalized)) return normalized;
|
|
38
|
+
return npmDistTagForCliVersion(normalized);
|
|
39
|
+
}
|
|
40
|
+
function publicCliCommand(args, cliVersion) {
|
|
41
|
+
const prefix = `npx ${CLI_PACKAGE_NAME}@${npmSelectorForCliVersion(cliVersion)}`;
|
|
42
|
+
return args.length > 0 ? `${prefix} ${args}` : prefix;
|
|
43
|
+
}
|
|
44
|
+
function normalizePublicCliCommand(command, cliVersion) {
|
|
45
|
+
if (command === "zitadel") return publicCliCommand("", cliVersion);
|
|
46
|
+
if (command.startsWith("zitadel ")) return publicCliCommand(command.slice(8), cliVersion);
|
|
47
|
+
return command;
|
|
48
|
+
}
|
|
49
|
+
function normalizePublicCliCommands(commands, cliVersion) {
|
|
50
|
+
return commands?.map((command) => normalizePublicCliCommand(command, cliVersion));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Rewrites `zitadel …` command mentions in scaffolded prose (the
|
|
54
|
+
* `.zitadel/**` READMEs) to the public `npx @zitadel/cli@<version> …` form.
|
|
55
|
+
*
|
|
56
|
+
* The checked-in README sources stay written with the bare `zitadel`
|
|
57
|
+
* command — that is the canonical, readable spelling — but the CLI is not a
|
|
58
|
+
* dependency of the scaffolded app, so the bare command does not exist on
|
|
59
|
+
* the user's PATH (and `npx zitadel` would fetch an unrelated npm package).
|
|
60
|
+
* Covers inline code spans (`` `zitadel plan` ``) and fenced-block lines
|
|
61
|
+
* that start with `zitadel `; prose file names like `zitadel.json` and
|
|
62
|
+
* `zitadel.db` don't match because the word must end the span or be
|
|
63
|
+
* followed by a space.
|
|
64
|
+
*/
|
|
65
|
+
function normalizePublicCliProse(content, cliVersion) {
|
|
66
|
+
const prefix = publicCliCommand("", cliVersion);
|
|
67
|
+
return rewriteCliCodeSpans(content, cliVersion).replace(/^(\s*)zitadel (.*)$/gm, (_match, indent, rest) => `${indent}${prefix} ${rest}`);
|
|
68
|
+
}
|
|
69
|
+
/** The inline-code-span half of {@link normalizePublicCliProse}. */
|
|
70
|
+
function rewriteCliCodeSpans(content, cliVersion) {
|
|
71
|
+
const prefix = publicCliCommand("", cliVersion);
|
|
72
|
+
return content.replace(/`zitadel( [^`\n]*)?`/g, (_match, args) => `\`${prefix}${args ?? ""}\``);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Rewrites `zitadel …` command mentions inside every string of a scaffolded
|
|
76
|
+
* JSON document — today the `.zitadel/meta/*.json` dialect files — returning
|
|
77
|
+
* a rewritten copy. The input is never mutated: the meta-schema bodies are
|
|
78
|
+
* imported JSON modules shared across calls.
|
|
79
|
+
*
|
|
80
|
+
* Those files are byte-copies of the meta-schemas the server embeds
|
|
81
|
+
* (`api/openapi/endpoints/schemas/*.json`), where the bare `zitadel` spelling
|
|
82
|
+
* is the correct one — the server documents the product command, not one
|
|
83
|
+
* project's install. Their `description` strings surface as editor tooltips
|
|
84
|
+
* on the scaffolded `.zitadel/**` files, where the bare command does not
|
|
85
|
+
* exist on the user's PATH, so the copy is normalized on the way out instead
|
|
86
|
+
* of the shared source being edited (same fix PR #872 made for the READMEs).
|
|
87
|
+
*
|
|
88
|
+
* Unlike {@link normalizePublicCliProse} this rewrites inline code spans
|
|
89
|
+
* only: a JSON `description` is prose, so a value that merely begins with
|
|
90
|
+
* the word `zitadel` is a sentence, not a command line.
|
|
91
|
+
*/
|
|
92
|
+
function normalizePublicCliJson(value, cliVersion) {
|
|
93
|
+
if (typeof value === "string") return rewriteCliCodeSpans(value, cliVersion);
|
|
94
|
+
if (Array.isArray(value)) return value.map((item) => normalizePublicCliJson(item, cliVersion));
|
|
95
|
+
if (typeof value === "object" && value !== null) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, normalizePublicCliJson(item, cliVersion)]));
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region src/lib/errors.ts
|
|
100
|
+
/**
|
|
101
|
+
* Maps each {@link ZitadelErrorCode} to the process exit code the CLI
|
|
102
|
+
* returns. The table is the single source of truth for exit semantics so
|
|
103
|
+
* scripts and CI can branch on stable, documented numbers.
|
|
104
|
+
*/
|
|
105
|
+
const EXIT_CODES = {
|
|
106
|
+
E_ALREADY_INIT: 0,
|
|
107
|
+
E_FRAMEWORK_NOT_DETECTED: 3,
|
|
108
|
+
E_UNSUPPORTED_PROJECT_SHAPE: 3,
|
|
109
|
+
E_NETWORK: 4,
|
|
110
|
+
E_AUTH: 1,
|
|
111
|
+
E_CONFLICT: 5,
|
|
112
|
+
E_LOCAL_SERVER_NOT_RUNNING: 4,
|
|
113
|
+
E_NOT_FOUND: 4,
|
|
114
|
+
E_PORT_IN_USE: 5,
|
|
115
|
+
E_VALIDATION: 3,
|
|
116
|
+
E_NOT_IMPLEMENTED: 2
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* The CLI's single error type. Carries a {@link ZitadelErrorCode} so the
|
|
120
|
+
* top-level handler can derive an exit code and structured output without
|
|
121
|
+
* pattern-matching on messages. Throwing this anywhere guarantees the user
|
|
122
|
+
* gets a categorised, hint-bearing failure instead of a raw stack trace.
|
|
123
|
+
*/
|
|
124
|
+
var ZitadelError = class extends Error {
|
|
125
|
+
code;
|
|
126
|
+
hint;
|
|
127
|
+
nextCommands;
|
|
128
|
+
details;
|
|
129
|
+
constructor(code, message, opts = {}) {
|
|
130
|
+
super(message);
|
|
131
|
+
this.name = "ZitadelError";
|
|
132
|
+
this.code = code;
|
|
133
|
+
this.hint = opts.hint;
|
|
134
|
+
this.nextCommands = opts.nextCommands;
|
|
135
|
+
this.details = opts.details;
|
|
136
|
+
}
|
|
137
|
+
get exitCode() {
|
|
138
|
+
return EXIT_CODES[this.code] ?? 1;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* Normalises any thrown value into a {@link ZitadelError}. Inspection is
|
|
143
|
+
* ordered most-specific-first (already-normalised, then errno/filesystem,
|
|
144
|
+
* network, Zod-like, generic `Error`, then a catch-all) so the most
|
|
145
|
+
* actionable category and hint win. This is the boundary that lets the rest
|
|
146
|
+
* of the CLI `throw` plain errors yet still produce consistent, categorised
|
|
147
|
+
* output. The original error shape is preserved under `details` for
|
|
148
|
+
* debugging without leaking it into the user-facing message.
|
|
149
|
+
*/
|
|
150
|
+
function toZitadelError(error) {
|
|
151
|
+
if (error instanceof ZitadelError) return error;
|
|
152
|
+
if (error instanceof ApiError) {
|
|
153
|
+
const details = {
|
|
154
|
+
status: error.status,
|
|
155
|
+
url: error.url,
|
|
156
|
+
body: error.body
|
|
157
|
+
};
|
|
158
|
+
if (error.status === 404) return new ZitadelError("E_NOT_FOUND", !isPlatformErrorEnvelope(error.body) ? `${apiErrorMessage(error)} — ${error.url} has no such endpoint; is this a Zitadel platform API?` : apiErrorMessage(error), { details });
|
|
159
|
+
return new ZitadelError(error.status === 401 || error.status === 403 ? "E_AUTH" : error.status >= 500 ? "E_NETWORK" : "E_VALIDATION", apiErrorMessage(error), { details });
|
|
160
|
+
}
|
|
161
|
+
if (isErrnoException(error)) {
|
|
162
|
+
const details = { original: pickErrorShape(error) };
|
|
163
|
+
if (error.code === "EACCES" || error.code === "EPERM") return new ZitadelError("E_AUTH", `Permission denied: ${error.message}`, {
|
|
164
|
+
hint: "Check file permissions or run with the right user.",
|
|
165
|
+
details
|
|
166
|
+
});
|
|
167
|
+
if (error.code === "EEXIST") return new ZitadelError("E_CONFLICT", error.message, {
|
|
168
|
+
hint: "A file already exists. Use --force to overwrite or remove it first.",
|
|
169
|
+
details
|
|
170
|
+
});
|
|
171
|
+
if (error.code === "ENOENT") return new ZitadelError("E_VALIDATION", error.message, {
|
|
172
|
+
hint: "A required file or directory is missing.",
|
|
173
|
+
details
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (isNetworkError(error)) return new ZitadelError("E_NETWORK", errorMessage(error), {
|
|
177
|
+
hint: "Check your connection, ZITADEL_API_BASE, or the configured server URL.",
|
|
178
|
+
details: { original: pickErrorShape(error) }
|
|
179
|
+
});
|
|
180
|
+
if (isZodLikeError(error)) return new ZitadelError("E_VALIDATION", errorMessage(error), { details: { issues: error.issues } });
|
|
181
|
+
if (error instanceof Error) return new ZitadelError("E_VALIDATION", error.message, { details: { original: pickErrorShape(error) } });
|
|
182
|
+
return new ZitadelError("E_VALIDATION", "Unknown error", { details: error });
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* True when a response body looks like the platform's structured error
|
|
186
|
+
* envelope (`{ code, message, … }`). Its presence proves the request reached
|
|
187
|
+
* a real Zitadel platform API rather than an arbitrary HTTP server.
|
|
188
|
+
*/
|
|
189
|
+
function isPlatformErrorEnvelope(body) {
|
|
190
|
+
return typeof body === "object" && body !== null && typeof body.code === "string";
|
|
191
|
+
}
|
|
192
|
+
function isErrnoException(error) {
|
|
193
|
+
return error instanceof Error && typeof error.code === "string";
|
|
194
|
+
}
|
|
195
|
+
function isNetworkError(error) {
|
|
196
|
+
if (!(error instanceof Error)) return false;
|
|
197
|
+
if (error.name === "TypeError" && /fetch failed|network|ECONNREFUSED|ENOTFOUND/i.test(error.message)) return true;
|
|
198
|
+
const cause = error.cause;
|
|
199
|
+
if (cause && typeof cause === "object" && "code" in cause) {
|
|
200
|
+
const code = String(cause.code);
|
|
201
|
+
return /^(ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|UND_ERR)/i.test(code);
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
function isZodLikeError(error) {
|
|
206
|
+
return typeof error === "object" && error !== null && "issues" in error && Array.isArray(error.issues);
|
|
207
|
+
}
|
|
208
|
+
function errorMessage(error) {
|
|
209
|
+
if (error instanceof Error) return error.message;
|
|
210
|
+
if (typeof error === "string") return error;
|
|
211
|
+
return String(error);
|
|
212
|
+
}
|
|
213
|
+
function pickErrorShape(error) {
|
|
214
|
+
return {
|
|
215
|
+
name: error.name,
|
|
216
|
+
message: error.message,
|
|
217
|
+
code: error.code
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
//#endregion
|
|
221
|
+
//#region src/lib/json.ts
|
|
222
|
+
/**
|
|
223
|
+
* Serialise a value to pretty-printed JSON with object keys sorted at every
|
|
224
|
+
* depth. Determinism is the point: managed files written by the CLI must be
|
|
225
|
+
* byte-stable across runs so diffs stay clean and content hashes don't churn
|
|
226
|
+
* when only key ordering would otherwise differ. Delegates the deterministic
|
|
227
|
+
* sort to `safe-stable-stringify`, matching `JSON.stringify(value, null, 2)`
|
|
228
|
+
* formatting. The `?? "null"` only applies to `undefined`/function inputs,
|
|
229
|
+
* which the CLI never serialises.
|
|
230
|
+
*/
|
|
231
|
+
function stableStringify(value) {
|
|
232
|
+
return stringify(value, null, 2) ?? "null";
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Replace (or insert) one top-level key's value in a JSON document by
|
|
236
|
+
* splicing text, leaving every other byte of the source untouched. The
|
|
237
|
+
* counterpart of {@link stableStringify} for files the CLI does *not* own
|
|
238
|
+
* (the user's `package.json`): blank lines, inline nested objects, key
|
|
239
|
+
* order, indentation style, line endings, and the trailing newline all
|
|
240
|
+
* survive, because nothing outside the spliced value is reserialized. The
|
|
241
|
+
* replaced value itself is rendered at the document's detected indent (a
|
|
242
|
+
* compact document stays compact).
|
|
243
|
+
*
|
|
244
|
+
* Defensive by construction: the result is re-parsed and compared against
|
|
245
|
+
* the expected mutation, so a splice that would corrupt the document throws
|
|
246
|
+
* instead of writing it.
|
|
247
|
+
*/
|
|
248
|
+
function setTopLevelJsonKey(source, path, key, value) {
|
|
249
|
+
const parsed = parseJsonObject(source, path);
|
|
250
|
+
const eol = source.includes("\r\n") ? "\r\n" : "\n";
|
|
251
|
+
const indent = detectIndent(source);
|
|
252
|
+
const rendered = renderValueAtDepthOne(value, indent, eol);
|
|
253
|
+
const layout = scanTopLevel(source);
|
|
254
|
+
const member = layout.members.find((candidate) => candidate.key === key);
|
|
255
|
+
let next;
|
|
256
|
+
if (member) next = `${source.slice(0, member.valueStart)}${rendered}${source.slice(member.valueEnd)}`;
|
|
257
|
+
else if (layout.members.length === 0) {
|
|
258
|
+
const inner = indent === 0 ? `${JSON.stringify(key)}:${rendered}` : `${eol}${indentString(indent)}${JSON.stringify(key)}: ${rendered}${eol}`;
|
|
259
|
+
next = `${source.slice(0, layout.open + 1)}${inner}${source.slice(layout.close)}`;
|
|
260
|
+
} else {
|
|
261
|
+
const last = layout.members[layout.members.length - 1];
|
|
262
|
+
const inner = indent === 0 ? `,${JSON.stringify(key)}:${rendered}` : `,${eol}${indentString(indent)}${JSON.stringify(key)}: ${rendered}`;
|
|
263
|
+
next = `${source.slice(0, last.valueEnd)}${inner}${source.slice(last.valueEnd)}`;
|
|
264
|
+
}
|
|
265
|
+
const verification = parseJsonObject(next, path);
|
|
266
|
+
const expected = {
|
|
267
|
+
...parsed,
|
|
268
|
+
[key]: value
|
|
269
|
+
};
|
|
270
|
+
if (JSON.stringify(verification) !== JSON.stringify(expected)) throw new Error(`refusing to write ${path}: the targeted JSON edit did not verify`);
|
|
271
|
+
return next;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Infers the indentation unit of a JSON document: the whitespace prefix of
|
|
275
|
+
* its first indented line, a compact document stays compact (`0`), and
|
|
276
|
+
* anything unrecognizable falls back to two spaces.
|
|
277
|
+
*/
|
|
278
|
+
function detectIndent(source) {
|
|
279
|
+
const match = source.match(/\n([ \t]+)\S/);
|
|
280
|
+
if (match?.[1]) return match[1];
|
|
281
|
+
return source.trimEnd().includes("\n") ? 2 : 0;
|
|
282
|
+
}
|
|
283
|
+
function indentString(indent) {
|
|
284
|
+
return typeof indent === "number" ? " ".repeat(indent) : indent;
|
|
285
|
+
}
|
|
286
|
+
/** Serializes a value as it should appear as a top-level member's value. */
|
|
287
|
+
function renderValueAtDepthOne(value, indent, eol) {
|
|
288
|
+
if (indent === 0) return JSON.stringify(value);
|
|
289
|
+
const unit = indentString(indent);
|
|
290
|
+
return JSON.stringify(value, null, unit).split("\n").join(`${eol}${unit}`);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Single-pass, string-aware scan of a JSON object document, collecting the
|
|
294
|
+
* exact text span of every top-level member's value plus the root braces.
|
|
295
|
+
* Only structural understanding needed for the splice — the document has
|
|
296
|
+
* already been validated by `JSON.parse`, so this can assume well-formed
|
|
297
|
+
* input and does not re-validate.
|
|
298
|
+
*/
|
|
299
|
+
function scanTopLevel(source) {
|
|
300
|
+
const members = [];
|
|
301
|
+
let open = -1;
|
|
302
|
+
let close = -1;
|
|
303
|
+
let depth = 0;
|
|
304
|
+
let index = 0;
|
|
305
|
+
while (index < source.length) {
|
|
306
|
+
const char = source[index];
|
|
307
|
+
if (char === "\"") {
|
|
308
|
+
const start = index;
|
|
309
|
+
index = skipString(source, index);
|
|
310
|
+
if (depth === 1 && isKeyPosition(source, start)) {
|
|
311
|
+
const key = JSON.parse(source.slice(start, index));
|
|
312
|
+
while (source[index] !== ":") index += 1;
|
|
313
|
+
index += 1;
|
|
314
|
+
while (index < source.length && isWhitespace(source[index])) index += 1;
|
|
315
|
+
const valueStart = index;
|
|
316
|
+
index = skipValue(source, index);
|
|
317
|
+
members.push({
|
|
318
|
+
key,
|
|
319
|
+
valueStart,
|
|
320
|
+
valueEnd: index
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (char === "{" || char === "[") {
|
|
326
|
+
depth += 1;
|
|
327
|
+
if (depth === 1 && open === -1) open = index;
|
|
328
|
+
} else if (char === "}" || char === "]") {
|
|
329
|
+
depth -= 1;
|
|
330
|
+
if (depth === 0) close = index;
|
|
331
|
+
}
|
|
332
|
+
index += 1;
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
open,
|
|
336
|
+
close,
|
|
337
|
+
members
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
/** True when the string starting at `start` is a top-level key, not a value. */
|
|
341
|
+
function isKeyPosition(source, start) {
|
|
342
|
+
for (let index = start - 1; index >= 0; index -= 1) {
|
|
343
|
+
const char = source[index];
|
|
344
|
+
if (isWhitespace(char)) continue;
|
|
345
|
+
return char === "{" || char === ",";
|
|
346
|
+
}
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
/** Returns the index just past the value starting at `index`. */
|
|
350
|
+
function skipValue(source, index) {
|
|
351
|
+
const char = source[index];
|
|
352
|
+
if (char === "\"") return skipString(source, index);
|
|
353
|
+
if (char === "{" || char === "[") {
|
|
354
|
+
let depth = 0;
|
|
355
|
+
let cursor = index;
|
|
356
|
+
while (cursor < source.length) {
|
|
357
|
+
const current = source[cursor];
|
|
358
|
+
if (current === "\"") {
|
|
359
|
+
cursor = skipString(source, cursor);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (current === "{" || current === "[") depth += 1;
|
|
363
|
+
if (current === "}" || current === "]") {
|
|
364
|
+
depth -= 1;
|
|
365
|
+
if (depth === 0) return cursor + 1;
|
|
366
|
+
}
|
|
367
|
+
cursor += 1;
|
|
368
|
+
}
|
|
369
|
+
return cursor;
|
|
370
|
+
}
|
|
371
|
+
let cursor = index;
|
|
372
|
+
while (cursor < source.length && !",}]".includes(source[cursor]) && !isWhitespace(source[cursor])) cursor += 1;
|
|
373
|
+
return cursor;
|
|
374
|
+
}
|
|
375
|
+
/** Returns the index just past the closing quote of the string at `index`. */
|
|
376
|
+
function skipString(source, index) {
|
|
377
|
+
let cursor = index + 1;
|
|
378
|
+
while (cursor < source.length) {
|
|
379
|
+
const char = source[cursor];
|
|
380
|
+
if (char === "\\") {
|
|
381
|
+
cursor += 2;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (char === "\"") return cursor + 1;
|
|
385
|
+
cursor += 1;
|
|
386
|
+
}
|
|
387
|
+
return cursor;
|
|
388
|
+
}
|
|
389
|
+
function isWhitespace(char) {
|
|
390
|
+
return char === " " || char === " " || char === "\n" || char === "\r";
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Parse `contents` as JSON and assert the root is a plain object (not an
|
|
394
|
+
* array or scalar). The CLI's config and secret files are always objects, so
|
|
395
|
+
* this guards callers from the `JSON.parse` return type of `any` and produces
|
|
396
|
+
* a `path`-qualified error message pointing at the offending file.
|
|
397
|
+
*/
|
|
398
|
+
function parseJsonObject(contents, path) {
|
|
399
|
+
const value = JSON.parse(contents);
|
|
400
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must contain a JSON object`);
|
|
401
|
+
return value;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Narrows an unknown value to a plain (non-array, non-null) object. Shared by
|
|
405
|
+
* the commands and the file-writer that walk parsed JSON, so the predicate
|
|
406
|
+
* isn't reimplemented per call site.
|
|
407
|
+
*/
|
|
408
|
+
function isObject(value) {
|
|
409
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
410
|
+
}
|
|
411
|
+
//#endregion
|
|
412
|
+
//#region src/lib/paths.ts
|
|
413
|
+
/**
|
|
414
|
+
* Resolve the working directory the CLI should operate against, defaulting to
|
|
415
|
+
* the process CWD when no `--cwd` override is given. Always returns an
|
|
416
|
+
* absolute path so downstream `join`/`readFile` calls are unaffected by later
|
|
417
|
+
* `process.chdir` or relative-path ambiguity.
|
|
418
|
+
*/
|
|
419
|
+
function resolveCwd(cwd) {
|
|
420
|
+
return resolve(cwd ?? process.cwd());
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Sentinel comment stamped at the top of every file the CLI generates and
|
|
424
|
+
* owns. Commands like `doctor` and `eject` look for this marker to decide
|
|
425
|
+
* whether a file is safe to touch; the trailing `v1` lets the format evolve
|
|
426
|
+
* without mistaking newer managed files for hand-edited ones.
|
|
427
|
+
*/
|
|
428
|
+
const MANAGED_MARKER = "// zitadel-cli: managed-file v1";
|
|
429
|
+
//#endregion
|
|
430
|
+
//#region src/lib/local-server/runtime.ts
|
|
431
|
+
const LOCAL_SERVER_IMAGE_NAME = "ghcr.io/zitadel/nextgen";
|
|
432
|
+
const DEFAULT_LOCAL_SERVER_IMAGE = `${LOCAL_SERVER_IMAGE_NAME}:latest`;
|
|
433
|
+
const DEFAULT_LOCAL_SERVER_PORT = 8080;
|
|
434
|
+
const DEFAULT_LOCAL_SERVER_URL = "http://localhost:8080";
|
|
435
|
+
const LOCAL_RUNTIME_DIR = ".zitadel/local";
|
|
436
|
+
const LOCAL_DATA_DIR = ".zitadel/local/nextgen-data";
|
|
437
|
+
const LOCAL_RUNTIME_FILE = ".zitadel/local/runtime.json";
|
|
438
|
+
const LOCAL_SERVER_LOG_FILE = ".zitadel/local/server.log";
|
|
439
|
+
const LOCAL_CONTAINER_PASSWD_FILE = ".zitadel/local/container-passwd";
|
|
440
|
+
const LOCAL_CONTAINER_GROUP_FILE = ".zitadel/local/container-group";
|
|
441
|
+
const CONTAINER_DATA_DIR = "/var/lib/zitadel/nextgen-data";
|
|
442
|
+
const CONTAINER_HTTP_PORT = 8080;
|
|
443
|
+
function localRuntimePaths(cwd) {
|
|
444
|
+
return {
|
|
445
|
+
runtimeDir: join(cwd, LOCAL_RUNTIME_DIR),
|
|
446
|
+
dataDir: join(cwd, LOCAL_DATA_DIR),
|
|
447
|
+
runtimeFile: join(cwd, LOCAL_RUNTIME_FILE),
|
|
448
|
+
logFile: join(cwd, LOCAL_SERVER_LOG_FILE),
|
|
449
|
+
containerPasswdFile: join(cwd, LOCAL_CONTAINER_PASSWD_FILE),
|
|
450
|
+
containerGroupFile: join(cwd, LOCAL_CONTAINER_GROUP_FILE)
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
function localContainerName(cwd) {
|
|
454
|
+
return `zitadel-server-${createHash("sha256").update(resolve(cwd)).digest("hex").slice(0, 12)}`;
|
|
455
|
+
}
|
|
456
|
+
function localServerUrl(port) {
|
|
457
|
+
return `http://localhost:${port}`;
|
|
458
|
+
}
|
|
459
|
+
function defaultLocalServerImageForCliVersion(cliVersion) {
|
|
460
|
+
const normalized = cliVersion.trim().replace(/^v/, "");
|
|
461
|
+
if (/^\d+\.\d+\.\d+-alpha\.\d+$/.test(normalized)) return `${LOCAL_SERVER_IMAGE_NAME}:${normalized}`;
|
|
462
|
+
return DEFAULT_LOCAL_SERVER_IMAGE;
|
|
463
|
+
}
|
|
464
|
+
async function ensureLocalState(cwd) {
|
|
465
|
+
const paths = localRuntimePaths(cwd);
|
|
466
|
+
await mkdir(paths.dataDir, {
|
|
467
|
+
recursive: true,
|
|
468
|
+
mode: 448
|
|
469
|
+
});
|
|
470
|
+
await appendGitignoreEntry(cwd, `${LOCAL_RUNTIME_DIR}/`);
|
|
471
|
+
return paths;
|
|
472
|
+
}
|
|
473
|
+
async function assertLocalStateWritable(cwd) {
|
|
474
|
+
const paths = localRuntimePaths(cwd);
|
|
475
|
+
const checkedPath = await nearestExistingDirectory(paths.dataDir);
|
|
476
|
+
await access(checkedPath, constants.W_OK);
|
|
477
|
+
return {
|
|
478
|
+
targetPath: paths.dataDir,
|
|
479
|
+
checkedPath
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
async function ensureContainerIdentity(cwd, user) {
|
|
483
|
+
if (user.uid === void 0 || user.uid <= 0) return;
|
|
484
|
+
const gid = user.gid ?? user.uid;
|
|
485
|
+
const paths = localRuntimePaths(cwd);
|
|
486
|
+
await mkdir(paths.runtimeDir, {
|
|
487
|
+
recursive: true,
|
|
488
|
+
mode: 448
|
|
489
|
+
});
|
|
490
|
+
await writeFile(paths.containerPasswdFile, [
|
|
491
|
+
"root:x:0:0:root:/root:/bin/sh",
|
|
492
|
+
"nonroot:x:65532:65532:nonroot:/nonexistent:/usr/sbin/nologin",
|
|
493
|
+
`zitadel-local:x:${String(user.uid)}:${String(gid)}:Zitadel local user:/tmp:/usr/sbin/nologin`,
|
|
494
|
+
""
|
|
495
|
+
].join("\n"), { mode: 420 });
|
|
496
|
+
await writeFile(paths.containerGroupFile, [
|
|
497
|
+
"root:x:0:",
|
|
498
|
+
"nonroot:x:65532:",
|
|
499
|
+
`zitadel-local:x:${String(gid)}:`,
|
|
500
|
+
""
|
|
501
|
+
].join("\n"), { mode: 420 });
|
|
502
|
+
return {
|
|
503
|
+
uid: user.uid,
|
|
504
|
+
gid,
|
|
505
|
+
passwdFile: paths.containerPasswdFile,
|
|
506
|
+
groupFile: paths.containerGroupFile
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
async function readRuntimeMetadata(cwd) {
|
|
510
|
+
const paths = localRuntimePaths(cwd);
|
|
511
|
+
let raw;
|
|
512
|
+
try {
|
|
513
|
+
raw = await readFile(paths.runtimeFile, "utf8");
|
|
514
|
+
} catch (error) {
|
|
515
|
+
if (isErrno(error, "ENOENT")) return;
|
|
516
|
+
throw error;
|
|
517
|
+
}
|
|
518
|
+
return normalizeRuntimeMetadata(parseJsonObject(raw, LOCAL_RUNTIME_FILE));
|
|
519
|
+
}
|
|
520
|
+
async function writeRuntimeMetadata(cwd, metadata) {
|
|
521
|
+
const paths = localRuntimePaths(cwd);
|
|
522
|
+
await mkdir(paths.runtimeDir, {
|
|
523
|
+
recursive: true,
|
|
524
|
+
mode: 448
|
|
525
|
+
});
|
|
526
|
+
await writeFile(paths.runtimeFile, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 384 });
|
|
527
|
+
}
|
|
528
|
+
async function removeRuntimeMetadata(cwd) {
|
|
529
|
+
await rm(localRuntimePaths(cwd).runtimeFile, { force: true });
|
|
530
|
+
}
|
|
531
|
+
async function removeLocalData(cwd) {
|
|
532
|
+
await rm(localRuntimePaths(cwd).dataDir, {
|
|
533
|
+
recursive: true,
|
|
534
|
+
force: true
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
async function checkLocalServerHealth(serverUrl, timeoutMs = 1500) {
|
|
538
|
+
try {
|
|
539
|
+
const healthUrl = new URL("/healthz", serverUrl);
|
|
540
|
+
return (await fetch(healthUrl, { signal: AbortSignal.timeout(timeoutMs) })).ok;
|
|
541
|
+
} catch {
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Best-effort local-server detection for optional UI (the setup wizard's
|
|
547
|
+
* server choice). Same sources as {@link resolveLocalServer} — the runtime
|
|
548
|
+
* metadata written by `zitadel start`, then the default localhost URL — but
|
|
549
|
+
* never throws: a malformed `runtime.json` or an unhealthy server yields
|
|
550
|
+
* `undefined` (doctor owns diagnosing those states), and an unhealthy
|
|
551
|
+
* metadata URL still falls back to the default-port probe so a server
|
|
552
|
+
* started from a different directory is found.
|
|
553
|
+
*/
|
|
554
|
+
async function detectHealthyLocalServer(cwd) {
|
|
555
|
+
let runtime;
|
|
556
|
+
try {
|
|
557
|
+
runtime = await readRuntimeMetadata(cwd);
|
|
558
|
+
} catch {
|
|
559
|
+
runtime = void 0;
|
|
560
|
+
}
|
|
561
|
+
if (runtime && await checkLocalServerHealth(runtime.server_url)) return runtime.server_url;
|
|
562
|
+
if (!(runtime?.server_url === "http://localhost:8080") && await checkLocalServerHealth("http://localhost:8080")) return DEFAULT_LOCAL_SERVER_URL;
|
|
563
|
+
}
|
|
564
|
+
async function resolveLocalServer(cwd) {
|
|
565
|
+
const runtime = await readRuntimeMetadata(cwd);
|
|
566
|
+
if (runtime) {
|
|
567
|
+
if (await checkLocalServerHealth(runtime.server_url)) return runtime.server_url;
|
|
568
|
+
throw localServerNotRunning(runtime.server_url);
|
|
569
|
+
}
|
|
570
|
+
if (await checkLocalServerHealth("http://localhost:8080")) return DEFAULT_LOCAL_SERVER_URL;
|
|
571
|
+
throw localServerNotRunning(DEFAULT_LOCAL_SERVER_URL);
|
|
572
|
+
}
|
|
573
|
+
function localServerNotRunning(serverUrl) {
|
|
574
|
+
return new ZitadelError("E_LOCAL_SERVER_NOT_RUNNING", "Local Zitadel server is not running", {
|
|
575
|
+
hint: `No healthy local server responded at ${serverUrl}.`,
|
|
576
|
+
nextCommands: ["zitadel start"],
|
|
577
|
+
details: { server_url: serverUrl }
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
async function appendGitignoreEntry(cwd, entry) {
|
|
581
|
+
const path = join(cwd, ".gitignore");
|
|
582
|
+
let existing = "";
|
|
583
|
+
try {
|
|
584
|
+
existing = await readFile(path, "utf8");
|
|
585
|
+
} catch (error) {
|
|
586
|
+
if (!isErrno(error, "ENOENT")) throw error;
|
|
587
|
+
}
|
|
588
|
+
if (existing.split(/\r?\n/).map((line) => line.trim()).includes(entry)) return;
|
|
589
|
+
const prefix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
590
|
+
await writeFile(path, `${existing}${prefix}${entry}\n`);
|
|
591
|
+
}
|
|
592
|
+
function normalizeRuntimeMetadata(input) {
|
|
593
|
+
if (input.schema_version !== 1 || typeof input.port !== "number" || !isValidPort(input.port) || typeof input.server_url !== "string" || !isValidServerUrl(input.server_url, input.port) || typeof input.data_dir !== "string" || typeof input.created_at !== "string" || typeof input.cli_version !== "string") throw malformedRuntime(input);
|
|
594
|
+
const backend = input.backend === void 0 ? "docker" : input.backend;
|
|
595
|
+
const base = {
|
|
596
|
+
schema_version: 1,
|
|
597
|
+
port: input.port,
|
|
598
|
+
server_url: input.server_url,
|
|
599
|
+
data_dir: input.data_dir,
|
|
600
|
+
created_at: input.created_at,
|
|
601
|
+
cli_version: input.cli_version
|
|
602
|
+
};
|
|
603
|
+
if (backend === "binary") {
|
|
604
|
+
if (typeof input.pid !== "number" || !Number.isInteger(input.pid) || input.pid <= 0 || typeof input.command !== "string" || typeof input.log_path !== "string" || typeof input.server_package !== "string" || typeof input.server_version !== "string") throw malformedRuntime(input);
|
|
605
|
+
return {
|
|
606
|
+
...base,
|
|
607
|
+
backend: "binary",
|
|
608
|
+
pid: input.pid,
|
|
609
|
+
command: input.command,
|
|
610
|
+
log_path: input.log_path,
|
|
611
|
+
server_package: input.server_package,
|
|
612
|
+
server_version: input.server_version
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
if (backend !== "docker" || typeof input.container_name !== "string" || typeof input.container_id !== "string" || typeof input.image !== "string") throw malformedRuntime(input);
|
|
616
|
+
return {
|
|
617
|
+
...base,
|
|
618
|
+
backend: "docker",
|
|
619
|
+
container_name: input.container_name,
|
|
620
|
+
container_id: input.container_id,
|
|
621
|
+
image: input.image
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
async function nearestExistingDirectory(path) {
|
|
625
|
+
let current = path;
|
|
626
|
+
while (true) try {
|
|
627
|
+
if (!(await stat(current)).isDirectory()) throw new Error(`${current} exists but is not a directory`);
|
|
628
|
+
return current;
|
|
629
|
+
} catch (error) {
|
|
630
|
+
if (!isErrno(error, "ENOENT")) throw error;
|
|
631
|
+
const parent = dirname(current);
|
|
632
|
+
if (parent === current) throw error;
|
|
633
|
+
current = parent;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
function isErrno(error, code) {
|
|
637
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
638
|
+
}
|
|
639
|
+
function runtimeSummary(metadata) {
|
|
640
|
+
if (!metadata) return { configured: false };
|
|
641
|
+
const base = {
|
|
642
|
+
configured: true,
|
|
643
|
+
backend: metadata.backend,
|
|
644
|
+
port: metadata.port,
|
|
645
|
+
server_url: metadata.server_url,
|
|
646
|
+
data_dir: metadata.data_dir,
|
|
647
|
+
created_at: metadata.created_at
|
|
648
|
+
};
|
|
649
|
+
if (metadata.backend === "binary") return {
|
|
650
|
+
...base,
|
|
651
|
+
pid: metadata.pid,
|
|
652
|
+
command: metadata.command,
|
|
653
|
+
log_path: metadata.log_path,
|
|
654
|
+
server_package: metadata.server_package,
|
|
655
|
+
server_version: metadata.server_version
|
|
656
|
+
};
|
|
657
|
+
return {
|
|
658
|
+
...base,
|
|
659
|
+
container_name: metadata.container_name,
|
|
660
|
+
container_id: metadata.container_id,
|
|
661
|
+
image: metadata.image
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
function isValidPort(value) {
|
|
665
|
+
return Number.isInteger(value) && value >= 1 && value <= 65535;
|
|
666
|
+
}
|
|
667
|
+
function isValidServerUrl(value, port) {
|
|
668
|
+
try {
|
|
669
|
+
const url = new URL(value);
|
|
670
|
+
return (url.protocol === "http:" || url.protocol === "https:") && url.hostname.length > 0 && explicitUrlPort(value) === port;
|
|
671
|
+
} catch {
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
function explicitUrlPort(value) {
|
|
676
|
+
const match = value.match(/^[a-z][a-z\d+\-.]*:\/\/(?:\[[^\]]+\]|[^/?#:]+):(\d+)(?:[/?#]|$)/i);
|
|
677
|
+
if (!match) return;
|
|
678
|
+
const port = Number(match[1]);
|
|
679
|
+
return isValidPort(port) ? port : void 0;
|
|
680
|
+
}
|
|
681
|
+
function malformedRuntime(input) {
|
|
682
|
+
return new ZitadelError("E_VALIDATION", `${LOCAL_RUNTIME_FILE} is malformed`, {
|
|
683
|
+
hint: "Run `zitadel reset --force`, then `zitadel start`.",
|
|
684
|
+
nextCommands: ["zitadel reset --force", "zitadel start"],
|
|
685
|
+
details: input
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
//#endregion
|
|
689
|
+
//#region src/lib/server.ts
|
|
690
|
+
/**
|
|
691
|
+
* Server URL used when nothing else resolves. Also surfaced in hints and
|
|
692
|
+
* the interactive setup prompt as the suggested value, so it is exported
|
|
693
|
+
* rather than kept private.
|
|
694
|
+
*/
|
|
695
|
+
const DEFAULT_SERVER = "https://api.zitadel.cloud";
|
|
696
|
+
/**
|
|
697
|
+
* Resolves which server the CLI should target, applying a fixed
|
|
698
|
+
* precedence: explicit `--server` flag, then `ZITADEL_API_BASE`, then the
|
|
699
|
+
* selected environment block in `zitadel.json`, then the config's
|
|
700
|
+
* top-level `server`, falling back to {@link DEFAULT_SERVER}. Every
|
|
701
|
+
* candidate is validated to a normalised origin; an invalid URL throws a
|
|
702
|
+
* `ZitadelError` rather than silently falling through.
|
|
703
|
+
*/
|
|
704
|
+
async function resolveServer(input) {
|
|
705
|
+
if (input.serverFlag) return validate(input.cwd, {
|
|
706
|
+
value: input.serverFlag,
|
|
707
|
+
origin: "flag"
|
|
708
|
+
});
|
|
709
|
+
const envValue = input.env.ZITADEL_API_BASE;
|
|
710
|
+
if (envValue) return validate(input.cwd, {
|
|
711
|
+
value: envValue,
|
|
712
|
+
origin: "env"
|
|
713
|
+
});
|
|
714
|
+
const config = await readConfig(input.cwd);
|
|
715
|
+
if (config) {
|
|
716
|
+
const envBranch = readEnvServer(config, input.environment);
|
|
717
|
+
if (envBranch) return validate(input.cwd, {
|
|
718
|
+
value: envBranch,
|
|
719
|
+
origin: "config-env"
|
|
720
|
+
});
|
|
721
|
+
if (typeof config.server === "string") return validate(input.cwd, {
|
|
722
|
+
value: config.server,
|
|
723
|
+
origin: "config-top"
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
return {
|
|
727
|
+
value: DEFAULT_SERVER,
|
|
728
|
+
origin: "default"
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
async function validate(cwd, resolved) {
|
|
732
|
+
if (resolved.value === "local") return {
|
|
733
|
+
value: await resolveLocalServer(cwd),
|
|
734
|
+
origin: "local"
|
|
735
|
+
};
|
|
736
|
+
try {
|
|
737
|
+
const url = new URL(resolved.value);
|
|
738
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw new ZitadelError("E_VALIDATION", `Server URL must use http(s): ${resolved.value}`, { hint: `Set "server" in zitadel.json to a URL like ${DEFAULT_SERVER}.` });
|
|
739
|
+
return {
|
|
740
|
+
value: url.origin,
|
|
741
|
+
origin: resolved.origin
|
|
742
|
+
};
|
|
743
|
+
} catch (error) {
|
|
744
|
+
if (error instanceof ZitadelError) throw error;
|
|
745
|
+
throw new ZitadelError("E_VALIDATION", `Invalid server "${resolved.value}"`, {
|
|
746
|
+
hint: `Use a URL like ${DEFAULT_SERVER}.`,
|
|
747
|
+
details: { origin: resolved.origin }
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
async function readConfig(cwd) {
|
|
752
|
+
try {
|
|
753
|
+
return parseJsonObject(await readFile(join(cwd, "zitadel.json"), "utf8"), "zitadel.json");
|
|
754
|
+
} catch (error) {
|
|
755
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return;
|
|
756
|
+
throw error;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
function readEnvServer(config, environment) {
|
|
760
|
+
if (!environment) return;
|
|
761
|
+
const envs = config.environments;
|
|
762
|
+
if (!isObject(envs)) return;
|
|
763
|
+
const branch = envs[environment];
|
|
764
|
+
if (!isObject(branch)) return;
|
|
765
|
+
return typeof branch.server === "string" ? branch.server : void 0;
|
|
766
|
+
}
|
|
767
|
+
//#endregion
|
|
768
|
+
//#region src/lib/telemetry/config.ts
|
|
769
|
+
/**
|
|
770
|
+
* Resolves the Mixpanel ingestion token and API host for a CLI invocation.
|
|
771
|
+
*
|
|
772
|
+
* The token is a *write-only* project token: it can ingest events but cannot
|
|
773
|
+
* read data back, so — unlike the project service-key — it is safe to ship
|
|
774
|
+
* inside the published CLI. This mirrors how Next.js, Astro, and other dev
|
|
775
|
+
* tools embed their telemetry token, and is the only workable model for a CLI
|
|
776
|
+
* (we cannot ask end users to supply one). It is intentionally not a secret.
|
|
777
|
+
*
|
|
778
|
+
* Dev and prod are separate Mixpanel projects (the skill's Phase 2 rule: never
|
|
779
|
+
* track dev traffic into the production project). By default the channel comes
|
|
780
|
+
* from a build-time stamp (see {@link resolveChannel}), so the published CLI
|
|
781
|
+
* routes real user traffic to production without any per-user env var while
|
|
782
|
+
* source/test runs stay on dev — but a runtime `ZITADEL_TELEMETRY_ENV` or
|
|
783
|
+
* `ZITADEL_TELEMETRY_BUILD_CHANNEL` overrides the stamp, and
|
|
784
|
+
* `ZITADEL_TELEMETRY_TOKEN` overrides the token outright.
|
|
785
|
+
*/
|
|
786
|
+
/**
|
|
787
|
+
* Development project token. Safe to commit (write-only ingestion key). Used
|
|
788
|
+
* when running from source or any non-production build.
|
|
789
|
+
*/
|
|
790
|
+
const DEV_TELEMETRY_TOKEN = "0fb432b08a9797b87b0eebcbee11706e";
|
|
791
|
+
/**
|
|
792
|
+
* Production project token. Used by the published CLI (the build stamps the
|
|
793
|
+
* production channel) and any `ZITADEL_TELEMETRY_ENV=production` run. Write-only
|
|
794
|
+
* ingestion key, like the dev token — safe to commit.
|
|
795
|
+
*/
|
|
796
|
+
const PROD_TELEMETRY_TOKEN = "f56fd7315ccd614fba8eecb2a8966152";
|
|
797
|
+
/** Mixpanel API hosts by data-residency region. */
|
|
798
|
+
const HOSTS = {
|
|
799
|
+
us: "api.mixpanel.com",
|
|
800
|
+
eu: "api-eu.mixpanel.com"
|
|
801
|
+
};
|
|
802
|
+
/**
|
|
803
|
+
* Channel stamped into the bundle at build time. tsdown's `define` always
|
|
804
|
+
* replaces the bare `__ZITADEL_TELEMETRY_CHANNEL__` identifier — with
|
|
805
|
+
* `"development"` by default and `"production"` only in the release build — so
|
|
806
|
+
* the shipped CLI routes to the right project with no per-user env var. The
|
|
807
|
+
* identifier is undefined only in unbundled runs (e.g. unit tests importing this
|
|
808
|
+
* module directly); the `typeof` guard returns `""` there so those runs fall
|
|
809
|
+
* through to the dev default without a ReferenceError.
|
|
810
|
+
*/
|
|
811
|
+
function buildStampedChannel() {
|
|
812
|
+
return "production".trim().toLowerCase();
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Decide which project the events belong to. Precedence: an explicit
|
|
816
|
+
* `ZITADEL_TELEMETRY_ENV`, then a `ZITADEL_TELEMETRY_BUILD_CHANNEL` env override
|
|
817
|
+
* (handy for CI/release), then the build-time channel stamp. The default —
|
|
818
|
+
* source/dev/test — is the dev project. Ambient `NODE_ENV` is deliberately NOT
|
|
819
|
+
* consulted: a source build with `NODE_ENV=production` must not route dev
|
|
820
|
+
* traffic to prod, nor a published run with `NODE_ENV=development` to dev.
|
|
821
|
+
*/
|
|
822
|
+
function resolveChannel(env) {
|
|
823
|
+
const explicit = (env.ZITADEL_TELEMETRY_ENV ?? "").trim().toLowerCase();
|
|
824
|
+
if (explicit === "production") return "production";
|
|
825
|
+
if (explicit === "development") return "development";
|
|
826
|
+
return (env.ZITADEL_TELEMETRY_BUILD_CHANNEL ?? buildStampedChannel()).trim().toLowerCase() === "production" ? "production" : "development";
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Resolve the ingestion token, or `undefined` when none is configured for the
|
|
830
|
+
* active channel. A `ZITADEL_TELEMETRY_TOKEN` override wins outright; otherwise
|
|
831
|
+
* the channel's baked token is used. An empty string (e.g. the unset prod
|
|
832
|
+
* token) resolves to `undefined`, which the caller treats as "telemetry off".
|
|
833
|
+
*/
|
|
834
|
+
function resolveTelemetryToken(env) {
|
|
835
|
+
const override = env.ZITADEL_TELEMETRY_TOKEN?.trim();
|
|
836
|
+
if (override) return override;
|
|
837
|
+
const token = resolveChannel(env) === "production" ? PROD_TELEMETRY_TOKEN : DEV_TELEMETRY_TOKEN;
|
|
838
|
+
return token.length > 0 ? token : void 0;
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Resolve the Mixpanel API host from `ZITADEL_TELEMETRY_REGION`. Defaults to the
|
|
842
|
+
* EU host, because the Zitadel Mixpanel projects live in the EU data-residency
|
|
843
|
+
* region. Set `ZITADEL_TELEMETRY_REGION=us` for a US-hosted project — events
|
|
844
|
+
* sent to the wrong host are silently dropped, so verify the first event lands
|
|
845
|
+
* in Live View.
|
|
846
|
+
*/
|
|
847
|
+
function resolveTelemetryHost(env) {
|
|
848
|
+
return (env.ZITADEL_TELEMETRY_REGION ?? "eu").trim().toLowerCase() === "us" ? HOSTS.us : HOSTS.eu;
|
|
849
|
+
}
|
|
850
|
+
//#endregion
|
|
851
|
+
//#region src/lib/telemetry/consent.ts
|
|
852
|
+
const DISABLED_VALUES$1 = new Set([
|
|
853
|
+
"",
|
|
854
|
+
"0",
|
|
855
|
+
"false",
|
|
856
|
+
"off",
|
|
857
|
+
"no"
|
|
858
|
+
]);
|
|
859
|
+
/**
|
|
860
|
+
* Resolve telemetry consent under an opt-out model: on by default, but any of
|
|
861
|
+
* several explicit signals turns it off, in precedence order.
|
|
862
|
+
*
|
|
863
|
+
* 1. `--no-telemetry` on the command line — the most explicit, per-invocation.
|
|
864
|
+
* 2. An automated test run (`VITEST`/`NODE_ENV=test`) — never emit synthetic
|
|
865
|
+
* traffic or pay the shutdown flush; spawned CLI subprocesses inherit it.
|
|
866
|
+
* 3. `DO_NOT_TRACK` — the cross-tool standard (https://consoledonottrack.com);
|
|
867
|
+
* any value other than `0`/empty disables.
|
|
868
|
+
* 4. `ZITADEL_TELEMETRY` set to a falsey token (`0`/`false`/`off`/`no`).
|
|
869
|
+
* 5. No ingestion token configured for the active channel — nothing to send to,
|
|
870
|
+
* so telemetry is inert regardless of consent.
|
|
871
|
+
*
|
|
872
|
+
* Consent being enabled does not by itself send anything; the caller still
|
|
873
|
+
* builds the client lazily and fails open on any transport error.
|
|
874
|
+
*/
|
|
875
|
+
function resolveConsent(input) {
|
|
876
|
+
if (input.flag === false) return {
|
|
877
|
+
enabled: false,
|
|
878
|
+
reason: "flag-opt-out"
|
|
879
|
+
};
|
|
880
|
+
if (input.env.VITEST || input.env.NODE_ENV === "test") return {
|
|
881
|
+
enabled: false,
|
|
882
|
+
reason: "test-runner"
|
|
883
|
+
};
|
|
884
|
+
const doNotTrack = input.env.DO_NOT_TRACK?.trim();
|
|
885
|
+
if (doNotTrack && doNotTrack !== "0") return {
|
|
886
|
+
enabled: false,
|
|
887
|
+
reason: "do-not-track"
|
|
888
|
+
};
|
|
889
|
+
const explicit = input.env.ZITADEL_TELEMETRY?.trim().toLowerCase();
|
|
890
|
+
if (explicit !== void 0 && DISABLED_VALUES$1.has(explicit)) return {
|
|
891
|
+
enabled: false,
|
|
892
|
+
reason: "env-opt-out"
|
|
893
|
+
};
|
|
894
|
+
const token = resolveTelemetryToken(input.env);
|
|
895
|
+
if (!token) return {
|
|
896
|
+
enabled: false,
|
|
897
|
+
reason: "no-token"
|
|
898
|
+
};
|
|
899
|
+
return {
|
|
900
|
+
enabled: true,
|
|
901
|
+
reason: "enabled",
|
|
902
|
+
token
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
//#endregion
|
|
906
|
+
//#region src/lib/telemetry/identity.ts
|
|
907
|
+
/**
|
|
908
|
+
* Resolve the directory the CLI persists cross-invocation state in, honoring
|
|
909
|
+
* the platform conventions: `XDG_CONFIG_HOME` then `~/.config` on Unix, and
|
|
910
|
+
* `%APPDATA%` on Windows. The anonymous id lives here so it survives between
|
|
911
|
+
* runs without touching the user's project tree.
|
|
912
|
+
*/
|
|
913
|
+
function telemetryConfigDir(env) {
|
|
914
|
+
if (process.platform === "win32" && env.APPDATA) return join(env.APPDATA, "zitadel", "cli");
|
|
915
|
+
return join(env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config"), "zitadel", "cli");
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Load the persisted anonymous id, minting and storing a new one on first run.
|
|
919
|
+
*
|
|
920
|
+
* Fail-open: if the config dir cannot be resolved, read, or written — including
|
|
921
|
+
* an `os.homedir()` throw in a restricted/containerized environment, a
|
|
922
|
+
* read-only home, a CI sandbox, or a permissions error — fall back to an
|
|
923
|
+
* ephemeral per-process id and report `isFirstRun: false` so we neither crash
|
|
924
|
+
* nor nag the user with the notice on every run. Telemetry is best-effort,
|
|
925
|
+
* never load-bearing.
|
|
926
|
+
*/
|
|
927
|
+
function loadOrCreateIdentity(env) {
|
|
928
|
+
let dir;
|
|
929
|
+
try {
|
|
930
|
+
dir = telemetryConfigDir(env);
|
|
931
|
+
} catch {
|
|
932
|
+
return {
|
|
933
|
+
distinctId: randomUUID(),
|
|
934
|
+
isFirstRun: false
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
const file = join(dir, "telemetry.json");
|
|
938
|
+
try {
|
|
939
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
940
|
+
if (typeof parsed.distinctId === "string" && parsed.distinctId.length > 0) return {
|
|
941
|
+
distinctId: parsed.distinctId,
|
|
942
|
+
isFirstRun: false
|
|
943
|
+
};
|
|
944
|
+
} catch {}
|
|
945
|
+
const distinctId = randomUUID();
|
|
946
|
+
try {
|
|
947
|
+
mkdirSync(dir, { recursive: true });
|
|
948
|
+
writeFileSync(file, `${JSON.stringify({ distinctId })}\n`, { mode: 384 });
|
|
949
|
+
return {
|
|
950
|
+
distinctId,
|
|
951
|
+
isFirstRun: true
|
|
952
|
+
};
|
|
953
|
+
} catch {
|
|
954
|
+
return {
|
|
955
|
+
distinctId,
|
|
956
|
+
isFirstRun: false
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
//#endregion
|
|
961
|
+
//#region src/lib/telemetry/util.ts
|
|
962
|
+
/**
|
|
963
|
+
* Return a new bag with empty values stripped, per Mixpanel's "omit, never send
|
|
964
|
+
* null/''" rule. Pure — the input is never mutated.
|
|
965
|
+
*/
|
|
966
|
+
function compact(properties) {
|
|
967
|
+
return Object.fromEntries(Object.entries(properties).filter(([, value]) => value !== void 0 && value !== null && value !== ""));
|
|
968
|
+
}
|
|
969
|
+
//#endregion
|
|
970
|
+
//#region src/lib/telemetry/index.ts
|
|
971
|
+
/**
|
|
972
|
+
* A generic, application-agnostic Mixpanel client wrapper for a single short
|
|
973
|
+
* process. It knows nothing about commands, CLIs, or event names — callers
|
|
974
|
+
* supply fully-built property bags. Its only opinions are operational, because
|
|
975
|
+
* telemetry must never degrade the host program:
|
|
976
|
+
*
|
|
977
|
+
* - Never throws — a missing token, opt-out, or transport error all degrade to
|
|
978
|
+
* a silent no-op.
|
|
979
|
+
* - Never blocks beyond {@link shutdown}'s timeout.
|
|
980
|
+
* - Never writes to stdout; the optional debug trace goes to stderr.
|
|
981
|
+
*
|
|
982
|
+
* Inputs are treated as immutable: {@link track}/{@link profile} build a new
|
|
983
|
+
* payload via spread and never mutate the bag they are given.
|
|
984
|
+
*/
|
|
985
|
+
var Telemetry = class Telemetry {
|
|
986
|
+
pending = [];
|
|
987
|
+
constructor(client, distinctId, isFirstRun, debug, newId) {
|
|
988
|
+
this.client = client;
|
|
989
|
+
this.distinctId = distinctId;
|
|
990
|
+
this.isFirstRun = isFirstRun;
|
|
991
|
+
this.debug = debug;
|
|
992
|
+
this.newId = newId;
|
|
993
|
+
}
|
|
994
|
+
/** Whether events will actually be sent (consent granted and a token configured). */
|
|
995
|
+
get enabled() {
|
|
996
|
+
return this.client !== void 0;
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Resolve consent, identity, token, and host, then build the Mixpanel client
|
|
1000
|
+
* lazily — only when consent is granted *and* a token is configured. Any
|
|
1001
|
+
* failure along the way yields an inert instance whose methods are no-ops.
|
|
1002
|
+
*/
|
|
1003
|
+
static create(deps) {
|
|
1004
|
+
const newId = deps.newId ?? randomUUID;
|
|
1005
|
+
const debug = deps.debug ?? false;
|
|
1006
|
+
const inert = (firstRun) => new Telemetry(void 0, "", firstRun, debug, newId);
|
|
1007
|
+
const consent = resolveConsent({
|
|
1008
|
+
env: deps.env,
|
|
1009
|
+
flag: deps.flag
|
|
1010
|
+
});
|
|
1011
|
+
if (debug) process.stderr.write(`[telemetry] consent: ${consent.reason}\n`);
|
|
1012
|
+
if (!consent.enabled || !consent.token) return inert(false);
|
|
1013
|
+
const identity = (deps.loadIdentity ?? loadOrCreateIdentity)(deps.env);
|
|
1014
|
+
let client;
|
|
1015
|
+
try {
|
|
1016
|
+
client = (deps.initClient ?? defaultInit)(consent.token, resolveTelemetryHost(deps.env));
|
|
1017
|
+
} catch {
|
|
1018
|
+
return inert(identity.isFirstRun);
|
|
1019
|
+
}
|
|
1020
|
+
return new Telemetry(client, identity.distinctId, identity.isFirstRun, debug, newId);
|
|
1021
|
+
}
|
|
1022
|
+
/** Send an event with the given properties; `distinct_id`/`$insert_id` are stamped here. */
|
|
1023
|
+
track(event, properties) {
|
|
1024
|
+
if (!this.client) return;
|
|
1025
|
+
const payload = compact({
|
|
1026
|
+
...properties,
|
|
1027
|
+
distinct_id: this.distinctId,
|
|
1028
|
+
$insert_id: this.newId()
|
|
1029
|
+
});
|
|
1030
|
+
if (this.debug) process.stderr.write(`[telemetry] ${event} ${JSON.stringify(payload)}\n`);
|
|
1031
|
+
this.enqueue((client, done) => client.track(event, payload, done));
|
|
1032
|
+
}
|
|
1033
|
+
/** Write a user profile via the People API. `modifiers` carries `$ip` etc. */
|
|
1034
|
+
profile(properties, modifiers = {}) {
|
|
1035
|
+
if (!this.client) return;
|
|
1036
|
+
const payload = compact(properties);
|
|
1037
|
+
if (this.debug) process.stderr.write(`[telemetry] people.set ${JSON.stringify(payload)}\n`);
|
|
1038
|
+
this.enqueue((client, done) => client.people.set(this.distinctId, payload, modifiers, done));
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Await in-flight sends so a short-lived process does not exit before the
|
|
1042
|
+
* requests complete, bounded by `timeoutMs`. The timeout is unref'd so the
|
|
1043
|
+
* losing race branch never holds the event loop open or needs a manual clear.
|
|
1044
|
+
* Safe to call on an inert instance.
|
|
1045
|
+
*/
|
|
1046
|
+
async shutdown(timeoutMs = 2e3) {
|
|
1047
|
+
if (this.pending.length === 0) return;
|
|
1048
|
+
await Promise.race([Promise.allSettled(this.pending), setTimeout$1(timeoutMs, void 0, { ref: false })]);
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Enqueue one fire-and-forget send. Resolves (never rejects) on any failure —
|
|
1052
|
+
* a synchronous throw or an error callback must not surface to the caller or
|
|
1053
|
+
* leak as an unhandled rejection.
|
|
1054
|
+
*/
|
|
1055
|
+
enqueue(send) {
|
|
1056
|
+
const client = this.client;
|
|
1057
|
+
if (!client) return;
|
|
1058
|
+
this.pending.push(new Promise((resolve) => {
|
|
1059
|
+
try {
|
|
1060
|
+
send(client, () => resolve());
|
|
1061
|
+
} catch {
|
|
1062
|
+
resolve();
|
|
1063
|
+
}
|
|
1064
|
+
}));
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
/** Real Mixpanel client construction, isolated so {@link Telemetry.create} can swap it in tests. */
|
|
1068
|
+
function defaultInit(token, host) {
|
|
1069
|
+
return mixpanelLib.init(token, {
|
|
1070
|
+
host,
|
|
1071
|
+
geolocate: false
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
//#endregion
|
|
1075
|
+
//#region src/lib/telemetry/dimensions/env-flag.ts
|
|
1076
|
+
const DISABLED_VALUES = new Set([
|
|
1077
|
+
"",
|
|
1078
|
+
"0",
|
|
1079
|
+
"false",
|
|
1080
|
+
"off",
|
|
1081
|
+
"no"
|
|
1082
|
+
]);
|
|
1083
|
+
/**
|
|
1084
|
+
* Whether an environment variable is set to an enabled value. A present-but-
|
|
1085
|
+
* falsey string (`CI=false`, `CI=0`) counts as disabled — unlike a bare
|
|
1086
|
+
* `Boolean(env.CI)` check, which is true for any non-empty string.
|
|
1087
|
+
*/
|
|
1088
|
+
function envEnabled(value) {
|
|
1089
|
+
return value !== void 0 && !DISABLED_VALUES.has(value.trim().toLowerCase());
|
|
1090
|
+
}
|
|
1091
|
+
//#endregion
|
|
1092
|
+
//#region src/lib/telemetry/dimensions/ci-flag.ts
|
|
1093
|
+
/** Whether the process is running inside an automated CI environment. */
|
|
1094
|
+
var CiFlag = class {
|
|
1095
|
+
value(env) {
|
|
1096
|
+
return envEnabled(env.CI) || envEnabled(env.GITHUB_ACTIONS) || envEnabled(env.GITLAB_CI);
|
|
1097
|
+
}
|
|
1098
|
+
};
|
|
1099
|
+
const ciFlag = new CiFlag();
|
|
1100
|
+
const ciProvider = new class CiProvider {
|
|
1101
|
+
/** Provider-marker env var → reported name, in match order. */
|
|
1102
|
+
static providers = [
|
|
1103
|
+
["GITHUB_ACTIONS", "github_actions"],
|
|
1104
|
+
["GITLAB_CI", "gitlab_ci"],
|
|
1105
|
+
["CIRCLECI", "circleci"],
|
|
1106
|
+
["BUILDKITE", "buildkite"],
|
|
1107
|
+
["JENKINS_URL", "jenkins"]
|
|
1108
|
+
];
|
|
1109
|
+
value(env) {
|
|
1110
|
+
const named = CiProvider.providers.find(([marker]) => envEnabled(env[marker]));
|
|
1111
|
+
if (named) return named[1];
|
|
1112
|
+
return envEnabled(env.CI) ? "unknown" : void 0;
|
|
1113
|
+
}
|
|
1114
|
+
}();
|
|
1115
|
+
//#endregion
|
|
1116
|
+
//#region src/lib/telemetry/dimensions/country.ts
|
|
1117
|
+
/**
|
|
1118
|
+
* ISO 3166-1 alpha-2 country for an IANA timezone, defaulting to the machine's
|
|
1119
|
+
* own zone. A curated subset of common zones — any zone not listed (or an
|
|
1120
|
+
* unknown/unavailable one) yields `undefined`, since we report no country
|
|
1121
|
+
* rather than guess. Derived from the timezone, never the IP, so no city or
|
|
1122
|
+
* region is ever inferred. The machine's own zone is resolved once and cached:
|
|
1123
|
+
* `Intl.DateTimeFormat` construction loads ICU data and is process-stable.
|
|
1124
|
+
*/
|
|
1125
|
+
var Country = class {
|
|
1126
|
+
byTimezone = {
|
|
1127
|
+
"Africa/Abidjan": "CI",
|
|
1128
|
+
"Africa/Accra": "GH",
|
|
1129
|
+
"Africa/Addis_Ababa": "ET",
|
|
1130
|
+
"Africa/Algiers": "DZ",
|
|
1131
|
+
"Africa/Cairo": "EG",
|
|
1132
|
+
"Africa/Casablanca": "MA",
|
|
1133
|
+
"Africa/Johannesburg": "ZA",
|
|
1134
|
+
"Africa/Lagos": "NG",
|
|
1135
|
+
"Africa/Nairobi": "KE",
|
|
1136
|
+
"Africa/Tunis": "TN",
|
|
1137
|
+
"America/Anchorage": "US",
|
|
1138
|
+
"America/Argentina/Buenos_Aires": "AR",
|
|
1139
|
+
"America/Bogota": "CO",
|
|
1140
|
+
"America/Chicago": "US",
|
|
1141
|
+
"America/Denver": "US",
|
|
1142
|
+
"America/Halifax": "CA",
|
|
1143
|
+
"America/Lima": "PE",
|
|
1144
|
+
"America/Los_Angeles": "US",
|
|
1145
|
+
"America/Mexico_City": "MX",
|
|
1146
|
+
"America/New_York": "US",
|
|
1147
|
+
"America/Phoenix": "US",
|
|
1148
|
+
"America/Santiago": "CL",
|
|
1149
|
+
"America/Sao_Paulo": "BR",
|
|
1150
|
+
"America/Toronto": "CA",
|
|
1151
|
+
"America/Vancouver": "CA",
|
|
1152
|
+
"Asia/Bangkok": "TH",
|
|
1153
|
+
"Asia/Dhaka": "BD",
|
|
1154
|
+
"Asia/Dubai": "AE",
|
|
1155
|
+
"Asia/Hong_Kong": "HK",
|
|
1156
|
+
"Asia/Jakarta": "ID",
|
|
1157
|
+
"Asia/Jerusalem": "IL",
|
|
1158
|
+
"Asia/Karachi": "PK",
|
|
1159
|
+
"Asia/Kolkata": "IN",
|
|
1160
|
+
"Asia/Kuala_Lumpur": "MY",
|
|
1161
|
+
"Asia/Manila": "PH",
|
|
1162
|
+
"Asia/Riyadh": "SA",
|
|
1163
|
+
"Asia/Seoul": "KR",
|
|
1164
|
+
"Asia/Shanghai": "CN",
|
|
1165
|
+
"Asia/Singapore": "SG",
|
|
1166
|
+
"Asia/Taipei": "TW",
|
|
1167
|
+
"Asia/Tehran": "IR",
|
|
1168
|
+
"Asia/Tokyo": "JP",
|
|
1169
|
+
"Australia/Adelaide": "AU",
|
|
1170
|
+
"Australia/Brisbane": "AU",
|
|
1171
|
+
"Australia/Melbourne": "AU",
|
|
1172
|
+
"Australia/Perth": "AU",
|
|
1173
|
+
"Australia/Sydney": "AU",
|
|
1174
|
+
"Europe/Amsterdam": "NL",
|
|
1175
|
+
"Europe/Athens": "GR",
|
|
1176
|
+
"Europe/Berlin": "DE",
|
|
1177
|
+
"Europe/Brussels": "BE",
|
|
1178
|
+
"Europe/Bucharest": "RO",
|
|
1179
|
+
"Europe/Budapest": "HU",
|
|
1180
|
+
"Europe/Copenhagen": "DK",
|
|
1181
|
+
"Europe/Dublin": "IE",
|
|
1182
|
+
"Europe/Helsinki": "FI",
|
|
1183
|
+
"Europe/Istanbul": "TR",
|
|
1184
|
+
"Europe/Kyiv": "UA",
|
|
1185
|
+
"Europe/Lisbon": "PT",
|
|
1186
|
+
"Europe/London": "GB",
|
|
1187
|
+
"Europe/Madrid": "ES",
|
|
1188
|
+
"Europe/Moscow": "RU",
|
|
1189
|
+
"Europe/Oslo": "NO",
|
|
1190
|
+
"Europe/Paris": "FR",
|
|
1191
|
+
"Europe/Prague": "CZ",
|
|
1192
|
+
"Europe/Rome": "IT",
|
|
1193
|
+
"Europe/Stockholm": "SE",
|
|
1194
|
+
"Europe/Vienna": "AT",
|
|
1195
|
+
"Europe/Warsaw": "PL",
|
|
1196
|
+
"Europe/Zurich": "CH",
|
|
1197
|
+
"Pacific/Auckland": "NZ",
|
|
1198
|
+
"Pacific/Honolulu": "US"
|
|
1199
|
+
};
|
|
1200
|
+
machineZone;
|
|
1201
|
+
machineZoneResolved = false;
|
|
1202
|
+
value(timezone) {
|
|
1203
|
+
const zone = timezone ?? this.resolveMachineZone();
|
|
1204
|
+
return zone ? this.byTimezone[zone] : void 0;
|
|
1205
|
+
}
|
|
1206
|
+
resolveMachineZone() {
|
|
1207
|
+
if (!this.machineZoneResolved) {
|
|
1208
|
+
this.machineZoneResolved = true;
|
|
1209
|
+
try {
|
|
1210
|
+
this.machineZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
1211
|
+
} catch {
|
|
1212
|
+
this.machineZone = void 0;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
return this.machineZone;
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
const country = new Country();
|
|
1219
|
+
const hostAgent = new class HostAgent {
|
|
1220
|
+
/** Predicate → reported name, in match order. */
|
|
1221
|
+
static agents = [
|
|
1222
|
+
[(env) => Boolean(env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT), "claude_code"],
|
|
1223
|
+
[(env) => Boolean(env.CURSOR_TRACE_ID || env.CURSOR_AGENT), "cursor"],
|
|
1224
|
+
[(env) => env.TERM_PROGRAM === "vscode", "vscode"]
|
|
1225
|
+
];
|
|
1226
|
+
value(env) {
|
|
1227
|
+
return HostAgent.agents.find(([matches]) => matches(env))?.[1] ?? "unknown";
|
|
1228
|
+
}
|
|
1229
|
+
}();
|
|
1230
|
+
const invocationChannel = new class InvocationChannel {
|
|
1231
|
+
/** Ordered so a more specific name wins — `pnpm` is matched before the `npm` prefix. */
|
|
1232
|
+
static managers = [
|
|
1233
|
+
"pnpm",
|
|
1234
|
+
"yarn",
|
|
1235
|
+
"bun",
|
|
1236
|
+
"npm"
|
|
1237
|
+
];
|
|
1238
|
+
value(env) {
|
|
1239
|
+
const userAgent = env.npm_config_user_agent ?? "";
|
|
1240
|
+
return InvocationChannel.managers.find((manager) => userAgent.startsWith(manager)) ?? "unknown";
|
|
1241
|
+
}
|
|
1242
|
+
}();
|
|
1243
|
+
const operatingSystem = new class OperatingSystem {
|
|
1244
|
+
static labels = {
|
|
1245
|
+
darwin: "Mac OS X",
|
|
1246
|
+
win32: "Windows",
|
|
1247
|
+
linux: "Linux",
|
|
1248
|
+
freebsd: "BSD",
|
|
1249
|
+
openbsd: "BSD",
|
|
1250
|
+
netbsd: "BSD",
|
|
1251
|
+
aix: "AIX",
|
|
1252
|
+
sunos: "Solaris"
|
|
1253
|
+
};
|
|
1254
|
+
value(platform) {
|
|
1255
|
+
return OperatingSystem.labels[platform] ?? platform;
|
|
1256
|
+
}
|
|
1257
|
+
}();
|
|
1258
|
+
//#endregion
|
|
1259
|
+
//#region src/lib/oclif/command-telemetry.ts
|
|
1260
|
+
/**
|
|
1261
|
+
* CLI-specific telemetry glue: the only place that turns a {@link GlobalOptions}
|
|
1262
|
+
* invocation into the property bags the generic `Telemetry` client sends,
|
|
1263
|
+
* keeping `lib/telemetry` free of CLI coupling.
|
|
1264
|
+
*/
|
|
1265
|
+
const CLI_COMMAND_STARTED = "cli_command_started";
|
|
1266
|
+
const CLI_COMMAND_COMPLETED = "cli_command_completed";
|
|
1267
|
+
const CLI_COMMAND_FAILED = "cli_command_failed";
|
|
1268
|
+
const FIRST_RUN_NOTICE = "Zitadel CLI collects anonymous usage analytics to help improve the tool. No personal data, project details, server URLs, or file contents are ever collected. Opt out any time with DO_NOT_TRACK=1, ZITADEL_TELEMETRY=0, or the --no-telemetry flag.";
|
|
1269
|
+
/**
|
|
1270
|
+
* Process-stable device facts shared by both the event bag and the user
|
|
1271
|
+
* profile, so the two never disagree on the same install's OS/arch/version.
|
|
1272
|
+
*/
|
|
1273
|
+
function deviceFacts(meta) {
|
|
1274
|
+
return {
|
|
1275
|
+
osLabel: operatingSystem.value(process.platform),
|
|
1276
|
+
countryCode: country.value(void 0),
|
|
1277
|
+
platform: process.platform,
|
|
1278
|
+
arch: process.arch,
|
|
1279
|
+
nodeVersion: process.versions.node,
|
|
1280
|
+
cliVersion: meta.cliVersion
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
function eventDeviceProperties(meta) {
|
|
1284
|
+
const facts = deviceFacts(meta);
|
|
1285
|
+
return {
|
|
1286
|
+
$os: facts.osLabel,
|
|
1287
|
+
mp_country_code: facts.countryCode,
|
|
1288
|
+
os: facts.platform,
|
|
1289
|
+
arch: facts.arch,
|
|
1290
|
+
node_version: facts.nodeVersion,
|
|
1291
|
+
cli_version: facts.cliVersion
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
function profileDeviceProperties(meta) {
|
|
1295
|
+
const facts = deviceFacts(meta);
|
|
1296
|
+
return {
|
|
1297
|
+
$os: facts.osLabel,
|
|
1298
|
+
$country_code: facts.countryCode,
|
|
1299
|
+
os: facts.platform,
|
|
1300
|
+
arch: facts.arch,
|
|
1301
|
+
node_version: facts.nodeVersion,
|
|
1302
|
+
cli_version: facts.cliVersion
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* Build the dimensions shared by every lifecycle event, merged with any
|
|
1307
|
+
* per-command `extra`. The allow-list: only enums, booleans, counts, and
|
|
1308
|
+
* versions cross the boundary — never URLs, project ids, file paths, emails, or
|
|
1309
|
+
* secrets. `extra` is spread first so the canonical base dimensions always win;
|
|
1310
|
+
* callers order their own extras so reserved lifecycle fields win over command
|
|
1311
|
+
* props.
|
|
1312
|
+
*/
|
|
1313
|
+
function commandEventProperties(meta, invocationId, extra = {}) {
|
|
1314
|
+
const { env } = meta;
|
|
1315
|
+
return {
|
|
1316
|
+
...extra,
|
|
1317
|
+
...eventDeviceProperties(meta),
|
|
1318
|
+
ip: 0,
|
|
1319
|
+
invocation_id: invocationId,
|
|
1320
|
+
command: meta.command,
|
|
1321
|
+
non_interactive: meta.nonInteractive,
|
|
1322
|
+
is_tty: meta.isTTY,
|
|
1323
|
+
is_ci: ciFlag.value(env),
|
|
1324
|
+
ci_provider: ciProvider.value(env),
|
|
1325
|
+
host_agent: hostAgent.value(env),
|
|
1326
|
+
invocation_channel: invocationChannel.value(env),
|
|
1327
|
+
dry_run: meta.dryRun,
|
|
1328
|
+
force: meta.force,
|
|
1329
|
+
server_kind: serverKind.value(meta.source)
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Anonymous user-profile properties for this install, so the device appears
|
|
1334
|
+
* under Mixpanel "Users". Only non-PII device facts; `$name` is a readable,
|
|
1335
|
+
* non-identifying label that falls back to the OS when the host agent is
|
|
1336
|
+
* unknown. `$ip` is passed separately as a modifier by the caller.
|
|
1337
|
+
*/
|
|
1338
|
+
function deviceProfileProperties(meta, distinctId) {
|
|
1339
|
+
const agent = hostAgent.value(meta.env);
|
|
1340
|
+
const label = agent === "unknown" ? process.platform : agent;
|
|
1341
|
+
return {
|
|
1342
|
+
...profileDeviceProperties(meta),
|
|
1343
|
+
$name: `${label} · ${distinctId.slice(0, 8)}`,
|
|
1344
|
+
host_agent: agent
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
//#endregion
|
|
1348
|
+
//#region src/lib/oclif/base.ts
|
|
1349
|
+
/**
|
|
1350
|
+
* Base class for every oclif command. Owns the global flags, builds the
|
|
1351
|
+
* {@link GlobalOptions} context (including server `source` resolution) the
|
|
1352
|
+
* subclass's `run` reads via `this.meta`, and turns the {@link CommandResult}
|
|
1353
|
+
* it returns into the JSON envelope (oclif serialises it natively in `--json`
|
|
1354
|
+
* mode) or human-facing text. Errors are translated into the failure envelope
|
|
1355
|
+
* and the mapped process exit code. Subclasses stay thin: parse flags, call
|
|
1356
|
+
* {@link toMeta}, do their work, and `return this.emit(...)`. The agent
|
|
1357
|
+
* contract (ADR 004) is preserved — oclif only replaces parsing, dispatch,
|
|
1358
|
+
* help, and JSON emission.
|
|
1359
|
+
*/
|
|
1360
|
+
var BaseCommand = class extends Command {
|
|
1361
|
+
/** Opt into oclif's native `--json` flag and JSON serialisation of the result. */
|
|
1362
|
+
static enableJsonFlag = true;
|
|
1363
|
+
/** Flags shared by every command, inherited via oclif `baseFlags`. */
|
|
1364
|
+
static baseFlags = {
|
|
1365
|
+
cwd: Flags.string({
|
|
1366
|
+
char: "c",
|
|
1367
|
+
description: "Project directory to operate on."
|
|
1368
|
+
}),
|
|
1369
|
+
server: Flags.string({
|
|
1370
|
+
char: "s",
|
|
1371
|
+
description: "Override the resolved server URL."
|
|
1372
|
+
}),
|
|
1373
|
+
"non-interactive": Flags.boolean({
|
|
1374
|
+
char: "n",
|
|
1375
|
+
description: "Disable prompts. Required when scripting or running as an agent."
|
|
1376
|
+
}),
|
|
1377
|
+
force: Flags.boolean({
|
|
1378
|
+
char: "f",
|
|
1379
|
+
description: "Overwrite protected files on conflict."
|
|
1380
|
+
}),
|
|
1381
|
+
"dry-run": Flags.boolean({ description: "Preview without mutating files or the platform." }),
|
|
1382
|
+
verbose: Flags.boolean({ description: "Verbose logging." }),
|
|
1383
|
+
debug: Flags.boolean({ description: "Debug logging." }),
|
|
1384
|
+
telemetry: Flags.boolean({
|
|
1385
|
+
default: true,
|
|
1386
|
+
allowNo: true,
|
|
1387
|
+
description: "Send anonymous usage analytics. Disable with --no-telemetry."
|
|
1388
|
+
})
|
|
1389
|
+
};
|
|
1390
|
+
/** Resolved context for the current invocation; set by {@link toMeta}. */
|
|
1391
|
+
meta = this.fallbackMeta();
|
|
1392
|
+
/**
|
|
1393
|
+
* Anonymous usage analytics for this invocation, created once in
|
|
1394
|
+
* {@link toMeta}. Subclasses add command-specific dimensions (framework,
|
|
1395
|
+
* counts, `step`, …) via {@link recordTelemetry}; the base class
|
|
1396
|
+
* fires the lifecycle events and flushes in {@link finally}.
|
|
1397
|
+
*/
|
|
1398
|
+
telemetry;
|
|
1399
|
+
/**
|
|
1400
|
+
* Per-command dimensions merged onto each lifecycle event emitted *after* they
|
|
1401
|
+
* are recorded — typically `completed`/`failed`, since `started` fires from
|
|
1402
|
+
* {@link openTelemetry} before a command body runs. Updated immutably via
|
|
1403
|
+
* {@link recordTelemetry} — never mutated in place.
|
|
1404
|
+
*/
|
|
1405
|
+
telemetryProps = Object.freeze({});
|
|
1406
|
+
/** Correlates the started/completed pair; minted at instance construction. */
|
|
1407
|
+
telemetryInvocationId = randomUUID();
|
|
1408
|
+
/**
|
|
1409
|
+
* Wall-clock start used to derive `duration_ms`. Captured at instance
|
|
1410
|
+
* construction (before flag parsing and server resolution) so the duration
|
|
1411
|
+
* covers the full invocation, even when telemetry is opened late from
|
|
1412
|
+
* {@link catch} after an early failure.
|
|
1413
|
+
*/
|
|
1414
|
+
telemetryStartedAt = Date.now();
|
|
1415
|
+
/**
|
|
1416
|
+
* Merge command-specific dimensions into {@link telemetryProps} immutably: a
|
|
1417
|
+
* new frozen bag replaces the previous one, so no shared object is ever
|
|
1418
|
+
* mutated. `step` advances by re-recording it at each milestone.
|
|
1419
|
+
*/
|
|
1420
|
+
recordTelemetry(patch) {
|
|
1421
|
+
this.telemetryProps = Object.freeze({
|
|
1422
|
+
...this.telemetryProps,
|
|
1423
|
+
...patch
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Builds {@link GlobalOptions} from parsed flags, resolving the server
|
|
1428
|
+
* `source` by the documented precedence and storing the result on
|
|
1429
|
+
* `this.meta` so the error handler can render a complete envelope.
|
|
1430
|
+
*/
|
|
1431
|
+
async toMeta(flags, options = {}) {
|
|
1432
|
+
const cwd = resolveCwd(typeof flags.cwd === "string" ? flags.cwd : void 0);
|
|
1433
|
+
const serverFlag = typeof flags.server === "string" ? flags.server : void 0;
|
|
1434
|
+
const environment = typeof flags.environment === "string" ? flags.environment : "development";
|
|
1435
|
+
const source = options.resolveServer === false ? {
|
|
1436
|
+
value: options.source ?? "",
|
|
1437
|
+
origin: "default"
|
|
1438
|
+
} : await resolveServer({
|
|
1439
|
+
cwd,
|
|
1440
|
+
env: process.env,
|
|
1441
|
+
serverFlag,
|
|
1442
|
+
environment
|
|
1443
|
+
});
|
|
1444
|
+
const json = this.jsonEnabled();
|
|
1445
|
+
const isTTY = Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
1446
|
+
const verbose = Boolean(flags.verbose);
|
|
1447
|
+
const debug = Boolean(flags.debug);
|
|
1448
|
+
consola.level = json ? -999 : debug ? 4 : 3;
|
|
1449
|
+
consola.options.formatOptions = {
|
|
1450
|
+
...consola.options.formatOptions,
|
|
1451
|
+
date: false,
|
|
1452
|
+
colors: true,
|
|
1453
|
+
compact: true
|
|
1454
|
+
};
|
|
1455
|
+
this.meta = {
|
|
1456
|
+
cwd,
|
|
1457
|
+
nonInteractive: Boolean(flags["non-interactive"]) || !isTTY || json,
|
|
1458
|
+
dryRun: Boolean(flags["dry-run"]),
|
|
1459
|
+
force: Boolean(flags.force),
|
|
1460
|
+
command: this.id ?? "(default)",
|
|
1461
|
+
cliVersion: this.config.version,
|
|
1462
|
+
source: source.value,
|
|
1463
|
+
serverFlag,
|
|
1464
|
+
verbose,
|
|
1465
|
+
debug,
|
|
1466
|
+
env: process.env,
|
|
1467
|
+
isTTY
|
|
1468
|
+
};
|
|
1469
|
+
this.openTelemetry(typeof flags.telemetry === "boolean" ? flags.telemetry : void 0);
|
|
1470
|
+
return this.meta;
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Create telemetry once per invocation and open the lifecycle (started event +
|
|
1474
|
+
* anonymous profile + first-run notice), reading every dimension from the
|
|
1475
|
+
* current {@link meta}. Guarded so a command that resolves meta more than once
|
|
1476
|
+
* does not double-count. Also called from {@link catch} so a failure thrown
|
|
1477
|
+
* before {@link toMeta} finished (e.g. server resolution, flag parsing) still
|
|
1478
|
+
* records the run. Returns early for an inert (opted-out / no-token /
|
|
1479
|
+
* test-runner) instance so a disabled run never builds the property bags —
|
|
1480
|
+
* no timezone→country resolution or URL parsing for users who opted out. The
|
|
1481
|
+
* anonymous device profile is install-level and stable, so it is written only
|
|
1482
|
+
* on first run rather than paying a `people.set` request on every command.
|
|
1483
|
+
*/
|
|
1484
|
+
/**
|
|
1485
|
+
* Telemetry factory seam. Production returns the real {@link Telemetry.create};
|
|
1486
|
+
* tests override it to inject a recording client and assert the lifecycle
|
|
1487
|
+
* ordering and opt-out behaviour that the central Vitest consent guard would
|
|
1488
|
+
* otherwise make untestable.
|
|
1489
|
+
*/
|
|
1490
|
+
createTelemetry(deps) {
|
|
1491
|
+
return Telemetry.create(deps);
|
|
1492
|
+
}
|
|
1493
|
+
openTelemetry(flag) {
|
|
1494
|
+
if (this.telemetry) return;
|
|
1495
|
+
this.telemetry = this.createTelemetry({
|
|
1496
|
+
env: process.env,
|
|
1497
|
+
flag,
|
|
1498
|
+
debug: this.meta.debug
|
|
1499
|
+
});
|
|
1500
|
+
if (!this.telemetry.enabled) return;
|
|
1501
|
+
this.telemetry.track(CLI_COMMAND_STARTED, commandEventProperties(this.meta, this.telemetryInvocationId, this.telemetryProps));
|
|
1502
|
+
if (this.telemetry.isFirstRun) {
|
|
1503
|
+
this.telemetry.profile(deviceProfileProperties(this.meta, this.telemetry.distinctId), { $ip: 0 });
|
|
1504
|
+
if (this.isInteractive()) process.stderr.write(`${FIRST_RUN_NOTICE}\n`);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Whether this invocation is an interactive human session, used to gate the
|
|
1509
|
+
* one-time first-run notice. Derived from argv + `jsonEnabled()` + TTY rather
|
|
1510
|
+
* than `meta.nonInteractive`, so it is correct even on the early-failure path
|
|
1511
|
+
* where {@link catch} opens telemetry against a fallback meta that has not yet
|
|
1512
|
+
* computed `nonInteractive` from the flags.
|
|
1513
|
+
*/
|
|
1514
|
+
isInteractive() {
|
|
1515
|
+
if (this.meta.nonInteractive || this.jsonEnabled()) return false;
|
|
1516
|
+
const argv = process.argv;
|
|
1517
|
+
if (argv.includes("--json") || argv.includes("--non-interactive") || argv.includes("-n")) return false;
|
|
1518
|
+
return Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
1519
|
+
}
|
|
1520
|
+
/**
|
|
1521
|
+
* Final step of every command: in human mode it prints the rendered result
|
|
1522
|
+
* (oclif suppresses {@link Command.log} under `--json`); it returns the
|
|
1523
|
+
* envelope so oclif's `--json` path serialises it.
|
|
1524
|
+
*/
|
|
1525
|
+
emit(result) {
|
|
1526
|
+
const normalized = normalizeCommandResult(result, this.meta);
|
|
1527
|
+
if (this.telemetry?.enabled) this.telemetry.track(CLI_COMMAND_COMPLETED, commandEventProperties(this.meta, this.telemetryInvocationId, {
|
|
1528
|
+
...this.telemetryProps,
|
|
1529
|
+
status: result.status,
|
|
1530
|
+
duration_ms: Date.now() - this.telemetryStartedAt
|
|
1531
|
+
}));
|
|
1532
|
+
this.log(renderPretty(normalized, this.meta));
|
|
1533
|
+
return toEnvelope(normalized, this.meta);
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Renders any thrown error as the failure envelope and exits with its code.
|
|
1537
|
+
* A flag-parse error fires before {@link toMeta} runs, so the local `meta`
|
|
1538
|
+
* here refreshes `command` from the now-resolved command id to keep the
|
|
1539
|
+
* envelope's `command` field accurate. If that early failure left telemetry
|
|
1540
|
+
* unopened, {@link openTelemetry} runs here so the failure is still recorded;
|
|
1541
|
+
* the flag isn't parsed yet on that path, so `--no-telemetry` is honoured from
|
|
1542
|
+
* argv.
|
|
1543
|
+
*/
|
|
1544
|
+
async catch(error) {
|
|
1545
|
+
const meta = {
|
|
1546
|
+
...this.meta,
|
|
1547
|
+
command: this.id ?? this.meta.command
|
|
1548
|
+
};
|
|
1549
|
+
const zitadelError = toZitadelError(error);
|
|
1550
|
+
this.meta = meta;
|
|
1551
|
+
this.openTelemetry(process.argv.includes("--no-telemetry") ? false : void 0);
|
|
1552
|
+
if (this.telemetry?.enabled) this.telemetry.track(CLI_COMMAND_FAILED, commandEventProperties(meta, this.telemetryInvocationId, {
|
|
1553
|
+
...this.telemetryProps,
|
|
1554
|
+
status: "error",
|
|
1555
|
+
reason: zitadelError.code,
|
|
1556
|
+
exit_code: zitadelError.exitCode,
|
|
1557
|
+
duration_ms: Date.now() - this.telemetryStartedAt
|
|
1558
|
+
}));
|
|
1559
|
+
if (this.jsonEnabled()) this.logJson(toErrorEnvelope(zitadelError, meta));
|
|
1560
|
+
else this.logToStderr(renderError(zitadelError, meta));
|
|
1561
|
+
return this.exit(zitadelError.exitCode);
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* oclif runs this after `run`/`catch` on every path. We flush pending
|
|
1565
|
+
* telemetry so a short-lived CLI process does not exit before the lifecycle
|
|
1566
|
+
* event is sent. The await is bounded by the flush budget, so a hung or
|
|
1567
|
+
* firewalled network adds at most ~1s.
|
|
1568
|
+
*
|
|
1569
|
+
* `mixpanel@0.18` always uses keep-alive agents (hardcoded; not configurable)
|
|
1570
|
+
* and exposes no request timeout or handle, so a completed *or* hung request
|
|
1571
|
+
* leaves a socket that keeps Node's event loop open past the await. The
|
|
1572
|
+
* failure path force-exits via oclif's `exit()`, but the success path would
|
|
1573
|
+
* otherwise hang, so we arm an unref'd watchdog: it cannot keep the loop alive
|
|
1574
|
+
* on a clean exit, but if a telemetry socket is still holding it open after
|
|
1575
|
+
* the grace, it force-exits with the resolved code.
|
|
1576
|
+
*/
|
|
1577
|
+
async finally(error) {
|
|
1578
|
+
await this.telemetry?.shutdown(1e3);
|
|
1579
|
+
if (this.telemetry?.enabled) setTimeout(() => process.exit(process.exitCode ?? 0), 250).unref();
|
|
1580
|
+
await super.finally(error);
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* Context used before {@link toMeta} runs, so an error thrown during flag
|
|
1584
|
+
* parsing still renders a complete envelope. Version comes from oclif's
|
|
1585
|
+
* resolved {@link Command.config}.
|
|
1586
|
+
*/
|
|
1587
|
+
fallbackMeta() {
|
|
1588
|
+
return {
|
|
1589
|
+
cwd: resolveCwd(void 0),
|
|
1590
|
+
nonInteractive: false,
|
|
1591
|
+
dryRun: false,
|
|
1592
|
+
force: false,
|
|
1593
|
+
command: "(default)",
|
|
1594
|
+
cliVersion: this.config.version,
|
|
1595
|
+
source: "",
|
|
1596
|
+
verbose: false,
|
|
1597
|
+
debug: false,
|
|
1598
|
+
env: process.env,
|
|
1599
|
+
isTTY: Boolean(process.stdout.isTTY && process.stdin.isTTY)
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
};
|
|
1603
|
+
function normalizeCommandResult(result, meta) {
|
|
1604
|
+
if (result.status === "ok") return {
|
|
1605
|
+
...result,
|
|
1606
|
+
data: normalizeDataNextCommands(result.data, meta)
|
|
1607
|
+
};
|
|
1608
|
+
return {
|
|
1609
|
+
...result,
|
|
1610
|
+
data: normalizeDataNextCommands(result.data, meta),
|
|
1611
|
+
nextCommands: normalizePublicCliCommands(result.nextCommands, meta.cliVersion)
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
function normalizeDataNextCommands(data, meta) {
|
|
1615
|
+
if (!isObject(data) || !Array.isArray(data.next_commands)) return data;
|
|
1616
|
+
return {
|
|
1617
|
+
...data,
|
|
1618
|
+
next_commands: data.next_commands.map((command) => typeof command === "string" ? normalizePublicCliCommand(command, meta.cliVersion) : command)
|
|
1619
|
+
};
|
|
1620
|
+
}
|
|
1621
|
+
/** Wraps a {@link CommandResult} with the invocation metadata into the final envelope. */
|
|
1622
|
+
function toEnvelope(result, meta) {
|
|
1623
|
+
const base = {
|
|
1624
|
+
cli_version: meta.cliVersion,
|
|
1625
|
+
command: meta.command,
|
|
1626
|
+
source: meta.source
|
|
1627
|
+
};
|
|
1628
|
+
if (result.status === "ok") return {
|
|
1629
|
+
...base,
|
|
1630
|
+
status: "ok",
|
|
1631
|
+
data: result.data,
|
|
1632
|
+
warnings: result.warnings ? [...result.warnings] : []
|
|
1633
|
+
};
|
|
1634
|
+
return {
|
|
1635
|
+
...base,
|
|
1636
|
+
status: "skipped",
|
|
1637
|
+
reason: result.reason,
|
|
1638
|
+
data: result.data,
|
|
1639
|
+
next_commands: result.nextCommands ? [...result.nextCommands] : void 0
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
/** Builds the failure envelope from a {@link ZitadelError} and the invocation metadata. */
|
|
1643
|
+
function toErrorEnvelope(error, meta) {
|
|
1644
|
+
return {
|
|
1645
|
+
status: "error",
|
|
1646
|
+
cli_version: meta.cliVersion,
|
|
1647
|
+
command: meta.command,
|
|
1648
|
+
source: meta.source,
|
|
1649
|
+
code: error.code,
|
|
1650
|
+
message: error.message,
|
|
1651
|
+
hint: error.hint,
|
|
1652
|
+
next_commands: normalizePublicCliCommands(error.nextCommands, meta.cliVersion),
|
|
1653
|
+
details: error.details
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* Renders a {@link CommandResult} as human-facing text for non-JSON mode. A
|
|
1658
|
+
* command may supply a bespoke `pretty` string (e.g. the `apply` plan diff);
|
|
1659
|
+
* otherwise success payloads are summarised by {@link formatData} and skips are
|
|
1660
|
+
* shown with their reason and follow-up commands.
|
|
1661
|
+
*/
|
|
1662
|
+
function renderPretty(result, meta) {
|
|
1663
|
+
if (result.pretty !== void 0) return result.pretty;
|
|
1664
|
+
if (result.status === "ok") return formatData(result.data, result.warnings ? [...result.warnings] : [], meta);
|
|
1665
|
+
const lines = [`Skipped: ${result.reason}${suffixBlock(meta)}`];
|
|
1666
|
+
if (result.nextCommands && result.nextCommands.length > 0) {
|
|
1667
|
+
lines.push("Next:");
|
|
1668
|
+
for (const cmd of result.nextCommands) lines.push(` $ ${cmd}`);
|
|
1669
|
+
}
|
|
1670
|
+
return lines.join("\n");
|
|
1671
|
+
}
|
|
1672
|
+
/**
|
|
1673
|
+
* Renders a {@link ZitadelError} as a human-readable block for stderr: the
|
|
1674
|
+
* coded message, an optional hint, and any suggested next commands.
|
|
1675
|
+
*/
|
|
1676
|
+
function renderError(error, meta) {
|
|
1677
|
+
const lines = [`Error ${error.code}: ${error.message}`];
|
|
1678
|
+
if (error.hint) lines.push(error.hint);
|
|
1679
|
+
const nextCommands = normalizePublicCliCommands(error.nextCommands, meta.cliVersion);
|
|
1680
|
+
if (nextCommands && nextCommands.length > 0) {
|
|
1681
|
+
lines.push("Next:");
|
|
1682
|
+
for (const cmd of nextCommands) lines.push(` $ ${cmd}`);
|
|
1683
|
+
}
|
|
1684
|
+
return lines.join("\n");
|
|
1685
|
+
}
|
|
1686
|
+
function formatData(data, warnings, opts) {
|
|
1687
|
+
if (typeof data === "string") {
|
|
1688
|
+
const suffix = sourceSuffix(opts);
|
|
1689
|
+
return suffix ? `${data}\n${suffix}` : data;
|
|
1690
|
+
}
|
|
1691
|
+
const lines = [];
|
|
1692
|
+
const titleLine = isObject(data) && typeof data.title === "string" ? String(data.title) : "Zitadel command completed.";
|
|
1693
|
+
lines.push(titleLine);
|
|
1694
|
+
const suffix = sourceSuffix(opts);
|
|
1695
|
+
if (suffix) lines.push(suffix);
|
|
1696
|
+
if (isObject(data)) {
|
|
1697
|
+
renderKnownSections(lines, data);
|
|
1698
|
+
if (Array.isArray(data.next_actions) && data.next_actions.length > 0) {
|
|
1699
|
+
lines.push("");
|
|
1700
|
+
lines.push("Next:");
|
|
1701
|
+
for (const action of data.next_actions) lines.push(` ${String(action)}`);
|
|
1702
|
+
}
|
|
1703
|
+
if (Array.isArray(data.next_commands) && data.next_commands.length > 0) {
|
|
1704
|
+
if (!Array.isArray(data.next_actions) || data.next_actions.length === 0) {
|
|
1705
|
+
lines.push("");
|
|
1706
|
+
lines.push("Next:");
|
|
1707
|
+
}
|
|
1708
|
+
for (const cmd of data.next_commands) lines.push(` $ ${String(cmd)}`);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
for (const warning of warnings.filter((warning) => !warningRenderedInChecks(data, warning))) lines.push(`Warning: ${warning}`);
|
|
1712
|
+
return lines.join("\n");
|
|
1713
|
+
}
|
|
1714
|
+
function warningRenderedInChecks(data, warning) {
|
|
1715
|
+
if (!isObject(data) || !Array.isArray(data.checks)) return false;
|
|
1716
|
+
return data.checks.some((check) => {
|
|
1717
|
+
if (!isObject(check) || check.status !== "warn") return false;
|
|
1718
|
+
return warning === `${String(check.name ?? "check")}: ${String(check.message ?? "")}`;
|
|
1719
|
+
});
|
|
1720
|
+
}
|
|
1721
|
+
function renderKnownSections(lines, data) {
|
|
1722
|
+
if (isObject(data.project)) {
|
|
1723
|
+
const project = data.project;
|
|
1724
|
+
const segments = [];
|
|
1725
|
+
if (typeof project.project_id === "string") segments.push(`project=${project.project_id}`);
|
|
1726
|
+
if (typeof project.lifecycle === "string") segments.push(`lifecycle=${project.lifecycle}`);
|
|
1727
|
+
if (typeof project.issuer === "string") segments.push(`issuer=${project.issuer}`);
|
|
1728
|
+
if (segments.length > 0) lines.push(`Project: ${segments.join(" ")}`);
|
|
1729
|
+
}
|
|
1730
|
+
if (typeof data.framework === "string") lines.push(`framework=${data.framework}`);
|
|
1731
|
+
if (Array.isArray(data.files_written) || Array.isArray(data.files_skipped)) {
|
|
1732
|
+
const written = Array.isArray(data.files_written) ? data.files_written.length : 0;
|
|
1733
|
+
const skippedCount = Array.isArray(data.files_skipped) ? data.files_skipped.length : 0;
|
|
1734
|
+
lines.push(`Files: ${written} written, ${skippedCount} unchanged`);
|
|
1735
|
+
}
|
|
1736
|
+
if (isObject(data.apply)) {
|
|
1737
|
+
const apply = data.apply;
|
|
1738
|
+
const bits = [];
|
|
1739
|
+
if (typeof apply.config_version === "number") bits.push(`v${apply.config_version}`);
|
|
1740
|
+
if (typeof apply.hash === "string") bits.push(`hash=${String(apply.hash).slice(0, 12)}`);
|
|
1741
|
+
if (typeof apply.environment === "string") bits.push(`env=${apply.environment}`);
|
|
1742
|
+
if (bits.length > 0) lines.push(`Apply: ${bits.join(" ")}`);
|
|
1743
|
+
}
|
|
1744
|
+
if (Array.isArray(data.checks) && data.checks.length > 0) {
|
|
1745
|
+
lines.push("Checks:");
|
|
1746
|
+
for (const check of data.checks) {
|
|
1747
|
+
if (!isObject(check)) continue;
|
|
1748
|
+
const status = check.status === "pass" ? "ok" : check.status === "warn" ? "warn" : "fail";
|
|
1749
|
+
lines.push(` [${status}] ${String(check.name ?? "check")}: ${String(check.message ?? "")}`);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
function sourceSuffix(opts) {
|
|
1754
|
+
try {
|
|
1755
|
+
const url = new URL(opts.source);
|
|
1756
|
+
if (url.host === "api.zitadel.cloud") return "";
|
|
1757
|
+
return `(server: ${url.host})`;
|
|
1758
|
+
} catch {
|
|
1759
|
+
return "";
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
function suffixBlock(opts) {
|
|
1763
|
+
const suffix = sourceSuffix(opts);
|
|
1764
|
+
return suffix ? ` ${suffix}` : "";
|
|
1765
|
+
}
|
|
1766
|
+
//#endregion
|
|
1767
|
+
export { npmDistTagForCliVersion as A, parseJsonObject as C, toZitadelError as D, ZitadelError as E, serverKind as M, normalizePublicCliJson as O, isObject as S, stableStringify as T, removeRuntimeMetadata as _, DEFAULT_LOCAL_SERVER_PORT as a, MANAGED_MARKER as b, checkLocalServerHealth as c, ensureContainerIdentity as d, ensureLocalState as f, removeLocalData as g, readRuntimeMetadata as h, CONTAINER_HTTP_PORT as i, publicCliCommand as j, normalizePublicCliProse as k, defaultLocalServerImageForCliVersion as l, localServerUrl as m, DEFAULT_SERVER as n, DEFAULT_LOCAL_SERVER_URL as o, localContainerName as p, CONTAINER_DATA_DIR as r, assertLocalStateWritable as s, BaseCommand as t, detectHealthyLocalServer as u, runtimeSummary as v, setTopLevelJsonKey as w, resolveCwd as x, writeRuntimeMetadata as y };
|
|
1768
|
+
|
|
1769
|
+
//# sourceMappingURL=oclif-CanO3zdt.mjs.map
|