@kustodian/cli 1.0.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/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@kustodian/cli",
3
+ "version": "1.0.0",
4
+ "description": "CLI framework with DI and middleware for Kustodian",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "bin": {
9
+ "kustodian": "./src/bin.ts"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./src/index.ts",
14
+ "import": "./src/index.ts"
15
+ }
16
+ },
17
+ "files": [
18
+ "src"
19
+ ],
20
+ "scripts": {
21
+ "test": "bun test",
22
+ "test:watch": "bun test --watch",
23
+ "typecheck": "bun run tsc --noEmit"
24
+ },
25
+ "keywords": [
26
+ "kustodian",
27
+ "cli"
28
+ ],
29
+ "author": "Luca Silverentand <luca@onezero.company>",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/lucasilverentand/kustodian.git",
34
+ "directory": "packages/cli"
35
+ },
36
+ "publishConfig": {
37
+ "registry": "https://npm.pkg.github.com"
38
+ },
39
+ "dependencies": {
40
+ "@kustodian/core": "workspace:*",
41
+ "@kustodian/generator": "workspace:*",
42
+ "@kustodian/loader": "workspace:*",
43
+ "@kustodian/nodes": "workspace:*",
44
+ "@kustodian/plugin-k0s": "workspace:*",
45
+ "@kustodian/plugins": "workspace:*",
46
+ "@kustodian/registry": "workspace:*",
47
+ "@kustodian/schema": "workspace:*",
48
+ "ora": "^9.0.0"
49
+ }
50
+ }
package/src/bin.ts ADDED
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { apply_command } from './commands/apply.js';
4
+ import { init_command } from './commands/init.js';
5
+ import { update_command } from './commands/update.js';
6
+ import { validate_command } from './commands/validate.js';
7
+ import { create_container } from './container.js';
8
+ import { create_cli } from './runner.js';
9
+
10
+ const VERSION = '0.1.0';
11
+
12
+ async function main() {
13
+ const args = process.argv.slice(2);
14
+
15
+ // Handle --version
16
+ if (args.includes('--version') || args.includes('-v')) {
17
+ console.log(`kustodian v${VERSION}`);
18
+ process.exit(0);
19
+ }
20
+
21
+ // Handle --help or no args
22
+ if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
23
+ console.log(`kustodian v${VERSION}`);
24
+ console.log('A GitOps templating framework for Kubernetes with Flux CD\n');
25
+ console.log('Usage: kustodian <command> [options]\n');
26
+ console.log('Commands:');
27
+ console.log(' init <name> Initialize a new Kustodian project');
28
+ console.log(' validate Validate cluster and template configurations');
29
+ console.log(
30
+ ' apply Apply full cluster configuration (generates, pushes OCI, deploys)',
31
+ );
32
+ console.log(' update Check and update image version substitutions\n');
33
+ console.log('Options:');
34
+ console.log(' --help, -h Show help');
35
+ console.log(' --version, -v Show version\n');
36
+ console.log('Examples:');
37
+ console.log(' kustodian init my-project');
38
+ console.log(' kustodian validate');
39
+ console.log(' kustodian apply --cluster production');
40
+ process.exit(0);
41
+ }
42
+
43
+ // Create CLI
44
+ const cli = create_cli({
45
+ name: 'kustodian',
46
+ version: VERSION,
47
+ description: 'A GitOps templating framework for Kubernetes with Flux CD',
48
+ });
49
+
50
+ // Register commands
51
+ cli.command(init_command);
52
+ cli.command(validate_command);
53
+ cli.command(apply_command);
54
+ cli.command(update_command);
55
+
56
+ // Create container
57
+ const container = create_container();
58
+
59
+ // Run CLI
60
+ const result = await cli.run(args, container);
61
+
62
+ if (!result.success) {
63
+ console.error(`\nError: ${result.error.message}`);
64
+ process.exit(1);
65
+ }
66
+ }
67
+
68
+ main().catch((error) => {
69
+ console.error('Unexpected error:', error);
70
+ process.exit(1);
71
+ });
package/src/command.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { type ResultType, success } from '@kustodian/core';
2
+ import type { KustodianErrorType } from '@kustodian/core';
3
+
4
+ import type { ContainerType } from './container.js';
5
+ import type { ContextType, MiddlewareType } from './middleware.js';
6
+
7
+ /**
8
+ * Option definition for a command.
9
+ */
10
+ export interface OptionType {
11
+ name: string;
12
+ short?: string;
13
+ description: string;
14
+ required?: boolean;
15
+ default_value?: unknown;
16
+ type?: 'string' | 'boolean' | 'number';
17
+ }
18
+
19
+ /**
20
+ * Argument definition for a command.
21
+ */
22
+ export interface ArgumentType {
23
+ name: string;
24
+ description: string;
25
+ required?: boolean;
26
+ variadic?: boolean;
27
+ }
28
+
29
+ /**
30
+ * Command handler function.
31
+ */
32
+ export type HandlerType = (
33
+ ctx: ContextType,
34
+ container: ContainerType,
35
+ ) => Promise<ResultType<void, KustodianErrorType>>;
36
+
37
+ /**
38
+ * Command definition.
39
+ */
40
+ export interface CommandType {
41
+ name: string;
42
+ description: string;
43
+ options?: OptionType[];
44
+ arguments?: ArgumentType[];
45
+ subcommands?: CommandType[];
46
+ middleware?: MiddlewareType[];
47
+ handler?: HandlerType;
48
+ }
49
+
50
+ /**
51
+ * Creates a command definition.
52
+ */
53
+ export function define_command(config: CommandType): CommandType {
54
+ return config;
55
+ }
56
+
57
+ /**
58
+ * Creates a no-op handler that returns success.
59
+ */
60
+ export function noop_handler(): HandlerType {
61
+ return async () => success(undefined);
62
+ }
63
+
64
+ /**
65
+ * Finds a subcommand by name.
66
+ */
67
+ export function find_subcommand(command: CommandType, name: string): CommandType | undefined {
68
+ return command.subcommands?.find((sub) => sub.name === name);
69
+ }
70
+
71
+ /**
72
+ * Gets the full command path (e.g., "nodes label").
73
+ */
74
+ export function get_command_path(commands: CommandType[]): string {
75
+ return commands.map((c) => c.name).join(' ');
76
+ }
@@ -0,0 +1,473 @@
1
+ import { exec } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { is_success, success } from '@kustodian/core';
4
+ import { find_project_root, load_project } from '@kustodian/loader';
5
+ import type { NodeListType } from '@kustodian/nodes';
6
+ import type { ClusterType } from '@kustodian/schema';
7
+
8
+ import { define_command } from '../command.js';
9
+
10
+ const execAsync = promisify(exec);
11
+
12
+ /**
13
+ * Apply command - orchestrates full cluster setup:
14
+ * 1. Bootstrap nodes with k0s
15
+ * 2. Install Flux CD
16
+ * 3. Deploy templates
17
+ */
18
+ export const apply_command = define_command({
19
+ name: 'apply',
20
+ description: 'Apply full cluster configuration (bootstrap + Flux + templates)',
21
+ options: [
22
+ {
23
+ name: 'cluster',
24
+ short: 'c',
25
+ description: 'Cluster name to apply',
26
+ type: 'string',
27
+ required: true,
28
+ },
29
+ {
30
+ name: 'provider',
31
+ short: 'P',
32
+ description: 'Cluster provider for bootstrap (default: k0s)',
33
+ type: 'string',
34
+ default_value: 'k0s',
35
+ },
36
+ {
37
+ name: 'project',
38
+ short: 'p',
39
+ description: 'Path to project root',
40
+ type: 'string',
41
+ },
42
+ {
43
+ name: 'dry-run',
44
+ short: 'd',
45
+ description: 'Preview what would happen without making changes',
46
+ type: 'boolean',
47
+ default_value: false,
48
+ },
49
+ {
50
+ name: 'skip-bootstrap',
51
+ description: 'Skip cluster bootstrap (use existing cluster)',
52
+ type: 'boolean',
53
+ default_value: false,
54
+ },
55
+ {
56
+ name: 'skip-flux',
57
+ description: 'Skip Flux CD installation',
58
+ type: 'boolean',
59
+ default_value: false,
60
+ },
61
+ {
62
+ name: 'skip-templates',
63
+ description: 'Skip template deployment',
64
+ type: 'boolean',
65
+ default_value: false,
66
+ },
67
+ ],
68
+ handler: async (ctx) => {
69
+ const cluster_name = ctx.options['cluster'] as string;
70
+ const provider_name = ctx.options['provider'] as string;
71
+ const project_path = (ctx.options['project'] as string) || process.cwd();
72
+ const dry_run = ctx.options['dry-run'] as boolean;
73
+ const skip_bootstrap = ctx.options['skip-bootstrap'] as boolean;
74
+ const skip_flux = ctx.options['skip-flux'] as boolean;
75
+ const skip_templates = ctx.options['skip-templates'] as boolean;
76
+
77
+ if (!cluster_name) {
78
+ console.error('Error: --cluster is required');
79
+ return {
80
+ success: false as const,
81
+ error: { code: 'INVALID_ARGS', message: '--cluster is required' },
82
+ };
83
+ }
84
+
85
+ console.log('\n━━━ Kustodian Apply ━━━');
86
+ console.log(`Cluster: ${cluster_name}`);
87
+ console.log(`Provider: ${provider_name}`);
88
+ if (dry_run) {
89
+ console.log('Mode: DRY RUN\n');
90
+ }
91
+
92
+ // ===== PHASE 1: Load Project =====
93
+ console.log('\n[1/3] Loading project configuration...');
94
+
95
+ const root_result = await find_project_root(project_path);
96
+ if (!root_result.success) {
97
+ console.error(` ✗ Error: ${root_result.error.message}`);
98
+ return root_result;
99
+ }
100
+
101
+ const project_root = root_result.value;
102
+ console.log(` → Project root: ${project_root}`);
103
+
104
+ const project_result = await load_project(project_root);
105
+ if (!project_result.success) {
106
+ console.error(` ✗ Error: ${project_result.error.message}`);
107
+ return project_result;
108
+ }
109
+
110
+ const project = project_result.value;
111
+ const loaded_cluster = project.clusters.find((c) => c.cluster.metadata.name === cluster_name);
112
+
113
+ if (!loaded_cluster) {
114
+ console.error(` ✗ Error: Cluster '${cluster_name}' not found`);
115
+ return {
116
+ success: false as const,
117
+ error: { code: 'NOT_FOUND', message: `Cluster '${cluster_name}' not found` },
118
+ };
119
+ }
120
+
121
+ console.log(' ✓ Loaded cluster configuration');
122
+ console.log(` ✓ Loaded ${project.templates.length} templates`);
123
+ console.log(` ✓ Loaded ${loaded_cluster.nodes.length} nodes`);
124
+
125
+ // ===== PHASE 2: Bootstrap Cluster =====
126
+ if (!skip_bootstrap) {
127
+ console.log('\n[2/3] Checking cluster status...');
128
+
129
+ // Check if cluster is already accessible
130
+ const { exec } = await import('node:child_process');
131
+ const { promisify } = await import('node:util');
132
+ const execAsync = promisify(exec);
133
+
134
+ let cluster_exists = false;
135
+ try {
136
+ await execAsync('kubectl cluster-info', { timeout: 5000 });
137
+ console.log(' ✓ Cluster is already running and accessible');
138
+ cluster_exists = true;
139
+ } catch {
140
+ console.log(' → No existing cluster detected');
141
+ }
142
+
143
+ if (!cluster_exists) {
144
+ console.log(' → Bootstrapping cluster with k0s...');
145
+
146
+ // Check if we have nodes to bootstrap
147
+ if (loaded_cluster.nodes.length === 0) {
148
+ console.error(' ✗ Error: No nodes defined for cluster');
149
+ console.error(
150
+ ' → Add nodes to cluster.yaml spec.nodes or create node files in nodes/ directory',
151
+ );
152
+ return {
153
+ success: false as const,
154
+ error: { code: 'NOT_FOUND', message: 'No nodes defined for cluster' },
155
+ };
156
+ }
157
+
158
+ // Build NodeListType for bootstrap workflow
159
+ const node_list: NodeListType = {
160
+ cluster: cluster_name,
161
+ nodes: loaded_cluster.nodes,
162
+ ...(loaded_cluster.cluster.spec.node_defaults?.label_prefix && {
163
+ label_prefix: loaded_cluster.cluster.spec.node_defaults.label_prefix,
164
+ }),
165
+ ...(loaded_cluster.cluster.spec.node_defaults?.ssh && {
166
+ ssh: loaded_cluster.cluster.spec.node_defaults.ssh,
167
+ }),
168
+ } as NodeListType;
169
+
170
+ // Load k0s provider
171
+ const { create_k0s_provider } = await import('@kustodian/plugin-k0s');
172
+ const provider = create_k0s_provider();
173
+
174
+ console.log(' → Validating cluster configuration...');
175
+ const validate_result = provider.validate(node_list);
176
+ if (!is_success(validate_result)) {
177
+ console.error(` ✗ Validation failed: ${validate_result.error.message}`);
178
+ return validate_result;
179
+ }
180
+ console.log(' ✓ Configuration valid');
181
+
182
+ console.log(' → Installing k0s cluster...');
183
+ if (dry_run) {
184
+ console.log(' [dry-run] Would run: k0sctl apply');
185
+ } else {
186
+ const install_result = await provider.install(node_list, { dry_run: false });
187
+ if (!is_success(install_result)) {
188
+ console.error(` ✗ Installation failed: ${install_result.error.message}`);
189
+ return install_result;
190
+ }
191
+ console.log(' ✓ k0s cluster installed');
192
+
193
+ console.log(' → Retrieving kubeconfig...');
194
+ const kubeconfig_result = await provider.get_kubeconfig(node_list);
195
+ if (!is_success(kubeconfig_result)) {
196
+ console.error(` ✗ Failed to get kubeconfig: ${kubeconfig_result.error.message}`);
197
+ return kubeconfig_result;
198
+ }
199
+ console.log(` ✓ Kubeconfig: ${kubeconfig_result.value}`);
200
+ }
201
+
202
+ console.log(' ✓ Cluster bootstrapped successfully');
203
+ }
204
+ } else {
205
+ console.log('\n[2/3] Skipping bootstrap (using existing cluster)');
206
+ }
207
+
208
+ // ===== PHASE 3: Install Flux CD =====
209
+ if (!skip_flux) {
210
+ console.log('\n[3/3] Checking Flux CD status...');
211
+
212
+ const { exec } = await import('node:child_process');
213
+ const { promisify } = await import('node:util');
214
+ const execAsync = promisify(exec);
215
+
216
+ let flux_installed = false;
217
+ try {
218
+ const { stdout } = await execAsync('kubectl get namespace flux-system', { timeout: 5000 });
219
+ if (stdout.includes('flux-system')) {
220
+ console.log(' ✓ Flux CD is already installed');
221
+ flux_installed = true;
222
+ }
223
+ } catch {
224
+ console.log(' → Flux CD not detected');
225
+ }
226
+
227
+ if (!flux_installed) {
228
+ console.log(' → Installing Flux CD...');
229
+
230
+ // Check if flux CLI is available
231
+ try {
232
+ await execAsync('flux --version', { timeout: 5000 });
233
+ } catch {
234
+ console.error(' ✗ Error: flux CLI not found');
235
+ console.error(' → Install with: brew install fluxcd/tap/flux');
236
+ return {
237
+ success: false as const,
238
+ error: { code: 'MISSING_DEPENDENCY', message: 'flux CLI not found' },
239
+ };
240
+ }
241
+
242
+ if (dry_run) {
243
+ console.log(' [dry-run] Would run: flux install');
244
+ } else {
245
+ try {
246
+ console.log(' Running: flux install');
247
+ const { stderr } = await execAsync('flux install', {
248
+ timeout: 300000, // 5 minutes timeout
249
+ });
250
+ if (stderr && !stderr.includes('successfully')) {
251
+ console.log(` ${stderr}`);
252
+ }
253
+ console.log(' ✓ Flux CD installed successfully');
254
+ } catch (error) {
255
+ const err = error as { message?: string; stderr?: string };
256
+ console.error(` ✗ Flux installation failed: ${err.message || err.stderr}`);
257
+ return {
258
+ success: false as const,
259
+ error: { code: 'FLUX_INSTALL_FAILED', message: 'Flux installation failed' },
260
+ };
261
+ }
262
+
263
+ // Wait for Flux to be ready
264
+ console.log(' Waiting for Flux components to be ready...');
265
+ try {
266
+ await execAsync('flux check --timeout=2m', { timeout: 150000 });
267
+ console.log(' ✓ Flux components are ready');
268
+ } catch {
269
+ console.log(' ⚠ Flux components may not be fully ready yet');
270
+ }
271
+ }
272
+ }
273
+ } else {
274
+ console.log('\n[3/3] Skipping Flux CD installation');
275
+ }
276
+
277
+ // ===== PHASE 4: Deploy Templates =====
278
+ if (!skip_templates) {
279
+ console.log('\n[4/4] Deploying templates...');
280
+
281
+ if (loaded_cluster.cluster.spec.oci) {
282
+ // OCI Mode - generate in memory and apply directly
283
+ console.log(' → Cluster uses OCI deployment');
284
+ console.log(' → Generating Flux resources...');
285
+
286
+ const { create_generator, serialize_resource } = await import('@kustodian/generator');
287
+ const oci_repository_name = 'kustodian-oci';
288
+ const generator = create_generator({
289
+ flux_namespace: 'flux-system',
290
+ git_repository_name: oci_repository_name,
291
+ });
292
+
293
+ const gen_result = await generator.generate(
294
+ loaded_cluster.cluster,
295
+ project.templates.map((t) => t.template),
296
+ {},
297
+ );
298
+
299
+ if (!gen_result.success) {
300
+ console.error(` ✗ Generation failed: ${gen_result.error.message}`);
301
+ return gen_result;
302
+ }
303
+
304
+ const gen_data = gen_result.value;
305
+ console.log(` ✓ Generated ${gen_data.kustomizations.length} Flux Kustomizations`);
306
+
307
+ if (dry_run) {
308
+ console.log('\n [dry-run] Would push to OCI and apply Flux resources');
309
+ if (gen_data.oci_repository) {
310
+ console.log(` → OCIRepository: ${gen_data.oci_repository.metadata.name}`);
311
+ }
312
+ for (const k of gen_data.kustomizations) {
313
+ console.log(` → Kustomization: ${k.name} (${k.path})`);
314
+ }
315
+ } else {
316
+ // Push to OCI registry
317
+ console.log(' → Pushing to OCI registry...');
318
+ const tag = await get_oci_tag(loaded_cluster.cluster, project_root);
319
+ const oci = loaded_cluster.cluster.spec.oci;
320
+ const oci_url = `oci://${oci.registry}/${oci.repository}:${tag}`;
321
+
322
+ try {
323
+ const git_source = await get_git_source(project_root);
324
+ const git_revision = await get_git_revision(project_root);
325
+
326
+ const push_cmd = `flux push artifact ${oci_url} --path="${project_root}" --source="${git_source}" --revision="${git_revision}"`;
327
+ await execAsync(push_cmd, { timeout: 120000 });
328
+ console.log(` ✓ Pushed to ${oci_url}`);
329
+ } catch (error) {
330
+ const err = error as Error;
331
+ console.error(` ✗ Push failed: ${err.message}`);
332
+ return {
333
+ success: false as const,
334
+ error: { code: 'PUSH_FAILED', message: err.message },
335
+ };
336
+ }
337
+
338
+ // Apply Flux resources directly (no file writes)
339
+ console.log(' → Applying Flux resources...');
340
+ try {
341
+ // Build combined YAML for all resources
342
+ const resources: object[] = [];
343
+
344
+ // Add OCIRepository with correct tag
345
+ if (gen_data.oci_repository) {
346
+ const oci_repo = { ...gen_data.oci_repository };
347
+ oci_repo.spec = { ...oci_repo.spec, ref: { tag } };
348
+ resources.push(oci_repo);
349
+ }
350
+
351
+ // Add all Kustomizations
352
+ for (const k of gen_data.kustomizations) {
353
+ resources.push(k.flux_kustomization);
354
+ }
355
+
356
+ // Serialize and apply via stdin
357
+ const yaml_content = resources.map((r) => serialize_resource(r)).join('---\n');
358
+ const { spawn } = await import('node:child_process');
359
+
360
+ await new Promise<void>((resolve, reject) => {
361
+ const kubectl = spawn('kubectl', ['apply', '-f', '-'], {
362
+ stdio: ['pipe', 'pipe', 'pipe'],
363
+ });
364
+
365
+ let stdout = '';
366
+ let stderr = '';
367
+
368
+ kubectl.stdout.on('data', (data) => {
369
+ stdout += data.toString();
370
+ });
371
+ kubectl.stderr.on('data', (data) => {
372
+ stderr += data.toString();
373
+ });
374
+
375
+ kubectl.on('close', (code) => {
376
+ if (code === 0) {
377
+ if (stdout) console.log(` ${stdout.trim()}`);
378
+ resolve();
379
+ } else {
380
+ reject(new Error(stderr || `kubectl exited with code ${code}`));
381
+ }
382
+ });
383
+
384
+ kubectl.stdin.write(yaml_content);
385
+ kubectl.stdin.end();
386
+ });
387
+
388
+ console.log(' ✓ Flux resources applied');
389
+ } catch (error) {
390
+ const err = error as Error;
391
+ console.error(` ✗ Apply failed: ${err.message}`);
392
+ return {
393
+ success: false as const,
394
+ error: { code: 'APPLY_FAILED', message: err.message },
395
+ };
396
+ }
397
+
398
+ console.log('\n ✓ Deployment complete - Flux will reconcile from OCI');
399
+ }
400
+ } else {
401
+ console.error(' ✗ Error: Cluster must have spec.oci configured');
402
+ console.error(' → Git-based deployment has been removed');
403
+ return {
404
+ success: false as const,
405
+ error: { code: 'INVALID_CONFIG', message: 'spec.oci configuration required' },
406
+ };
407
+ }
408
+ } else {
409
+ console.log('\n[4/4] Skipping template deployment');
410
+ }
411
+
412
+ console.log('\n━━━ Apply Complete ━━━\n');
413
+ return success(undefined);
414
+ },
415
+ });
416
+
417
+ /**
418
+ * Resolves the OCI tag based on cluster strategy.
419
+ */
420
+ async function get_oci_tag(cluster: ClusterType, project_root: string): Promise<string> {
421
+ if (!cluster.spec.oci) {
422
+ return 'latest';
423
+ }
424
+
425
+ const strategy = cluster.spec.oci.tag_strategy || 'git-sha';
426
+
427
+ switch (strategy) {
428
+ case 'cluster':
429
+ return cluster.metadata.name;
430
+ case 'manual':
431
+ return cluster.spec.oci.tag || 'latest';
432
+ case 'version': {
433
+ try {
434
+ const { stdout } = await execAsync('git describe --tags --abbrev=0', { cwd: project_root });
435
+ return stdout.trim();
436
+ } catch {
437
+ return 'latest';
438
+ }
439
+ }
440
+ default: {
441
+ try {
442
+ const { stdout } = await execAsync('git rev-parse --short HEAD', { cwd: project_root });
443
+ return `sha1-${stdout.trim()}`;
444
+ } catch {
445
+ return 'latest';
446
+ }
447
+ }
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Gets the git remote URL for source metadata.
453
+ */
454
+ async function get_git_source(project_root: string): Promise<string> {
455
+ try {
456
+ const { stdout } = await execAsync('git config --get remote.origin.url', { cwd: project_root });
457
+ return stdout.trim();
458
+ } catch {
459
+ return 'unknown';
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Gets the current git revision for source metadata.
465
+ */
466
+ async function get_git_revision(project_root: string): Promise<string> {
467
+ try {
468
+ const { stdout } = await execAsync('git rev-parse HEAD', { cwd: project_root });
469
+ return `sha1:${stdout.trim()}`;
470
+ } catch {
471
+ return 'unknown';
472
+ }
473
+ }