buckyos 0.7.119 → 0.7.121

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.
Files changed (44) hide show
  1. package/cli/README.md +24 -3
  2. package/cli/core/app.ts +28 -0
  3. package/cli/deno.json +2 -1
  4. package/cli/dist/cli.mjs +777 -169
  5. package/cli/dist/cli.mjs.map +1 -1
  6. package/cli/modules/app.ts +213 -94
  7. package/cli/modules/provision.ts +690 -0
  8. package/cli/runtime/host.ts +25 -2
  9. package/cli/runtime/host_deno.ts +4 -0
  10. package/cli/runtime/host_node.ts +4 -0
  11. package/cli/version.ts +1 -1
  12. package/dist/browser.cjs +1 -1
  13. package/dist/browser.mjs +124 -124
  14. package/dist/index.cjs +1 -1
  15. package/dist/index.mjs +124 -124
  16. package/dist/{ndm_proxy-c00bc561.mjs → ndm_proxy-8f547002.mjs} +113 -216
  17. package/dist/ndm_proxy-8f547002.mjs.map +1 -0
  18. package/dist/ndm_proxy-93a8869b.js +13 -0
  19. package/dist/ndm_proxy-93a8869b.js.map +1 -0
  20. package/dist/ndn_types-44c69036.js +2 -0
  21. package/dist/ndn_types-44c69036.js.map +1 -0
  22. package/dist/{ndn_types-f6c08d20.mjs → ndn_types-d768245f.mjs} +149 -39
  23. package/dist/ndn_types-d768245f.mjs.map +1 -0
  24. package/dist/node.cjs +1 -1
  25. package/dist/node.mjs +124 -124
  26. package/dist/provision.cjs +6 -6
  27. package/dist/provision.cjs.map +1 -1
  28. package/dist/provision.d.ts +2 -0
  29. package/dist/provision.d.ts.map +1 -1
  30. package/dist/provision.mjs +1084 -35
  31. package/dist/provision.mjs.map +1 -1
  32. package/dist/provision_activate.d.ts +146 -0
  33. package/dist/provision_activate.d.ts.map +1 -0
  34. package/dist/{tus_client-573066b3.mjs → tus_client-b1c35e8c.mjs} +2 -2
  35. package/dist/{tus_client-573066b3.mjs.map → tus_client-b1c35e8c.mjs.map} +1 -1
  36. package/dist/{tus_client-83ad0f4f.js → tus_client-d0798623.js} +2 -2
  37. package/dist/{tus_client-83ad0f4f.js.map → tus_client-d0798623.js.map} +1 -1
  38. package/package.json +1 -1
  39. package/dist/ndm_proxy-68354a89.js +0 -13
  40. package/dist/ndm_proxy-68354a89.js.map +0 -1
  41. package/dist/ndm_proxy-c00bc561.mjs.map +0 -1
  42. package/dist/ndn_types-859bbd48.js +0 -2
  43. package/dist/ndn_types-859bbd48.js.map +0 -1
  44. package/dist/ndn_types-f6c08d20.mjs.map +0 -1
