pinokiod 8.2.2 → 8.2.4
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/Dockerfile +3 -2
- package/kernel/anywhere/index.js +706 -0
- package/kernel/anywhere/localapi.js +147 -0
- package/kernel/anywhere/ports.js +51 -0
- package/kernel/anywhere/serve.js +95 -0
- package/kernel/bin/bun.js +1 -1
- package/kernel/bin/conda-meta.js +15 -0
- package/kernel/bin/conda-pins.js +22 -0
- package/kernel/bin/conda.js +66 -5
- package/kernel/bin/index.js +4 -3
- package/kernel/bin/node.js +1 -1
- package/kernel/bin/setup.js +29 -0
- package/kernel/bin/tailscaled.js +72 -0
- package/kernel/bin/uv.js +1 -1
- package/kernel/index.js +15 -0
- package/kernel/shell.js +8 -1
- package/package.json +1 -1
- package/server/index.js +123 -0
- package/server/public/anywhere.webmanifest +1 -0
- package/server/views/anywhere.ejs +436 -0
- package/server/views/app.ejs +3 -1
- package/server/views/index.ejs +6 -0
- package/server/views/partials/main_sidebar.ejs +39 -0
- package/test/anywhere.test.js +461 -0
- package/test/conda-bin.test.js +126 -8
- package/test/node-bin.test.js +3 -3
- package/test/shell-run-template.test.js +46 -0
- package/test/uv-bin.test.js +5 -5
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
const assert = require('node:assert/strict')
|
|
2
|
+
const test = require('node:test')
|
|
3
|
+
const http = require('node:http')
|
|
4
|
+
const os = require('node:os')
|
|
5
|
+
const fs = require('node:fs')
|
|
6
|
+
const path = require('node:path')
|
|
7
|
+
|
|
8
|
+
const { buildServeConfig, describeServeConfig, sameServeConfig } = require('../kernel/anywhere/serve')
|
|
9
|
+
const { assignPorts } = require('../kernel/anywhere/ports')
|
|
10
|
+
const { LocalAPI } = require('../kernel/anywhere/localapi')
|
|
11
|
+
const Anywhere = require('../kernel/anywhere')
|
|
12
|
+
const { spawn } = require('node:child_process')
|
|
13
|
+
const Tailscaled = require('../kernel/bin/tailscaled')
|
|
14
|
+
const Setup = require('../kernel/bin/setup')
|
|
15
|
+
|
|
16
|
+
test('serve config maps pinokiod to 443 and each app to its stable remote port', () => {
|
|
17
|
+
const config = buildServeConfig({
|
|
18
|
+
dnsName: 'pinokio.tail1234.ts.net.',
|
|
19
|
+
serverPort: 42000,
|
|
20
|
+
targets: [{ listen: 43001, target: 8188 }, { listen: 43000, target: 7860 }, { listen: 43000, target: 9999 }, { listen: 443, target: 1 }, { listen: 'nope', target: 1 }, { listen: 43002, target: 70000 }],
|
|
21
|
+
})
|
|
22
|
+
assert.deepEqual(Object.keys(config.TCP), ['443', '43000', '43001'])
|
|
23
|
+
assert.equal(config.TCP['43000'].HTTPS, true)
|
|
24
|
+
assert.equal(config.Web['pinokio.tail1234.ts.net:443'].Handlers['/'].Proxy, 'http://127.0.0.1:42000')
|
|
25
|
+
assert.equal(config.Web['pinokio.tail1234.ts.net:43000'].Handlers['/'].Proxy, 'http://127.0.0.1:7860')
|
|
26
|
+
assert.equal(config.Web['pinokio.tail1234.ts.net:43001'].Handlers['/'].Proxy, 'http://127.0.0.1:8188')
|
|
27
|
+
assert.equal(config.AllowFunnel, undefined, 'serve-only: never emits funnel entries')
|
|
28
|
+
|
|
29
|
+
const described = describeServeConfig(config, 'pinokio.tail1234.ts.net', { 43000: 'comfy' })
|
|
30
|
+
assert.deepEqual(described.map((d) => [d.url, d.local, d.name]), [
|
|
31
|
+
['https://pinokio.tail1234.ts.net', 42000, null],
|
|
32
|
+
['https://pinokio.tail1234.ts.net:43000', 7860, 'comfy'],
|
|
33
|
+
['https://pinokio.tail1234.ts.net:43001', 8188, null],
|
|
34
|
+
])
|
|
35
|
+
const same = buildServeConfig({ dnsName: 'pinokio.tail1234.ts.net', serverPort: 42000, targets: [{ listen: 43000, target: 7860 }, { listen: 43001, target: 8188 }] })
|
|
36
|
+
assert.equal(sameServeConfig(config, same), true, 'order of targets does not matter')
|
|
37
|
+
const relaunched = buildServeConfig({ dnsName: 'pinokio.tail1234.ts.net', serverPort: 42000, targets: [{ listen: 43000, target: 7861 }, { listen: 43001, target: 8188 }] })
|
|
38
|
+
assert.equal(sameServeConfig(config, relaunched), false, 'a new local port changes the mapping but not the address')
|
|
39
|
+
assert.equal(describeServeConfig(relaunched, 'pinokio.tail1234.ts.net')[1].url, 'https://pinokio.tail1234.ts.net:43000')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('stable ports are allocated once, lowest free first, and never move', () => {
|
|
43
|
+
let r = assignPorts({}, ['comfy', 'forge'])
|
|
44
|
+
assert.deepEqual(r.map, { comfy: 43000, forge: 43001 })
|
|
45
|
+
assert.equal(r.changed, true)
|
|
46
|
+
r = assignPorts(r.map, ['forge', 'webui'])
|
|
47
|
+
assert.deepEqual(r.map, { comfy: 43000, forge: 43001, webui: 43002 })
|
|
48
|
+
r = assignPorts(r.map, ['forge'])
|
|
49
|
+
assert.equal(r.changed, false, 'known apps need no write')
|
|
50
|
+
assert.equal(assignPorts({ a: 43000, b: 43002 }, ['c']).map.c, 43001, 'gaps are reused')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('anywhere preset requires tailscaled but not caddy', () => {
|
|
54
|
+
const preset = Setup.anywhere({})
|
|
55
|
+
const names = preset.requirements.map((r) => r.name)
|
|
56
|
+
assert.ok(names.includes('tailscaled'))
|
|
57
|
+
assert.ok(!names.includes('caddy'))
|
|
58
|
+
assert.ok(preset.conda_requirements.includes('tailscaled'))
|
|
59
|
+
assert.ok(!preset.conda_requirements.includes('caddy'))
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('tailscaled bin module pins 1.104.2 and checks the installed version', async () => {
|
|
63
|
+
const mod = new Tailscaled()
|
|
64
|
+
assert.equal(mod.cmd(), 'tailscaled=1.104.2')
|
|
65
|
+
mod.kernel = {
|
|
66
|
+
platform: 'darwin',
|
|
67
|
+
bin: {
|
|
68
|
+
installed: { conda: new Set(['tailscaled']), conda_versions: { tailscaled: '1.104.2' } },
|
|
69
|
+
path: (...segments) => segments.join('/'),
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
assert.equal(await mod.installed(), true)
|
|
73
|
+
mod.kernel.bin.installed.conda_versions.tailscaled = '1.104.1'
|
|
74
|
+
assert.equal(await mod.installed(), false)
|
|
75
|
+
mod.kernel.bin.installed.conda_versions.tailscaled = '1.104.2'
|
|
76
|
+
assert.equal(mod.binPath(), 'miniforge/bin/tailscaled')
|
|
77
|
+
mod.kernel.bin.installed.conda_versions.tailscaled = '1.98.5'
|
|
78
|
+
assert.equal(await mod.installed(), false, 'older than 1.104 (no --windows-mode=dev) is not accepted')
|
|
79
|
+
mod.kernel.bin.installed.conda = new Set()
|
|
80
|
+
assert.equal(await mod.installed(), false)
|
|
81
|
+
mod.kernel.platform = 'win32'
|
|
82
|
+
assert.equal(mod.binPath(), 'miniforge/Library/bin/tailscaled.exe')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
function withMockDaemon(handler, fn) {
|
|
86
|
+
const socketPath = path.join(os.tmpdir(), `pk-ts-test-${process.pid}-${Date.now()}.sock`)
|
|
87
|
+
const server = http.createServer(handler)
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
server.listen(socketPath, async () => {
|
|
90
|
+
try {
|
|
91
|
+
resolve(await fn(socketPath))
|
|
92
|
+
} catch (e) {
|
|
93
|
+
reject(e)
|
|
94
|
+
} finally {
|
|
95
|
+
server.close()
|
|
96
|
+
fs.rmSync(socketPath, { force: true })
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
test('LocalAPI client talks plain HTTP over the socket with the Sec-Tailscale header', { skip: process.platform === 'win32' }, async () => {
|
|
103
|
+
const seen = []
|
|
104
|
+
await withMockDaemon((req, res) => {
|
|
105
|
+
let body = ''
|
|
106
|
+
req.on('data', (c) => body += c)
|
|
107
|
+
req.on('end', () => {
|
|
108
|
+
seen.push({ method: req.method, url: req.url, sec: req.headers['sec-tailscale'], ifMatch: req.headers['if-match'], body })
|
|
109
|
+
if (req.url === '/localapi/v0/status') {
|
|
110
|
+
res.setHeader('Content-Type', 'application/json')
|
|
111
|
+
res.end(JSON.stringify({ BackendState: 'Running', Self: { DNSName: 'pinokio.tail1.ts.net.' } }))
|
|
112
|
+
} else if (req.url === '/localapi/v0/serve-config' && req.method === 'GET') {
|
|
113
|
+
res.setHeader('Etag', 'abc')
|
|
114
|
+
res.end('null')
|
|
115
|
+
} else if (req.url === '/localapi/v0/serve-config' && req.method === 'POST') {
|
|
116
|
+
res.statusCode = 200
|
|
117
|
+
res.end()
|
|
118
|
+
} else if (req.url === '/localapi/v0/login-interactive') {
|
|
119
|
+
res.statusCode = 204
|
|
120
|
+
res.end()
|
|
121
|
+
} else {
|
|
122
|
+
res.statusCode = 404
|
|
123
|
+
res.end('nope')
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
}, async (socketPath) => {
|
|
127
|
+
const api = new LocalAPI({ socketPath })
|
|
128
|
+
const status = await api.status()
|
|
129
|
+
assert.equal(status.BackendState, 'Running')
|
|
130
|
+
const { etag } = await api.serveConfig()
|
|
131
|
+
assert.equal(etag, 'abc')
|
|
132
|
+
await api.setServeConfig({ TCP: {} }, etag)
|
|
133
|
+
await api.loginInteractive()
|
|
134
|
+
await assert.rejects(() => api.expect('GET', '/missing'), /404/)
|
|
135
|
+
})
|
|
136
|
+
const post = seen.find((s) => s.method === 'POST' && s.url === '/localapi/v0/serve-config')
|
|
137
|
+
assert.equal(post.sec, 'localapi')
|
|
138
|
+
assert.equal(post.ifMatch, 'abc')
|
|
139
|
+
assert.equal(post.body, JSON.stringify({ TCP: {} }))
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
function fakeKernel(tmp, { installed = true } = {}) {
|
|
143
|
+
return {
|
|
144
|
+
platform: 'darwin',
|
|
145
|
+
homedir: tmp,
|
|
146
|
+
server_port: 42000,
|
|
147
|
+
path: (...segments) => path.join(tmp, ...segments),
|
|
148
|
+
processes: {},
|
|
149
|
+
memory: { local: {} },
|
|
150
|
+
bin: {
|
|
151
|
+
path: (...segments) => path.join(tmp, 'bin', ...segments),
|
|
152
|
+
mod: { tailscaled: { installed: async () => installed, binPath: () => path.join(tmp, 'bin', 'tailscaled') } },
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
test('anywhere snapshot walks setup -> off -> login -> https -> ready', async () => {
|
|
158
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
|
|
159
|
+
try {
|
|
160
|
+
const kernel = fakeKernel(tmp, { installed: false })
|
|
161
|
+
const anywhere = new Anywhere(kernel)
|
|
162
|
+
assert.equal((await anywhere.snapshot()).phase, 'setup')
|
|
163
|
+
|
|
164
|
+
kernel.bin.mod.tailscaled.installed = async () => true
|
|
165
|
+
assert.equal((await anywhere.snapshot()).phase, 'off')
|
|
166
|
+
|
|
167
|
+
await anywhere.saveConfig({ enabled: true })
|
|
168
|
+
assert.equal(await anywhere.configured(), true)
|
|
169
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(tmp, 'anywhere', 'config.json'), 'utf8')).enabled, true)
|
|
170
|
+
|
|
171
|
+
// stub the daemon
|
|
172
|
+
let status = { BackendState: 'NeedsLogin', AuthURL: 'https://login.tailscale.com/a/abc', Self: {}, Peer: null, User: null }
|
|
173
|
+
let loginRequests = 0
|
|
174
|
+
let pushed = null
|
|
175
|
+
anywhere.pid = 123
|
|
176
|
+
anywhere.api = {
|
|
177
|
+
status: async () => status,
|
|
178
|
+
loginInteractive: async () => { loginRequests += 1 },
|
|
179
|
+
queryFeature: async () => ({ Complete: false, Text: 'approve', URL: 'https://login.tailscale.com/f/serve?node=1' }),
|
|
180
|
+
serveConfig: async () => ({ config: null, etag: 'e1' }),
|
|
181
|
+
setServeConfig: async (config) => { pushed = config },
|
|
182
|
+
}
|
|
183
|
+
let snap = await anywhere.snapshot()
|
|
184
|
+
assert.equal(snap.phase, 'login')
|
|
185
|
+
assert.equal(snap.loginUrl, 'https://login.tailscale.com/a/abc')
|
|
186
|
+
assert.equal(loginRequests, 0, 'uses the AuthURL the daemon already has')
|
|
187
|
+
|
|
188
|
+
anywhere.lastStatusAt = 0
|
|
189
|
+
status = { BackendState: 'NeedsLogin', AuthURL: '', Self: {}, Peer: null, User: null }
|
|
190
|
+
snap = await anywhere.snapshot()
|
|
191
|
+
assert.equal(loginRequests, 1, 'asks the daemon for a login URL when it has none')
|
|
192
|
+
|
|
193
|
+
anywhere.lastStatusAt = 0
|
|
194
|
+
status = {
|
|
195
|
+
BackendState: 'Running',
|
|
196
|
+
Version: '1.104.1',
|
|
197
|
+
TailscaleIPs: ['100.1.2.3'],
|
|
198
|
+
CertDomains: null,
|
|
199
|
+
Self: { DNSName: 'pinokio.tail1.ts.net.', UserID: 7, KeyExpiry: '2027-04-20T04:33:03Z' },
|
|
200
|
+
User: { '7': { LoginName: 'me@example.com', DisplayName: 'Me' } },
|
|
201
|
+
Peer: { a: { HostName: 'iphone', OS: 'iOS', Online: true, DNSName: 'iphone.tail1.ts.net.', TailscaleIPs: ['100.1.2.4'], LastSeen: '0001-01-01T00:00:00Z' } },
|
|
202
|
+
CurrentTailnet: { Name: 'me@example.com', MagicDNSSuffix: 'tail1.ts.net', MagicDNSEnabled: true },
|
|
203
|
+
}
|
|
204
|
+
snap = await anywhere.snapshot()
|
|
205
|
+
assert.equal(snap.phase, 'https')
|
|
206
|
+
assert.equal(snap.approval.url, 'https://login.tailscale.com/f/serve?node=1')
|
|
207
|
+
assert.equal(snap.address, 'https://pinokio.tail1.ts.net')
|
|
208
|
+
|
|
209
|
+
anywhere.lastStatusAt = 0
|
|
210
|
+
anywhere.featureAt = 0
|
|
211
|
+
status.CertDomains = ['pinokio.tail1.ts.net']
|
|
212
|
+
kernel.memory.local = { [path.join(tmp, 'api', 'comfy', 'start.json')]: { url: 'http://localhost:7860', other: 'not a url' } }
|
|
213
|
+
snap = await anywhere.snapshot()
|
|
214
|
+
assert.equal(snap.phase, 'ready')
|
|
215
|
+
assert.equal(snap.account.email, 'me@example.com')
|
|
216
|
+
assert.equal(snap.keyExpiry, '2027-04-20T04:33:03Z')
|
|
217
|
+
assert.equal(snap.mobilePeerPresent, true)
|
|
218
|
+
assert.equal(snap.mobilePeerOnline, true)
|
|
219
|
+
assert.equal(snap.connectUrl, 'https://pinokio.co/anywhere/connect#u=https%3A%2F%2Fpinokio.tail1.ts.net&a=me%40example.com')
|
|
220
|
+
assert.deepEqual(snap.serve.map((s) => [s.url, s.local, s.name]), [['https://pinokio.tail1.ts.net', 42000, null], ['https://pinokio.tail1.ts.net:43000', 7860, 'comfy']])
|
|
221
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(tmp, 'anywhere', 'ports.json'), 'utf8')), { comfy: 43000 }, 'stable port persisted')
|
|
222
|
+
|
|
223
|
+
// menu links are rewritten only for requests addressed to the node itself
|
|
224
|
+
const menu = () => [
|
|
225
|
+
{ href: 'http://localhost:7860/?x=1#t', src: 'http://localhost:7860/?x=1#t', target_full: 'http://localhost:7860/?x=1#t', menu: [{ href: 'http://127.0.0.1:42000/home' }] },
|
|
226
|
+
{ href: 'http://localhost:9999/' },
|
|
227
|
+
{ href: '/files/api/comfy' },
|
|
228
|
+
{ href: 'https://example.com:7860/' },
|
|
229
|
+
]
|
|
230
|
+
for (const host of ['localhost:42000', '192.168.1.5:42000', 'pinokio.p5.localhost', undefined]) {
|
|
231
|
+
assert.deepEqual(await anywhere.rewriteMenu(host, menu()), menu(), `untouched for host ${host}`)
|
|
232
|
+
}
|
|
233
|
+
for (const host of ['pinokio.tail1.ts.net', 'PINOKIO.TAIL1.TS.NET:443']) {
|
|
234
|
+
const rewritten = await anywhere.rewriteMenu(host, menu())
|
|
235
|
+
assert.equal(rewritten[0].href, 'https://pinokio.tail1.ts.net:43000/?x=1#t')
|
|
236
|
+
assert.equal(rewritten[0].src, 'https://pinokio.tail1.ts.net:43000/?x=1#t')
|
|
237
|
+
assert.equal(rewritten[0].target_full, 'https://pinokio.tail1.ts.net:43000/?x=1#t', 'the popout target follows the href')
|
|
238
|
+
assert.equal(rewritten[0].menu[0].href, 'https://pinokio.tail1.ts.net/home', 'pinokiod itself maps to the bare address')
|
|
239
|
+
assert.equal(rewritten[1].href, 'http://localhost:9999/', 'unpublished ports are left alone')
|
|
240
|
+
assert.equal(rewritten[2].href, '/files/api/comfy')
|
|
241
|
+
assert.equal(rewritten[3].href, 'https://example.com:7860/')
|
|
242
|
+
}
|
|
243
|
+
// sync pushes once, then only on changes
|
|
244
|
+
assert.equal(await anywhere.sync(), true)
|
|
245
|
+
assert.deepEqual(Object.keys(pushed.TCP), ['443', '43000'])
|
|
246
|
+
pushed = null
|
|
247
|
+
assert.equal(await anywhere.sync(), false)
|
|
248
|
+
assert.equal(pushed, null)
|
|
249
|
+
|
|
250
|
+
// an app that just published its URL gets its stable port at render time
|
|
251
|
+
// and its route is pushed right away, without waiting for the next sync tick
|
|
252
|
+
kernel.memory.local[path.join(tmp, 'api', 'forge', 'start.json')] = { url: 'http://127.0.0.1:8188' }
|
|
253
|
+
const fresh = await anywhere.rewriteMenu('pinokio.tail1.ts.net', [{ href: 'http://127.0.0.1:8188/' }])
|
|
254
|
+
assert.equal(fresh[0].href, 'https://pinokio.tail1.ts.net:43001/')
|
|
255
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(tmp, 'anywhere', 'ports.json'), 'utf8')), { comfy: 43000, forge: 43001 })
|
|
256
|
+
await anywhere.syncing
|
|
257
|
+
assert.deepEqual(Object.keys(pushed.TCP), ['443', '43000', '43001'], 'route pushed at render time')
|
|
258
|
+
pushed = null
|
|
259
|
+
assert.equal(await anywhere.sync(), false)
|
|
260
|
+
kernel.memory.local = {}
|
|
261
|
+
assert.equal(await anywhere.sync(), true, 'stopped app removes its entry')
|
|
262
|
+
assert.deepEqual(Object.keys(pushed.TCP), ['443'])
|
|
263
|
+
} finally {
|
|
264
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
test('anywhere uses dev mode flags on windows and a short socket path on long homes', () => {
|
|
269
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
|
|
270
|
+
try {
|
|
271
|
+
const kernel = fakeKernel(tmp)
|
|
272
|
+
kernel.platform = 'win32'
|
|
273
|
+
const win = new Anywhere(kernel)
|
|
274
|
+
assert.ok(win.daemonArgs().includes('--windows-mode=dev'))
|
|
275
|
+
assert.ok(win.daemonArgs().includes('--tun=userspace-networking'))
|
|
276
|
+
assert.equal(win.socketPath(), '\\\\.\\pipe\\pinokio-tailscaled')
|
|
277
|
+
|
|
278
|
+
const longHome = path.join(tmp, 'x'.repeat(120))
|
|
279
|
+
const deep = new Anywhere(fakeKernel(longHome))
|
|
280
|
+
assert.ok(Buffer.byteLength(deep.socketPath()) <= 100, deep.socketPath())
|
|
281
|
+
assert.ok(deep.socketPath().startsWith(os.tmpdir()))
|
|
282
|
+
const shallow = new Anywhere(fakeKernel('/Users/me/pinokio'))
|
|
283
|
+
assert.equal(shallow.socketPath(), '/Users/me/pinokio/anywhere/tailscaled/tailscaled.sock')
|
|
284
|
+
} finally {
|
|
285
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('while Anywhere is off, sync and the menu rewrite are no-ops and never touch the daemon', async () => {
|
|
290
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
|
|
291
|
+
try {
|
|
292
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
293
|
+
anywhere.api = new Proxy({}, { get() { throw new Error('LocalAPI must not be called while Anywhere is off') } })
|
|
294
|
+
assert.equal(await anywhere.sync(), false)
|
|
295
|
+
await anywhere.loadConfig()
|
|
296
|
+
assert.equal(await anywhere.sync(), false)
|
|
297
|
+
const menu = [{ href: 'http://localhost:7860/' }, { href: 'http://127.0.0.1:42000/home' }]
|
|
298
|
+
assert.deepEqual(await anywhere.rewriteMenu('pinokio.tail1.ts.net', JSON.parse(JSON.stringify(menu))), menu)
|
|
299
|
+
} finally {
|
|
300
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('serve targets follow the Home Server router rules for an app\'s URL variables', () => {
|
|
305
|
+
const tmp = path.join(os.tmpdir(), 'pk-home')
|
|
306
|
+
const kernel = fakeKernel(tmp)
|
|
307
|
+
kernel.router = {
|
|
308
|
+
localhost_variable_router: {
|
|
309
|
+
domain(name, key) {
|
|
310
|
+
if (name === 'comfy') return key === 'url' ? '@' : (key === 'api_url' ? 'api' : undefined)
|
|
311
|
+
throw new Error('no pinokio config for ' + name) // what Processor.domain does for an unknown app
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
}
|
|
315
|
+
kernel.memory.local = {
|
|
316
|
+
[path.join(tmp, 'api', 'comfy', 'start.json')]: {
|
|
317
|
+
url: 'http://localhost:7860',
|
|
318
|
+
api_url: 'http://127.0.0.1:7861',
|
|
319
|
+
other: 'http://localhost:7862', // not dns-mapped
|
|
320
|
+
remote: 'http://192.168.1.9:7863', // not loopback: cannot be proxied
|
|
321
|
+
noport: 'http://localhost',
|
|
322
|
+
count: 3,
|
|
323
|
+
},
|
|
324
|
+
[path.join(tmp, 'api', 'nocfg', 'start.json')]: { url: 'http://localhost:8000', other: 'http://localhost:8001' },
|
|
325
|
+
['/elsewhere/start.json']: { url: 'http://localhost:9000' }, // outside ~/pinokio/api
|
|
326
|
+
}
|
|
327
|
+
const anywhere = new Anywhere(kernel)
|
|
328
|
+
assert.deepEqual(anywhere.appTargets(), [ // `url` first, then the other keys alphabetically
|
|
329
|
+
{ key: 'comfy', name: 'comfy', local: 7860 },
|
|
330
|
+
{ key: 'api.comfy', name: 'comfy', local: 7861 },
|
|
331
|
+
{ key: 'nocfg', name: 'nocfg', local: 8000 },
|
|
332
|
+
])
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
test('a failed https feature query keeps the page in starting instead of showing an approval', async () => {
|
|
336
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
|
|
337
|
+
try {
|
|
338
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
339
|
+
await anywhere.saveConfig({ enabled: true })
|
|
340
|
+
anywhere.pid = 123
|
|
341
|
+
let feature = () => { throw new Error('503 no netmap') }
|
|
342
|
+
anywhere.api = {
|
|
343
|
+
status: async () => ({ BackendState: 'Running', CertDomains: null, Self: { DNSName: 'pinokio.tail1.ts.net.', UserID: 7 }, User: {}, Peer: null }),
|
|
344
|
+
queryFeature: async () => feature(),
|
|
345
|
+
}
|
|
346
|
+
let snap = await anywhere.snapshot()
|
|
347
|
+
assert.equal(snap.phase, 'starting')
|
|
348
|
+
assert.equal(snap.approval, null)
|
|
349
|
+
feature = () => ({ Complete: false, Text: 'approve', URL: 'https://login.tailscale.com/f/serve?node=1' })
|
|
350
|
+
anywhere.lastStatusAt = 0
|
|
351
|
+
snap = await anywhere.snapshot()
|
|
352
|
+
assert.equal(snap.phase, 'https')
|
|
353
|
+
assert.equal(snap.approval.url, 'https://login.tailscale.com/f/serve?node=1')
|
|
354
|
+
} finally {
|
|
355
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
// a stand-in daemon process we can safely signal
|
|
360
|
+
function spawnIdle() {
|
|
361
|
+
const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'], { stdio: 'ignore' }) // self-exits if a run is cut short
|
|
362
|
+
const exited = new Promise((resolve) => child.on('exit', () => resolve(true)))
|
|
363
|
+
return { child, exited: (ms = 3000) => Promise.race([exited, new Promise((resolve) => setTimeout(() => resolve(false), ms))]) }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
test('adopting a daemon left by a previous run recovers its pid from the pid file', async () => {
|
|
367
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
|
|
368
|
+
const idle = spawnIdle()
|
|
369
|
+
try {
|
|
370
|
+
const kernel = fakeKernel(tmp)
|
|
371
|
+
const anywhere = new Anywhere(kernel)
|
|
372
|
+
fs.mkdirSync(anywhere.stateDir(), { recursive: true })
|
|
373
|
+
const dead = spawnIdle(); dead.child.kill('SIGKILL'); await dead.exited()
|
|
374
|
+
fs.writeFileSync(anywhere.pidPath(), String(dead.child.pid))
|
|
375
|
+
assert.equal(anywhere.readPid(), null, 'a pid that no longer exists is ignored')
|
|
376
|
+
fs.writeFileSync(anywhere.pidPath(), String(idle.child.pid))
|
|
377
|
+
await anywhere.saveConfig({ enabled: true })
|
|
378
|
+
anywhere.api = {
|
|
379
|
+
ping: async () => true,
|
|
380
|
+
patchPrefs: async () => ({}),
|
|
381
|
+
status: async () => ({ BackendState: 'NeedsLogin', AuthURL: 'https://login.tailscale.com/a/abc', Self: {}, User: null, Peer: null }),
|
|
382
|
+
}
|
|
383
|
+
await anywhere.start()
|
|
384
|
+
assert.equal(anywhere.pid, idle.child.pid)
|
|
385
|
+
} finally {
|
|
386
|
+
idle.child.kill('SIGKILL')
|
|
387
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
388
|
+
}
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
test('a daemon that cannot be spawned reports the cause at once instead of waiting 20 s', async () => {
|
|
393
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
|
|
394
|
+
try {
|
|
395
|
+
const kernel = fakeKernel(tmp)
|
|
396
|
+
kernel.bin.mod.tailscaled.binPath = () => path.join(tmp, 'no-such-tailscaled')
|
|
397
|
+
const anywhere = new Anywhere(kernel)
|
|
398
|
+
await anywhere.saveConfig({ enabled: true })
|
|
399
|
+
const started = Date.now()
|
|
400
|
+
await assert.rejects(() => anywhere.start(), /failed to start/)
|
|
401
|
+
assert.ok(Date.now() - started < 5000, 'did not sit out the 20 s daemon timeout')
|
|
402
|
+
assert.equal((await anywhere.snapshot()).phase, 'error')
|
|
403
|
+
assert.equal(fs.existsSync(anywhere.pidPath()), false)
|
|
404
|
+
} finally {
|
|
405
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
406
|
+
}
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
test('sign-out resets the login state, and a link the server rejected is replaced instead of shown', async () => {
|
|
410
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
|
|
411
|
+
try {
|
|
412
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
413
|
+
await anywhere.saveConfig({ enabled: true })
|
|
414
|
+
anywhere.pid = 123
|
|
415
|
+
const calls = []
|
|
416
|
+
let status = { BackendState: 'NeedsLogin', AuthURL: 'https://login.tailscale.com/a/dead', Self: {}, User: null, Peer: null,
|
|
417
|
+
Health: ['You are logged out. The last login error was: register request: http 410: auth path not found'] }
|
|
418
|
+
anywhere.api = {
|
|
419
|
+
status: async () => status,
|
|
420
|
+
logout: async () => { calls.push('logout') },
|
|
421
|
+
patchPrefs: async (p) => { calls.push('prefs:' + (p.LoggedOut === false ? 'login' : 'other')); return {} },
|
|
422
|
+
start: async () => { calls.push('start') },
|
|
423
|
+
loginInteractive: async () => { calls.push('login-interactive') },
|
|
424
|
+
}
|
|
425
|
+
await anywhere.logout()
|
|
426
|
+
assert.deepEqual(calls, ['logout', 'prefs:login', 'start'], 'sign-out: logout, then back to logged-in prefs, then reset')
|
|
427
|
+
|
|
428
|
+
calls.length = 0
|
|
429
|
+
let snap = await anywhere.snapshot()
|
|
430
|
+
assert.equal(snap.loginUrl, null, 'the rejected link is not offered')
|
|
431
|
+
assert.equal(snap.phase, 'starting')
|
|
432
|
+
assert.deepEqual(calls, ['start', 'login-interactive'], 'a rejected link triggers a reset and a fresh request')
|
|
433
|
+
|
|
434
|
+
// after the reset the daemon keeps a stale "last login error: ... context canceled" line; that is not a rejection
|
|
435
|
+
anywhere.lastStatusAt = 0
|
|
436
|
+
calls.length = 0
|
|
437
|
+
status = { BackendState: 'NeedsLogin', AuthURL: 'https://login.tailscale.com/a/fresh', Self: {}, User: null, Peer: null,
|
|
438
|
+
Health: ['You are logged out. The last login error was: fetch control key: Get "https://controlplane.tailscale.com/key?v=148": context canceled'] }
|
|
439
|
+
snap = await anywhere.snapshot()
|
|
440
|
+
assert.equal(snap.loginUrl, 'https://login.tailscale.com/a/fresh', 'a fresh link is offered as is')
|
|
441
|
+
assert.equal(snap.phase, 'login')
|
|
442
|
+
assert.deepEqual(calls, [], 'no reset loop on a stale error line')
|
|
443
|
+
} finally {
|
|
444
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
445
|
+
}
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
test('old Tailscale daemons are stopped and cannot be adopted through direct start', async t => {
|
|
450
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-version-'))
|
|
451
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
|
|
452
|
+
const anywhere = new Anywhere(fakeKernel(root, { installed: false }))
|
|
453
|
+
let stopped = 0
|
|
454
|
+
anywhere.daemonAlive = async () => true
|
|
455
|
+
anywhere.enabled = async () => true
|
|
456
|
+
anywhere.stop = async () => { stopped++ }
|
|
457
|
+
assert.equal(await anywhere.autostart(), false)
|
|
458
|
+
assert.equal(stopped, 1)
|
|
459
|
+
await assert.rejects(anywhere.start(), /Update the tailscaled tool/)
|
|
460
|
+
assert.equal(stopped, 2)
|
|
461
|
+
})
|
package/test/conda-bin.test.js
CHANGED
|
@@ -6,16 +6,40 @@ const os = require('node:os')
|
|
|
6
6
|
const path = require('node:path')
|
|
7
7
|
const test = require('node:test')
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
let Bin
|
|
10
|
+
let Conda
|
|
11
11
|
const {
|
|
12
12
|
CONDA_PIN_VERSION,
|
|
13
13
|
PYTHON_INSTALL_SPEC,
|
|
14
14
|
WINDOWS_PYTHON_SSL_FIX_SPEC,
|
|
15
15
|
isExpectedCondaPinned,
|
|
16
16
|
isExpectedPythonPinned,
|
|
17
|
+
TLS_PACKAGE_MINIMUMS,
|
|
18
|
+
TLS_PACKAGE_SPECS,
|
|
19
|
+
PIP_INSTALL_SPEC,
|
|
20
|
+
PIP_MIN_VERSION,
|
|
17
21
|
} = require('../kernel/bin/conda-pins')
|
|
18
22
|
|
|
23
|
+
test.beforeEach(t => {
|
|
24
|
+
const realExecFile = childProcess.execFile.bind(childProcess)
|
|
25
|
+
t.mock.method(childProcess, 'execFile', (binary, args, options, done) => {
|
|
26
|
+
if (args[0] !== '-I' || args[1] !== '-c') return realExecFile(binary, args, options, done)
|
|
27
|
+
const prefix = path.basename(binary) === 'python.exe' ? path.dirname(binary) : path.dirname(path.dirname(binary))
|
|
28
|
+
if (args[2].includes('certifi.where()')) {
|
|
29
|
+
done(null, path.join(prefix, 'certificates.pem') + '\n2026.10.19\n')
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
fs.readFile(path.join(prefix, 'test-pip-version'), 'utf8').then(
|
|
33
|
+
version => done(null, version), error => done(error, '')
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
for (const file of ['../kernel/bin/conda-meta', '../kernel/bin/conda', '../kernel/bin']) {
|
|
37
|
+
delete require.cache[require.resolve(file)]
|
|
38
|
+
}
|
|
39
|
+
Bin = require('../kernel/bin')
|
|
40
|
+
Conda = require('../kernel/bin/conda')
|
|
41
|
+
})
|
|
42
|
+
|
|
19
43
|
async function pathExists(target) {
|
|
20
44
|
return fs.access(target).then(() => true).catch(() => false)
|
|
21
45
|
}
|
|
@@ -49,6 +73,10 @@ async function writeHealthyCondaMeta(root, platform = 'win32', condaVersion = CO
|
|
|
49
73
|
await writeCondaMeta(root, 'conda', condaVersion, 'py310')
|
|
50
74
|
await writeCondaMeta(root, 'conda-libmamba-solver', '25.4.0', 'pyhd3eb1b0_0')
|
|
51
75
|
await writeCondaMeta(root, 'python', '3.10.20', platform === 'win32' ? 'h4de0772_1_cpython' : 'cpython')
|
|
76
|
+
for (const [name, version] of Object.entries(TLS_PACKAGE_MINIMUMS)) {
|
|
77
|
+
await writeCondaMeta(root, name, version)
|
|
78
|
+
}
|
|
79
|
+
await fs.writeFile(path.join(root, 'bin/miniforge/test-pip-version'), PIP_MIN_VERSION)
|
|
52
80
|
}
|
|
53
81
|
|
|
54
82
|
async function writeFakeManagedConda(root, platform = 'win32') {
|
|
@@ -87,6 +115,7 @@ function restoreEnv(name, value) {
|
|
|
87
115
|
function createConda(kernel) {
|
|
88
116
|
const conda = new Conda()
|
|
89
117
|
conda.kernel = kernel
|
|
118
|
+
conda.verifyInstaller = async () => {} // Installer lifecycle tests mock the download.
|
|
90
119
|
return conda
|
|
91
120
|
}
|
|
92
121
|
|
|
@@ -161,7 +190,7 @@ test('Conda uses Miniforge assets and writes conda-forge-only config', async ()
|
|
|
161
190
|
|
|
162
191
|
assert.equal(
|
|
163
192
|
conda.urls.win32.x64,
|
|
164
|
-
'https://github.com/conda-forge/miniforge/releases/download/26.
|
|
193
|
+
'https://github.com/conda-forge/miniforge/releases/download/26.9.4-0/Miniforge3-Windows-x86_64.exe'
|
|
165
194
|
)
|
|
166
195
|
|
|
167
196
|
await conda.init()
|
|
@@ -201,7 +230,7 @@ test('Conda init writes the current Conda pin into an existing runtime', async (
|
|
|
201
230
|
|
|
202
231
|
assert.equal(
|
|
203
232
|
await fs.readFile(path.join(root, 'bin', 'miniforge', 'conda-meta', 'pinned'), 'utf8'),
|
|
204
|
-
'conda ==26.5.3'
|
|
233
|
+
['conda ==26.5.3', ...TLS_PACKAGE_SPECS].join('\n')
|
|
205
234
|
)
|
|
206
235
|
})
|
|
207
236
|
|
|
@@ -253,13 +282,14 @@ test('Conda install requests the Python 3.10.20 pin on macOS/Linux', async () =>
|
|
|
253
282
|
assert.equal(await pathExists(path.join(root, 'bin', 'miniconda')), true)
|
|
254
283
|
const condaInstall = calls.find((call) => Array.isArray(call.message))
|
|
255
284
|
assert.ok(condaInstall)
|
|
285
|
+
assert.equal(condaInstall.message[2], `python -I -m pip --isolated --disable-pip-version-check install --no-index --no-deps "${PIP_INSTALL_SPEC}"`)
|
|
256
286
|
assert.equal(
|
|
257
287
|
condaInstall.message[1],
|
|
258
|
-
'conda install -y --override-channels -c conda-forge "conda=26.5.3" "python=3.10.20" "conda-libmamba-solver>=25.4.0"'
|
|
288
|
+
'conda install -y --override-channels -c conda-forge "conda=26.5.3" "python=3.10.20" "conda-libmamba-solver>=25.4.0" pip "ca-certificates>=2026.10.19" "certifi>=2026.10.19"'
|
|
259
289
|
)
|
|
260
290
|
assert.equal(
|
|
261
291
|
await fs.readFile(path.join(root, 'bin', 'miniforge', 'conda-meta', 'pinned'), 'utf8'),
|
|
262
|
-
'conda ==26.5.3'
|
|
292
|
+
['conda ==26.5.3', ...TLS_PACKAGE_SPECS].join('\n')
|
|
263
293
|
)
|
|
264
294
|
})
|
|
265
295
|
|
|
@@ -287,7 +317,7 @@ test('Conda install keeps the Windows Python 3.10.20 SSL-fixed build pin', async
|
|
|
287
317
|
assert.ok(condaInstall)
|
|
288
318
|
assert.equal(
|
|
289
319
|
condaInstall.message[1],
|
|
290
|
-
'conda install -y --override-channels -c conda-forge "conda=26.5.3" "python=3.10.20=*_1_cpython" "conda-libmamba-solver>=25.4.0"'
|
|
320
|
+
'conda install -y --override-channels -c conda-forge "conda=26.5.3" "python=3.10.20=*_1_cpython" "conda-libmamba-solver>=25.4.0" pip "ca-certificates>=2026.10.19" "certifi>=2026.10.19"'
|
|
291
321
|
)
|
|
292
322
|
})
|
|
293
323
|
|
|
@@ -319,7 +349,7 @@ test('Conda install replaces the stale runtime and rebuilds declared Conda modul
|
|
|
319
349
|
assert.ok(condaInstall)
|
|
320
350
|
assert.equal(
|
|
321
351
|
condaInstall.message[1],
|
|
322
|
-
'conda install -y --override-channels -c conda-forge "conda=26.5.3" "python=3.10.20" "conda-libmamba-solver>=25.4.0" uv=0.11.23 nodejs=22.21.1 pnpm'
|
|
352
|
+
'conda install -y --override-channels -c conda-forge "conda=26.5.3" "python=3.10.20" "conda-libmamba-solver>=25.4.0" pip "ca-certificates>=2026.10.19" "certifi>=2026.10.19" uv=0.11.23 nodejs=22.21.1 pnpm'
|
|
323
353
|
)
|
|
324
354
|
})
|
|
325
355
|
|
|
@@ -583,3 +613,91 @@ test('Conda install reports recovery details when the old runtime cannot be remo
|
|
|
583
613
|
fsModule.promises.rm = originalRm
|
|
584
614
|
}
|
|
585
615
|
})
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
test('stale certificate packages trigger the existing Conda setup checks', async t => {
|
|
619
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-conda-certs-'))
|
|
620
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
621
|
+
await writeHealthyCondaMeta(root, 'darwin')
|
|
622
|
+
await writeFakeManagedConda(root, 'darwin')
|
|
623
|
+
for (const [name, version] of Object.entries(TLS_PACKAGE_MINIMUMS)) {
|
|
624
|
+
await fs.rm(path.join(root, 'bin/miniforge/conda-meta', `${name}-${version}.json`))
|
|
625
|
+
await writeCondaMeta(root, name, '2026.4.22')
|
|
626
|
+
}
|
|
627
|
+
assert.equal(await createConda(createKernel(root, 'darwin')).check(), false)
|
|
628
|
+
const bin = createBin(root, 'darwin')
|
|
629
|
+
await bin.tryList()
|
|
630
|
+
assert.equal(bin.correct_conda, false)
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
test('tampered Miniforge installers leave the existing runtime untouched', async t => {
|
|
634
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-conda-hash-'))
|
|
635
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
636
|
+
await createMiniforge(root)
|
|
637
|
+
const kernel = createKernel(root, 'darwin')
|
|
638
|
+
kernel.bin.download = async () => fs.writeFile(path.join(root, 'bin/installer.sh'), 'tampered')
|
|
639
|
+
const conda = new Conda()
|
|
640
|
+
conda.kernel = kernel
|
|
641
|
+
await assert.rejects(conda._install({}, () => {}), /SHA-256 verification failed/)
|
|
642
|
+
assert.equal(await pathExists(path.join(root, 'bin/miniforge/Scripts/conda.exe')), true)
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
test('old pip cannot pass Conda readiness even with current CA packages', async t => {
|
|
647
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-pip-ready-'))
|
|
648
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
649
|
+
await writeHealthyCondaMeta(root, 'darwin')
|
|
650
|
+
await writeFakeManagedConda(root, 'darwin')
|
|
651
|
+
await fs.writeFile(path.join(root, 'bin/miniforge/test-pip-version'), '26.2.1')
|
|
652
|
+
assert.equal(await createConda(createKernel(root, 'darwin')).check(), false)
|
|
653
|
+
const bin = createBin(root, 'darwin')
|
|
654
|
+
await bin.tryList()
|
|
655
|
+
assert.equal(bin.correct_conda, false)
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
test('certificate refresh preserves named environments and uses the new CA bundle', async t => {
|
|
659
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-ca-refresh-'))
|
|
660
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
661
|
+
await writeHealthyCondaMeta(root, 'darwin')
|
|
662
|
+
await writeFakeManagedConda(root, 'darwin')
|
|
663
|
+
const marker = path.join(root, 'bin/miniforge/envs/app/keep')
|
|
664
|
+
await fs.mkdir(path.dirname(marker), { recursive: true })
|
|
665
|
+
await fs.writeFile(marker, 'existing app')
|
|
666
|
+
const kernel = createKernel(root, 'darwin')
|
|
667
|
+
const calls = []
|
|
668
|
+
kernel.bin.download = async () => assert.fail('must not replace a working runtime')
|
|
669
|
+
kernel.bin.exec = async payload => { calls.push(payload); return {} }
|
|
670
|
+
await createConda(kernel).install({}, () => {})
|
|
671
|
+
assert.equal(await fs.readFile(marker, 'utf8'), 'existing app')
|
|
672
|
+
assert.equal(calls.length, 2)
|
|
673
|
+
assert.equal(calls[1].env.CONDA_SSL_VERIFY, path.join(root, 'bin/miniforge/certificates.pem'))
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
test('failed pip bootstrap stops certificate refresh without replacing environments', async t => {
|
|
677
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-ca-failed-'))
|
|
678
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
679
|
+
await writeHealthyCondaMeta(root, 'darwin')
|
|
680
|
+
await writeFakeManagedConda(root, 'darwin')
|
|
681
|
+
const kernel = createKernel(root, 'darwin')
|
|
682
|
+
kernel.bin.exec = async () => ({ error: ['ERROR: hash verification failed'] })
|
|
683
|
+
kernel.bin.download = async () => assert.fail('must preserve the runtime')
|
|
684
|
+
await assert.rejects(createConda(kernel).install({}, () => {}), /bootstrap failed/)
|
|
685
|
+
assert.equal(await pathExists(path.join(root, 'bin/miniforge/conda-meta')), true)
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
test('fresh installation does not report a failed pip bootstrap as finished', async t => {
|
|
689
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-install-failed-'))
|
|
690
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
691
|
+
await fs.mkdir(path.join(root, 'bin'), { recursive: true })
|
|
692
|
+
const kernel = createKernel(root, 'darwin')
|
|
693
|
+
kernel.bin.mods = []
|
|
694
|
+
kernel.bin.download = async () => {}
|
|
695
|
+
kernel.bin.exec = async payload => {
|
|
696
|
+
if (payload.conda?.skip) {
|
|
697
|
+
await fs.mkdir(path.join(root, 'bin/miniforge/conda-meta'), { recursive: true })
|
|
698
|
+
return {}
|
|
699
|
+
}
|
|
700
|
+
return { error: ['ERROR: pip download failed'] }
|
|
701
|
+
}
|
|
702
|
+
await assert.rejects(createConda(kernel)._install({}, () => {}), /pip installation failed/)
|
|
703
|
+
})
|