pinokiod 8.2.8 → 8.2.10

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/shell.js CHANGED
@@ -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
  }
@@ -1393,14 +1393,14 @@ class Shell {
1393
1393
  } else {
1394
1394
  // when python version is not specified, use the default python -m venv
1395
1395
  venv_activation = [
1396
- this.isPowerShell() ? `python -m venv ${shellEnvPath}; if ($LASTEXITCODE -ne 0) { exit 1 }` : `python -m venv ${shellEnvPath} || exit 1`,
1396
+ `python -m venv ${shellEnvPath}`,
1397
1397
  activate_command,
1398
1398
  // `python -m pip install --upgrade pip setuptools wheel`,
1399
1399
  deactivate_path,
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
+ this.isPowerShell() ? `${PIP_INSTALL_COMMAND}; if ($LASTEXITCODE -ne 0) { exit 1 }` : this.isCmdShell() ? `(${PIP_INSTALL_COMMAND} || exit 1)` : `${PIP_INSTALL_COMMAND} || exit 1`,
1404
1404
  ]
1405
1405
  }
1406
1406
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.2.8",
3
+ "version": "8.2.10",
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
+ })
@@ -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 () => {
@@ -385,7 +403,7 @@ 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' },
@@ -427,7 +445,7 @@ test('only new default venvs bootstrap pip across supported shells', async t =>
427
445
  assert.equal(command.split(PIP_INSTALL_COMMAND).length - 1, 1)
428
446
  assert.ok(command.indexOf(PIP_INSTALL_COMMAND) > command.lastIndexOf('activate'))
429
447
  const envPath = path.resolve(root, typeof scenario.venv === 'string' ? scenario.venv : scenario.venv.path)
430
- const shellPath = platform === 'win32' && /bash/i.test(shellName) ? `"${require('../kernel/util').p2u(envPath)}"` : envPath
448
+ const shellPath = shell.isCmdShell() ? `"${envPath}"` : shell.quoteArgForShell(platform === 'win32' && /bash/i.test(shellName) ? require('../kernel/util').p2u(envPath) : envPath)
431
449
  for (const step of [`python -m venv ${shellPath}`, PIP_INSTALL_COMMAND]) {
432
450
  assert.ok(command.includes(shell.isPowerShell()
433
451
  ? `${step}; if ($LASTEXITCODE -ne 0) { exit 1 }`
@@ -436,6 +454,16 @@ test('only new default venvs bootstrap pip across supported shells', async t =>
436
454
  } else {
437
455
  assert.doesNotMatch(command, /-m pip/)
438
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/)
466
+ }
439
467
  assert.equal(shell.env.UV_PYTHON_PREFERENCE, 'only-managed')
440
468
  }
441
469
  }
@@ -475,3 +503,95 @@ fi
475
503
  assert.equal(result.stdout.includes('PINOKIO_PIP_BOOTSTRAP_RAN'), venvExit === 0)
476
504
  }
477
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
+ },
592
+ })
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)
596
+ }
597
+ })