@@ -0,0 +1,690 @@
1
+ import type {
2
+ CommandDefinition,
3
+ CommandModule,
4
+ JsonSchema,
5
+ OptionDefinition,
6
+ } from '../core/command.ts'
7
+ import type { CommandContext } from '../core/context.ts'
8
+ import {
9
+ EXIT_OPERATION,
10
+ EXIT_PARTIAL,
11
+ EXIT_PERMISSION,
12
+ EXIT_USAGE,
13
+ ToolError,
14
+ UsageError,
15
+ } from '../core/errors.ts'
16
+ import { getHost, isHostError } from '../runtime/host.ts'
17
+ import { TOOL_VERSION } from '../version.ts'
18
+ import * as provisionSdk from 'buckyos/provision'
19
+
20
+ // provision: first activation of an installed, not yet activated BuckyOS root on
21
+ // this machine (single OOD, did:web, no SN/BNS). Every command is local: no
22
+ // Zone session, no verify-hub, no TaskManager, no network. The activation
23
+ // implementation lives in the SDK (buckyos/provision); this module is the thin
24
+ // argv/confirmation/secret/output layer described in doc/modules/provision.md.
25
+
26
+ // The built SDK entry is untyped for `deno check` (as buckyos/node is for the
27
+ // core); these interfaces mirror the API documented in src/provision_activate.ts.
28
+ export type ActivationState = 'not_installed' | 'unactivated' | 'partial' | 'configured' | 'invalid'
29
+
30
+ export interface ActivationProblem {
31
+ code: string
32
+ message: string
33
+ }
34
+
35
+ export interface ActivationFileEntry {
36
+ path: string
37
+ role: string
38
+ required: boolean
39
+ secret: boolean
40
+ }
41
+
42
+ export interface ObservedActivationFile extends ActivationFileEntry {
43
+ present: boolean
44
+ }
45
+
46
+ export interface CommittedActivationFile extends ActivationFileEntry {
47
+ sha256: string | null
48
+ }
49
+
50
+ export interface ActivationDnsRecord {
51
+ type: 'A' | 'AAAA'
52
+ name: string
53
+ value: string
54
+ }
55
+
56
+ export interface ActivationLockInfo {
57
+ path: string
58
+ schemaVersion: number | null
59
+ traceId: string | null
60
+ startedAt: number | null
61
+ domain: string | null
62
+ stage: string | null
63
+ committedFiles: string[]
64
+ ownerKeyBackupPath: string | null
65
+ corrupt: boolean
66
+ }
67
+
68
+ export interface ActivationStatus {
69
+ rootDir: string
70
+ state: ActivationState
71
+ startupRequired: boolean
72
+ observedAt: number
73
+ zoneDid: string | null
74
+ ownerDid: string | null
75
+ deviceDid: string | null
76
+ deviceName: string | null
77
+ accessHostname: string | null
78
+ files: ObservedActivationFile[]
79
+ problems: ActivationProblem[]
80
+ warnings: string[]
81
+ lock: ActivationLockInfo | null
82
+ }
83
+
84
+ export interface OfflineActivationCheckOptions {
85
+ rootDir: string
86
+ domain: string
87
+ ownerName: string
88
+ ownerKeyBackupPath: string
89
+ rtcpPort?: number
90
+ guestAccess?: boolean
91
+ publicIp?: string
92
+ }
93
+
94
+ export interface OfflineActivationOptions extends OfflineActivationCheckOptions {
95
+ adminPassword: string
96
+ traceId?: string
97
+ }
98
+
99
+ export interface OfflineActivationPrecheck {
100
+ ready: boolean
101
+ rootDir: string
102
+ state: ActivationState
103
+ domain: string
104
+ ownerName: string
105
+ ownerDid: string
106
+ zoneDid: string
107
+ deviceDid: string
108
+ deviceName: string
109
+ accessHostname: string
110
+ rtcpPort: number
111
+ guestAccess: boolean
112
+ publicIp: string | null
113
+ dnsRecords: ActivationDnsRecord[]
114
+ ownerKeyBackup: { path: string; directoryExists: boolean; exists: boolean }
115
+ plannedFiles: ActivationFileEntry[]
116
+ problems: ActivationProblem[]
117
+ status: ActivationStatus
118
+ }
119
+
120
+ export interface OfflineActivationResult {
121
+ rootDir: string
122
+ state: 'configured'
123
+ startupRequired: true
124
+ domain: string
125
+ ownerName: string
126
+ ownerDid: string
127
+ zoneDid: string
128
+ deviceDid: string
129
+ deviceName: string
130
+ accessHostname: string
131
+ rtcpPort: number
132
+ guestAccess: boolean
133
+ publicIp: string | null
134
+ documentIat: number
135
+ documentExp: number
136
+ ownerKeyBackup: { path: string; saved: true }
137
+ dnsRecords: ActivationDnsRecord[]
138
+ committedFiles: CommittedActivationFile[]
139
+ warnings: string[]
140
+ }
141
+
142
+ interface ProvisionSdk {
143
+ inspectActivationRoot(rootDir: string): Promise<ActivationStatus>
144
+ checkOfflineActivation(options: OfflineActivationCheckOptions): Promise<OfflineActivationPrecheck>
145
+ activateOfflineZone(options: OfflineActivationOptions): Promise<OfflineActivationResult>
146
+ }
147
+
148
+ const sdk = provisionSdk as unknown as ProvisionSdk
149
+
150
+ export interface ProvisionModuleDependencies {
151
+ inspect?: ProvisionSdk['inspectActivationRoot']
152
+ check?: ProvisionSdk['checkOfflineActivation']
153
+ activate?: ProvisionSdk['activateOfflineZone']
154
+ }
155
+
156
+ const OBJECT_OUTPUT: JsonSchema = { type: 'object', additionalProperties: true }
157
+
158
+ const ROOT_OPTION: OptionDefinition = {
159
+ name: 'root',
160
+ description:
161
+ 'Installed BuckyOS root on this machine (BUCKYOS_ROOT); read by status/check, written by activate',
162
+ type: 'string',
163
+ required: true,
164
+ }
165
+
166
+ const TARGET_OPTIONS: OptionDefinition[] = [
167
+ ROOT_OPTION,
168
+ {
169
+ name: 'domain',
170
+ description: 'Fixed public host name; Owner DID and Zone DID become did:web:<domain>',
171
+ type: 'string',
172
+ required: true,
173
+ },
174
+ {
175
+ name: 'owner-name',
176
+ property: 'owner_name',
177
+ description: 'Local administrator / OwnerDocument name (lowercase DNS label)',
178
+ type: 'string',
179
+ required: true,
180
+ },
181
+ {
182
+ name: 'owner-key-backup',
183
+ property: 'owner_key_backup',
184
+ description:
185
+ 'New file for the Owner private-key recovery copy; its directory must exist and the file must not',
186
+ type: 'string',
187
+ required: true,
188
+ },
189
+ {
190
+ name: 'public-ip',
191
+ property: 'public_ip',
192
+ description: 'Fixed public IP used only for the A/AAAA record instructions',
193
+ type: 'string',
194
+ },
195
+ {
196
+ name: 'rtcp-port',
197
+ property: 'rtcp_port',
198
+ description: 'RTCP port, default 2980',
199
+ type: 'integer',
200
+ },
201
+ {
202
+ name: 'guest-access',
203
+ property: 'guest_access',
204
+ description: 'Enable guest access in the first-boot configuration',
205
+ type: 'boolean',
206
+ },
207
+ ]
208
+
209
+ const TARGET_INPUT_PROPERTIES: Record<string, JsonSchema> = {
210
+ root: { type: 'string', minLength: 1 },
211
+ domain: { type: 'string', minLength: 1 },
212
+ owner_name: { type: 'string', minLength: 1 },
213
+ owner_key_backup: { type: 'string', minLength: 1 },
214
+ public_ip: { type: 'string' },
215
+ rtcp_port: { type: 'integer', minimum: 1 },
216
+ guest_access: { type: 'boolean' },
217
+ }
218
+
219
+ const TARGET_REQUIRED = ['root', 'domain', 'owner_name', 'owner_key_backup']
220
+
221
+ const POLICY_NOTE =
222
+ 'Paths are read from argv by the launcher to grant the minimal filesystem permissions; ' +
223
+ 'root and owner_key_backup given only inside --input are limited to the policy roots (cwd, ' +
224
+ 'and the paired BUCKYOS_ROOT of the system distribution).'
225
+
226
+ export function createProvisionModule(
227
+ dependencies: ProvisionModuleDependencies = {},
228
+ ): CommandModule {
229
+ const deps: ResolvedDependencies = {
230
+ inspect: dependencies.inspect ?? sdk.inspectActivationRoot,
231
+ check: dependencies.check ?? sdk.checkOfflineActivation,
232
+ activate: dependencies.activate ?? sdk.activateOfflineZone,
233
+ }
234
+ return {
235
+ name: 'provision',
236
+ summary: 'Activate an installed BuckyOS root on this machine (first Zone, Owner, and OOD)',
237
+ commands: [statusCommand(deps), checkCommand(deps), activateCommand(deps)],
238
+ }
239
+ }
240
+
241
+ type ResolvedDependencies = Required<ProvisionModuleDependencies>
242
+
243
+ function statusCommand(deps: ResolvedDependencies): CommandDefinition {
244
+ return {
245
+ verb: 'status',
246
+ summary: 'Inspect the local activation material of an installed BuckyOS root',
247
+ description:
248
+ 'Read-only. Reports not_installed, unactivated, partial, configured, or invalid from the ' +
249
+ 'files under the root; configured only means the first-boot material is complete and internally ' +
250
+ 'consistent (startup_required=true). Service health, DNS, TLS, and public DID discovery are not checked. ' +
251
+ POLICY_NOTE,
252
+ options: [ROOT_OPTION],
253
+ inputSchema: {
254
+ type: 'object',
255
+ properties: { root: { type: 'string', minLength: 1 } },
256
+ required: ['root'],
257
+ additionalProperties: false,
258
+ },
259
+ outputSchema: {
260
+ ...OBJECT_OUTPUT,
261
+ properties: {
262
+ root: { type: 'string' },
263
+ state: {
264
+ type: 'string',
265
+ enum: ['not_installed', 'unactivated', 'partial', 'configured', 'invalid'],
266
+ },
267
+ startup_required: { type: 'boolean' },
268
+ },
269
+ required: ['root', 'state', 'startup_required'],
270
+ },
271
+ resultSchemaVersion: 1,
272
+ access: { mode: 'fixed', level: 'read' },
273
+ asyncMode: 'sync',
274
+ requiresSession: false,
275
+ execution: 'local',
276
+ networkAccess: false,
277
+ examples: ['buckyos provision status --root /opt/buckyos'],
278
+ handler: async (ctx, input) => {
279
+ const root = resolvePath(ctx, requiredString(input, 'root'))
280
+ await assertHostAccess(root, 'read')
281
+ return statusView(await runSdk(() => deps.inspect(root)))
282
+ },
283
+ }
284
+ }
285
+
286
+ function checkCommand(deps: ResolvedDependencies): CommandDefinition {
287
+ return {
288
+ verb: 'check',
289
+ summary: 'Validate activation parameters and the target root without writing anything',
290
+ description:
291
+ 'Read-only precheck shared with activate: parameters, target state, and the owner key ' +
292
+ 'backup location. No keys are generated, no backup is written, and no operation is produced; ' +
293
+ 'activate re-validates the target itself. Fails with the first problem code when the target is not ready. ' +
294
+ POLICY_NOTE,
295
+ options: TARGET_OPTIONS,
296
+ inputSchema: {
297
+ type: 'object',
298
+ properties: TARGET_INPUT_PROPERTIES,
299
+ required: TARGET_REQUIRED,
300
+ additionalProperties: false,
301
+ },
302
+ outputSchema: {
303
+ ...OBJECT_OUTPUT,
304
+ properties: {
305
+ ready: { type: 'boolean' },
306
+ root: { type: 'string' },
307
+ state: { type: 'string' },
308
+ },
309
+ required: ['ready', 'root', 'state'],
310
+ },
311
+ resultSchemaVersion: 1,
312
+ access: { mode: 'fixed', level: 'read' },
313
+ asyncMode: 'sync',
314
+ requiresSession: false,
315
+ execution: 'local',
316
+ networkAccess: false,
317
+ examples: [
318
+ 'buckyos provision check --root /opt/buckyos --domain corp.example.com --owner-name admin --owner-key-backup /secure-backup/corp-owner.pem',
319
+ ],
320
+ handler: async (ctx, input) => {
321
+ const options = activationOptions(ctx, input)
322
+ await assertHostAccess(options.rootDir, 'read')
323
+ await assertHostAccess(getHost().path.dirname(options.ownerKeyBackupPath), 'read')
324
+ const precheck = await runSdk(() => deps.check(options))
325
+ if (!precheck.ready) throw precheckError(precheck)
326
+ return precheckView(precheck)
327
+ },
328
+ }
329
+ }
330
+
331
+ function activateCommand(deps: ResolvedDependencies): CommandDefinition {
332
+ return {
333
+ verb: 'activate',
334
+ summary:
335
+ 'Activate the root: create the first Zone, Owner, and OOD identity and the first-boot configuration',
336
+ description:
337
+ 'Privileged local write. Generates random Ed25519 Owner/device keys and current-time documents, ' +
338
+ 'saves the Owner private-key backup, then commits the identity, Zone, and start_config files; ' +
339
+ 'etc/node_identity.json is written last because node-daemon treats it as the activation switch. ' +
340
+ 'The administrator password is read from the secret admin_password field of --input JSON or from a ' +
341
+ 'hidden terminal prompt; there is no --admin-password option. Non-interactive runs require --yes. ' +
342
+ 'Repeated, partial, and invalid targets are rejected; there is no --force. After success restart ' +
343
+ 'BuckyOS through the installer or node control entry; this command never reports the Zone as online. ' +
344
+ POLICY_NOTE,
345
+ options: TARGET_OPTIONS,
346
+ inputSchema: {
347
+ type: 'object',
348
+ properties: {
349
+ ...TARGET_INPUT_PROPERTIES,
350
+ admin_password: {
351
+ type: 'string',
352
+ secret: true,
353
+ description: 'Administrator password (secret; --input JSON or hidden prompt only)',
354
+ },
355
+ },
356
+ required: TARGET_REQUIRED,
357
+ additionalProperties: false,
358
+ },
359
+ outputSchema: {
360
+ ...OBJECT_OUTPUT,
361
+ properties: {
362
+ root: { type: 'string' },
363
+ state: { type: 'string', enum: ['configured'] },
364
+ startup_required: { type: 'boolean' },
365
+ },
366
+ required: ['root', 'state', 'startup_required'],
367
+ },
368
+ resultSchemaVersion: 1,
369
+ access: { mode: 'fixed', level: 'privileged' },
370
+ asyncMode: 'sync',
371
+ requiresSession: false,
372
+ execution: 'local',
373
+ networkAccess: false,
374
+ examples: [
375
+ 'buckyos provision activate --root /opt/buckyos --domain corp.example.com --owner-name admin --owner-key-backup /secure-backup/corp-owner.pem',
376
+ 'buckyos --non-interactive --yes --input activation.json provision activate --root /opt/buckyos --owner-key-backup /secure-backup/corp-owner.pem',
377
+ ],
378
+ handler: async (ctx, input) => {
379
+ const options = activationOptions(ctx, input)
380
+ const backupDirectory = getHost().path.dirname(options.ownerKeyBackupPath)
381
+ await assertHostAccess(options.rootDir, 'read')
382
+ await assertHostAccess(options.rootDir, 'write')
383
+ await assertHostAccess(backupDirectory, 'read')
384
+ await assertHostAccess(backupDirectory, 'write')
385
+ const precheck = await runSdk(() => deps.check(options))
386
+ if (!precheck.ready) throw precheckError(precheck)
387
+ const adminPassword = await obtainAdminPassword(ctx, input)
388
+ await confirmActivation(ctx, precheck)
389
+ const result = await runSdk(() =>
390
+ deps.activate({ ...options, adminPassword, traceId: ctx.traceId })
391
+ )
392
+ return resultView(ctx, input, result)
393
+ },
394
+ }
395
+ }
396
+
397
+ // ---------------------------------------------------------------------------
398
+ // input handling
399
+ // ---------------------------------------------------------------------------
400
+
401
+ function activationOptions(
402
+ ctx: CommandContext,
403
+ input: Record<string, unknown>,
404
+ ): OfflineActivationCheckOptions {
405
+ return {
406
+ rootDir: resolvePath(ctx, requiredString(input, 'root')),
407
+ domain: requiredString(input, 'domain'),
408
+ ownerName: requiredString(input, 'owner_name'),
409
+ ownerKeyBackupPath: resolvePath(ctx, requiredString(input, 'owner_key_backup')),
410
+ publicIp: optionalString(input, 'public_ip'),
411
+ rtcpPort: typeof input.rtcp_port === 'number' ? input.rtcp_port : undefined,
412
+ guestAccess: input.guest_access === true,
413
+ }
414
+ }
415
+
416
+ function requiredString(input: Record<string, unknown>, key: string): string {
417
+ const value = optionalString(input, key)
418
+ if (!value) throw new UsageError('INVALID_ARGUMENT', `${key} is required`)
419
+ return value
420
+ }
421
+
422
+ function optionalString(input: Record<string, unknown>, key: string): string | undefined {
423
+ const value = input[key]
424
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
425
+ }
426
+
427
+ function resolvePath(ctx: CommandContext, value: string): string {
428
+ const path = getHost().path
429
+ return path.isAbsolute(value) ? path.resolve(value) : path.resolve(ctx.cwd, value)
430
+ }
431
+
432
+ async function assertHostAccess(path: string, operation: 'read' | 'write'): Promise<void> {
433
+ try {
434
+ await getHost().assertAccess(path, operation)
435
+ } catch (error) {
436
+ if (isHostError(error, 'PermissionDenied')) {
437
+ throw new ToolError(
438
+ 'HOST_ACCESS_DENIED',
439
+ `${error.message}; pass the path on the command line (--root / --owner-key-backup) so the launcher can grant it`,
440
+ EXIT_PERMISSION,
441
+ false,
442
+ { path, operation },
443
+ )
444
+ }
445
+ throw error
446
+ }
447
+ }
448
+
449
+ async function obtainAdminPassword(
450
+ ctx: CommandContext,
451
+ input: Record<string, unknown>,
452
+ ): Promise<string> {
453
+ if (typeof input.admin_password === 'string') return input.admin_password
454
+ if (ctx.config.nonInteractive) {
455
+ throw new UsageError(
456
+ 'SECRET_REQUIRED',
457
+ 'admin_password must be provided through --input JSON in non-interactive mode',
458
+ )
459
+ }
460
+ if (!ctx.io.inputIsTerminal) {
461
+ throw new UsageError(
462
+ 'SECRET_INPUT_UNAVAILABLE',
463
+ 'the administrator password requires an interactive terminal or --input JSON',
464
+ )
465
+ }
466
+ const host = getHost()
467
+ let password: string
468
+ let confirmation: string
469
+ try {
470
+ password = await host.readSecret('Administrator password: ')
471
+ confirmation = await host.readSecret('Confirm administrator password: ')
472
+ } catch (error) {
473
+ if (isHostError(error, 'PermissionDenied')) {
474
+ throw new UsageError('SECRET_INPUT_UNAVAILABLE', error.message)
475
+ }
476
+ throw error
477
+ }
478
+ if (password !== confirmation) {
479
+ throw new UsageError('PASSWORD_MISMATCH', 'the administrator passwords do not match')
480
+ }
481
+ return password
482
+ }
483
+
484
+ async function confirmActivation(
485
+ ctx: CommandContext,
486
+ precheck: OfflineActivationPrecheck,
487
+ ): Promise<void> {
488
+ if (ctx.confirmed) return
489
+ if (ctx.config.nonInteractive) {
490
+ throw new ToolError(
491
+ 'CONFIRMATION_REQUIRED',
492
+ 'provision activate requires --yes in non-interactive mode',
493
+ EXIT_PERMISSION,
494
+ )
495
+ }
496
+ if (!ctx.io.inputIsTerminal) {
497
+ throw new ToolError(
498
+ 'CONFIRMATION_REQUIRED',
499
+ 'provision activate requires an interactive terminal or --yes',
500
+ EXIT_PERMISSION,
501
+ )
502
+ }
503
+ await ctx.io.stderr(
504
+ [
505
+ 'Activation summary',
506
+ ` BUCKYOS_ROOT : ${precheck.rootDir}`,
507
+ ` Owner/Zone : ${precheck.zoneDid}`,
508
+ ` Device : ${precheck.deviceDid}`,
509
+ ` Admin : ${precheck.ownerName}`,
510
+ ` Network : ${'wan'} (no SN, no BNS)`,
511
+ ` RTCP port : ${precheck.rtcpPort}`,
512
+ ` Guest access : ${precheck.guestAccess}`,
513
+ ` Owner backup : ${precheck.ownerKeyBackup.path}`,
514
+ '',
515
+ ].join('\n'),
516
+ )
517
+ const answer = (await ctx.io.prompt('Write this activation to disk? [y/N] '))?.trim()
518
+ .toLowerCase()
519
+ if (answer !== 'y' && answer !== 'yes') {
520
+ throw new ToolError('CONFIRMATION_DECLINED', 'activation was declined', EXIT_PERMISSION)
521
+ }
522
+ }
523
+
524
+ // ---------------------------------------------------------------------------
525
+ // SDK error mapping
526
+ // ---------------------------------------------------------------------------
527
+
528
+ interface SdkError extends Error {
529
+ code: string
530
+ details: Record<string, unknown>
531
+ }
532
+
533
+ function isSdkError(error: unknown): error is SdkError {
534
+ return error instanceof Error && error.name === 'ActivationError' &&
535
+ typeof (error as { code?: unknown }).code === 'string'
536
+ }
537
+
538
+ async function runSdk<T>(run: () => Promise<T>): Promise<T> {
539
+ try {
540
+ return await run()
541
+ } catch (error) {
542
+ if (isSdkError(error)) {
543
+ const exitCode = error.code === 'INVALID_ARGUMENT'
544
+ ? EXIT_USAGE
545
+ : error.code === 'ACTIVATION_COMMIT_FAILED'
546
+ ? EXIT_PARTIAL
547
+ : EXIT_OPERATION
548
+ throw new ToolError(error.code, error.message, exitCode, false, error.details ?? {})
549
+ }
550
+ throw error
551
+ }
552
+ }
553
+
554
+ function precheckError(precheck: OfflineActivationPrecheck): ToolError {
555
+ const [first] = precheck.problems
556
+ const single = precheck.problems.length === 1
557
+ return new ToolError(
558
+ single ? first.code : 'ACTIVATION_PRECHECK_FAILED',
559
+ single
560
+ ? first.message
561
+ : `activation precheck failed: ${precheck.problems.map((p) => p.message).join('; ')}`,
562
+ EXIT_OPERATION,
563
+ false,
564
+ precheckView(precheck),
565
+ )
566
+ }
567
+
568
+ // ---------------------------------------------------------------------------
569
+ // output views (snake_case, never secrets)
570
+ // ---------------------------------------------------------------------------
571
+
572
+ function statusView(status: ActivationStatus): Record<string, unknown> {
573
+ return {
574
+ root: status.rootDir,
575
+ state: status.state,
576
+ startup_required: status.startupRequired,
577
+ observed_at: status.observedAt,
578
+ zone_did: status.zoneDid,
579
+ owner_did: status.ownerDid,
580
+ device_did: status.deviceDid,
581
+ device_name: status.deviceName,
582
+ access_hostname: status.accessHostname,
583
+ files: status.files.map((file) => ({
584
+ path: file.path,
585
+ role: file.role,
586
+ required: file.required,
587
+ secret: file.secret,
588
+ present: file.present,
589
+ })),
590
+ problems: status.problems,
591
+ warnings: status.warnings,
592
+ lock: status.lock
593
+ ? {
594
+ path: status.lock.path,
595
+ stage: status.lock.stage,
596
+ trace_id: status.lock.traceId,
597
+ started_at: status.lock.startedAt,
598
+ domain: status.lock.domain,
599
+ committed_files: status.lock.committedFiles,
600
+ owner_key_backup: status.lock.ownerKeyBackupPath,
601
+ corrupt: status.lock.corrupt,
602
+ }
603
+ : null,
604
+ }
605
+ }
606
+
607
+ function precheckView(precheck: OfflineActivationPrecheck): Record<string, unknown> {
608
+ return {
609
+ ready: precheck.ready,
610
+ root: precheck.rootDir,
611
+ state: precheck.state,
612
+ domain: precheck.domain,
613
+ owner_name: precheck.ownerName,
614
+ owner_did: precheck.ownerDid,
615
+ zone_did: precheck.zoneDid,
616
+ device_did: precheck.deviceDid,
617
+ device_name: precheck.deviceName,
618
+ access_hostname: precheck.accessHostname,
619
+ rtcp_port: precheck.rtcpPort,
620
+ guest_access: precheck.guestAccess,
621
+ public_ip: precheck.publicIp,
622
+ dns_records: precheck.dnsRecords,
623
+ owner_key_backup: {
624
+ path: precheck.ownerKeyBackup.path,
625
+ directory_exists: precheck.ownerKeyBackup.directoryExists,
626
+ exists: precheck.ownerKeyBackup.exists,
627
+ },
628
+ planned_files: precheck.plannedFiles,
629
+ problems: precheck.problems,
630
+ target: statusView(precheck.status),
631
+ }
632
+ }
633
+
634
+ function resultView(
635
+ ctx: CommandContext,
636
+ input: Record<string, unknown>,
637
+ result: OfflineActivationResult,
638
+ ): Record<string, unknown> {
639
+ const dnsNotes = [
640
+ ...result.dnsRecords.map((record) => `${record.type} ${record.name} ${record.value}`),
641
+ ...(result.publicIp ? [] : [`A/AAAA ${result.domain} <this node's fixed public IP>`]),
642
+ `The address record is also saved in etc/zone_dns_records.json under ${result.rootDir}.`,
643
+ `Local boot uses etc/${result.domain}.zone.json; no DNS BOOT/PKX/DEV records are required.`,
644
+ 'TLS/ACME is not provided by SN in this mode; configure it on the public gateway.',
645
+ ]
646
+ return {
647
+ root: result.rootDir,
648
+ state: result.state,
649
+ startup_required: result.startupRequired,
650
+ domain: result.domain,
651
+ owner_name: result.ownerName,
652
+ owner_did: result.ownerDid,
653
+ zone_did: result.zoneDid,
654
+ device_did: result.deviceDid,
655
+ device_name: result.deviceName,
656
+ access_hostname: result.accessHostname,
657
+ rtcp_port: result.rtcpPort,
658
+ guest_access: result.guestAccess,
659
+ public_ip: result.publicIp,
660
+ document_iat: result.documentIat,
661
+ document_exp: result.documentExp,
662
+ owner_key_backup: result.ownerKeyBackup,
663
+ dns_records: result.dnsRecords,
664
+ dns_notes: dnsNotes,
665
+ committed_files: result.committedFiles,
666
+ warnings: result.warnings,
667
+ next_steps: [
668
+ 'Restart BuckyOS through the installer or the node control entry to leave activation mode and boot the new Zone.',
669
+ 'Check the running Zone afterwards with system status; activation does not verify service health, DNS, or TLS.',
670
+ `Keep the Owner private-key backup ${result.ownerKeyBackup.path} offline; it is the only recovery copy.`,
671
+ ],
672
+ audit: {
673
+ module: ctx.command.module,
674
+ verb: ctx.command.verb,
675
+ trace_id: ctx.traceId,
676
+ tool_version: TOOL_VERSION,
677
+ input_fingerprint: inputFingerprint(input),
678
+ },
679
+ }
680
+ }
681
+
682
+ function inputFingerprint(input: Record<string, unknown>): string {
683
+ const material: Record<string, unknown> = {}
684
+ for (const key of Object.keys(TARGET_INPUT_PROPERTIES).sort()) {
685
+ if (input[key] !== undefined) material[key] = input[key]
686
+ }
687
+ const hash = getHost().createHash('sha256')
688
+ hash.update(new TextEncoder().encode(JSON.stringify(material)))
689
+ return hash.digestHex()
690
+ }