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.
@@ -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', '-c', 'from importlib.metadata import version; print(version("pip"))'], {
116
+ execFile(python, ['-I', '-m', 'pip', '--version'], {
117
117
  env: managedPythonEnv(), windowsHide: true, timeout: 15000,
118
118
  }, (error, stdout) => {
119
- const version = !error && semver.valid(String(stdout).trim())
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
  })
@@ -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 PIP_INSTALL_SPEC = 'pip @ https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl#sha256=fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70'
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])
@@ -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: [`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" pip ${TLS_PACKAGE_SPECS.map(spec => `"${spec}"`).join(" ")}`, PIP_INSTALL_COMMAND],
195
- env: { CONDA_SSL_VERIFY: caFile },
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
- const result = await this.kernel.bin.exec({
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, PIP_MIN_VERSION } = require('./bin/conda-pins')
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
- // CALL's second expansion is required because these commands are
1284
- // joined on one cmd.exe line and must read LIB after vcvars updates it.
1285
- conda_activation.push(`CALL set "LIB=%%CONDA_PREFIX%%\\Library\\lib;%%CUDA_HOME%%\\lib;%%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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.2.7",
3
+ "version": "8.2.9",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
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
- async collectHomeServerApps() {
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
- let entries = []
1085
- try {
1086
- entries = await this.kernel.api.listApps()
1087
- } catch (_) {
1088
- entries = []
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
+ })
@@ -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' || args[1] !== '-c') return realExecFile(binary, args, options, done)
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
- if (args[2].includes('certifi.where()')) {
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.match(condaInstall.message[2], /^python -I -m pip --isolated install --no-index "pip @ https:\/\/files\.pythonhosted\.org\/packages\/[a-f0-9/]+\/pip-26\.2\.2-py3-none-any\.whl#sha256=fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70"$/)
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" pip "ca-certificates>=2026.10.19" "certifi>=2026.10.19"'
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" pip "ca-certificates>=2026.10.19" "certifi>=2026.10.19"'
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" pip "ca-certificates>=2026.10.19" "certifi>=2026.10.19" uv=0.11.23 nodejs=22.21.1 pnpm'
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 existing runtime untouched', async t => {
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
- kernel.bin.download = async () => fs.writeFile(path.join(root, 'bin/installer.sh'), 'tampered')
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._install({}, () => {}), /SHA-256 verification failed/)
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
- await fs.writeFile(path.join(root, 'bin/miniforge/test-pip-version'), '26.2.1')
651
- assert.equal(await createConda(createKernel(root, 'darwin')).check(), false)
652
- const bin = createBin(root, 'darwin')
653
- await bin.tryList()
654
- assert.equal(bin.correct_conda, false)
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("certificate refresh preserves named environments and uses pip's bundled CA", async t => {
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
- await writeHealthyCondaMeta(root, 'darwin')
661
- await writeFakeManagedConda(root, 'darwin')
662
- const marker = path.join(root, 'bin/miniforge/envs/app/keep')
663
- await fs.mkdir(path.dirname(marker), { recursive: true })
664
- await fs.writeFile(marker, 'existing app')
665
- const kernel = createKernel(root, 'darwin')
666
- const calls = []
667
- kernel.bin.download = async () => assert.fail('must not replace a working runtime')
668
- kernel.bin.exec = async payload => { calls.push(payload); return {} }
669
- await createConda(kernel).install({}, () => {})
670
- assert.equal(await fs.readFile(marker, 'utf8'), 'existing app')
671
- assert.equal(calls.length, 2)
672
- assert.match(calls[0].message, /^python -I -m pip --isolated install --no-index /)
673
- assert.equal(calls[1].env.CONDA_SSL_VERIFY, path.join(root, 'bin/miniforge/certificates.pem'))
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 pip bootstrap stops certificate refresh without replacing environments', async t => {
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
- await fs.writeFile(path.join(root, 'bin/miniforge/test-pip-version'), '26.2.1')
684
- for (const response of [{ error: ['ERROR: hash verification failed'] }, {}]) {
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({}, () => {}), /bootstrap failed/)
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 does not report a failed pip bootstrap as finished', async t => {
692
- const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-install-failed-'))
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 fs.mkdir(path.join(root, 'bin/miniforge/conda-meta'), { recursive: true })
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
- return { error: ['ERROR: pip download failed'] }
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 { PIP_MIN_VERSION } = require('../kernel/bin/conda-pins')
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, '@echo off\n')
187
- await fs.writeFile(cudaScript, '@echo off\n')
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=%%CONDA_PREFIX%%\\Library\\lib;%%CUDA_HOME%%\\lib;%%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('venvs preserve creation behavior and enforce the pip minimum across shells', async t => {
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: 'default-env' },
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|python -I|files\.pythonhosted\.org/)
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
- const updatePip = `uv pip install --exclude-newer-package pip=false "pip>=${PIP_MIN_VERSION}"`
427
- if (scenario.venv && !scenario.version) {
428
- assert.equal(command.split(updatePip).length - 1, 1)
429
- assert.ok(command.indexOf(updatePip) > command.lastIndexOf('activate'))
430
- assert.ok(command.includes(shell.isPowerShell()
431
- ? `${updatePip}; if ($LASTEXITCODE -ne 0) { exit 1 }`
432
- : `${updatePip} || exit 1`))
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, /uv pip install/)
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 pip updates stop the application even with an unconditional command chain', async t => {
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(root, 'env', 'bin', 'activate'), 'true\n')
450
- await fs.writeFile(path.join(binDir, 'uv'), '#!/bin/sh\nexit "$PINOKIO_TEST_UV_EXIT_CODE"\n', { mode: 0o755 })
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 exitCode of [0, 7]) {
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}`, PINOKIO_TEST_UV_EXIT_CODE: String(exitCode) },
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, exitCode ? 1 : 0, result.stderr)
464
- assert.equal(result.stdout.includes('PINOKIO_APPLICATION_RAN'), exitCode === 0)
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
  })