pinokiod 8.2.7 → 8.2.9
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/kernel/bin/conda-meta.js +2 -2
- package/kernel/bin/conda-pins.js +1 -2
- package/kernel/bin/conda.js +5 -16
- package/kernel/shell.js +5 -8
- package/package.json +1 -1
- package/server/index.js +22 -7
- package/server/public/anywhere.webmanifest +1 -1
- package/server/views/partials/home_server_popover.ejs +1 -0
- package/server/views/remote.ejs +97 -0
- package/test/anywhere-remote.test.js +48 -0
- package/test/conda-bin.test.js +97 -47
- package/test/shell-run-template.test.js +154 -23
package/kernel/bin/conda-meta.js
CHANGED
|
@@ -113,10 +113,10 @@ function resolveCondaBinary(minicondaPath, platform = process.platform, allowFal
|
|
|
113
113
|
async function hasSecureManagedPip(prefix, platform = process.platform) {
|
|
114
114
|
const python = path.join(prefix, platform === 'win32' ? 'python.exe' : 'bin/python')
|
|
115
115
|
return await new Promise(resolve => {
|
|
116
|
-
execFile(python, ['-I', '-
|
|
116
|
+
execFile(python, ['-I', '-m', 'pip', '--version'], {
|
|
117
117
|
env: managedPythonEnv(), windowsHide: true, timeout: 15000,
|
|
118
118
|
}, (error, stdout) => {
|
|
119
|
-
const version = !error && semver.
|
|
119
|
+
const version = !error && semver.coerce(String(stdout).trim().match(/^pip\s+(\S+)/)?.[1] || '')
|
|
120
120
|
resolve(!!version && semver.gte(version, PIP_MIN_VERSION))
|
|
121
121
|
})
|
|
122
122
|
})
|
package/kernel/bin/conda-pins.js
CHANGED
|
@@ -10,8 +10,7 @@ const TLS_PACKAGE_MINIMUMS = {
|
|
|
10
10
|
// conda-forge's patched pip requires Python 3.11; this official wheel supports 3.10.
|
|
11
11
|
// Bootstrap URL and hash: https://discuss.python.org/t/109576
|
|
12
12
|
const PIP_MIN_VERSION = '26.2.2'
|
|
13
|
-
const
|
|
14
|
-
const PIP_INSTALL_COMMAND = `python -I -m pip --isolated install --no-index "${PIP_INSTALL_SPEC}"`
|
|
13
|
+
const PIP_INSTALL_COMMAND = 'python -I -m pip --isolated install --no-index "pip @ https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl#sha256=fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70"'
|
|
15
14
|
const TLS_PACKAGE_SPECS = Object.entries(TLS_PACKAGE_MINIMUMS).map(([name, version]) => `${name}>=${version}`)
|
|
16
15
|
const hasSecureTlsPackages = (versions = {}) => Object.entries(TLS_PACKAGE_MINIMUMS).every(([name, minimum]) => {
|
|
17
16
|
const version = semver.coerce(versions[name])
|
package/kernel/bin/conda.js
CHANGED
|
@@ -2,8 +2,6 @@ const fs = require('fs')
|
|
|
2
2
|
const { glob } = require('glob')
|
|
3
3
|
const semver = require('semver')
|
|
4
4
|
const { createHash } = require('crypto')
|
|
5
|
-
const { execFile } = require('child_process')
|
|
6
|
-
const { managedPythonEnv } = require('../python_env')
|
|
7
5
|
const { isPathRemovalBlockedError, removePath } = require('../path_removal')
|
|
8
6
|
const { buildCondaListFromMeta, managedCondaRuns, hasSecureManagedPip } = require('./conda-meta')
|
|
9
7
|
const {
|
|
@@ -181,18 +179,11 @@ report_errors: false`)
|
|
|
181
179
|
await managedCondaRuns(this.kernel.bin.path(CONDA_ROOT_DIR), this.kernel.platform)) {
|
|
182
180
|
// Refresh a working runtime in place, preserving named app environments.
|
|
183
181
|
await this.init()
|
|
184
|
-
const bootstrap = await this.kernel.bin.exec({
|
|
185
|
-
message: PIP_INSTALL_COMMAND,
|
|
186
|
-
}, ondata)
|
|
187
|
-
if (bootstrap?.error?.length || !(await hasSecureManagedPip(this.kernel.bin.path(CONDA_ROOT_DIR), this.kernel.platform))) throw new Error("Conda certificate bootstrap failed; existing environments were preserved.")
|
|
188
|
-
const python = this.kernel.bin.path(CONDA_ROOT_DIR, this.kernel.platform === 'win32' ? 'python.exe' : 'bin/python')
|
|
189
|
-
const caFile = await new Promise((resolve, reject) => execFile(python,
|
|
190
|
-
['-I', '-c', 'from pip._vendor import certifi; print(certifi.where())'],
|
|
191
|
-
{ env: managedPythonEnv(), windowsHide: true, timeout: 15000 },
|
|
192
|
-
(error, stdout) => error ? reject(error) : resolve(stdout.trim())))
|
|
193
182
|
const result = await this.kernel.bin.exec({
|
|
194
|
-
message: [
|
|
195
|
-
|
|
183
|
+
message: [
|
|
184
|
+
`conda install -y --override-channels -c conda-forge "${this.kernel.platform === "win32" ? WINDOWS_PYTHON_SSL_FIX_SPEC : PYTHON_INSTALL_SPEC}" "conda-libmamba-solver>=25.4.0" ${TLS_PACKAGE_SPECS.map(spec => `"${spec}"`).join(" ")}`,
|
|
185
|
+
PIP_INSTALL_COMMAND,
|
|
186
|
+
],
|
|
196
187
|
}, ondata)
|
|
197
188
|
if (result?.error?.length || !(await this.check())) throw new Error("Conda certificate update failed; existing environments were preserved.")
|
|
198
189
|
return
|
|
@@ -281,7 +272,6 @@ report_errors: false`)
|
|
|
281
272
|
`"conda=${CONDA_PIN_VERSION}"`,
|
|
282
273
|
this.kernel.platform === "win32" ? `"${WINDOWS_PYTHON_SSL_FIX_SPEC}"` : `"${PYTHON_INSTALL_SPEC}"`,
|
|
283
274
|
`"conda-libmamba-solver>=25.4.0"`,
|
|
284
|
-
"pip",
|
|
285
275
|
...TLS_PACKAGE_SPECS.map(spec => `"${spec}"`),
|
|
286
276
|
]
|
|
287
277
|
|
|
@@ -290,7 +280,7 @@ report_errors: false`)
|
|
|
290
280
|
`conda install -y --override-channels -c conda-forge ${condaPackages.join(" ")} ${mods}`.trim(),
|
|
291
281
|
PIP_INSTALL_COMMAND,
|
|
292
282
|
]
|
|
293
|
-
|
|
283
|
+
await this.kernel.bin.exec({
|
|
294
284
|
message: cmds,
|
|
295
285
|
env: {
|
|
296
286
|
PIP_REQUIRE_VIRTUALENV: "false"
|
|
@@ -298,7 +288,6 @@ report_errors: false`)
|
|
|
298
288
|
}, (stream) => {
|
|
299
289
|
ondata(stream)
|
|
300
290
|
})
|
|
301
|
-
if (result?.error?.length) throw new Error("Conda package or pip installation failed.")
|
|
302
291
|
if (this.kernel.platform === "win32") {
|
|
303
292
|
// copy python.exe to python3.exe so you can run with both python3 and python
|
|
304
293
|
await fs.promises.copyFile(
|
package/kernel/shell.js
CHANGED
|
@@ -21,7 +21,7 @@ const ShellParser = require('./shell_parser')
|
|
|
21
21
|
const AnsiStreamTracker = require('./ansi_stream_tracker')
|
|
22
22
|
const ShellStateSync = require('./shell_state_sync')
|
|
23
23
|
const ShellRunTemplate = require('./api/shell_run_template')
|
|
24
|
-
const { PYTHON_INSTALL_SPEC,
|
|
24
|
+
const { PYTHON_INSTALL_SPEC, PIP_INSTALL_COMMAND } = require('./bin/conda-pins')
|
|
25
25
|
const CondaRuntimeGuard = require('./shell_conda_runtime_guard')
|
|
26
26
|
const home = os.homedir()
|
|
27
27
|
const WINDOWS_CMD_CONDA_COMMAND_PATTERN = /(?:^|[&|()]\s*)@?\s*(?:call\s+)?(?:"[^"]*[\\/]conda(?:\.bat|\.exe)?"|(?:[a-z]:)?[^&|()\s"]*[\\/]conda(?:\.bat|\.exe)?|conda(?:\.bat|\.exe)?)\s+/i
|
|
@@ -1280,9 +1280,9 @@ class Shell {
|
|
|
1280
1280
|
// calling vcvars, but vcvars rebuilds LIB and drops that entry.
|
|
1281
1281
|
// Restore both the active Conda environment and managed CUDA import
|
|
1282
1282
|
// libraries after all compiler activation scripts have completed.
|
|
1283
|
-
//
|
|
1284
|
-
//
|
|
1285
|
-
conda_activation.push(`CALL set "LIB
|
|
1283
|
+
// Escape the quotes and variable names for interactive cmd.exe so
|
|
1284
|
+
// CALL expands the current values only after activation has finished.
|
|
1285
|
+
conda_activation.push(`CALL set ^"LIB=%^CONDA_PREFIX%\\Library\\lib;%^CUDA_HOME%\\lib;%^LIB%^"`)
|
|
1286
1286
|
} catch (e) {
|
|
1287
1287
|
console.log('vc vars setup', e)
|
|
1288
1288
|
}
|
|
@@ -1400,13 +1400,10 @@ class Shell {
|
|
|
1400
1400
|
// timeout,
|
|
1401
1401
|
activate_command,
|
|
1402
1402
|
// timeout,
|
|
1403
|
+
this.isPowerShell() ? `${PIP_INSTALL_COMMAND}; if ($LASTEXITCODE -ne 0) { exit 1 }` : `${PIP_INSTALL_COMMAND} || exit 1`,
|
|
1403
1404
|
]
|
|
1404
1405
|
}
|
|
1405
1406
|
}
|
|
1406
|
-
if (!use_uv) {
|
|
1407
|
-
const updatePip = `uv pip install --exclude-newer-package pip=false "pip>=${PIP_MIN_VERSION}"`
|
|
1408
|
-
venv_activation.push(this.isPowerShell() ? `${updatePip}; if ($LASTEXITCODE -ne 0) { exit 1 }` : `${updatePip} || exit 1`)
|
|
1409
|
-
}
|
|
1410
1407
|
} else {
|
|
1411
1408
|
venv_activation = []
|
|
1412
1409
|
}
|
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -1043,12 +1043,17 @@ class Server {
|
|
|
1043
1043
|
if (light) {
|
|
1044
1044
|
return snap
|
|
1045
1045
|
}
|
|
1046
|
+
let entries = []
|
|
1047
|
+
try {
|
|
1048
|
+
entries = await this.kernel.api.listApps()
|
|
1049
|
+
} catch (_) { }
|
|
1050
|
+
snap.installed = entries.length
|
|
1046
1051
|
snap.apps = []
|
|
1047
1052
|
if (snap.phase === "ready") {
|
|
1048
1053
|
const serveByLocalPort = new Map((snap.serve || []).map((entry) => [entry.local, entry]))
|
|
1049
1054
|
let running = []
|
|
1050
1055
|
try {
|
|
1051
|
-
running = await this.collectHomeServerApps()
|
|
1056
|
+
running = await this.collectHomeServerApps(entries)
|
|
1052
1057
|
} catch (_) {
|
|
1053
1058
|
running = []
|
|
1054
1059
|
}
|
|
@@ -1056,6 +1061,7 @@ class Server {
|
|
|
1056
1061
|
const port = this.portFromUrl(app.ready_url || app.url)
|
|
1057
1062
|
const entry = port ? serveByLocalPort.get(port) : null
|
|
1058
1063
|
snap.apps.push({
|
|
1064
|
+
id: app.id,
|
|
1059
1065
|
name: app.name,
|
|
1060
1066
|
icon: app.icon,
|
|
1061
1067
|
state: app.state,
|
|
@@ -1076,16 +1082,18 @@ class Server {
|
|
|
1076
1082
|
}
|
|
1077
1083
|
return snap
|
|
1078
1084
|
}
|
|
1079
|
-
|
|
1085
|
+
// `entries`: an app list from kernel.api.listApps() the caller already has
|
|
1086
|
+
async collectHomeServerApps(entries = null) {
|
|
1080
1087
|
const apps = []
|
|
1081
1088
|
if (!this.kernel || !this.kernel.api || typeof this.kernel.api.listApps !== "function") {
|
|
1082
1089
|
return apps
|
|
1083
1090
|
}
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1091
|
+
if (!Array.isArray(entries)) {
|
|
1092
|
+
try {
|
|
1093
|
+
entries = await this.kernel.api.listApps()
|
|
1094
|
+
} catch (_) {
|
|
1095
|
+
entries = []
|
|
1096
|
+
}
|
|
1089
1097
|
}
|
|
1090
1098
|
for (const entry of entries) {
|
|
1091
1099
|
const appId = entry && (entry.id || entry.name) ? String(entry.id || entry.name) : ""
|
|
@@ -14261,6 +14269,13 @@ class Server {
|
|
|
14261
14269
|
agent: req.agent,
|
|
14262
14270
|
})
|
|
14263
14271
|
}))
|
|
14272
|
+
// the phone-first launcher: running apps with their stable Tailscale addresses
|
|
14273
|
+
this.app.get("/remote", ex(async (req, res) => {
|
|
14274
|
+
res.render("remote", {
|
|
14275
|
+
status: await this.composeAnywhereStatus(),
|
|
14276
|
+
theme: this.theme,
|
|
14277
|
+
})
|
|
14278
|
+
}))
|
|
14264
14279
|
this.app.get("/anywhere/status", ex(async (req, res) => {
|
|
14265
14280
|
res.set("Cache-Control", "no-store")
|
|
14266
14281
|
res.json(await this.composeAnywhereStatus({ light: req.query.light === "1" }))
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name":"Pinokio","short_name":"Pinokio","start_url":"/","scope":"/","display":"standalone","background_color":"#ffffff","theme_color":"#000000","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"},{"src":"/apple-touch-icon.png","sizes":"180x180","type":"image/png"}]}
|
|
1
|
+
{"name":"Pinokio","short_name":"Pinokio","start_url":"/remote","scope":"/","display":"standalone","background_color":"#ffffff","theme_color":"#000000","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"},{"src":"/apple-touch-icon.png","sizes":"180x180","type":"image/png"}]}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
<a class="btn2 home-server-popover__trigger" href="/remote" data-tippy-content="Launcher" title="Launcher" aria-label="Launcher"><i class="fa-solid fa-table-cells-large"></i></a>
|
|
1
2
|
<div class="home-server-popover" data-home-server-popover data-home-server-status="loading">
|
|
2
3
|
<button
|
|
3
4
|
type="button"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<title>Pinokio</title>
|
|
5
|
+
<meta charset="UTF-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
|
7
|
+
<base target="_top">
|
|
8
|
+
<% if (locals.anywhere_host) { %>
|
|
9
|
+
<link rel="manifest" href="/anywhere.webmanifest">
|
|
10
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
11
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
12
|
+
<meta name="apple-mobile-web-app-title" content="Pinokio">
|
|
13
|
+
<% } %>
|
|
14
|
+
<link href="/css/fontawesome.min.css" rel="stylesheet">
|
|
15
|
+
<link href="/css/solid.min.css" rel="stylesheet">
|
|
16
|
+
<style>
|
|
17
|
+
:root { --bg: #f6f5f1; --card: #fff; --text: #1a1a1a; --muted: #6b6a66; --line: #dcdad2; --accent: #1a1a1a; --on: #fff; }
|
|
18
|
+
body.dark { --bg: #141414; --card: #1e1e1e; --text: #f1f1f1; --muted: #a8a69f; --line: #333; --accent: #f1f1f1; --on: #141414; }
|
|
19
|
+
* { box-sizing: border-box; }
|
|
20
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; -webkit-text-size-adjust: 100%; }
|
|
21
|
+
main { max-width: 480px; margin: 0 auto; padding: 24px 16px 48px; }
|
|
22
|
+
h1 { font-size: 22px; font-weight: 600; margin: 0 0 18px; }
|
|
23
|
+
h2 { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); margin: 0 0 8px; }
|
|
24
|
+
.card { display: flex; align-items: center; gap: 12px; background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 12px; margin: 0 0 10px; }
|
|
25
|
+
.card img { width: 44px; height: 44px; border-radius: 10px; object-fit: cover; background: var(--bg); flex: none; }
|
|
26
|
+
.card .body { flex: 1; min-width: 0; }
|
|
27
|
+
.card .name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
28
|
+
.card .sub { font-size: 13px; color: var(--muted); }
|
|
29
|
+
.card .sub a { color: inherit; }
|
|
30
|
+
.open { flex: none; padding: 10px 16px; border-radius: 10px; background: var(--accent); color: var(--on); font-weight: 600; text-decoration: none; }
|
|
31
|
+
.state { flex: none; font-size: 13px; color: var(--muted); }
|
|
32
|
+
.notice { text-align: center; padding: 28px 16px; color: var(--muted); }
|
|
33
|
+
.notice strong { display: block; color: var(--text); font-size: 17px; margin-bottom: 4px; }
|
|
34
|
+
.door { display: flex; align-items: center; gap: 12px; border-radius: 14px; padding: 14px; text-decoration: none; color: var(--text); margin-top: 14px; }
|
|
35
|
+
.door.dashed { border: 1px dashed var(--muted); }
|
|
36
|
+
.door.solid { background: var(--accent); color: var(--on); }
|
|
37
|
+
.door .body { flex: 1; }
|
|
38
|
+
.door .title { font-weight: 600; }
|
|
39
|
+
.door .cap { font-size: 13px; opacity: 0.75; }
|
|
40
|
+
.door i { font-size: 18px; }
|
|
41
|
+
.full { display: block; text-align: center; margin-top: 18px; color: var(--muted); font-size: 14px; text-decoration: none; }
|
|
42
|
+
.full i { margin-right: 6px; }
|
|
43
|
+
[hidden] { display: none !important; }
|
|
44
|
+
</style>
|
|
45
|
+
</head>
|
|
46
|
+
<body class="<%=theme%>">
|
|
47
|
+
<main>
|
|
48
|
+
<h1>Pinokio</h1>
|
|
49
|
+
<section id="running" hidden>
|
|
50
|
+
<h2>Running now</h2>
|
|
51
|
+
<div id="cards"></div>
|
|
52
|
+
</section>
|
|
53
|
+
<div id="notice" class="notice" hidden><strong>Nothing is running</strong>Start an app on your computer and it shows up here.</div>
|
|
54
|
+
<a id="door" class="door" href="/home">
|
|
55
|
+
<i class="fa-solid fa-plus"></i>
|
|
56
|
+
<div class="body"><div class="title" id="door-title"></div><div class="cap" id="door-cap"></div></div>
|
|
57
|
+
<i class="fa-solid fa-chevron-right"></i>
|
|
58
|
+
</a>
|
|
59
|
+
<a class="full" href="/home"><i class="fa-solid fa-house"></i> Open full Pinokio</a>
|
|
60
|
+
</main>
|
|
61
|
+
<script>
|
|
62
|
+
(() => {
|
|
63
|
+
let current = <%- JSON.stringify(status || {}).replace(/</g, '\\u003c') %>
|
|
64
|
+
const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
|
65
|
+
const port = (url) => { try { return new URL(url).port } catch (_) { return '' } }
|
|
66
|
+
const render = (status) => {
|
|
67
|
+
current = status || {}
|
|
68
|
+
const apps = Array.isArray(current.apps) ? current.apps : []
|
|
69
|
+
document.getElementById('running').hidden = apps.length === 0
|
|
70
|
+
document.getElementById('notice').hidden = apps.length > 0
|
|
71
|
+
document.getElementById('cards').innerHTML = apps.map((app) => `
|
|
72
|
+
<div class="card">
|
|
73
|
+
<img src="${esc(app.icon || '/pinokio-black.png')}" alt="">
|
|
74
|
+
<div class="body">
|
|
75
|
+
<div class="name">${esc(app.name)}</div>
|
|
76
|
+
<div class="sub">${app.remote ? ':' + esc(port(app.remote)) + ' · ' : ''}<a href="/p/${encodeURIComponent(app.id || '')}">${app.remote ? 'manage' : 'terminal'}</a></div>
|
|
77
|
+
</div>
|
|
78
|
+
${app.remote ? `<a class="open" href="${esc(app.remote)}">Open</a>` : `<span class="state">${esc(app.state || 'starting')}…</span>`}
|
|
79
|
+
</div>`).join('')
|
|
80
|
+
const door = document.getElementById('door')
|
|
81
|
+
door.className = 'door ' + (apps.length > 0 ? 'dashed' : 'solid')
|
|
82
|
+
document.getElementById('door-title').textContent = apps.length > 0 ? 'Start another app' : 'Start an app'
|
|
83
|
+
document.getElementById('door-cap').textContent = `${current.installed || 0} installed apps in Pinokio`
|
|
84
|
+
}
|
|
85
|
+
const poll = async () => {
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch('/anywhere/status', { cache: 'no-store' })
|
|
88
|
+
if (response.ok) render(await response.json())
|
|
89
|
+
} catch (_) { }
|
|
90
|
+
setTimeout(poll, 5000)
|
|
91
|
+
}
|
|
92
|
+
render(current)
|
|
93
|
+
setTimeout(poll, 5000)
|
|
94
|
+
})()
|
|
95
|
+
</script>
|
|
96
|
+
</body>
|
|
97
|
+
</html>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const assert = require('node:assert/strict')
|
|
2
|
+
const path = require('node:path')
|
|
3
|
+
const test = require('node:test')
|
|
4
|
+
const ejs = require('ejs')
|
|
5
|
+
const Server = require('../server/index.js')
|
|
6
|
+
|
|
7
|
+
const view = path.resolve(__dirname, '..', 'server', 'views', 'remote.ejs')
|
|
8
|
+
|
|
9
|
+
// composeAnywhereStatus with its collaborators stubbed: the launcher needs the
|
|
10
|
+
// installed count and each running app's id next to its remote address.
|
|
11
|
+
test('anywhere status carries the installed count and each app id', async () => {
|
|
12
|
+
const fake = {
|
|
13
|
+
kernel: {
|
|
14
|
+
anywhere: {
|
|
15
|
+
snapshot: async () => ({ phase: 'ready', connectUrl: null, serve: [{ local: 7860, url: 'https://pinokio.tail1.ts.net:43000' }] }),
|
|
16
|
+
},
|
|
17
|
+
api: { listApps: async () => [{ id: 'comfy' }, { id: 'forge' }, { id: 'idle' }] },
|
|
18
|
+
},
|
|
19
|
+
collectHomeServerApps: async () => [
|
|
20
|
+
{ id: 'comfy', name: 'ComfyUI', icon: '/c.png', ready_url: 'http://127.0.0.1:7860', state: 'ready' },
|
|
21
|
+
{ id: 'forge', name: 'Forge', icon: '/f.png', ready_url: '', state: 'starting' },
|
|
22
|
+
],
|
|
23
|
+
portFromUrl: Server.prototype.portFromUrl,
|
|
24
|
+
}
|
|
25
|
+
const status = await Server.prototype.composeAnywhereStatus.call(fake, {})
|
|
26
|
+
assert.equal(status.installed, 3)
|
|
27
|
+
assert.deepEqual(status.apps.map((a) => [a.id, a.remote]), [['comfy', 'https://pinokio.tail1.ts.net:43000'], ['forge', null]])
|
|
28
|
+
const light = await Server.prototype.composeAnywhereStatus.call(fake, { light: true })
|
|
29
|
+
assert.equal(light.installed, undefined, 'the light status is unchanged')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('the launcher page renders the running apps and one door to the full interface', async () => {
|
|
33
|
+
const render = (status) => ejs.renderFile(view, { status, theme: 'light', locals: {} })
|
|
34
|
+
|
|
35
|
+
const none = await render({ installed: 12, apps: [] })
|
|
36
|
+
assert.match(none, /Nothing is running/)
|
|
37
|
+
assert.match(none, /href="\/home"/)
|
|
38
|
+
assert.match(none, /Open full Pinokio/)
|
|
39
|
+
assert.match(none, /"installed":12/)
|
|
40
|
+
|
|
41
|
+
const some = await render({ installed: 12, apps: [
|
|
42
|
+
{ id: 'comfy', name: 'ComfyUI', icon: '/c.png', state: 'ready', remote: 'https://pinokio.tail1.ts.net:43000' },
|
|
43
|
+
{ id: 'forge', name: 'Forge', icon: '/f.png', state: 'starting', remote: null },
|
|
44
|
+
] })
|
|
45
|
+
assert.match(some, /"remote":"https:\/\/pinokio\.tail1\.ts\.net:43000"/)
|
|
46
|
+
assert.match(some, /"id":"forge"/)
|
|
47
|
+
assert.doesNotMatch(some, /<link rel="manifest"/, 'manifest tags only for the Tailscale host')
|
|
48
|
+
})
|
package/test/conda-bin.test.js
CHANGED
|
@@ -16,20 +16,17 @@ const {
|
|
|
16
16
|
isExpectedPythonPinned,
|
|
17
17
|
TLS_PACKAGE_SPECS,
|
|
18
18
|
PIP_MIN_VERSION,
|
|
19
|
+
PIP_INSTALL_COMMAND,
|
|
19
20
|
} = require('../kernel/bin/conda-pins')
|
|
20
21
|
|
|
21
22
|
test.beforeEach(t => {
|
|
22
23
|
const realExecFile = childProcess.execFile.bind(childProcess)
|
|
23
24
|
t.mock.method(childProcess, 'execFile', (binary, args, options, done) => {
|
|
24
|
-
if (args[0] !== '-I'
|
|
25
|
+
if (args[0] !== '-I') return realExecFile(binary, args, options, done)
|
|
25
26
|
const prefix = path.basename(binary) === 'python.exe' ? path.dirname(binary) : path.dirname(path.dirname(binary))
|
|
26
|
-
|
|
27
|
-
assert.match(args[2], /^from pip\._vendor import certifi;/)
|
|
28
|
-
done(null, path.join(prefix, 'certificates.pem') + '\n')
|
|
29
|
-
return
|
|
30
|
-
}
|
|
27
|
+
assert.deepEqual(args, ['-I', '-m', 'pip', '--version'])
|
|
31
28
|
fs.readFile(path.join(prefix, 'test-pip-version'), 'utf8').then(
|
|
32
|
-
version => done(null, version), error => done(error, '')
|
|
29
|
+
version => done(null, `pip ${version.trim()} from ${prefix} (python 3.10)`), error => done(error, '')
|
|
33
30
|
)
|
|
34
31
|
})
|
|
35
32
|
for (const file of ['../kernel/bin/conda-meta', '../kernel/bin/conda', '../kernel/bin']) {
|
|
@@ -281,10 +278,10 @@ test('Conda install requests the Python 3.10.20 pin on macOS/Linux', async () =>
|
|
|
281
278
|
assert.equal(await pathExists(path.join(root, 'bin', 'miniconda')), true)
|
|
282
279
|
const condaInstall = calls.find((call) => Array.isArray(call.message))
|
|
283
280
|
assert.ok(condaInstall)
|
|
284
|
-
assert.
|
|
281
|
+
assert.equal(condaInstall.message[2], PIP_INSTALL_COMMAND)
|
|
285
282
|
assert.equal(
|
|
286
283
|
condaInstall.message[1],
|
|
287
|
-
'conda install -y --override-channels -c conda-forge "conda=26.5.3" "python=3.10.20" "conda-libmamba-solver>=25.4.0"
|
|
284
|
+
'conda install -y --override-channels -c conda-forge "conda=26.5.3" "python=3.10.20" "conda-libmamba-solver>=25.4.0" "ca-certificates>=2026.10.19" "certifi>=2026.10.19"'
|
|
288
285
|
)
|
|
289
286
|
assert.equal(
|
|
290
287
|
await fs.readFile(path.join(root, 'bin', 'miniforge', 'conda-meta', 'pinned'), 'utf8'),
|
|
@@ -316,7 +313,7 @@ test('Conda install keeps the Windows Python 3.10.20 SSL-fixed build pin', async
|
|
|
316
313
|
assert.ok(condaInstall)
|
|
317
314
|
assert.equal(
|
|
318
315
|
condaInstall.message[1],
|
|
319
|
-
'conda install -y --override-channels -c conda-forge "conda=26.5.3" "python=3.10.20=*_1_cpython" "conda-libmamba-solver>=25.4.0"
|
|
316
|
+
'conda install -y --override-channels -c conda-forge "conda=26.5.3" "python=3.10.20=*_1_cpython" "conda-libmamba-solver>=25.4.0" "ca-certificates>=2026.10.19" "certifi>=2026.10.19"'
|
|
320
317
|
)
|
|
321
318
|
})
|
|
322
319
|
|
|
@@ -348,7 +345,7 @@ test('Conda install replaces the stale runtime and rebuilds declared Conda modul
|
|
|
348
345
|
assert.ok(condaInstall)
|
|
349
346
|
assert.equal(
|
|
350
347
|
condaInstall.message[1],
|
|
351
|
-
'conda install -y --override-channels -c conda-forge "conda=26.5.3" "python=3.10.20" "conda-libmamba-solver>=25.4.0"
|
|
348
|
+
'conda install -y --override-channels -c conda-forge "conda=26.5.3" "python=3.10.20" "conda-libmamba-solver>=25.4.0" "ca-certificates>=2026.10.19" "certifi>=2026.10.19" uv=0.11.23 nodejs=22.21.1 pnpm'
|
|
352
349
|
)
|
|
353
350
|
})
|
|
354
351
|
|
|
@@ -629,78 +626,131 @@ test('stale certificate packages trigger the existing Conda setup checks', async
|
|
|
629
626
|
assert.equal(bin.correct_conda, false)
|
|
630
627
|
})
|
|
631
628
|
|
|
632
|
-
test('tampered Miniforge installers leave the
|
|
629
|
+
test('tampered Miniforge installers stop immediately and leave the runtime untouched', async t => {
|
|
633
630
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-conda-hash-'))
|
|
634
631
|
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
635
632
|
await createMiniforge(root)
|
|
636
633
|
const kernel = createKernel(root, 'darwin')
|
|
637
|
-
|
|
634
|
+
let downloads = 0
|
|
635
|
+
kernel.bin.download = async () => {
|
|
636
|
+
downloads++
|
|
637
|
+
await fs.writeFile(path.join(root, 'bin/installer.sh'), 'tampered')
|
|
638
|
+
}
|
|
639
|
+
kernel.bin.mod = { caddy: { running: () => assert.fail('must verify before stopping services') } }
|
|
638
640
|
const conda = new Conda()
|
|
639
641
|
conda.kernel = kernel
|
|
640
|
-
await assert.rejects(conda.
|
|
642
|
+
await assert.rejects(conda.install({}, () => {}), /SHA-256 verification failed/)
|
|
643
|
+
assert.equal(downloads, 1)
|
|
641
644
|
assert.equal(await pathExists(path.join(root, 'bin/miniforge/Scripts/conda.exe')), true)
|
|
642
645
|
})
|
|
643
646
|
|
|
644
|
-
|
|
645
|
-
test('old pip cannot pass Conda readiness even with current CA packages', async t => {
|
|
647
|
+
test('old or missing pip cannot pass readiness even with current CA packages', async t => {
|
|
646
648
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-pip-ready-'))
|
|
647
649
|
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
648
650
|
await writeHealthyCondaMeta(root, 'darwin')
|
|
649
651
|
await writeFakeManagedConda(root, 'darwin')
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
652
|
+
for (const version of ['26.2.1', null]) {
|
|
653
|
+
const pip = path.join(root, 'bin/miniforge/test-pip-version')
|
|
654
|
+
if (version) await fs.writeFile(pip, version)
|
|
655
|
+
else await fs.rm(pip)
|
|
656
|
+
assert.equal(await createConda(createKernel(root, 'darwin')).check(), false)
|
|
657
|
+
const bin = createBin(root, 'darwin')
|
|
658
|
+
await bin.tryList()
|
|
659
|
+
assert.equal(bin.correct_conda, false)
|
|
660
|
+
}
|
|
655
661
|
})
|
|
656
662
|
|
|
657
|
-
test(
|
|
663
|
+
test('certificate refresh preserves named environments and existing configuration', async t => {
|
|
658
664
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-ca-refresh-'))
|
|
659
665
|
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
666
|
+
for (const platform of ['win32', 'darwin', 'linux']) {
|
|
667
|
+
const dir = path.join(root, platform)
|
|
668
|
+
await writeHealthyCondaMeta(dir, platform)
|
|
669
|
+
await writeFakeManagedConda(dir, platform)
|
|
670
|
+
await fs.writeFile(path.join(dir, 'bin/miniforge/test-pip-version'), '23.0.1')
|
|
671
|
+
const marker = path.join(dir, 'bin/miniforge/envs/app/keep')
|
|
672
|
+
await fs.mkdir(path.dirname(marker), { recursive: true })
|
|
673
|
+
await fs.writeFile(marker, 'existing app')
|
|
674
|
+
const config = 'CONDA_SSL_VERIFY=private CA.pem\nPIP_CONFIG_FILE=pip.conf\n'
|
|
675
|
+
await fs.writeFile(path.join(dir, 'ENVIRONMENT'), config)
|
|
676
|
+
const kernel = createKernel(dir, platform)
|
|
677
|
+
const calls = []
|
|
678
|
+
kernel.bin.download = async () => assert.fail('must not replace a working runtime')
|
|
679
|
+
kernel.bin.exec = async payload => {
|
|
680
|
+
calls.push(payload)
|
|
681
|
+
await writeHealthyCondaMeta(dir, platform)
|
|
682
|
+
return {}
|
|
683
|
+
}
|
|
684
|
+
await createConda(kernel).install({}, () => {})
|
|
685
|
+
assert.equal(await fs.readFile(marker, 'utf8'), 'existing app')
|
|
686
|
+
assert.equal(await fs.readFile(path.join(dir, 'ENVIRONMENT'), 'utf8'), config)
|
|
687
|
+
assert.equal(calls.length, 1)
|
|
688
|
+
assert.equal(calls[0].env, undefined)
|
|
689
|
+
assert.equal(calls[0]['-env'], undefined)
|
|
690
|
+
const python = platform === 'win32' ? WINDOWS_PYTHON_SSL_FIX_SPEC : PYTHON_INSTALL_SPEC
|
|
691
|
+
assert.deepEqual(calls[0].message, [
|
|
692
|
+
`conda install -y --override-channels -c conda-forge "${python}" "conda-libmamba-solver>=25.4.0" ${TLS_PACKAGE_SPECS.map(spec => `"${spec}"`).join(' ')}`,
|
|
693
|
+
PIP_INSTALL_COMMAND,
|
|
694
|
+
])
|
|
695
|
+
}
|
|
674
696
|
})
|
|
675
697
|
|
|
676
|
-
test('failed
|
|
698
|
+
test('failed refresh reports an error without falling back to runtime replacement', async t => {
|
|
677
699
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-ca-failed-'))
|
|
678
700
|
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
679
701
|
await writeHealthyCondaMeta(root, 'darwin')
|
|
680
702
|
await writeFakeManagedConda(root, 'darwin')
|
|
703
|
+
const marker = path.join(root, 'bin/miniforge/envs/app/keep')
|
|
704
|
+
await fs.mkdir(path.dirname(marker), { recursive: true })
|
|
705
|
+
await fs.writeFile(marker, 'preserve')
|
|
681
706
|
const kernel = createKernel(root, 'darwin')
|
|
682
707
|
kernel.bin.download = async () => assert.fail('must preserve the runtime')
|
|
683
|
-
|
|
684
|
-
|
|
708
|
+
for (const response of [{ error: ['ERROR: download failed'] }, {}]) {
|
|
709
|
+
await fs.rm(path.join(root, 'bin/miniforge/test-pip-version'), { force: true })
|
|
685
710
|
kernel.bin.exec = async () => response
|
|
686
|
-
await assert.rejects(createConda(kernel).install({}, () => {}), /
|
|
711
|
+
await assert.rejects(createConda(kernel).install({}, () => {}), /certificate update failed; existing environments were preserved/)
|
|
712
|
+
assert.equal(await fs.readFile(marker, 'utf8'), 'preserve')
|
|
687
713
|
}
|
|
688
|
-
assert.equal(await pathExists(path.join(root, 'bin/miniforge/conda-meta')), true)
|
|
689
714
|
})
|
|
690
715
|
|
|
691
|
-
test('fresh installation
|
|
692
|
-
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-install-
|
|
716
|
+
test('fresh installation retains readiness-based retries after package or pip errors', async t => {
|
|
717
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-install-retry-'))
|
|
693
718
|
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
694
719
|
await fs.mkdir(path.join(root, 'bin'), { recursive: true })
|
|
695
720
|
const kernel = createKernel(root, 'darwin')
|
|
721
|
+
let attempts = 0
|
|
696
722
|
kernel.bin.mods = []
|
|
697
|
-
kernel.bin.download = async () => {}
|
|
723
|
+
kernel.bin.download = async () => { attempts++ }
|
|
724
|
+
kernel.bin.rm = async () => {}
|
|
698
725
|
kernel.bin.exec = async payload => {
|
|
699
726
|
if (payload.conda?.skip) {
|
|
700
|
-
await
|
|
727
|
+
await writeHealthyCondaMeta(root, 'darwin')
|
|
728
|
+
await writeFakeManagedConda(root, 'darwin')
|
|
729
|
+
await fs.writeFile(path.join(root, 'bin/miniforge/test-pip-version'), '23.0.1')
|
|
701
730
|
return {}
|
|
702
731
|
}
|
|
703
|
-
|
|
732
|
+
assert.deepEqual(payload.env, { PIP_REQUIRE_VIRTUALENV: 'false' })
|
|
733
|
+
assert.equal(payload.message[2], PIP_INSTALL_COMMAND)
|
|
734
|
+
if (attempts === 1) return { error: ['ERROR: pip download failed'] }
|
|
735
|
+
await fs.writeFile(path.join(root, 'bin/miniforge/test-pip-version'), PIP_MIN_VERSION)
|
|
736
|
+
return {}
|
|
737
|
+
}
|
|
738
|
+
const conda = createConda(kernel)
|
|
739
|
+
await conda.install({}, () => {})
|
|
740
|
+
assert.equal(attempts, 2)
|
|
741
|
+
assert.equal(await conda.check(), true)
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
test('pip readiness accepts two-part release versions through the actual pip CLI', async t => {
|
|
745
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-pip-release-'))
|
|
746
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
747
|
+
await writeHealthyCondaMeta(root, 'darwin')
|
|
748
|
+
await writeFakeManagedConda(root, 'darwin')
|
|
749
|
+
for (const version of ['26.3', '27.0']) {
|
|
750
|
+
await fs.writeFile(path.join(root, 'bin/miniforge/test-pip-version'), version)
|
|
751
|
+
assert.equal(await createConda(createKernel(root, 'darwin')).check(), true)
|
|
752
|
+
const bin = createBin(root, 'darwin')
|
|
753
|
+
await bin.tryList()
|
|
754
|
+
assert.equal(bin.correct_conda, true)
|
|
704
755
|
}
|
|
705
|
-
await assert.rejects(createConda(kernel)._install({}, () => {}), /pip installation failed/)
|
|
706
756
|
})
|
|
@@ -7,7 +7,7 @@ const { spawnSync } = require('node:child_process')
|
|
|
7
7
|
|
|
8
8
|
const ShellRunTemplate = require('../kernel/api/shell_run_template')
|
|
9
9
|
const Shell = require('../kernel/shell')
|
|
10
|
-
const {
|
|
10
|
+
const { PIP_INSTALL_COMMAND } = require('../kernel/bin/conda-pins')
|
|
11
11
|
|
|
12
12
|
function createKernel(root = process.cwd()) {
|
|
13
13
|
return {
|
|
@@ -183,8 +183,8 @@ test('Shell.activate restores Conda and CUDA library paths after Windows compile
|
|
|
183
183
|
const compilerScript = path.join(activateRoot, 'vs2019_compiler_vars.bat')
|
|
184
184
|
const cudaScript = path.join(activateRoot, '~cuda-nvcc_activate.bat')
|
|
185
185
|
await fs.mkdir(activateRoot, { recursive: true })
|
|
186
|
-
await fs.writeFile(compilerScript, '@
|
|
187
|
-
await fs.writeFile(cudaScript, '@
|
|
186
|
+
await fs.writeFile(compilerScript, '@set "CONDA_PREFIX=C:\\active conda"\r\n@set "LIB=C:\\Visual Studio (x86)\\lib"\r\n')
|
|
187
|
+
await fs.writeFile(cudaScript, '@set "CUDA_HOME=C:\\active cuda"\r\n')
|
|
188
188
|
|
|
189
189
|
const kernel = createKernel(root)
|
|
190
190
|
kernel.bin = {
|
|
@@ -199,6 +199,7 @@ test('Shell.activate restores Conda and CUDA library paths after Windows compile
|
|
|
199
199
|
|
|
200
200
|
const params = await shell.activate({
|
|
201
201
|
build: true,
|
|
202
|
+
conda: { skip: true },
|
|
202
203
|
path: root,
|
|
203
204
|
message: ['uv pip install cupy'],
|
|
204
205
|
})
|
|
@@ -206,7 +207,7 @@ test('Shell.activate restores Conda and CUDA library paths after Windows compile
|
|
|
206
207
|
const activation = params.message[0]
|
|
207
208
|
const compilerIndex = activation.indexOf(`CALL "${compilerScript}"`)
|
|
208
209
|
const cudaIndex = activation.indexOf(`CALL "${cudaScript}"`)
|
|
209
|
-
const libraryCommand = 'CALL set "LIB
|
|
210
|
+
const libraryCommand = 'CALL set ^"LIB=%^CONDA_PREFIX%\\Library\\lib;%^CUDA_HOME%\\lib;%^LIB%^"'
|
|
210
211
|
const libraryIndex = activation.indexOf(libraryCommand)
|
|
211
212
|
|
|
212
213
|
assert.notEqual(compilerIndex, -1)
|
|
@@ -215,6 +216,23 @@ test('Shell.activate restores Conda and CUDA library paths after Windows compile
|
|
|
215
216
|
assert.ok(compilerIndex < cudaIndex)
|
|
216
217
|
assert.ok(cudaIndex < libraryIndex)
|
|
217
218
|
assert.equal(params.message[1], 'uv pip install cupy')
|
|
219
|
+
|
|
220
|
+
await t.test('reads activated values in interactive cmd.exe', {
|
|
221
|
+
skip: process.platform !== 'win32' && 'requires Windows cmd.exe',
|
|
222
|
+
}, () => {
|
|
223
|
+
for (const mode of ['OFF', 'ON']) {
|
|
224
|
+
const result = spawnSync('cmd.exe', ['/D', '/Q', `/V:${mode}`], {
|
|
225
|
+
env: { ...process.env, CONDA_PREFIX: 'stale-conda', CUDA_HOME: 'stale-cuda', LIB: 'stale-lib' },
|
|
226
|
+
input: `${activation} & echo. & set LIB\r\nexit\r\n`,
|
|
227
|
+
encoding: 'utf8',
|
|
228
|
+
timeout: 10000,
|
|
229
|
+
windowsHide: true,
|
|
230
|
+
})
|
|
231
|
+
assert.ifError(result.error)
|
|
232
|
+
assert.equal(result.status, 0, result.stdout + result.stderr)
|
|
233
|
+
assert.match(result.stdout, /^LIB=C:\\active conda\\Library\\lib;C:\\active cuda\\lib;C:\\Visual Studio \(x86\)\\lib\r?$/m)
|
|
234
|
+
}
|
|
235
|
+
})
|
|
218
236
|
})
|
|
219
237
|
|
|
220
238
|
test('Shell.init_env defaults the uv HTTP timeout without overriding apps', async () => {
|
|
@@ -381,11 +399,11 @@ test('redactEnvArgs summarizes protected argv env values', () => {
|
|
|
381
399
|
})
|
|
382
400
|
|
|
383
401
|
|
|
384
|
-
test('
|
|
402
|
+
test('only new default venvs bootstrap pip across supported shells', async t => {
|
|
385
403
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-venv-selection-'))
|
|
386
404
|
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
387
405
|
const cases = [
|
|
388
|
-
{ venv:
|
|
406
|
+
{ venv: "default env $NAME O'Brien (test)" },
|
|
389
407
|
{ venv: { path: 'object-default-env' } },
|
|
390
408
|
{ venv: 'explicit-env', venv_python: '3.10', version: '3.10' },
|
|
391
409
|
{ venv: { path: 'object-env', python: '3.11' }, version: '3.11' },
|
|
@@ -413,7 +431,7 @@ test('venvs preserve creation behavior and enforce the pip minimum across shells
|
|
|
413
431
|
message: 'echo PINOKIO_REQUIREMENTS_RAN',
|
|
414
432
|
})
|
|
415
433
|
const command = shell.build(params)
|
|
416
|
-
assert.doesNotMatch(command, /--seed|--no-config|--python-preference|
|
|
434
|
+
assert.doesNotMatch(command, /--seed|--no-config|--python-preference|uv pip install/)
|
|
417
435
|
assert.match(command, /echo PINOKIO_REQUIREMENTS_RAN$/)
|
|
418
436
|
if (!scenario.venv || scenario.existing) {
|
|
419
437
|
assert.doesNotMatch(command, /python -m venv|uv venv/)
|
|
@@ -423,15 +441,28 @@ test('venvs preserve creation behavior and enforce the pip minimum across shells
|
|
|
423
441
|
} else {
|
|
424
442
|
assert.match(command, /python -m venv /)
|
|
425
443
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
assert.
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
444
|
+
if (scenario.venv && !scenario.version && !scenario.existing) {
|
|
445
|
+
assert.equal(command.split(PIP_INSTALL_COMMAND).length - 1, 1)
|
|
446
|
+
assert.ok(command.indexOf(PIP_INSTALL_COMMAND) > command.lastIndexOf('activate'))
|
|
447
|
+
const envPath = path.resolve(root, typeof scenario.venv === 'string' ? scenario.venv : scenario.venv.path)
|
|
448
|
+
const shellPath = shell.isCmdShell() ? `"${envPath}"` : shell.quoteArgForShell(platform === 'win32' && /bash/i.test(shellName) ? require('../kernel/util').p2u(envPath) : envPath)
|
|
449
|
+
for (const step of [`python -m venv ${shellPath}`, PIP_INSTALL_COMMAND]) {
|
|
450
|
+
assert.ok(command.includes(shell.isPowerShell()
|
|
451
|
+
? `${step}; if ($LASTEXITCODE -ne 0) { exit 1 }`
|
|
452
|
+
: `${step} || exit 1`))
|
|
453
|
+
}
|
|
433
454
|
} else {
|
|
434
|
-
assert.doesNotMatch(command,
|
|
455
|
+
assert.doesNotMatch(command, /-m pip/)
|
|
456
|
+
}
|
|
457
|
+
if (scenario.venv) {
|
|
458
|
+
const envPath = path.resolve(root, typeof scenario.venv === 'string' ? scenario.venv : scenario.venv.path)
|
|
459
|
+
const script = path.resolve(envPath, platform === 'win32' ? 'Scripts' : 'bin', shell.isPowerShell() ? 'Activate.ps1' : 'activate')
|
|
460
|
+
const scriptPath = shell.isCmdShell() ? `"${script}"` : shell.quoteArgForShell(platform === 'win32' && /bash/i.test(shellName) ? require('../kernel/util').p2u(script) : script)
|
|
461
|
+
assert.ok(command.includes(shell.isPowerShell()
|
|
462
|
+
? `& ${scriptPath}`
|
|
463
|
+
: shell.isCmdShell() ? scriptPath : `source ${scriptPath}`), command)
|
|
464
|
+
assert.ok(command.includes(shell.isPowerShell() ? '; if (!$?) { exit 1 }' : ' || exit 1'), command)
|
|
465
|
+
if (shell.isPowerShell()) assert.doesNotMatch(command, /Scripts[\\/]deactivate/)
|
|
435
466
|
}
|
|
436
467
|
assert.equal(shell.env.UV_PYTHON_PREFERENCE, 'only-managed')
|
|
437
468
|
}
|
|
@@ -439,15 +470,22 @@ test('venvs preserve creation behavior and enforce the pip minimum across shells
|
|
|
439
470
|
})
|
|
440
471
|
|
|
441
472
|
|
|
442
|
-
test('failed
|
|
473
|
+
test('failed venv creation or pip bootstrap stops an unconditional command chain', async t => {
|
|
443
474
|
if (process.platform === 'win32') return t.skip('executes the POSIX shell failure guard')
|
|
444
475
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-pip-failure-'))
|
|
445
476
|
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
446
477
|
const binDir = path.join(root, 'bin')
|
|
447
|
-
await fs.mkdir(path.join(root, 'env', 'bin'), { recursive: true })
|
|
448
478
|
await fs.mkdir(binDir)
|
|
449
|
-
await fs.writeFile(path.join(
|
|
450
|
-
|
|
479
|
+
await fs.writeFile(path.join(binDir, 'python'), `#!/bin/sh
|
|
480
|
+
if [ "$1" = "-m" ] && [ "$2" = "venv" ]; then
|
|
481
|
+
[ "$PINOKIO_TEST_VENV_EXIT_CODE" = "0" ] || exit "$PINOKIO_TEST_VENV_EXIT_CODE"
|
|
482
|
+
mkdir -p "$3/bin"
|
|
483
|
+
echo 'deactivate() { :; }' > "$3/bin/activate"
|
|
484
|
+
else
|
|
485
|
+
echo PINOKIO_PIP_BOOTSTRAP_RAN
|
|
486
|
+
exit "$PINOKIO_TEST_PIP_EXIT_CODE"
|
|
487
|
+
fi
|
|
488
|
+
`, { mode: 0o755 })
|
|
451
489
|
const shell = createShell(createKernel(root))
|
|
452
490
|
shell.shell = 'bash'
|
|
453
491
|
const params = await shell.activate({
|
|
@@ -455,12 +493,105 @@ test('failed pip updates stop the application even with an unconditional command
|
|
|
455
493
|
chain: '*', message: 'echo PINOKIO_APPLICATION_RAN',
|
|
456
494
|
})
|
|
457
495
|
const command = shell.build(params)
|
|
458
|
-
for (const
|
|
496
|
+
for (const [venvExit, pipExit] of [[9, 0], [0, 7], [0, 0]]) {
|
|
459
497
|
const result = spawnSync('/bin/bash', ['-c', command], {
|
|
460
498
|
encoding: 'utf8',
|
|
461
|
-
env: { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
|
|
499
|
+
env: { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH}`, PINOKIO_TEST_VENV_EXIT_CODE: String(venvExit), PINOKIO_TEST_PIP_EXIT_CODE: String(pipExit) },
|
|
500
|
+
})
|
|
501
|
+
assert.equal(result.status, venvExit || pipExit ? 1 : 0, result.stderr)
|
|
502
|
+
assert.equal(result.stdout.includes('PINOKIO_APPLICATION_RAN'), venvExit === 0 && pipExit === 0)
|
|
503
|
+
assert.equal(result.stdout.includes('PINOKIO_PIP_BOOTSTRAP_RAN'), venvExit === 0)
|
|
504
|
+
}
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
test('venv paths remain literal through creation and repeated activation', async t => {
|
|
509
|
+
if (process.platform === 'win32') return t.skip('executes bash commands')
|
|
510
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-venv-paths-'))
|
|
511
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
512
|
+
const bin = path.join(root, 'bin')
|
|
513
|
+
const project = path.join(root, "project & $NAME O'Brien (test)")
|
|
514
|
+
await fs.mkdir(bin)
|
|
515
|
+
await fs.mkdir(project)
|
|
516
|
+
const fixture = path.join(root, 'activate')
|
|
517
|
+
await fs.writeFile(fixture, 'export VIRTUAL_ENV="$PINOKIO_TEST_ENV"\ndeactivate() { unset VIRTUAL_ENV; }\n')
|
|
518
|
+
const executable = `#!/bin/sh
|
|
519
|
+
if [ "$1" = "-m" ] && [ "$2" = "venv" ]; then
|
|
520
|
+
[ "$#" = "3" ] || exit 9
|
|
521
|
+
target=$3
|
|
522
|
+
elif [ "$1" = "venv" ]; then
|
|
523
|
+
[ "$#" = "4" ] || exit 9
|
|
524
|
+
target=$2
|
|
525
|
+
else
|
|
526
|
+
echo "PINOKIO_BOOTSTRAP_TARGET=$VIRTUAL_ENV"
|
|
527
|
+
exit 0
|
|
528
|
+
fi
|
|
529
|
+
printf '%s' "$target" > "$PINOKIO_TEST_CREATED"
|
|
530
|
+
mkdir -p "$target/bin"
|
|
531
|
+
cp "$PINOKIO_TEST_ACTIVATE" "$target/bin/activate"
|
|
532
|
+
`
|
|
533
|
+
await fs.writeFile(path.join(bin, 'python'), executable, { mode: 0o755 })
|
|
534
|
+
await fs.writeFile(path.join(bin, 'uv'), executable, { mode: 0o755 })
|
|
535
|
+
for (const explicit of [false, true]) {
|
|
536
|
+
const venv = explicit ? 'uv env $NAME' : 'default env $NAME'
|
|
537
|
+
const envPath = path.join(project, venv)
|
|
538
|
+
const created = path.join(root, 'created')
|
|
539
|
+
for (const existing of [false, true]) {
|
|
540
|
+
const shell = createShell(createKernel(root))
|
|
541
|
+
shell.shell = 'bash'
|
|
542
|
+
const params = await shell.activate({
|
|
543
|
+
path: project, conda: { skip: true }, venv,
|
|
544
|
+
...(explicit ? { venv_python: '3.10' } : {}),
|
|
545
|
+
chain: '*', message: 'printf "PINOKIO_APP_TARGET=%s\\n" "$VIRTUAL_ENV"',
|
|
546
|
+
})
|
|
547
|
+
const result = spawnSync('/bin/bash', ['-c', shell.build(params)], {
|
|
548
|
+
encoding: 'utf8', env: {
|
|
549
|
+
...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH}`,
|
|
550
|
+
VIRTUAL_ENV: '', NAME: 'must-not-expand',
|
|
551
|
+
PINOKIO_TEST_ENV: envPath, PINOKIO_TEST_CREATED: created, PINOKIO_TEST_ACTIVATE: fixture,
|
|
552
|
+
},
|
|
553
|
+
})
|
|
554
|
+
assert.equal(result.status, 0, result.stdout + result.stderr)
|
|
555
|
+
assert.equal(await fs.readFile(created, 'utf8'), envPath)
|
|
556
|
+
assert.ok(result.stdout.includes(`PINOKIO_APP_TARGET=${envPath}\n`), result.stdout)
|
|
557
|
+
assert.equal(result.stdout.includes(`PINOKIO_BOOTSTRAP_TARGET=${envPath}\n`), !explicit && !existing)
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
test('failed activation stops before pip or application commands', async t => {
|
|
563
|
+
if (process.platform === 'win32') return t.skip('executes bash commands')
|
|
564
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-venv-activation-'))
|
|
565
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
566
|
+
const bin = path.join(root, 'bin')
|
|
567
|
+
await fs.mkdir(bin)
|
|
568
|
+
await fs.writeFile(path.join(bin, 'python'), `#!/bin/sh
|
|
569
|
+
if [ "$1" = "-m" ] && [ "$2" = "venv" ]; then
|
|
570
|
+
mkdir -p "$3/bin"
|
|
571
|
+
if [ "$PINOKIO_TEST_FAILURE" = "second" ]; then
|
|
572
|
+
echo 'deactivate() { rm -f "$PINOKIO_TEST_ENV/bin/activate"; }' > "$3/bin/activate"
|
|
573
|
+
fi
|
|
574
|
+
else
|
|
575
|
+
echo PINOKIO_BOOTSTRAP_RAN
|
|
576
|
+
fi
|
|
577
|
+
`, { mode: 0o755 })
|
|
578
|
+
for (const failure of ['first', 'second', 'existing']) {
|
|
579
|
+
const venv = `${failure} env`
|
|
580
|
+
if (failure === 'existing') await fs.mkdir(path.join(root, venv))
|
|
581
|
+
const shell = createShell(createKernel(root))
|
|
582
|
+
shell.shell = 'bash'
|
|
583
|
+
const params = await shell.activate({
|
|
584
|
+
path: root, conda: { skip: true }, venv,
|
|
585
|
+
chain: '*', message: 'echo PINOKIO_APPLICATION_RAN',
|
|
586
|
+
})
|
|
587
|
+
const result = spawnSync('/bin/bash', ['-c', shell.build(params)], {
|
|
588
|
+
encoding: 'utf8', env: {
|
|
589
|
+
...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH}`,
|
|
590
|
+
PINOKIO_TEST_FAILURE: failure, PINOKIO_TEST_ENV: path.join(root, venv),
|
|
591
|
+
},
|
|
462
592
|
})
|
|
463
|
-
assert.equal(result.status,
|
|
464
|
-
assert.equal(result.stdout.includes('
|
|
593
|
+
assert.equal(result.status, 1, `${failure}: ${result.stdout}${result.stderr}`)
|
|
594
|
+
assert.equal(result.stdout.includes('PINOKIO_BOOTSTRAP_RAN'), false, failure)
|
|
595
|
+
assert.equal(result.stdout.includes('PINOKIO_APPLICATION_RAN'), false, failure)
|
|
465
596
|
}
|
|
466
597
|
})
|