pinokiod 8.2.10 → 8.2.12
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/anywhere/cli.js +100 -0
- package/kernel/anywhere/index.js +237 -161
- package/kernel/anywhere/ports.js +6 -9
- package/kernel/anywhere/serve.js +17 -7
- package/kernel/anywhere/state.js +14 -0
- package/kernel/bin/conda-meta.js +19 -1
- package/kernel/bin/conda-pins.js +7 -3
- package/kernel/bin/conda.js +67 -15
- package/kernel/bin/index.js +5 -2
- package/kernel/bin/setup.js +3 -17
- package/kernel/bin/tailscaled.js +23 -13
- package/kernel/index.js +11 -9
- package/kernel/shell.js +14 -6
- package/kernel/util.js +1 -0
- package/package.json +1 -1
- package/server/index.js +15 -19
- package/server/public/universal-launcher.js +5 -0
- package/server/views/anywhere.ejs +13 -13
- package/server/views/partials/main_sidebar.ejs +0 -1
- package/server/views/remote.ejs +12 -4
- package/system/pip_seed.json +6 -0
- package/system/repair_pip_seed.py +100 -0
- package/test/anywhere-remote.test.js +182 -8
- package/test/anywhere.test.js +1063 -131
- package/test/conda-bin.test.js +254 -4
- package/test/pip_seed_test.py +183 -0
- package/test/shell-run-template.test.js +147 -140
- package/test/tailscale-cli.test.js +239 -0
- package/kernel/anywhere/localapi.js +0 -147
package/server/index.js
CHANGED
|
@@ -34,6 +34,7 @@ const Git = require("../kernel/git")
|
|
|
34
34
|
const TerminalApi = require('../kernel/api/terminal')
|
|
35
35
|
const PluginSources = require("../kernel/plugin_sources")
|
|
36
36
|
const ManagedSkills = require("../kernel/managed_skills")
|
|
37
|
+
const { rewriteHref: rewriteAnywhereHref } = require("../kernel/anywhere/serve")
|
|
37
38
|
const { isPathRemovalBlockedError, removePath } = require("../kernel/path_removal")
|
|
38
39
|
|
|
39
40
|
const git = require('isomorphic-git')
|
|
@@ -1024,8 +1025,6 @@ class Server {
|
|
|
1024
1025
|
try {
|
|
1025
1026
|
const parsed = new URL(value)
|
|
1026
1027
|
if (parsed.port) return Number(parsed.port)
|
|
1027
|
-
if (parsed.protocol === "https:") return 443
|
|
1028
|
-
if (parsed.protocol === "http:") return 80
|
|
1029
1028
|
} catch (_) { }
|
|
1030
1029
|
return null
|
|
1031
1030
|
}
|
|
@@ -1046,17 +1045,19 @@ class Server {
|
|
|
1046
1045
|
let entries = []
|
|
1047
1046
|
try {
|
|
1048
1047
|
entries = await this.kernel.api.listApps()
|
|
1049
|
-
} catch (
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
return Object.assign(snap, { phase: "error", error: `Could not list apps: ${error.message}` })
|
|
1050
|
+
}
|
|
1050
1051
|
snap.installed = entries.length
|
|
1051
|
-
snap.apps = []
|
|
1052
1052
|
if (snap.phase === "ready") {
|
|
1053
1053
|
const serveByLocalPort = new Map((snap.serve || []).map((entry) => [entry.local, entry]))
|
|
1054
1054
|
let running = []
|
|
1055
1055
|
try {
|
|
1056
|
-
running = await this.collectHomeServerApps(entries)
|
|
1057
|
-
} catch (
|
|
1058
|
-
running
|
|
1056
|
+
running = await this.collectHomeServerApps(entries, true)
|
|
1057
|
+
} catch (error) {
|
|
1058
|
+
return Object.assign(snap, { phase: "error", error: `Could not list running apps: ${error.message}` })
|
|
1059
1059
|
}
|
|
1060
|
+
snap.apps = []
|
|
1060
1061
|
for (const app of running) {
|
|
1061
1062
|
const port = this.portFromUrl(app.ready_url || app.url)
|
|
1062
1063
|
const entry = port ? serveByLocalPort.get(port) : null
|
|
@@ -1064,8 +1065,8 @@ class Server {
|
|
|
1064
1065
|
id: app.id,
|
|
1065
1066
|
name: app.name,
|
|
1066
1067
|
icon: app.icon,
|
|
1067
|
-
state: app.state,
|
|
1068
|
-
remote: entry ?
|
|
1068
|
+
state: entry ? app.state : (app.state === "ready" ? "publishing" : app.state),
|
|
1069
|
+
remote: entry ? rewriteAnywhereHref(app.ready_url || app.url, snap.serve) : null,
|
|
1069
1070
|
exposed: !!entry,
|
|
1070
1071
|
})
|
|
1071
1072
|
}
|
|
@@ -1083,7 +1084,7 @@ class Server {
|
|
|
1083
1084
|
return snap
|
|
1084
1085
|
}
|
|
1085
1086
|
// `entries`: an app list from kernel.api.listApps() the caller already has
|
|
1086
|
-
async collectHomeServerApps(entries = null) {
|
|
1087
|
+
async collectHomeServerApps(entries = null, strict = false) {
|
|
1087
1088
|
const apps = []
|
|
1088
1089
|
if (!this.kernel || !this.kernel.api || typeof this.kernel.api.listApps !== "function") {
|
|
1089
1090
|
return apps
|
|
@@ -1106,7 +1107,8 @@ class Server {
|
|
|
1106
1107
|
runtime = this.appRegistry && typeof this.appRegistry.collectAppRuntime === "function"
|
|
1107
1108
|
? this.appRegistry.collectAppRuntime(appRoot)
|
|
1108
1109
|
: null
|
|
1109
|
-
} catch (
|
|
1110
|
+
} catch (error) {
|
|
1111
|
+
if (strict) throw error
|
|
1110
1112
|
runtime = null
|
|
1111
1113
|
}
|
|
1112
1114
|
if (!runtime || !runtime.running) {
|
|
@@ -14241,7 +14243,7 @@ class Server {
|
|
|
14241
14243
|
})
|
|
14242
14244
|
}))
|
|
14243
14245
|
this.app.get("/anywhere", ex(async (req, res) => {
|
|
14244
|
-
let {
|
|
14246
|
+
let { install_required, requirements_pending } = await this.kernel.bin.check({
|
|
14245
14247
|
bin: this.kernel.bin.preset("anywhere"),
|
|
14246
14248
|
})
|
|
14247
14249
|
if (!requirements_pending && install_required) {
|
|
@@ -14259,12 +14261,6 @@ class Server {
|
|
|
14259
14261
|
const status = await this.composeAnywhereStatus()
|
|
14260
14262
|
res.render("anywhere", {
|
|
14261
14263
|
status,
|
|
14262
|
-
requirements_pending,
|
|
14263
|
-
install_required,
|
|
14264
|
-
error,
|
|
14265
|
-
docs: this.docs,
|
|
14266
|
-
portal: this.portal,
|
|
14267
|
-
logo: this.logo,
|
|
14268
14264
|
theme: this.theme,
|
|
14269
14265
|
agent: req.agent,
|
|
14270
14266
|
})
|
|
@@ -14287,7 +14283,7 @@ class Server {
|
|
|
14287
14283
|
return
|
|
14288
14284
|
}
|
|
14289
14285
|
try {
|
|
14290
|
-
await fn(anywhere
|
|
14286
|
+
await fn(anywhere)
|
|
14291
14287
|
res.json({ success: true, status: await this.composeAnywhereStatus() })
|
|
14292
14288
|
} catch (error) {
|
|
14293
14289
|
console.log(`[anywhere] ${label} failed`, error)
|
|
@@ -4429,6 +4429,11 @@
|
|
|
4429
4429
|
ui.intentWrap.setAttribute('aria-hidden', hasPresetIntent ? 'true' : 'false');
|
|
4430
4430
|
}
|
|
4431
4431
|
ui.setIntent(type);
|
|
4432
|
+
// setIntent saved the cleared textarea over the draft when the intent was
|
|
4433
|
+
// already selected (the default), dropping a prompt passed in defaults.
|
|
4434
|
+
// Restore it as if typed, so the draft, folder name and height follow.
|
|
4435
|
+
ui.promptTextarea.value = buildInitialPrompt(type, prompt);
|
|
4436
|
+
ui.promptTextarea.dispatchEvent(new Event('input'));
|
|
4432
4437
|
ui.setMode(mode);
|
|
4433
4438
|
if (name) {
|
|
4434
4439
|
ui.nameInput.value = name;
|
|
@@ -136,7 +136,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
136
136
|
<p class='task-description'>Open this Pinokio and its running apps from your phone or another computer, anywhere, over a private encrypted network.</p>
|
|
137
137
|
</div>
|
|
138
138
|
<div class='task-header-actions'>
|
|
139
|
-
<div class='anywhere-switch' id='anywhere-switch' data-on='false'
|
|
139
|
+
<div class='anywhere-switch' id='anywhere-switch' data-on='false'>
|
|
140
140
|
<i class="fa-solid fa-power-off"></i> <span id='anywhere-switch-label'>Off</span>
|
|
141
141
|
</div>
|
|
142
142
|
</div>
|
|
@@ -190,7 +190,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
190
190
|
<div class='anywhere-card'>
|
|
191
191
|
<h2>Something went wrong</h2>
|
|
192
192
|
<div class='anywhere-error' id='anywhere-error'></div>
|
|
193
|
-
<p><button class='anywhere-btn' data-action='start'><i class="fa-solid fa-rotate-right"></i> Try again</button></p>
|
|
193
|
+
<p><button class='anywhere-btn' id='anywhere-retry' data-action='start'><i class="fa-solid fa-rotate-right"></i> Try again</button></p>
|
|
194
194
|
</div>
|
|
195
195
|
</div>
|
|
196
196
|
|
|
@@ -255,7 +255,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
255
255
|
const agent = document.body.getAttribute('data-agent')
|
|
256
256
|
let current = <%- JSON.stringify(status || {}).replace(/</g, '\\u003c') %>
|
|
257
257
|
let busy = false
|
|
258
|
-
let timer = null
|
|
259
258
|
|
|
260
259
|
const esc = (value) => String(value == null ? '' : value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
|
261
260
|
const openExternal = (href) => {
|
|
@@ -315,6 +314,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
315
314
|
}
|
|
316
315
|
if (phase === 'error') {
|
|
317
316
|
document.getElementById('anywhere-error').textContent = current.error || current.serveError || 'unknown error'
|
|
317
|
+
document.getElementById('anywhere-retry').dataset.action = current.enabled === false ? 'stop' : 'start'
|
|
318
318
|
}
|
|
319
319
|
if (phase === 'ready') {
|
|
320
320
|
const qr = document.getElementById('anywhere-qr')
|
|
@@ -368,32 +368,32 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
368
368
|
const poll = async () => {
|
|
369
369
|
try {
|
|
370
370
|
const response = await fetch('/anywhere/status', { cache: 'no-store' })
|
|
371
|
-
if (response.ok) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
371
|
+
if (!response.ok) throw new Error(`Could not refresh Anywhere status (${response.status})`)
|
|
372
|
+
render(await response.json())
|
|
373
|
+
} catch (e) {
|
|
374
|
+
render(Object.assign({}, current, { phase: 'error', error: e.message }))
|
|
375
|
+
}
|
|
375
376
|
const phase = current.phase || ''
|
|
376
377
|
const delay = (phase === 'ready' || phase === 'off') ? 5000 : 2000
|
|
377
|
-
|
|
378
|
+
setTimeout(poll, delay)
|
|
378
379
|
}
|
|
379
380
|
|
|
380
381
|
const act = async (action) => {
|
|
381
382
|
if (busy) return
|
|
382
383
|
busy = true
|
|
383
|
-
|
|
384
|
+
const enabled = action === 'stop' ? false : action === 'start' ? true : current.enabled
|
|
384
385
|
try {
|
|
385
386
|
const response = await fetch(`/anywhere/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
|
386
387
|
const data = await response.json()
|
|
387
388
|
if (data && data.status) {
|
|
388
389
|
render(data.status)
|
|
389
390
|
} else if (data && data.error) {
|
|
390
|
-
render(Object.assign({}, current, { phase: 'error', error: data.error }))
|
|
391
|
+
render(Object.assign({}, current, { phase: 'error', error: data.error, enabled }))
|
|
391
392
|
}
|
|
392
393
|
} catch (e) {
|
|
393
|
-
render(Object.assign({}, current, { phase: 'error', error: e.message }))
|
|
394
|
+
render(Object.assign({}, current, { phase: 'error', error: e.message, enabled }))
|
|
394
395
|
} finally {
|
|
395
396
|
busy = false
|
|
396
|
-
switchEl.dataset.busy = 'false'
|
|
397
397
|
}
|
|
398
398
|
}
|
|
399
399
|
|
|
@@ -429,7 +429,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
429
429
|
})
|
|
430
430
|
|
|
431
431
|
render(current)
|
|
432
|
-
|
|
432
|
+
setTimeout(poll, 1500)
|
|
433
433
|
})()
|
|
434
434
|
</script>
|
|
435
435
|
</body>
|
|
@@ -140,7 +140,6 @@
|
|
|
140
140
|
badge.textContent = label;
|
|
141
141
|
badge.hidden = false;
|
|
142
142
|
badge.dataset.state = on ? "on" : "off";
|
|
143
|
-
tab.dataset.anywhereStatus = phase;
|
|
144
143
|
tab.setAttribute("title", `Pinokio Anywhere ${label}`);
|
|
145
144
|
};
|
|
146
145
|
document.addEventListener("pinokio:anywhere-status", (event) => {
|
package/server/views/remote.ejs
CHANGED
|
@@ -50,7 +50,7 @@ h2 { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacin
|
|
|
50
50
|
<h2>Running now</h2>
|
|
51
51
|
<div id="cards"></div>
|
|
52
52
|
</section>
|
|
53
|
-
<div id="notice" class="notice" hidden
|
|
53
|
+
<div id="notice" class="notice" hidden></div>
|
|
54
54
|
<a id="door" class="door" href="/home">
|
|
55
55
|
<i class="fa-solid fa-plus"></i>
|
|
56
56
|
<div class="body"><div class="title" id="door-title"></div><div class="cap" id="door-cap"></div></div>
|
|
@@ -66,8 +66,13 @@ h2 { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacin
|
|
|
66
66
|
const render = (status) => {
|
|
67
67
|
current = status || {}
|
|
68
68
|
const apps = Array.isArray(current.apps) ? current.apps : []
|
|
69
|
+
const available = current.phase === 'ready' && Array.isArray(current.apps)
|
|
69
70
|
document.getElementById('running').hidden = apps.length === 0
|
|
70
71
|
document.getElementById('notice').hidden = apps.length > 0
|
|
72
|
+
const states = { setup: 'Set up Pinokio Anywhere', off: 'Pinokio Anywhere is off', starting: 'Starting Pinokio Anywhere…', login: 'Sign in to Tailscale', machine_auth: 'Device approval required', https: 'HTTPS approval required' }
|
|
73
|
+
document.getElementById('notice').innerHTML = available
|
|
74
|
+
? '<strong>Nothing is running</strong>Start an app on your computer and it shows up here.'
|
|
75
|
+
: `<strong>${esc(current.error || current.serveError || states[current.phase] || 'Pinokio Anywhere is unavailable')}</strong><a href="/anywhere">Open Anywhere</a>`
|
|
71
76
|
document.getElementById('cards').innerHTML = apps.map((app) => `
|
|
72
77
|
<div class="card">
|
|
73
78
|
<img src="${esc(app.icon || '/pinokio-black.png')}" alt="">
|
|
@@ -80,13 +85,16 @@ h2 { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacin
|
|
|
80
85
|
const door = document.getElementById('door')
|
|
81
86
|
door.className = 'door ' + (apps.length > 0 ? 'dashed' : 'solid')
|
|
82
87
|
document.getElementById('door-title').textContent = apps.length > 0 ? 'Start another app' : 'Start an app'
|
|
83
|
-
document.getElementById('door-cap').textContent = `${current.installed
|
|
88
|
+
document.getElementById('door-cap').textContent = typeof current.installed === 'number' ? `${current.installed} installed apps in Pinokio` : ''
|
|
84
89
|
}
|
|
85
90
|
const poll = async () => {
|
|
86
91
|
try {
|
|
87
92
|
const response = await fetch('/anywhere/status', { cache: 'no-store' })
|
|
88
|
-
if (response.ok)
|
|
89
|
-
|
|
93
|
+
if (!response.ok) throw new Error(`Could not refresh app status (${response.status})`)
|
|
94
|
+
render(await response.json())
|
|
95
|
+
} catch (error) {
|
|
96
|
+
render({ ...current, phase: 'error', error: error.message, apps: undefined })
|
|
97
|
+
}
|
|
90
98
|
setTimeout(poll, 5000)
|
|
91
99
|
}
|
|
92
100
|
render(current)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "26.2.2",
|
|
3
|
+
"filename": "pip-26.2.2-py3-none-any.whl",
|
|
4
|
+
"url": "https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl",
|
|
5
|
+
"sha256": "fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70"
|
|
6
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Prepare a managed interpreter's local pip seed without changing its venvs."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def repair(wheel_path, prefix, check=False):
|
|
7
|
+
if sys.version_info < (3, 10):
|
|
8
|
+
if check:
|
|
9
|
+
raise RuntimeError("pip seed mitigation requires Python 3.10+")
|
|
10
|
+
return
|
|
11
|
+
|
|
12
|
+
import ensurepip
|
|
13
|
+
from email.parser import BytesParser
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
import re
|
|
19
|
+
import stat
|
|
20
|
+
import tempfile
|
|
21
|
+
import zipfile
|
|
22
|
+
|
|
23
|
+
def resolved(value):
|
|
24
|
+
return os.path.normcase(os.path.realpath(value))
|
|
25
|
+
|
|
26
|
+
prefix = resolved(prefix)
|
|
27
|
+
source = Path(ensurepip.__file__).resolve()
|
|
28
|
+
if (resolved(sys.prefix) != prefix or resolved(sys.base_prefix) != prefix
|
|
29
|
+
or os.path.commonpath([prefix, resolved(source)]) != prefix):
|
|
30
|
+
raise RuntimeError("Refusing pip seed repair outside the selected Python prefix")
|
|
31
|
+
if getattr(ensurepip, "_WHEEL_PKG_DIR", None):
|
|
32
|
+
raise RuntimeError("Unsupported external ensurepip wheel directory")
|
|
33
|
+
|
|
34
|
+
pin = json.loads(Path(__file__).with_name("pip_seed.json").read_text())
|
|
35
|
+
content = source.read_bytes()
|
|
36
|
+
match = re.search(br'^_PIP_VERSION = ["\']([^"\']+)["\']', content, re.M)
|
|
37
|
+
if not match or not re.fullmatch(br"\d+(?:\.\d+)*", match[1]):
|
|
38
|
+
raise RuntimeError("Unrecognized ensurepip version reference")
|
|
39
|
+
version = match[1].decode("ascii")
|
|
40
|
+
release = lambda value: tuple(map(int, value.split(".")))
|
|
41
|
+
|
|
42
|
+
def verified(path):
|
|
43
|
+
return hashlib.sha256(path.read_bytes()).hexdigest() == pin["sha256"]
|
|
44
|
+
|
|
45
|
+
wheel_data = Path(wheel_path).read_bytes()
|
|
46
|
+
if hashlib.sha256(wheel_data).hexdigest() != pin["sha256"]:
|
|
47
|
+
raise RuntimeError("Cached pip wheel SHA-256 verification failed")
|
|
48
|
+
bundled = source.parent / "_bundled"
|
|
49
|
+
if os.path.commonpath([prefix, resolved(bundled)]) != prefix:
|
|
50
|
+
raise RuntimeError("Refusing pip seed repair outside the selected Python prefix")
|
|
51
|
+
destination = bundled / pin["filename"]
|
|
52
|
+
|
|
53
|
+
def clear_bytecode():
|
|
54
|
+
for cache in (source.parent / "__pycache__").glob("__init__.*.pyc"):
|
|
55
|
+
cache.unlink(missing_ok=True)
|
|
56
|
+
|
|
57
|
+
if release(version) > release(pin["version"]):
|
|
58
|
+
newer = bundled / ("pip-" + version + "-py3-none-any.whl")
|
|
59
|
+
try:
|
|
60
|
+
with zipfile.ZipFile(newer) as archive:
|
|
61
|
+
metadata = BytesParser().parsebytes(archive.read("pip-" + version + ".dist-info/METADATA"))
|
|
62
|
+
valid = (metadata["Name"] == "pip" and metadata["Version"] == version
|
|
63
|
+
and "pip/__init__.py" in archive.namelist() and archive.testzip() is None)
|
|
64
|
+
except (OSError, KeyError, zipfile.BadZipFile):
|
|
65
|
+
valid = False
|
|
66
|
+
if valid:
|
|
67
|
+
if ensurepip.version() != version:
|
|
68
|
+
if check:
|
|
69
|
+
raise RuntimeError("Managed Python ensurepip bytecode needs repair")
|
|
70
|
+
clear_bytecode()
|
|
71
|
+
return
|
|
72
|
+
if check:
|
|
73
|
+
if version != pin["version"] or ensurepip.version() != version or not verified(destination):
|
|
74
|
+
raise RuntimeError("Managed Python pip seed needs repair")
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
def publish(path, data, mode):
|
|
78
|
+
descriptor, temporary = tempfile.mkstemp(dir=str(path.parent))
|
|
79
|
+
try:
|
|
80
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
81
|
+
handle.write(data)
|
|
82
|
+
os.chmod(temporary, mode)
|
|
83
|
+
os.replace(temporary, path)
|
|
84
|
+
finally:
|
|
85
|
+
if os.path.exists(temporary):
|
|
86
|
+
os.unlink(temporary)
|
|
87
|
+
|
|
88
|
+
# Publish the wheel first and retain the old one if source replacement fails.
|
|
89
|
+
if not destination.is_file() or not verified(destination):
|
|
90
|
+
mode = stat.S_IMODE(destination.stat().st_mode) if destination.exists() else 0o644
|
|
91
|
+
publish(destination, wheel_data, mode)
|
|
92
|
+
updated = content[:match.start(1)] + pin["version"].encode("ascii") + content[match.end(1):]
|
|
93
|
+
if updated != content:
|
|
94
|
+
publish(source, updated, stat.S_IMODE(source.stat().st_mode))
|
|
95
|
+
if updated != content or ensurepip.version() != pin["version"]:
|
|
96
|
+
clear_bytecode()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
repair(sys.argv[1], sys.argv[2], "--check" in sys.argv[3:])
|
|
@@ -3,32 +3,137 @@ const path = require('node:path')
|
|
|
3
3
|
const test = require('node:test')
|
|
4
4
|
const ejs = require('ejs')
|
|
5
5
|
const Server = require('../server/index.js')
|
|
6
|
+
const Anywhere = require('../kernel/anywhere')
|
|
7
|
+
const { buildServeConfig, describeServeConfig } = require('../kernel/anywhere/serve')
|
|
8
|
+
const { JSDOM } = require('jsdom')
|
|
6
9
|
|
|
7
10
|
const view = path.resolve(__dirname, '..', 'server', 'views', 'remote.ejs')
|
|
8
11
|
|
|
9
12
|
// composeAnywhereStatus with its collaborators stubbed: the launcher needs the
|
|
10
13
|
// installed count and each running app's id next to its remote address.
|
|
11
|
-
test('anywhere status carries the installed count and
|
|
14
|
+
test('anywhere status carries the installed count, app ids and complete app URLs', async () => {
|
|
15
|
+
const anywhere = new Anywhere({})
|
|
16
|
+
anywhere.config = { enabled: true }
|
|
17
|
+
anywhere.lastStatus = { Self: { DNSName: 'pinokio.tail1.ts.net.' } }
|
|
18
|
+
anywhere.lastServe = buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', targets: [{ listen: 43000, target: 7860 }] })
|
|
19
|
+
anywhere.snapshot = async () => ({ phase: 'ready', dnsName: 'pinokio.tail1.ts.net', serve: describeServeConfig(anywhere.lastServe) })
|
|
20
|
+
let readyUrl
|
|
12
21
|
const fake = {
|
|
13
22
|
kernel: {
|
|
14
|
-
anywhere
|
|
15
|
-
snapshot: async () => ({ phase: 'ready', connectUrl: null, serve: [{ local: 7860, url: 'https://pinokio.tail1.ts.net:43000' }] }),
|
|
16
|
-
},
|
|
23
|
+
anywhere,
|
|
17
24
|
api: { listApps: async () => [{ id: 'comfy' }, { id: 'forge' }, { id: 'idle' }] },
|
|
18
25
|
},
|
|
19
26
|
collectHomeServerApps: async () => [
|
|
20
|
-
{ id: 'comfy', name: 'ComfyUI', icon: '/c.png', ready_url:
|
|
27
|
+
{ id: 'comfy', name: 'ComfyUI', icon: '/c.png', ready_url: readyUrl, state: 'ready' },
|
|
21
28
|
{ id: 'forge', name: 'Forge', icon: '/f.png', ready_url: '', state: 'starting' },
|
|
22
29
|
],
|
|
23
30
|
portFromUrl: Server.prototype.portFromUrl,
|
|
24
31
|
}
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
for (const [url, expected] of [
|
|
33
|
+
['http://127.0.0.1:7860/ui/?token=keep%20me#view', 'https://pinokio.tail1.ts.net:43000/ui/?token=keep%20me#view'],
|
|
34
|
+
['http://127.0.0.1:7860', 'https://pinokio.tail1.ts.net:43000/'],
|
|
35
|
+
]) {
|
|
36
|
+
readyUrl = url
|
|
37
|
+
const status = await Server.prototype.composeAnywhereStatus.call(fake, {})
|
|
38
|
+
assert.equal(status.installed, 3)
|
|
39
|
+
assert.deepEqual(status.apps.map((a) => [a.id, a.remote]), [['comfy', expected], ['forge', null]])
|
|
40
|
+
}
|
|
28
41
|
const light = await Server.prototype.composeAnywhereStatus.call(fake, { light: true })
|
|
29
42
|
assert.equal(light.installed, undefined, 'the light status is unchanged')
|
|
30
43
|
})
|
|
31
44
|
|
|
45
|
+
test('remote links use captured routes when Anywhere stops during app collection', async () => {
|
|
46
|
+
for (const stage of ['list', 'runtime']) {
|
|
47
|
+
const anywhere = new Anywhere({})
|
|
48
|
+
anywhere.config = { enabled: true }
|
|
49
|
+
anywhere.lastStatus = { Self: { DNSName: 'pinokio.tail1.ts.net.' } }
|
|
50
|
+
anywhere.lastServe = buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', targets: [{ listen: 43000, target: 7860 }] })
|
|
51
|
+
anywhere.snapshot = async () => ({ phase: 'ready', dnsName: 'pinokio.tail1.ts.net', serve: describeServeConfig(anywhere.lastServe) })
|
|
52
|
+
const stop = () => { anywhere.config.enabled = false; anywhere.lastServe = undefined; anywhere.lastStatus = null }
|
|
53
|
+
const fake = {
|
|
54
|
+
kernel: { anywhere, api: { listApps: async () => { if (stage === 'list') stop(); return [{ id: 'app' }] } } },
|
|
55
|
+
collectHomeServerApps: async () => {
|
|
56
|
+
if (stage === 'runtime') stop()
|
|
57
|
+
return [{ id: 'app', state: 'ready', ready_url: 'http://127.0.0.1:7860/ui/?token=keep%20me#view' }]
|
|
58
|
+
},
|
|
59
|
+
portFromUrl: Server.prototype.portFromUrl,
|
|
60
|
+
}
|
|
61
|
+
const status = await Server.prototype.composeAnywhereStatus.call(fake)
|
|
62
|
+
assert.equal(status.apps[0].remote, 'https://pinokio.tail1.ts.net:43000/ui/?token=keep%20me#view', stage)
|
|
63
|
+
assert.equal(status.apps[0].exposed, true)
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('unavailable app lists remain distinct from a confirmed empty list', async () => {
|
|
68
|
+
for (const phase of ['setup', 'off', 'starting', 'login', 'machine_auth', 'https', 'error']) {
|
|
69
|
+
const status = await Server.prototype.composeAnywhereStatus.call({
|
|
70
|
+
kernel: { anywhere: { snapshot: async () => ({ phase }) }, api: { listApps: async () => [] } },
|
|
71
|
+
})
|
|
72
|
+
assert.equal(status.apps, undefined, phase)
|
|
73
|
+
}
|
|
74
|
+
const failed = await Server.prototype.composeAnywhereStatus.call({
|
|
75
|
+
kernel: { anywhere: { snapshot: async () => ({ phase: 'ready' }) }, api: { listApps: async () => { throw new Error('unavailable') } } },
|
|
76
|
+
})
|
|
77
|
+
assert.equal(failed.phase, 'error')
|
|
78
|
+
assert.match(failed.error, /unavailable/)
|
|
79
|
+
assert.equal(failed.apps, undefined)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('Anywhere reports runtime inspection failures while Home Server keeps its existing behavior', async () => {
|
|
83
|
+
const fake = {
|
|
84
|
+
kernel: {
|
|
85
|
+
path: (...parts) => path.join('/managed', ...parts),
|
|
86
|
+
anywhere: { snapshot: async () => ({ phase: 'ready', enabled: true, serve: [] }) },
|
|
87
|
+
api: { listApps: async () => [{ id: 'comfy', name: 'ComfyUI' }] },
|
|
88
|
+
},
|
|
89
|
+
appRegistry: { collectAppRuntime() { throw new Error('runtime inspection failed') } },
|
|
90
|
+
collectHomeServerApps: Server.prototype.collectHomeServerApps,
|
|
91
|
+
portFromUrl: Server.prototype.portFromUrl,
|
|
92
|
+
}
|
|
93
|
+
const status = await Server.prototype.composeAnywhereStatus.call(fake)
|
|
94
|
+
assert.equal(status.phase, 'error')
|
|
95
|
+
assert.match(status.error, /runtime inspection failed/)
|
|
96
|
+
assert.equal(status.apps, undefined)
|
|
97
|
+
const html = await ejs.renderFile(view, { status, theme: 'light', locals: {} })
|
|
98
|
+
const dom = new JSDOM(html, { runScripts: 'outside-only', url: 'http://localhost:42000' })
|
|
99
|
+
try {
|
|
100
|
+
dom.window.setTimeout = () => 0
|
|
101
|
+
dom.window.eval(dom.window.document.querySelector('script').textContent)
|
|
102
|
+
assert.match(dom.window.document.getElementById('notice').textContent, /runtime inspection failed/)
|
|
103
|
+
assert.doesNotMatch(dom.window.document.getElementById('notice').textContent, /Nothing is running/)
|
|
104
|
+
} finally { dom.window.close() }
|
|
105
|
+
assert.deepEqual(await fake.collectHomeServerApps(), [])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('launcher renders actual states, top-level links and polling failures', async () => {
|
|
109
|
+
for (const status of [
|
|
110
|
+
{ phase: 'off' }, { phase: 'setup' }, { phase: 'error', error: '<broken>' },
|
|
111
|
+
{ phase: 'ready', installed: 0, apps: [] },
|
|
112
|
+
{ phase: 'ready', installed: 2, apps: [
|
|
113
|
+
{ id: 'comfy', name: 'Comfy', remote: 'https://pinokio.tail1.ts.net:43000' },
|
|
114
|
+
{ id: 'forge', name: 'Forge', state: 'starting', remote: null },
|
|
115
|
+
] },
|
|
116
|
+
]) {
|
|
117
|
+
const html = await ejs.renderFile(view, { status, theme: 'light', locals: {} })
|
|
118
|
+
const dom = new JSDOM(html, { runScripts: 'outside-only', url: 'http://localhost:42000' })
|
|
119
|
+
try {
|
|
120
|
+
const timers = []
|
|
121
|
+
dom.window.setTimeout = (fn) => timers.push(fn)
|
|
122
|
+
dom.window.fetch = async () => ({ ok: false, status: 503 })
|
|
123
|
+
dom.window.eval(dom.window.document.querySelector('script').textContent)
|
|
124
|
+
const doc = dom.window.document
|
|
125
|
+
assert.equal(doc.querySelector('base').target, '_top')
|
|
126
|
+
assert.equal(doc.querySelector('#notice').textContent.includes('Nothing is running'), status.phase === 'ready')
|
|
127
|
+
assert.equal(doc.querySelectorAll('a.open').length, status.apps && status.apps.length ? 1 : 0)
|
|
128
|
+
if (status.error) assert.ok(doc.querySelector('#notice').textContent.includes('<broken>'))
|
|
129
|
+
await timers.shift()()
|
|
130
|
+
assert.equal(doc.querySelectorAll('a.open').length, 0)
|
|
131
|
+
assert.match(doc.querySelector('#notice').textContent, /503/)
|
|
132
|
+
assert.doesNotMatch(doc.querySelector('#notice').textContent, /Nothing is running/)
|
|
133
|
+
} finally { dom.window.close() }
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
|
|
32
137
|
test('the launcher page renders the running apps and one door to the full interface', async () => {
|
|
33
138
|
const render = (status) => ejs.renderFile(view, { status, theme: 'light', locals: {} })
|
|
34
139
|
|
|
@@ -46,3 +151,72 @@ test('the launcher page renders the running apps and one door to the full interf
|
|
|
46
151
|
assert.match(some, /"id":"forge"/)
|
|
47
152
|
assert.doesNotMatch(some, /<link rel="manifest"/, 'manifest tags only for the Tailscale host')
|
|
48
153
|
})
|
|
154
|
+
|
|
155
|
+
test('Anywhere shows polling failures and recovers on the next successful response', async () => {
|
|
156
|
+
for (const failure of ['http', 'network']) {
|
|
157
|
+
const status = { phase: 'ready', enabled: true }
|
|
158
|
+
const html = await ejs.renderFile(path.resolve(__dirname, '..', 'server/views/anywhere.ejs'), {
|
|
159
|
+
status, theme: 'light', agent: 'browser',
|
|
160
|
+
}, { includer: () => ({ template: '' }) })
|
|
161
|
+
const dom = new JSDOM(html, { runScripts: 'outside-only', url: 'http://localhost:42000' })
|
|
162
|
+
try {
|
|
163
|
+
const timers = []
|
|
164
|
+
let requests = 0
|
|
165
|
+
dom.window.setTimeout = fn => timers.push(fn)
|
|
166
|
+
dom.window.fetch = async () => {
|
|
167
|
+
if (++requests === 1) {
|
|
168
|
+
if (failure === 'network') throw new Error('Connection failed')
|
|
169
|
+
return { ok: false, status: 503 }
|
|
170
|
+
}
|
|
171
|
+
return { ok: true, json: async () => status }
|
|
172
|
+
}
|
|
173
|
+
const { document } = dom.window
|
|
174
|
+
dom.window.eval([...document.querySelectorAll('script')].at(-1).textContent)
|
|
175
|
+
await timers.shift()()
|
|
176
|
+
assert.equal(document.getElementById('anywhere-root').dataset.phase, 'error')
|
|
177
|
+
assert.match(document.getElementById('anywhere-error').textContent, failure === 'http' ? /503/ : /Connection failed/)
|
|
178
|
+
await timers.shift()()
|
|
179
|
+
assert.equal(document.getElementById('anywhere-root').dataset.phase, 'ready')
|
|
180
|
+
} finally { dom.window.close() }
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('Anywhere retries the requested start or stop after API and network failures', async () => {
|
|
185
|
+
for (const action of ['start', 'stop']) {
|
|
186
|
+
for (const failure of ['saved', 'api', 'network']) {
|
|
187
|
+
const enabled = action === 'start'
|
|
188
|
+
const status = failure === 'saved'
|
|
189
|
+
? { phase: 'error', enabled, error: 'Previous failure' }
|
|
190
|
+
: { phase: enabled ? 'off' : 'ready', enabled: !enabled }
|
|
191
|
+
const html = await ejs.renderFile(path.resolve(__dirname, '..', 'server/views/anywhere.ejs'), {
|
|
192
|
+
status, theme: 'light', agent: 'browser',
|
|
193
|
+
}, { includer: () => ({ template: '' }) })
|
|
194
|
+
const dom = new JSDOM(html, { runScripts: 'outside-only', url: 'http://localhost:42000' })
|
|
195
|
+
try {
|
|
196
|
+
const requests = []
|
|
197
|
+
const { document } = dom.window
|
|
198
|
+
dom.window.setTimeout = () => 0
|
|
199
|
+
dom.window.fetch = async (url, options) => {
|
|
200
|
+
requests.push(url)
|
|
201
|
+
assert.equal(options.method, 'POST')
|
|
202
|
+
if (requests.length === 1 && failure !== 'saved') {
|
|
203
|
+
if (failure === 'network') throw new Error('Connection failed')
|
|
204
|
+
return { json: async () => ({ error: 'Operation failed' }) }
|
|
205
|
+
}
|
|
206
|
+
return { json: async () => ({ status: { phase: enabled ? 'starting' : 'off', enabled } }) }
|
|
207
|
+
}
|
|
208
|
+
dom.window.eval([...document.querySelectorAll('script')].at(-1).textContent)
|
|
209
|
+
if (failure !== 'saved') {
|
|
210
|
+
document.getElementById('anywhere-switch').click()
|
|
211
|
+
await new Promise(setImmediate)
|
|
212
|
+
assert.equal(document.getElementById('anywhere-root').dataset.phase, 'error')
|
|
213
|
+
assert.match(document.getElementById('anywhere-error').textContent, /failed/)
|
|
214
|
+
}
|
|
215
|
+
document.getElementById('anywhere-retry').click()
|
|
216
|
+
await new Promise(setImmediate)
|
|
217
|
+
assert.deepEqual(requests, Array(failure === 'saved' ? 1 : 2).fill(`/anywhere/${action}`))
|
|
218
|
+
assert.equal(document.getElementById('anywhere-root').dataset.phase, enabled ? 'starting' : 'off')
|
|
219
|
+
} finally { dom.window.close() }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
})
|