diff --git a/.github/workflows/unit-ci.yml b/.github/workflows/unit-ci.yml index 3061d2fd..abd8c2b1 100644 --- a/.github/workflows/unit-ci.yml +++ b/.github/workflows/unit-ci.yml @@ -25,6 +25,7 @@ on: - 'utils/release-*.unit.spec.ts' - 'utils/couchdb/**' - 'utils/flyio/**' + - 'utils/setup/**' - '.github/workflows/prepare-release.yml' - '.github/workflows/finalise-release.yml' - '.github/workflows/release.yml' @@ -48,6 +49,7 @@ on: - 'utils/release-*.unit.spec.ts' - 'utils/couchdb/**' - 'utils/flyio/**' + - 'utils/setup/**' - '.github/workflows/prepare-release.yml' - '.github/workflows/finalise-release.yml' - '.github/workflows/release.yml' diff --git a/devs.md b/devs.md index f48af9b1..4b8fa855 100644 --- a/devs.md +++ b/devs.md @@ -68,7 +68,7 @@ To facilitate development and testing, the build process can automatically copy - If you add a feature that interacts with the remote database (e.g., replication changes, custom changes feed parameters, or custom HTTP queries), you strongly expected to write an integration test to verify the behaviour against a real CouchDB server. - **Commonlib Tests**: Commonlib owns unit and package tests for shared RPC, storage, replication, and platform contracts. LiveSync CI verifies the exact packed dependency as a downstream consumer. - **CLI E2E** (`src/apps/cli/testdeno/`): Host-independent consumer workflows. The canonical Compose P2P suite covers ordinary two-peer synchronisation, replacement of the current replicator followed by transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. Its lifecycle entry point is included only in the Docker test build and does not add a public CLI command. Run `npm run test:e2e:cli` for the ordinary suite or `npm run test:e2e:cli:p2p` for P2P validation. -- **Self-hosted setup tools** (`utils/couchdb/` and `utils/flyio/`): Deno contract tests consume the exact locked Commonlib registry package, verify current self-hosted Setup URI defaults and remote profiles, and keep CouchDB administration separate from package-owned LiveSync database-version negotiation. `unit-ci` also provisions a real temporary CouchDB database and verifies its version document against the installed Commonlib package. Run `npm run test:setup-tools` for the local contract gate. +- **Self-hosted setup tools** (`utils/couchdb/`, `utils/setup/`, and `utils/flyio/`): Deno contract tests consume the exact locked Commonlib registry package, verify current CouchDB, Object Storage, and random-room P2P Setup URI defaults and remote profiles, and keep CouchDB administration separate from package-owned LiveSync database-version negotiation. `unit-ci` also provisions a real temporary CouchDB database and verifies its version document against the installed Commonlib package. Run `npm run test:setup-tools` for the local contract gate. - **Real Obsidian E2E** (`test/e2e-obsidian/`): Local-first scripts that launch real Obsidian with temporary vaults and the built Self-hosted LiveSync plug-in. Use these for boot-up sequence, vault reflection, RedFlag flows, Fast Setup (Simple Fetch), settings dialogues, restart-sensitive workflows, Object Storage regressions, and other behaviour that depends on Obsidian itself. Run focused scripts such as `npm run test:e2e:obsidian:two-vault-sync`, or use `npm run test:e2e:obsidian:local-suite:services` to run the broader local suite with CouchDB and MinIO fixtures managed by the wrapper. - **Docker Services**: Service-backed tests use CouchDB and MinIO (S3). Canonical P2P validation owns its relay through the CLI Compose runner: diff --git a/package.json b/package.json index 0ee50f5a..99257ce8 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "check": "npm run tsc-check && npm run tsc-check:apps && npm run lint && npm run lint:community -- --quiet && npm run svelte-check && npm run check:compatibility", "unittest": "deno test -A --no-check --coverage=cov_profile --v8-flags=--expose-gc --trace-leaks ./src/", "test:unit": "vitest run --config vitest.config.unit.ts", - "test:setup-tools": "bash utils/flyio/setenv.test.sh && deno test -A --config=utils/flyio/deno.jsonc --frozen --lock=utils/flyio/deno.lock utils/couchdb/provision.test.ts utils/flyio/generate_setupuri.test.ts", + "test:setup-tools": "bash utils/flyio/setenv.test.sh && deno test -A --config=utils/flyio/deno.jsonc --frozen --lock=utils/flyio/deno.lock utils/couchdb/provision.test.ts utils/setup/generate_setup_uri.test.ts utils/flyio/generate_setupuri.test.ts", "test:contract:contexts": "vitest run --config vitest.config.unit.ts src/modules/services/ObsidianServiceContext.unit.spec.ts src/apps/cli/services/NodeServiceContext.unit.spec.ts src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts", "test:contract:context:webapp": "vitest run --config vitest.config.unit.ts src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts", "test:contract:context:cli": "npm run build --workspace self-hosted-livesync-cli && deno task --cwd src/apps/cli/testdeno test:setup-put-cat", diff --git a/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte b/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte index d131e774..1970c991 100644 --- a/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte +++ b/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte @@ -21,7 +21,12 @@ import { TrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator"; import type { ReplicatorHostEnv } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/types"; - import { copyTo, pickP2PSyncSettings, type SimpleStore } from "@vrtmrz/livesync-commonlib/compat/common/utils"; + import { + copyTo, + generateP2PRoomId, + pickP2PSyncSettings, + type SimpleStore, + } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { onMount } from "svelte"; import { getDialogContext, type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog"; import { SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types"; @@ -151,17 +156,7 @@ let processing = $state(false); function generateDefaultGroupId() { - const randomValues = new Uint16Array(4); - crypto.getRandomValues(randomValues); - const MAX_UINT16 = 65536; - const a = Math.floor((randomValues[0] / MAX_UINT16) * 1000); - const b = Math.floor((randomValues[1] / MAX_UINT16) * 1000); - const c = Math.floor((randomValues[2] / MAX_UINT16) * 1000); - const d_range = 36 * 36 * 36; - const d = Math.floor((randomValues[3] / MAX_UINT16) * d_range); - syncSetting.P2P_roomID = `${a.toString().padStart(3, "0")}-${b - .toString() - .padStart(3, "0")}-${c.toString().padStart(3, "0")}-${d.toString(36).padStart(3, "0")}`; + syncSetting.P2P_roomID = generateP2PRoomId(); } async function checkAndCommit() { diff --git a/updates.md b/updates.md index debfef06..e6ef1882 100644 --- a/updates.md +++ b/updates.md @@ -17,7 +17,7 @@ Earlier releases remain available in the [0.25 release history](docs/releases/0. - Wizard-driven new-device and existing-device setup now reserves Rebuild or Fetch before enabling imported settings, preventing ordinary start-up work from running ahead of the selected initialisation. - Manual onboarding now creates and selects CouchDB, Object Storage, and P2P remote profiles directly while preserving existing profiles. Current Setup URIs retain profile names and selections, while older flat settings remain supported through compatibility migration. - P2P panes and explicit rebuild actions now use the current transport after settings or database replacement. Disconnecting leaves the LiveSync room and closes signalling relay sockets without destroying Trystero-owned shared peers. -- Self-hosted CouchDB provisioning and Setup URI generation now consume the immutable Commonlib registry package. New database provisioning records the package-owned database version, and generated URIs use current new-Vault defaults and a selected CouchDB profile instead of legacy embedded settings. +- Self-hosted CouchDB provisioning and Setup URI generation now consume the immutable Commonlib registry package. New database provisioning records the package-owned database version, and generated CouchDB, Object Storage, and random-room P2P URIs use current new-Vault defaults and selected remote profiles instead of legacy embedded settings. ### Security diff --git a/utils/flyio/generate_setupuri.ts b/utils/flyio/generate_setupuri.ts index c4f35245..5df0e7d6 100644 --- a/utils/flyio/generate_setupuri.ts +++ b/utils/flyio/generate_setupuri.ts @@ -1,52 +1,6 @@ -import { encodeSettingsToSetupURI } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/API/processSetting"; -import { upsertRemoteConfigurationInPlace } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/remote-configurations"; -import { - createNewVaultSettings, - PREFERRED_SETTING_SELF_HOSTED, -} from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/settings"; +import { runSetupURIGenerator } from "../setup/generate_setup_uri.ts"; -function requireEnvironment(name: string): string { - const value = Deno.env.get(name)?.trim(); - if (!value) throw new Error(`${name} is required`); - return value; -} - -function generateSetupPassphrase(): string { - const bytes = crypto.getRandomValues(new Uint8Array(24)); - return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll( - "/", - "_", - ).replace(/=+$/, ""); -} - -async function main() { - const setupPassphrase = Deno.env.get("uri_passphrase")?.trim() || - generateSetupPassphrase(); - const settings = createNewVaultSettings(); - Object.assign(settings, PREFERRED_SETTING_SELF_HOSTED, { - couchDB_URI: requireEnvironment("hostname"), - couchDB_USER: requireEnvironment("username"), - couchDB_PASSWORD: requireEnvironment("password"), - couchDB_DBNAME: requireEnvironment("database"), - batchSave: true, - periodicReplication: true, - syncOnStart: true, - syncOnFileOpen: true, - syncAfterMerge: true, - encrypt: true, - passphrase: requireEnvironment("passphrase"), - usePathObfuscation: true, - }); - upsertRemoteConfigurationInPlace(settings, "couchdb", { activate: true }); - - const setupURI = await encodeSettingsToSetupURI(settings, setupPassphrase, [ - "pluginSyncExtendedSetting", - "doNotUseFixedRevisionForChunks", - ], true); - - console.log("\nYour passphrase for the Setup URI is:", setupPassphrase); - console.log("This passphrase is never shown again, so store it safely."); - console.log(setupURI.trim()); -} - -await main(); +await runSetupURIGenerator({ + ...Deno.env.toObject(), + remote_type: "couchdb", +}); diff --git a/utils/readme.md b/utils/readme.md index 501fc9c7..190fe16c 100644 --- a/utils/readme.md +++ b/utils/readme.md @@ -27,7 +27,11 @@ Authentication and other non-retryable HTTP failures stop immediately. Network a ## Setup URI generation -`flyio/generate_setupuri.ts` creates a current self-hosted configuration from Commonlib's new-Vault and self-hosted presets, stores the CouchDB connection as the selected remote profile, and encodes it with Commonlib's Setup URI contract. +`setup/generate_setup_uri.ts` creates current CouchDB, Object Storage, and P2P configurations from Commonlib's new-Vault and remote-specific presets. It stores the connection as the selected remote profile and encodes the result with Commonlib's Setup URI contract. Set `remote_type` to `couchdb`, `s3`, or `p2p`; CouchDB is the default. + +The existing `flyio/generate_setupuri.ts` path remains a CouchDB-only compatibility wrapper for the Fly.io deployment script. + +### CouchDB ```sh export hostname=https://couch.example.com @@ -36,11 +40,37 @@ export password=couchdb-admin-password export database=obsidiannotes export passphrase=a-strong-vault-encryption-passphrase export uri_passphrase=a-separate-setup-uri-passphrase # Optional -deno run --minimum-dependency-age=0 --allow-env ./flyio/generate_setupuri.ts +deno run --minimum-dependency-age=0 --config=./flyio/deno.jsonc --frozen --lock=./flyio/deno.lock --allow-env ./setup/generate_setup_uri.ts ``` If `uri_passphrase` is omitted, the tool generates and prints a cryptographically random one. Store the Setup URI and its passphrase separately. The `passphrase` value protects synchronised Vault data and must also be stored safely. +### Object Storage + +```sh +export remote_type=s3 +export endpoint=https://objects.example.com +export access_key= +export secret_key= +export bucket=vault-data +export region=auto +export bucket_prefix=team-a # Optional +export passphrase= +deno run --minimum-dependency-age=0 --config=./flyio/deno.jsonc --frozen --lock=./flyio/deno.lock --allow-env ./setup/generate_setup_uri.ts +``` + +Optional Object Storage variables are `use_custom_request_handler`, `force_path_style`, and `bucket_custom_headers`. + +### P2P + +```sh +export remote_type=p2p +export passphrase= +deno run --minimum-dependency-age=0 --config=./flyio/deno.jsonc --frozen --lock=./flyio/deno.lock --allow-env ./setup/generate_setup_uri.ts +``` + +If `p2p_room_id` or `p2p_passphrase` is omitted, the tool generates it with Commonlib's room-ID contract or cryptographically secure randomness. Optional variables are `p2p_relays`, `p2p_app_id`, `p2p_auto_start`, and `p2p_auto_broadcast`. Auto-start and auto-broadcast retain Commonlib's disabled defaults unless explicitly enabled. A peer name is deliberately absent because it identifies one device and must not be copied to another device through a Setup URI. + ## Fly.io deployment `flyio/deploy-server.sh` deploys CouchDB through `flyctl`, provisions the selected database through the tool above, and prints a Commonlib-generated Setup URI. Both `flyctl` and Deno 2 are required. diff --git a/utils/setup/generate_setup_uri.test.ts b/utils/setup/generate_setup_uri.test.ts new file mode 100644 index 00000000..70b756cf --- /dev/null +++ b/utils/setup/generate_setup_uri.test.ts @@ -0,0 +1,109 @@ +import { decodeSettingsFromSetupURI } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/API/processSetting"; +import { DEFAULT_SETTINGS } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/settings"; +import { generateSetupURI } from "./generate_setup_uri.ts"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +Deno.test("generates an Object Storage Setup URI with a selected S3 profile", async () => { + const generated = await generateSetupURI({ + remote_type: "s3", + endpoint: "https://objects.example.test", + access_key: "access-key", + secret_key: "secret-key", + bucket: "vault-data", + region: "auto", + bucket_prefix: "team-a", + passphrase: "vault-secret", + uri_passphrase: "setup-secret", + }); + const decoded = await decodeSettingsFromSetupURI( + generated.setupURI, + generated.setupPassphrase, + ); + assert(decoded, "Commonlib could not decode the Object Storage Setup URI"); + const effective = { ...DEFAULT_SETTINGS, ...decoded }; + assert( + effective.customChunkSize === 10, + "the journal chunk-size preset was not applied", + ); + assert( + effective.liveSync, + "Object Storage was not configured for live journal synchronisation", + ); + assert( + effective.endpoint === "https://objects.example.test", + "the endpoint was not preserved", + ); + assert( + effective.bucketPrefix === "team-a", + "the bucket prefix was not preserved", + ); + + const profiles = Object.values(decoded.remoteConfigurations ?? {}); + assert( + profiles.length === 1, + "the Setup URI did not contain exactly one Object Storage profile", + ); + assert( + decoded.activeConfigurationId === profiles[0].id, + "the Object Storage profile was not selected", + ); + assert( + profiles[0].uri.startsWith("sls+s3://"), + "the selected profile was not an S3 connection URI", + ); +}); + +Deno.test("generates a random-room P2P Setup URI without copying a device identity", async () => { + const generated = await generateSetupURI({ + remote_type: "p2p", + passphrase: "vault-secret", + uri_passphrase: "setup-secret", + }); + const decoded = await decodeSettingsFromSetupURI( + generated.setupURI, + generated.setupPassphrase, + ); + assert(decoded, "Commonlib could not decode the P2P Setup URI"); + const effective = { ...DEFAULT_SETTINGS, ...decoded }; + assert( + /^\d{3}-\d{3}-\d{3}-[a-z0-9]{3}$/.test(effective.P2P_roomID), + "Commonlib did not generate the expected random room ID", + ); + assert( + /^[A-Za-z0-9_-]{32}$/.test(effective.P2P_passphrase), + "the generated P2P passphrase was not a 32-character base64url secret", + ); + assert( + !effective.P2P_AutoStart, + "P2P auto-start was enabled without an explicit request", + ); + assert( + !effective.P2P_AutoBroadcast, + "P2P auto-broadcast was enabled without an explicit request", + ); + assert( + !Object.hasOwn(decoded, "P2P_DevicePeerName"), + "the Setup URI copied a device-specific P2P peer name", + ); + + const profiles = Object.values(decoded.remoteConfigurations ?? {}); + assert( + profiles.length === 1, + "the Setup URI did not contain exactly one P2P profile", + ); + assert( + decoded.activeConfigurationId === profiles[0].id, + "the P2P profile was not selected as the main remote", + ); + assert( + decoded.P2P_ActiveRemoteConfigurationId === profiles[0].id, + "the P2P profile was not selected for P2P features", + ); + assert( + profiles[0].uri.startsWith("sls+p2p://"), + "the selected profile was not a P2P connection URI", + ); +}); diff --git a/utils/setup/generate_setup_uri.ts b/utils/setup/generate_setup_uri.ts new file mode 100644 index 00000000..eab45dd0 --- /dev/null +++ b/utils/setup/generate_setup_uri.ts @@ -0,0 +1,188 @@ +import { encodeSettingsToSetupURI } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/API/processSetting"; +import { generateP2PRoomId } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/common/utils"; +import { upsertRemoteConfigurationInPlace } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/remote-configurations"; +import { + createNewVaultSettings, + type ObsidianLiveSyncSettings, + P2P_DEFAULT_SETTINGS, + PREFERRED_BASE, + PREFERRED_JOURNAL_SYNC, + PREFERRED_SETTING_SELF_HOSTED, +} from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/settings"; + +export type SetupRemoteType = "couchdb" | "s3" | "p2p"; +export type SetupGeneratorEnvironment = Readonly< + Record +>; + +export interface GeneratedSetupURI { + remoteType: SetupRemoteType; + setupURI: string; + setupPassphrase: string; +} + +function requireValue( + environment: SetupGeneratorEnvironment, + name: string, +): string { + const value = environment[name]?.trim(); + if (!value) throw new Error(`${name} is required`); + return value; +} + +function optionalBoolean( + environment: SetupGeneratorEnvironment, + name: string, + fallback: boolean, +): boolean { + const value = environment[name]?.trim().toLowerCase(); + if (!value) return fallback; + if (value === "true" || value === "1") return true; + if (value === "false" || value === "0") return false; + throw new Error(`${name} must be true, false, 1, or 0`); +} + +export function generateSecret(): string { + const bytes = crypto.getRandomValues(new Uint8Array(24)); + return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll( + "/", + "_", + ).replace(/=+$/, ""); +} + +function applyEncryptedVaultSettings( + settings: ObsidianLiveSyncSettings, + environment: SetupGeneratorEnvironment, +): void { + Object.assign(settings, { + encrypt: true, + passphrase: requireValue(environment, "passphrase"), + usePathObfuscation: true, + }); +} + +function createCouchDBSettings( + environment: SetupGeneratorEnvironment, +): ObsidianLiveSyncSettings { + const settings = createNewVaultSettings(); + Object.assign(settings, PREFERRED_SETTING_SELF_HOSTED, { + couchDB_URI: requireValue(environment, "hostname"), + couchDB_USER: requireValue(environment, "username"), + couchDB_PASSWORD: requireValue(environment, "password"), + couchDB_DBNAME: requireValue(environment, "database"), + batchSave: true, + periodicReplication: true, + syncOnStart: true, + syncOnFileOpen: true, + syncAfterMerge: true, + }); + applyEncryptedVaultSettings(settings, environment); + upsertRemoteConfigurationInPlace(settings, "couchdb", { activate: true }); + return settings; +} + +function createObjectStorageSettings( + environment: SetupGeneratorEnvironment, +): ObsidianLiveSyncSettings { + const settings = createNewVaultSettings(); + Object.assign(settings, PREFERRED_JOURNAL_SYNC, { + endpoint: requireValue(environment, "endpoint"), + accessKey: requireValue(environment, "access_key"), + secretKey: requireValue(environment, "secret_key"), + bucket: requireValue(environment, "bucket"), + region: environment.region?.trim() || "auto", + bucketPrefix: environment.bucket_prefix?.trim() || "", + bucketCustomHeaders: environment.bucket_custom_headers?.trim() || "", + useCustomRequestHandler: optionalBoolean( + environment, + "use_custom_request_handler", + false, + ), + forcePathStyle: optionalBoolean(environment, "force_path_style", true), + liveSync: true, + }); + applyEncryptedVaultSettings(settings, environment); + upsertRemoteConfigurationInPlace(settings, "s3", { activate: true }); + return settings; +} + +function createP2PSettings( + environment: SetupGeneratorEnvironment, +): ObsidianLiveSyncSettings { + const settings = createNewVaultSettings(); + Object.assign(settings, PREFERRED_BASE, P2P_DEFAULT_SETTINGS, { + P2P_Enabled: true, + P2P_roomID: environment.p2p_room_id?.trim() || generateP2PRoomId(), + P2P_passphrase: environment.p2p_passphrase?.trim() || generateSecret(), + P2P_relays: environment.p2p_relays?.trim() || + P2P_DEFAULT_SETTINGS.P2P_relays, + P2P_AppID: environment.p2p_app_id?.trim() || P2P_DEFAULT_SETTINGS.P2P_AppID, + P2P_AutoStart: optionalBoolean( + environment, + "p2p_auto_start", + P2P_DEFAULT_SETTINGS.P2P_AutoStart, + ), + P2P_AutoBroadcast: optionalBoolean( + environment, + "p2p_auto_broadcast", + P2P_DEFAULT_SETTINGS.P2P_AutoBroadcast, + ), + }); + applyEncryptedVaultSettings(settings, environment); + upsertRemoteConfigurationInPlace(settings, "p2p", { + activate: true, + activateForP2P: true, + }); + return settings; +} + +function parseRemoteType( + environment: SetupGeneratorEnvironment, +): SetupRemoteType { + const remoteType = environment.remote_type?.trim().toLowerCase() || "couchdb"; + if (remoteType === "couchdb" || remoteType === "s3" || remoteType === "p2p") { + return remoteType; + } + throw new Error("remote_type must be couchdb, s3, or p2p"); +} + +export function createSetupSettings( + environment: SetupGeneratorEnvironment, +): { remoteType: SetupRemoteType; settings: ObsidianLiveSyncSettings } { + const remoteType = parseRemoteType(environment); + if (remoteType === "couchdb") { + return { remoteType, settings: createCouchDBSettings(environment) }; + } + if (remoteType === "s3") { + return { remoteType, settings: createObjectStorageSettings(environment) }; + } + return { remoteType, settings: createP2PSettings(environment) }; +} + +export async function generateSetupURI( + environment: SetupGeneratorEnvironment, +): Promise { + const setupPassphrase = environment.uri_passphrase?.trim() || + generateSecret(); + const { remoteType, settings } = createSetupSettings(environment); + const setupURI = await encodeSettingsToSetupURI(settings, setupPassphrase, [ + "pluginSyncExtendedSetting", + "doNotUseFixedRevisionForChunks", + ], true); + return { remoteType, setupURI: setupURI.trim(), setupPassphrase }; +} + +export async function runSetupURIGenerator( + environment: SetupGeneratorEnvironment = Deno.env.toObject(), +): Promise { + const generated = await generateSetupURI(environment); + console.log(`\nGenerated ${generated.remoteType} Setup URI.`); + console.log( + "Your passphrase for the Setup URI is:", + generated.setupPassphrase, + ); + console.log("This passphrase is never shown again, so store it safely."); + console.log(generated.setupURI); +} + +if (import.meta.main) await runSetupURIGenerator();