phio 0.3.5 → 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.
- package/CHANGELOG.md +141 -0
- package/README.md +44 -35
- package/package.json +32 -22
- package/src/cli.ts +9 -4
- package/src/commands/DevCommand.ts +17 -6
- package/src/commands/InfoCommand.ts +27 -4
- package/src/commands/LinkCommand.ts +1 -1
- package/src/commands/LogsCommand.ts +14 -11
- package/src/commands/SftpCommand.ts +99 -0
- package/src/lib/constants.ts +2 -0
- package/src/lib/defaultInstanceId.ts +58 -36
- package/src/lib/deployKey.ts +222 -0
- package/src/lib/fetchEventSource.ts +3 -0
- package/src/lib/getClient.ts +30 -2
- package/src/lib/phioRoot.ts +67 -0
- package/src/lib/sftpConnection.ts +33 -0
- package/src/lib/sshPublicKey.ts +128 -0
- package/vendor/ftp-deploy/HashDiff.ts +122 -0
- package/vendor/ftp-deploy/LICENSE +21 -0
- package/vendor/ftp-deploy/README.md +3 -0
- package/vendor/ftp-deploy/deploy.ts +226 -0
- package/vendor/ftp-deploy/errorHandling.ts +67 -0
- package/vendor/ftp-deploy/localFiles.ts +47 -0
- package/vendor/ftp-deploy/module.ts +28 -0
- package/vendor/ftp-deploy/sftpDeploy.ts +233 -0
- package/vendor/ftp-deploy/sftpSyncProvider.ts +188 -0
- package/vendor/ftp-deploy/sshPrivateKey.ts +66 -0
- package/vendor/ftp-deploy/syncProvider.ts +189 -0
- package/vendor/ftp-deploy/types.ts +226 -0
- package/vendor/ftp-deploy/utilities.ts +217 -0
- package/src/commands/WhoAmICommand.ts +0 -15
- package/src/global.d.ts +0 -3
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
} else {
|
|
44
|
-
content.instanceName = instanceName
|
|
60
|
+
const phioConfig = readPhioConfig()
|
|
61
|
+
if (phioConfig?.instanceName) {
|
|
62
|
+
return phioConfig.instanceName
|
|
45
63
|
}
|
|
46
64
|
|
|
47
|
-
|
|
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
|
+
}
|
package/src/lib/getClient.ts
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
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
|
+
}
|