phio 0.3.4 → 0.4.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.
@@ -1,48 +1,70 @@
1
- import { existsSync, readFileSync, writeFileSync } from 'fs'
2
- import { PHIO_INSTANCE_NAME } from './constants'
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs'
2
+ import { PHIO_CONFIG_FILE, PHIO_INSTANCE_NAME } from './constants'
3
3
 
4
- export const savedInstanceName = () => {
5
- if (PHIO_INSTANCE_NAME()) {
6
- return PHIO_INSTANCE_NAME()
4
+ type PhioConfig = {
5
+ instanceName?: string
6
+ }
7
+
8
+ const readPhioConfig = (): PhioConfig | null => {
9
+ if (!existsSync(PHIO_CONFIG_FILE)) {
10
+ return null
11
+ }
12
+ return JSON.parse(readFileSync(PHIO_CONFIG_FILE).toString()) as PhioConfig
13
+ }
14
+
15
+ const writePhioConfig = (instanceName: string) => {
16
+ writeFileSync(PHIO_CONFIG_FILE, JSON.stringify({ instanceName }, null, 2) + '\n')
17
+ }
18
+
19
+ const migrateLegacyPackageJson = (): string | null => {
20
+ if (!existsSync('package.json')) {
21
+ return null
7
22
  }
8
- if (existsSync('package.json')) {
9
- const pkg = JSON.parse(readFileSync('package.json').toString())
10
- if (pkg.pockethost?.instanceName) {
11
- return pkg.pockethost.instanceName
12
- }
23
+ const pkg = JSON.parse(readFileSync('package.json').toString())
24
+ const instanceName = pkg.pockethost?.instanceName
25
+ if (!instanceName) {
26
+ return null
13
27
  }
14
- if (existsSync('pockethost.json')) {
15
- const pkg = JSON.parse(readFileSync('pockethost.json').toString())
16
- if (pkg.instanceName) {
17
- return pkg.instanceName
18
- }
28
+
29
+ writePhioConfig(instanceName)
30
+ delete pkg.pockethost.instanceName
31
+ if (Object.keys(pkg.pockethost).length === 0) {
32
+ delete pkg.pockethost
19
33
  }
20
- return null
34
+ writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n')
35
+ console.log(`Migrated instance link from package.json to ${PHIO_CONFIG_FILE}`)
36
+ return instanceName
21
37
  }
22
38
 
23
- export const saveInstanceName = (
24
- instanceName: string,
25
- file: 'package.json' | 'pockethost.json'
26
- ) => {
27
- if (!existsSync(file)) {
28
- // Create new file if it doesn't exist
29
- const newContent =
30
- file === 'package.json'
31
- ? { pockethost: { instanceName } }
32
- : { instanceName }
33
- writeFileSync(file, JSON.stringify(newContent, null, 2))
34
- return
39
+ const migrateLegacyPockethostJson = (): string | null => {
40
+ if (!existsSync('pockethost.json')) {
41
+ return null
42
+ }
43
+ const legacy = JSON.parse(readFileSync('pockethost.json').toString())
44
+ const instanceName = legacy.instanceName
45
+ if (!instanceName) {
46
+ return null
35
47
  }
36
48
 
37
- // Update existing file
38
- const content = JSON.parse(readFileSync(file).toString())
49
+ writePhioConfig(instanceName)
50
+ unlinkSync('pockethost.json')
51
+ console.log(`Migrated instance link from pockethost.json to ${PHIO_CONFIG_FILE}`)
52
+ return instanceName
53
+ }
54
+
55
+ export const savedInstanceName = () => {
56
+ if (PHIO_INSTANCE_NAME()) {
57
+ return PHIO_INSTANCE_NAME()
58
+ }
39
59
 
40
- if (file === 'package.json') {
41
- content.pockethost = content.pockethost || {}
42
- content.pockethost.instanceName = instanceName
43
- } else {
44
- content.instanceName = instanceName
60
+ const phioConfig = readPhioConfig()
61
+ if (phioConfig?.instanceName) {
62
+ return phioConfig.instanceName
45
63
  }
46
64
 
47
- writeFileSync(file, JSON.stringify(content, null, 2))
65
+ return migrateLegacyPackageJson() ?? migrateLegacyPockethostJson()
66
+ }
67
+
68
+ export const saveInstanceName = (instanceName: string) => {
69
+ writePhioConfig(instanceName)
48
70
  }
@@ -0,0 +1,222 @@
1
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from 'fs'
2
+ import { generateKeyPairSync, type KeyObject } from 'crypto'
3
+ import type PocketBase from 'pocketbase'
4
+ import { PHIO_HOME } from './constants'
5
+ import { fingerprintForPublicKey, parseSshEd25519PublicKey } from './sshPublicKey'
6
+ import { isPkcs8PrivateKey, pkcs8Ed25519ToOpenSshPrivateKeyPem } from '../../vendor/ftp-deploy/sshPrivateKey'
7
+
8
+ const SSH_KEYS_COLLECTION = 'ssh_keys'
9
+ export const DEPLOY_KEY_LABEL = 'Phio'
10
+
11
+ const PRIVATE_KEY_FILE = 'phio_deploy_ed25519'
12
+ const PUBLIC_KEY_FILE = 'phio_deploy_ed25519.pub'
13
+
14
+ export type DeployKeyPaths = {
15
+ privateKeyPath: string
16
+ publicKeyPath: string
17
+ }
18
+
19
+ export const getDeployKeyPaths = (): DeployKeyPaths => ({
20
+ privateKeyPath: PHIO_HOME(PRIVATE_KEY_FILE),
21
+ publicKeyPath: PHIO_HOME(PUBLIC_KEY_FILE),
22
+ })
23
+
24
+ const writeSshString = (value: Buffer | string) => {
25
+ const buf = typeof value === 'string' ? Buffer.from(value) : value
26
+ const len = Buffer.alloc(4)
27
+ len.writeUInt32BE(buf.length)
28
+ return Buffer.concat([len, buf])
29
+ }
30
+
31
+ const spkiToOpenSshPublicKey = (publicKey: KeyObject, comment = 'phio') => {
32
+ const der = publicKey.export({ format: 'der', type: 'spki' })
33
+ const raw = der.subarray(der.length - 32)
34
+ const wire = Buffer.concat([writeSshString('ssh-ed25519'), writeSshString(raw)])
35
+ return `ssh-ed25519 ${wire.toString('base64')} ${comment}`
36
+ }
37
+
38
+ const writeOpenSshPrivateKey = (privateKeyPath: string, privateKey: KeyObject) => {
39
+ const privateKeyPem = privateKey.export({ format: 'pem', type: 'pkcs8' }).toString()
40
+ writeFileSync(privateKeyPath, pkcs8Ed25519ToOpenSshPrivateKeyPem(privateKeyPem), { mode: 0o600 })
41
+ }
42
+
43
+ const migratePkcs8PrivateKeyIfNeeded = (privateKeyPath: string) => {
44
+ const pem = readFileSync(privateKeyPath, 'utf8')
45
+ if (!isPkcs8PrivateKey(pem)) {
46
+ return
47
+ }
48
+ writeFileSync(privateKeyPath, pkcs8Ed25519ToOpenSshPrivateKeyPem(pem), { mode: 0o600 })
49
+ try {
50
+ chmodSync(privateKeyPath, 0o600)
51
+ } catch {
52
+ // Windows may ignore mode bits
53
+ }
54
+ }
55
+
56
+ const ensureLocalKeyPair = () => {
57
+ const { privateKeyPath, publicKeyPath } = getDeployKeyPaths()
58
+
59
+ if (existsSync(privateKeyPath) && existsSync(publicKeyPath)) {
60
+ migratePkcs8PrivateKeyIfNeeded(privateKeyPath)
61
+ return {
62
+ privateKeyPath,
63
+ publicKeyPath,
64
+ publicKey: readFileSync(publicKeyPath, 'utf8').trim(),
65
+ }
66
+ }
67
+
68
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519')
69
+ const publicKeyLine = spkiToOpenSshPublicKey(publicKey)
70
+ writeOpenSshPrivateKey(privateKeyPath, privateKey)
71
+ writeFileSync(publicKeyPath, `${publicKeyLine}\n`, { mode: 0o644 })
72
+ try {
73
+ chmodSync(privateKeyPath, 0o600)
74
+ } catch {
75
+ // Windows may ignore mode bits
76
+ }
77
+
78
+ return {
79
+ privateKeyPath,
80
+ publicKeyPath,
81
+ publicKey: publicKeyLine,
82
+ }
83
+ }
84
+
85
+ const publicKeysMatch = (localPublicKey: string, remotePublicKey: string) => {
86
+ const local = parseSshEd25519PublicKey(localPublicKey)
87
+ const remote = parseSshEd25519PublicKey(remotePublicKey)
88
+ return local.normalized === remote.normalized
89
+ }
90
+
91
+ export type DeployKeyRemoteStatus = 'not_checked' | 'missing' | 'registered' | 'mismatch'
92
+
93
+ export type DeployKeyStatus = {
94
+ local: 'missing' | 'present'
95
+ privateKeyPath: string
96
+ publicKeyPath: string
97
+ fingerprint?: string
98
+ remote: DeployKeyRemoteStatus
99
+ }
100
+
101
+ const readLocalDeployKey = () => {
102
+ const { privateKeyPath, publicKeyPath } = getDeployKeyPaths()
103
+ if (!existsSync(privateKeyPath) || !existsSync(publicKeyPath)) {
104
+ return { local: 'missing' as const, privateKeyPath, publicKeyPath }
105
+ }
106
+
107
+ const publicKey = readFileSync(publicKeyPath, 'utf8').trim()
108
+ const parsed = parseSshEd25519PublicKey(publicKey)
109
+ return {
110
+ local: 'present' as const,
111
+ privateKeyPath,
112
+ publicKeyPath,
113
+ publicKey: parsed.normalized,
114
+ fingerprint: fingerprintForPublicKey(parsed.normalized),
115
+ }
116
+ }
117
+
118
+ /** Read-only deploy key status. Does not generate keys or register remotely. */
119
+ export const getDeployKeyStatus = async (client?: PocketBase): Promise<DeployKeyStatus> => {
120
+ const localKey = readLocalDeployKey()
121
+ if (localKey.local === 'missing') {
122
+ return {
123
+ local: 'missing',
124
+ privateKeyPath: localKey.privateKeyPath,
125
+ publicKeyPath: localKey.publicKeyPath,
126
+ remote: client?.authStore.isValid ? 'missing' : 'not_checked',
127
+ }
128
+ }
129
+
130
+ let remote: DeployKeyRemoteStatus = client?.authStore.isValid ? 'missing' : 'not_checked'
131
+ if (client?.authStore.isValid) {
132
+ const userId = client.authStore.record?.id
133
+ if (userId) {
134
+ try {
135
+ const remoteKey = await client.collection(SSH_KEYS_COLLECTION).getFirstListItem(
136
+ `user=${JSON.stringify(userId)} && label=${JSON.stringify(DEPLOY_KEY_LABEL)}`
137
+ )
138
+ remote = publicKeysMatch(localKey.publicKey, remoteKey.public_key) ? 'registered' : 'mismatch'
139
+ } catch {
140
+ remote = 'missing'
141
+ }
142
+ }
143
+ }
144
+
145
+ return {
146
+ local: 'present',
147
+ privateKeyPath: localKey.privateKeyPath,
148
+ publicKeyPath: localKey.publicKeyPath,
149
+ fingerprint: localKey.fingerprint,
150
+ remote,
151
+ }
152
+ }
153
+
154
+ export const formatDeployKeyRemoteStatus = (remote: DeployKeyRemoteStatus) => {
155
+ switch (remote) {
156
+ case 'registered':
157
+ return 'registered (matches local)'
158
+ case 'missing':
159
+ return 'not registered (created on deploy)'
160
+ case 'mismatch':
161
+ return 'mismatch with local key'
162
+ case 'not_checked':
163
+ return 'not checked'
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Ensures a local Ed25519 deploy key exists under PHIO_HOME and that Account → Keys
169
+ * has a matching "Phio" entry. Creates the remote key on first run.
170
+ */
171
+ export const ensureDeployKey = async (client: PocketBase) => {
172
+ const userId = client.authStore.record?.id
173
+ if (!userId) {
174
+ throw new Error(`You must be logged in first. Use 'phio login'`)
175
+ }
176
+
177
+ const { privateKeyPath, publicKeyPath, publicKey } = ensureLocalKeyPair()
178
+ const parsed = parseSshEd25519PublicKey(publicKey)
179
+ const fingerprint = fingerprintForPublicKey(parsed.normalized)
180
+
181
+ let remoteKey: { id: string; public_key: string } | undefined
182
+ try {
183
+ remoteKey = await client.collection(SSH_KEYS_COLLECTION).getFirstListItem(
184
+ `user=${JSON.stringify(userId)} && label=${JSON.stringify(DEPLOY_KEY_LABEL)}`
185
+ )
186
+ } catch {
187
+ remoteKey = undefined
188
+ }
189
+
190
+ if (remoteKey) {
191
+ if (!publicKeysMatch(parsed.normalized, remoteKey.public_key)) {
192
+ throw new Error(
193
+ `Account key "${DEPLOY_KEY_LABEL}" does not match the local key in ${PHIO_HOME()}. ` +
194
+ `Update it at https://pockethost.io/account/keys or delete ${PUBLIC_KEY_FILE} to regenerate locally.`
195
+ )
196
+ }
197
+ return { privateKeyPath, publicKeyPath, publicKey: parsed.normalized, fingerprint }
198
+ }
199
+
200
+ try {
201
+ await client.collection(SSH_KEYS_COLLECTION).create({
202
+ label: DEPLOY_KEY_LABEL,
203
+ public_key: parsed.normalized,
204
+ fingerprint,
205
+ all_instances: true,
206
+ instances: [],
207
+ user: userId,
208
+ })
209
+ console.log(`Registered SFTP deploy key "${DEPLOY_KEY_LABEL}" under Account → Keys`)
210
+ } catch (error) {
211
+ const message = `${error}`
212
+ if (message.includes('fingerprint') || message.includes('unique')) {
213
+ throw new Error(
214
+ `This deploy key is already registered under a different label. ` +
215
+ `Add or rename a key labeled "${DEPLOY_KEY_LABEL}" at https://pockethost.io/account/keys.`
216
+ )
217
+ }
218
+ throw error
219
+ }
220
+
221
+ return { privateKeyPath, publicKeyPath, publicKey: parsed.normalized, fingerprint }
222
+ }
@@ -0,0 +1,3 @@
1
+ import microsoftFetchEventSource from '@microsoft/fetch-event-source'
2
+
3
+ export const { fetchEventSource } = microsoftFetchEventSource
@@ -2,9 +2,32 @@ import PocketBase from 'pocketbase'
2
2
  import { runTasks } from './../lib/Task'
3
3
  import { config } from './config'
4
4
  import { PHIO_MOTHERSHIP_URL, PHIO_PASSWORD, PHIO_USERNAME } from './constants'
5
+ import { ensureDeployKey } from './deployKey'
5
6
  import { ensureLoggedIn } from './ensureLoggedIn'
6
7
 
7
8
  let client: PocketBase | undefined
9
+
10
+ export type AuthStatus =
11
+ | { state: 'logged_out' }
12
+ | { state: 'session_expired'; email: string }
13
+ | { state: 'authenticated'; email: string; client: PocketBase }
14
+
15
+ export const resolveAuthStatus = async (): Promise<AuthStatus> => {
16
+ const savedEmail = config('email')
17
+ const client = await getClient()
18
+
19
+ if (client.authStore.isValid) {
20
+ const email = (client.authStore.record?.email as string | undefined) || savedEmail || ''
21
+ return { state: 'authenticated', email, client }
22
+ }
23
+
24
+ if (savedEmail) {
25
+ return { state: 'session_expired', email: savedEmail }
26
+ }
27
+
28
+ return { state: 'logged_out' }
29
+ }
30
+
8
31
  export const getClient = async () => {
9
32
  if (client) {
10
33
  return client
@@ -33,8 +56,12 @@ export const getClient = async () => {
33
56
  }
34
57
  config('pb_auth', client.authStore.exportToCookie())
35
58
  })
36
- await client.collection(`users`).authRefresh()
37
- config(`pb_auth`, client.authStore.exportToCookie())
59
+ try {
60
+ await client.collection(`users`).authRefresh()
61
+ config(`pb_auth`, client.authStore.exportToCookie())
62
+ } catch {
63
+ client.authStore.clear()
64
+ }
38
65
  }
39
66
  return client
40
67
  }
@@ -72,5 +99,6 @@ const unsafeLogin = async (username: string, password: string) => {
72
99
  export const login = async (username: string, password: string) => {
73
100
  const client = await getClient()
74
101
  await unsafeLogin(username, password)
102
+ await ensureDeployKey(client)
75
103
  return client.authStore
76
104
  }
@@ -0,0 +1,67 @@
1
+ import { existsSync } from 'fs'
2
+ import env from 'env-var'
3
+ import { dirname, join, relative } from 'path'
4
+ import { fileURLToPath } from 'url'
5
+ import { PHIO_CONFIG_FILE } from './constants'
6
+
7
+ const phioPackageRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
8
+
9
+ const isInsidePhioCliPackage = (dir: string): boolean => {
10
+ const rel = relative(phioPackageRoot, dir)
11
+ return rel === '' || (!rel.startsWith('..') && rel !== '')
12
+ }
13
+
14
+ const resolveStartDir = (startDir: string): string => {
15
+ if (isInsidePhioCliPackage(startDir)) {
16
+ return dirname(phioPackageRoot)
17
+ }
18
+ return startDir
19
+ }
20
+
21
+ const walkUp = (startDir: string, predicate: (dir: string) => boolean): string | null => {
22
+ let dir = startDir
23
+ while (true) {
24
+ if (predicate(dir)) {
25
+ return dir
26
+ }
27
+ const parent = dirname(dir)
28
+ if (parent === dir) {
29
+ return null
30
+ }
31
+ dir = parent
32
+ }
33
+ }
34
+
35
+ const hasPhioConfig = (dir: string): boolean => {
36
+ return existsSync(join(dir, PHIO_CONFIG_FILE)) && !isInsidePhioCliPackage(dir)
37
+ }
38
+
39
+ const hasProjectPackageJson = (dir: string): boolean => {
40
+ return existsSync(join(dir, 'package.json')) && !isInsidePhioCliPackage(dir)
41
+ }
42
+
43
+ export const findPhioRoot = (
44
+ startDir = env.get('PHIO_PROJECT_DIR').asString() ??
45
+ process.env.INIT_CWD ??
46
+ process.cwd()
47
+ ): string => {
48
+ const resolvedStart = resolveStartDir(startDir)
49
+
50
+ const phioConfigRoot = walkUp(resolvedStart, hasPhioConfig)
51
+ if (phioConfigRoot) {
52
+ return phioConfigRoot
53
+ }
54
+
55
+ const packageJsonRoot = walkUp(resolvedStart, hasProjectPackageJson)
56
+ if (packageJsonRoot) {
57
+ return packageJsonRoot
58
+ }
59
+
60
+ return resolvedStart
61
+ }
62
+
63
+ export const ensurePhioRoot = (): string => {
64
+ const root = findPhioRoot()
65
+ process.chdir(root)
66
+ return root
67
+ }
@@ -0,0 +1,33 @@
1
+ export const PHIO_SFTP_HOST = 'ftp.pockethost.io'
2
+ export const PHIO_SFTP_PORT = 2222
3
+
4
+ export type SftpConnection = {
5
+ host: string
6
+ port: number
7
+ username: string
8
+ privateKeyPath: string
9
+ remoteDir: string
10
+ }
11
+
12
+ export const buildSftpTarget = ({ username, host, remoteDir }: SftpConnection) => {
13
+ const userHost = `${username}@${host}`
14
+ return remoteDir ? `${userHost}:${remoteDir}` : userHost
15
+ }
16
+
17
+ export const buildSftpArgs = (connection: SftpConnection) => [
18
+ '-i',
19
+ connection.privateKeyPath,
20
+ '-P',
21
+ String(connection.port),
22
+ buildSftpTarget(connection),
23
+ ]
24
+
25
+ const shellQuote = (value: string) => {
26
+ if (/^[a-zA-Z0-9_./:-]+$/.test(value)) {
27
+ return value
28
+ }
29
+ return `'${value.replace(/'/g, `'\\''`)}'`
30
+ }
31
+
32
+ export const formatSftpCommand = (connection: SftpConnection, sftpBin = 'sftp') =>
33
+ [sftpBin, ...buildSftpArgs(connection).map(shellQuote)].join(' ')
@@ -0,0 +1,128 @@
1
+ import { createHash } from 'crypto'
2
+
3
+ /** ssh-ed25519 public key parsing (mirrors pockethost/common). */
4
+
5
+ const ED25519_ALGO = 'ssh-ed25519'
6
+ const ED25519_WIRE_KEY_LEN = 32
7
+ const ED25519_WIRE_LEN = 4 + ED25519_ALGO.length + 4 + ED25519_WIRE_KEY_LEN
8
+
9
+ export type ParsedSshEd25519PublicKey = {
10
+ normalized: string
11
+ wire: Uint8Array
12
+ }
13
+
14
+ const readUint32BE = (bytes: Uint8Array, offset: number) => {
15
+ if (offset + 4 > bytes.length) {
16
+ throw new Error('Invalid public key encoding.')
17
+ }
18
+ return (
19
+ ((bytes[offset]! << 24) | (bytes[offset + 1]! << 16) | (bytes[offset + 2]! << 8) | bytes[offset + 3]!) >>> 0
20
+ )
21
+ }
22
+
23
+ const readSshString = (bytes: Uint8Array, offset: number) => {
24
+ const length = readUint32BE(bytes, offset)
25
+ offset += 4
26
+ if (length < 0 || offset + length > bytes.length) {
27
+ throw new Error('Invalid public key encoding.')
28
+ }
29
+ const value = bytes.slice(offset, offset + length)
30
+ return { value, nextOffset: offset + length }
31
+ }
32
+
33
+ const bytesToAscii = (bytes: Uint8Array) => {
34
+ let out = ''
35
+ for (let i = 0; i < bytes.length; i++) {
36
+ out += String.fromCharCode(bytes[i]!)
37
+ }
38
+ return out
39
+ }
40
+
41
+ const decodeBase64 = (value: string) => {
42
+ const normalized = value.replace(/[\s\r\n]+/g, '')
43
+ if (!normalized || normalized.length % 4 === 1 || !/^[A-Za-z0-9+/]+=*$/.test(normalized)) {
44
+ throw new Error('Public key base64 is invalid.')
45
+ }
46
+
47
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
48
+ const bytes: number[] = []
49
+ let buffer = 0
50
+ let bits = 0
51
+
52
+ for (const char of normalized.replace(/=+$/, '')) {
53
+ const index = alphabet.indexOf(char)
54
+ if (index === -1) {
55
+ throw new Error('Public key base64 is invalid.')
56
+ }
57
+ buffer = (buffer << 6) | index
58
+ bits += 6
59
+ if (bits >= 8) {
60
+ bits -= 8
61
+ bytes.push((buffer >> bits) & 0xff)
62
+ }
63
+ }
64
+
65
+ return new Uint8Array(bytes)
66
+ }
67
+
68
+ const validateWire = (wire: Uint8Array) => {
69
+ if (wire.length !== ED25519_WIRE_LEN) {
70
+ throw new Error('Invalid Ed25519 public key length.')
71
+ }
72
+
73
+ let offset = 0
74
+ const algo = readSshString(wire, offset)
75
+ offset = algo.nextOffset
76
+ if (bytesToAscii(algo.value) !== ED25519_ALGO) {
77
+ throw new Error('Public key algorithm must be ssh-ed25519.')
78
+ }
79
+
80
+ const key = readSshString(wire, offset)
81
+ if (key.value.length !== ED25519_WIRE_KEY_LEN) {
82
+ throw new Error('Invalid Ed25519 public key length.')
83
+ }
84
+ if (key.nextOffset !== wire.length) {
85
+ throw new Error('Invalid public key encoding.')
86
+ }
87
+ }
88
+
89
+ export const parseSshEd25519PublicKey = (input: string): ParsedSshEd25519PublicKey => {
90
+ const trimmed = input.trim()
91
+ if (!trimmed) {
92
+ throw new Error('Public key is required.')
93
+ }
94
+
95
+ const lines = trimmed
96
+ .split(/\r?\n/)
97
+ .map((line) => line.trim())
98
+ .filter(Boolean)
99
+ if (lines.length > 1) {
100
+ throw new Error('Paste a single public key line only.')
101
+ }
102
+
103
+ const line = lines[0] ?? ''
104
+ const parts = line.split(/\s+/).filter(Boolean)
105
+ if (parts.length < 2) {
106
+ throw new Error('Public key must look like: ssh-ed25519 AAAA… comment')
107
+ }
108
+
109
+ const algo = parts[0]
110
+ const keyData = parts[1]
111
+ if (algo !== ED25519_ALGO) {
112
+ throw new Error('Only ssh-ed25519 public keys are supported.')
113
+ }
114
+
115
+ const wire = decodeBase64(keyData!)
116
+ validateWire(wire)
117
+
118
+ const comment = parts.slice(2).join(' ')
119
+ const normalized = comment ? `${ED25519_ALGO} ${keyData} ${comment}` : `${ED25519_ALGO} ${keyData}`
120
+
121
+ return { normalized, wire }
122
+ }
123
+
124
+ export const fingerprintForPublicKey = (publicKeyLine: string): string => {
125
+ const { wire } = parseSshEd25519PublicKey(publicKeyLine)
126
+ const hash = createHash('sha256').update(wire).digest('base64').replace(/=+$/, '')
127
+ return `SHA256:${hash}`
128
+ }