pinokiod 8.0.12 → 8.0.14
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/path_removal.js +17 -17
- package/package.json +1 -1
- package/server/index.js +58 -3
- package/server/public/common.js +10 -6
- package/server/public/task-launcher.css +0 -10
- package/server/views/app.ejs +2 -2
- package/server/views/download.ejs +2 -2
- package/server/views/editor.ejs +2 -2
- package/server/views/env_editor.ejs +2 -2
- package/server/views/file_explorer.ejs +2 -2
- package/server/views/index.ejs +2 -2
- package/server/views/install.ejs +42 -3
- package/server/views/net.ejs +2 -2
- package/server/views/settings.ejs +4 -4
- package/server/views/setup.ejs +6 -30
- package/server/views/terminal.ejs +2 -2
- package/server/views/tools.ejs +13 -0
- package/test/path-removal-ui.test.js +207 -3
- package/test/path-removal.test.js +25 -0
- package/test/startup-git-index-route.test.js +52 -0
- package/test/tools-reinstall-ui.test.js +84 -0
package/kernel/path_removal.js
CHANGED
|
@@ -3,6 +3,7 @@ const path = require("path")
|
|
|
3
3
|
const { inspectWindowsFileLocks } = require("./windows_restart_manager")
|
|
4
4
|
|
|
5
5
|
const PATH_REMOVAL_BLOCKED_CODE = "PINOKIO_PATH_REMOVE_BLOCKED"
|
|
6
|
+
const PATH_REMOVAL_PENDING_CODE = "PINOKIO_PATH_REMOVE_PENDING"
|
|
6
7
|
const REMAINING_PREVIEW_LIMIT = 20
|
|
7
8
|
|
|
8
9
|
class PathRemovalBlockedError extends Error {
|
|
@@ -38,7 +39,17 @@ class PathRemovalBlockedError extends Error {
|
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
class PathRemovalPendingError extends Error {
|
|
43
|
+
constructor(target, cause) {
|
|
44
|
+
super(`Windows still could not remove ${target}.`)
|
|
45
|
+
this.name = "PathRemovalPendingError"
|
|
46
|
+
this.code = PATH_REMOVAL_PENDING_CODE
|
|
47
|
+
this.cause = cause
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
41
51
|
const isPathRemovalBlockedError = (error) => !!(error && error.code === PATH_REMOVAL_BLOCKED_CODE)
|
|
52
|
+
const isPathRemovalPendingError = (error) => !!(error && error.code === PATH_REMOVAL_PENDING_CODE)
|
|
42
53
|
|
|
43
54
|
const collectRemaining = async (target, fsPromises = fs.promises) => {
|
|
44
55
|
const files = []
|
|
@@ -87,43 +98,30 @@ const createPathRemover = (dependencies = {}) => {
|
|
|
87
98
|
const resolvedTarget = path.resolve(target)
|
|
88
99
|
const {
|
|
89
100
|
operation = "Removing files",
|
|
101
|
+
diagnose = true,
|
|
90
102
|
...rmOptions
|
|
91
103
|
} = options
|
|
92
|
-
const trace = (stage, details = "") => {
|
|
93
|
-
if (platform === "win32") {
|
|
94
|
-
console.log(`[path removal] ${stage} target=${resolvedTarget}${details ? ` ${details}` : ""}`)
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
104
|
|
|
98
|
-
const removeStartedAt = Date.now()
|
|
99
|
-
trace("rm:start")
|
|
100
105
|
try {
|
|
101
106
|
await fsPromises.rm(resolvedTarget, rmOptions)
|
|
102
|
-
trace("rm:done", `elapsedMs=${Date.now() - removeStartedAt}`)
|
|
103
107
|
return { removed: true, target: resolvedTarget }
|
|
104
108
|
} catch (error) {
|
|
105
109
|
if (platform !== "win32") {
|
|
106
110
|
throw error
|
|
107
111
|
}
|
|
108
|
-
|
|
112
|
+
if (!diagnose) {
|
|
113
|
+
throw new PathRemovalPendingError(resolvedTarget, error)
|
|
114
|
+
}
|
|
109
115
|
|
|
110
|
-
const scanStartedAt = Date.now()
|
|
111
|
-
trace("scan:start")
|
|
112
116
|
const remaining = await collectRemaining(resolvedTarget, fsPromises)
|
|
113
|
-
trace("scan:done", `elapsedMs=${Date.now() - scanStartedAt} paths=${remaining.paths.length} files=${remaining.files.length}`)
|
|
114
117
|
if (remaining.paths.length === 0) {
|
|
115
118
|
throw error
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
let inspection = { blockers: [] }
|
|
119
|
-
const inspectionStartedAt = Date.now()
|
|
120
|
-
trace("restart-manager:start", `files=${remaining.files.length}`)
|
|
121
122
|
try {
|
|
122
123
|
inspection = await inspectLocks(remaining.files)
|
|
123
|
-
const blockerCount = Array.isArray(inspection && inspection.blockers) ? inspection.blockers.length : 0
|
|
124
|
-
trace("restart-manager:done", `elapsedMs=${Date.now() - inspectionStartedAt} blockers=${blockerCount}`)
|
|
125
124
|
} catch (inspectionError) {
|
|
126
|
-
trace("restart-manager:failed", `elapsedMs=${Date.now() - inspectionStartedAt} message=${inspectionError && inspectionError.message ? inspectionError.message : String(inspectionError)}`)
|
|
127
125
|
console.warn("[path removal] Restart Manager inspection failed", inspectionError)
|
|
128
126
|
}
|
|
129
127
|
|
|
@@ -143,8 +141,10 @@ const removePath = createPathRemover()
|
|
|
143
141
|
|
|
144
142
|
module.exports = {
|
|
145
143
|
PATH_REMOVAL_BLOCKED_CODE,
|
|
144
|
+
PATH_REMOVAL_PENDING_CODE,
|
|
146
145
|
collectRemaining,
|
|
147
146
|
createPathRemover,
|
|
148
147
|
isPathRemovalBlockedError,
|
|
148
|
+
isPathRemovalPendingError,
|
|
149
149
|
removePath,
|
|
150
150
|
}
|
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -34,7 +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 { isPathRemovalBlockedError, removePath } = require("../kernel/path_removal")
|
|
37
|
+
const { isPathRemovalBlockedError, isPathRemovalPendingError, removePath } = require("../kernel/path_removal")
|
|
38
38
|
|
|
39
39
|
const git = require('isomorphic-git')
|
|
40
40
|
const http = require('isomorphic-git/http/node')
|
|
@@ -63,6 +63,40 @@ const ex = fn => (req, res, next) => {
|
|
|
63
63
|
Promise.resolve(fn(req, res, next)).catch(next);
|
|
64
64
|
};
|
|
65
65
|
|
|
66
|
+
const isPathInsideRoot = (candidatePath, rootPath) => {
|
|
67
|
+
const relative = path.relative(rootPath, candidatePath)
|
|
68
|
+
return relative === "" || (
|
|
69
|
+
relative !== ".."
|
|
70
|
+
&& !relative.startsWith(`..${path.sep}`)
|
|
71
|
+
&& !path.isAbsolute(relative)
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const resolveAppRemovalPath = async (apiRoot, name) => {
|
|
76
|
+
if (typeof name !== "string" || !name || name.includes("\0")) {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
const root = path.resolve(apiRoot)
|
|
80
|
+
const target = path.resolve(root, name)
|
|
81
|
+
if (target === root || !isPathInsideRoot(target, root)) {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
const stat = await fs.promises.lstat(target).catch((error) => {
|
|
85
|
+
if (error && error.code === "ENOENT") return null
|
|
86
|
+
throw error
|
|
87
|
+
})
|
|
88
|
+
if (stat && !stat.isSymbolicLink()) {
|
|
89
|
+
const [realRoot, realTarget] = await Promise.all([
|
|
90
|
+
fs.promises.realpath(root),
|
|
91
|
+
fs.promises.realpath(target),
|
|
92
|
+
])
|
|
93
|
+
if (realTarget === realRoot || !isPathInsideRoot(realTarget, realRoot)) {
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return target
|
|
98
|
+
}
|
|
99
|
+
|
|
66
100
|
|
|
67
101
|
|
|
68
102
|
|
|
@@ -16263,6 +16297,10 @@ class Server {
|
|
|
16263
16297
|
console.timeEnd("/p/" + req.params.name + "/" + d)
|
|
16264
16298
|
}))
|
|
16265
16299
|
this.app.post("/pinokio/delete", ex(async (req, res) => {
|
|
16300
|
+
if (!privacyFilterCache.isInstallRequestAllowed(req)) {
|
|
16301
|
+
res.status(403).json({ error: "Cross-site deletion is not allowed" })
|
|
16302
|
+
return
|
|
16303
|
+
}
|
|
16266
16304
|
try {
|
|
16267
16305
|
if (req.body.type === 'conda-runtime') {
|
|
16268
16306
|
for (const runtimeName of ["miniconda", "miniforge"]) {
|
|
@@ -16270,6 +16308,7 @@ class Server {
|
|
|
16270
16308
|
await removePath(runtimePath, {
|
|
16271
16309
|
recursive: true,
|
|
16272
16310
|
force: true,
|
|
16311
|
+
diagnose: req.body.background !== true,
|
|
16273
16312
|
operation: "Updating Miniforge",
|
|
16274
16313
|
})
|
|
16275
16314
|
}
|
|
@@ -16279,15 +16318,18 @@ class Server {
|
|
|
16279
16318
|
await removePath(folderPath, {
|
|
16280
16319
|
recursive: true,
|
|
16281
16320
|
force: true,
|
|
16321
|
+
diagnose: req.body.background !== true,
|
|
16282
16322
|
operation: "Resetting managed tools",
|
|
16283
16323
|
})
|
|
16284
16324
|
await fs.promises.mkdir(folderPath, { recursive: true }).catch((e) => { })
|
|
16325
|
+
await this.kernel.bin.refreshInstalled()
|
|
16285
16326
|
res.json({ success: true })
|
|
16286
16327
|
} else if (req.body.type === 'cache') {
|
|
16287
16328
|
let folderPath = this.kernel.path("cache")
|
|
16288
16329
|
await removePath(folderPath, {
|
|
16289
16330
|
recursive: true,
|
|
16290
16331
|
force: true,
|
|
16332
|
+
diagnose: req.body.background !== true,
|
|
16291
16333
|
operation: "Clearing the Pinokio cache",
|
|
16292
16334
|
})
|
|
16293
16335
|
await fs.promises.mkdir(folderPath, { recursive: true }).catch((e) => { })
|
|
@@ -16306,10 +16348,15 @@ class Server {
|
|
|
16306
16348
|
}
|
|
16307
16349
|
res.json({ success: true })
|
|
16308
16350
|
} else if (req.body.name) {
|
|
16309
|
-
|
|
16351
|
+
const folderPath = await resolveAppRemovalPath(this.kernel.path("api"), req.body.name)
|
|
16352
|
+
if (!folderPath) {
|
|
16353
|
+
res.status(400).json({ error: "Invalid app deletion path" })
|
|
16354
|
+
return
|
|
16355
|
+
}
|
|
16310
16356
|
await removePath(folderPath, {
|
|
16311
16357
|
recursive: true,
|
|
16312
16358
|
force: true,
|
|
16359
|
+
diagnose: req.body.background !== true,
|
|
16313
16360
|
operation: `Deleting ${req.body.name}`,
|
|
16314
16361
|
})
|
|
16315
16362
|
// await fs.promises.mkdir(folderPath, { recursive: true }).catch((e) => { })
|
|
@@ -16322,6 +16369,10 @@ class Server {
|
|
|
16322
16369
|
res.json({ success: true })
|
|
16323
16370
|
}
|
|
16324
16371
|
} catch(err) {
|
|
16372
|
+
if (isPathRemovalPendingError(err)) {
|
|
16373
|
+
res.status(423).json({ success: false, pending: true })
|
|
16374
|
+
return
|
|
16375
|
+
}
|
|
16325
16376
|
if (isPathRemovalBlockedError(err)) {
|
|
16326
16377
|
res.status(423).json({ success: false, error: err.toJSON() })
|
|
16327
16378
|
return
|
|
@@ -16568,6 +16619,7 @@ class Server {
|
|
|
16568
16619
|
this.app.post("/pinokio/install", ex((req, res) => {
|
|
16569
16620
|
req.session.requirements = req.body.requirements
|
|
16570
16621
|
req.session.callback = req.body.callback
|
|
16622
|
+
req.session.fresh = req.body.fresh === "1"
|
|
16571
16623
|
res.redirect("/pinokio/install")
|
|
16572
16624
|
}))
|
|
16573
16625
|
this.app.post("/pinokio/install/validate", ex(async (req, res) => {
|
|
@@ -16579,8 +16631,10 @@ class Server {
|
|
|
16579
16631
|
res.set("Cache-Control", "no-store")
|
|
16580
16632
|
let requirements = req.session.requirements
|
|
16581
16633
|
let callback = req.session.callback
|
|
16634
|
+
let fresh = req.session.fresh === true
|
|
16582
16635
|
req.session.requirements = null
|
|
16583
16636
|
req.session.callback = null
|
|
16637
|
+
req.session.fresh = null
|
|
16584
16638
|
res.render("install", {
|
|
16585
16639
|
logo: this.logo,
|
|
16586
16640
|
theme: this.theme,
|
|
@@ -16589,7 +16643,8 @@ class Server {
|
|
|
16589
16643
|
display: ["form"],
|
|
16590
16644
|
// query: req.query,
|
|
16591
16645
|
requirements,
|
|
16592
|
-
callback
|
|
16646
|
+
callback,
|
|
16647
|
+
fresh,
|
|
16593
16648
|
})
|
|
16594
16649
|
}))
|
|
16595
16650
|
this.app.get("/pinokio", ex((req, res) => {
|
package/server/public/common.js
CHANGED
|
@@ -82,7 +82,7 @@ const PinokioPathRemoval = (() => {
|
|
|
82
82
|
: ""
|
|
83
83
|
throw new Error(text || `Request failed (${response.status || "unknown"})`)
|
|
84
84
|
}
|
|
85
|
-
if (!response.ok && !blockedDetails(payload)) {
|
|
85
|
+
if (!response.ok && !blockedDetails(payload) && !(payload && payload.pending === true)) {
|
|
86
86
|
const message = payload && payload.error
|
|
87
87
|
throw new Error(typeof message === "string" ? message : `Request failed (${response.status})`)
|
|
88
88
|
}
|
|
@@ -121,9 +121,13 @@ const PinokioPathRemoval = (() => {
|
|
|
121
121
|
const attempt = async (manual = false) => {
|
|
122
122
|
if (retrying || settled || typeof retry !== "function") return false
|
|
123
123
|
retrying = true
|
|
124
|
-
if (typeof Swal.disableButtons === "function") Swal.disableButtons()
|
|
124
|
+
if (manual && typeof Swal.disableButtons === "function") Swal.disableButtons()
|
|
125
125
|
try {
|
|
126
|
-
const result = await retry()
|
|
126
|
+
const result = await retry(manual)
|
|
127
|
+
if (settled) return false
|
|
128
|
+
if (result && result.pending === true) {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
127
131
|
const nextBlocked = blockedDetails(result)
|
|
128
132
|
if (nextBlocked) {
|
|
129
133
|
current = nextBlocked
|
|
@@ -138,7 +142,7 @@ const PinokioPathRemoval = (() => {
|
|
|
138
142
|
return false
|
|
139
143
|
} finally {
|
|
140
144
|
retrying = false
|
|
141
|
-
if (!settled && typeof Swal.enableButtons === "function") Swal.enableButtons()
|
|
145
|
+
if (manual && !settled && typeof Swal.enableButtons === "function") Swal.enableButtons()
|
|
142
146
|
}
|
|
143
147
|
}
|
|
144
148
|
|
|
@@ -169,7 +173,7 @@ const PinokioPathRemoval = (() => {
|
|
|
169
173
|
}
|
|
170
174
|
|
|
171
175
|
const request = async (requestFactory) => {
|
|
172
|
-
const execute = async () => parseResponse(await requestFactory())
|
|
176
|
+
const execute = async (background = false) => parseResponse(await requestFactory(background))
|
|
173
177
|
const initial = await execute()
|
|
174
178
|
const details = blockedDetails(initial)
|
|
175
179
|
if (!details) {
|
|
@@ -180,7 +184,7 @@ const PinokioPathRemoval = (() => {
|
|
|
180
184
|
}
|
|
181
185
|
return show({
|
|
182
186
|
details,
|
|
183
|
-
retry: execute,
|
|
187
|
+
retry: (manual) => execute(!manual),
|
|
184
188
|
})
|
|
185
189
|
}
|
|
186
190
|
|
|
@@ -1902,16 +1902,6 @@ body.dark .setup-requirement-row.needs-update .setup-requirement-state {
|
|
|
1902
1902
|
color: var(--task-muted);
|
|
1903
1903
|
}
|
|
1904
1904
|
|
|
1905
|
-
.setup-reset-loading {
|
|
1906
|
-
display: flex;
|
|
1907
|
-
align-items: center;
|
|
1908
|
-
gap: 8px;
|
|
1909
|
-
margin-top: 12px;
|
|
1910
|
-
color: var(--task-muted);
|
|
1911
|
-
font-size: 12px;
|
|
1912
|
-
line-height: 1.45;
|
|
1913
|
-
}
|
|
1914
|
-
|
|
1915
1905
|
.setup-state-root {
|
|
1916
1906
|
min-height: 260px;
|
|
1917
1907
|
display: flex;
|
package/server/views/app.ejs
CHANGED
|
@@ -11866,12 +11866,12 @@ const rerenderMenuSection = (container, html) => {
|
|
|
11866
11866
|
actions: "hidden"
|
|
11867
11867
|
}
|
|
11868
11868
|
});
|
|
11869
|
-
let res = await PinokioPathRemoval.request(() => fetch("/pinokio/delete", {
|
|
11869
|
+
let res = await PinokioPathRemoval.request((background) => fetch("/pinokio/delete", {
|
|
11870
11870
|
method: "post",
|
|
11871
11871
|
headers: {
|
|
11872
11872
|
"Content-Type": "application/json"
|
|
11873
11873
|
},
|
|
11874
|
-
body: JSON.stringify({ name })
|
|
11874
|
+
body: JSON.stringify({ name, background })
|
|
11875
11875
|
}))
|
|
11876
11876
|
console.log("res", res)
|
|
11877
11877
|
Swal.close()
|
|
@@ -231,12 +231,12 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|
|
231
231
|
if (proceed) {
|
|
232
232
|
document.querySelector(".reset-bin-loading").classList.remove("hidden")
|
|
233
233
|
document.querySelector("#del-bin").classList.add("hidden")
|
|
234
|
-
let res = await PinokioPathRemoval.request(() => fetch("/pinokio/delete", {
|
|
234
|
+
let res = await PinokioPathRemoval.request((background) => fetch("/pinokio/delete", {
|
|
235
235
|
method: "post",
|
|
236
236
|
headers: {
|
|
237
237
|
"Content-Type": "application/json"
|
|
238
238
|
},
|
|
239
|
-
body: JSON.stringify({ type: "bin" })
|
|
239
|
+
body: JSON.stringify({ type: "bin", background })
|
|
240
240
|
}))
|
|
241
241
|
console.log(res)
|
|
242
242
|
document.querySelector(".reset-bin-loading").classList.add("hidden")
|
package/server/views/editor.ejs
CHANGED
|
@@ -940,12 +940,12 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|
|
940
940
|
if (proceed) {
|
|
941
941
|
document.querySelector(".reset-bin-loading").classList.remove("hidden")
|
|
942
942
|
document.querySelector("#del-bin").classList.add("hidden")
|
|
943
|
-
let res = await PinokioPathRemoval.request(() => fetch("/pinokio/delete", {
|
|
943
|
+
let res = await PinokioPathRemoval.request((background) => fetch("/pinokio/delete", {
|
|
944
944
|
method: "post",
|
|
945
945
|
headers: {
|
|
946
946
|
"Content-Type": "application/json"
|
|
947
947
|
},
|
|
948
|
-
body: JSON.stringify({ type: "bin" })
|
|
948
|
+
body: JSON.stringify({ type: "bin", background })
|
|
949
949
|
}))
|
|
950
950
|
console.log(res)
|
|
951
951
|
document.querySelector(".reset-bin-loading").classList.add("hidden")
|
|
@@ -447,12 +447,12 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|
|
447
447
|
}
|
|
448
448
|
})
|
|
449
449
|
try {
|
|
450
|
-
const res = await PinokioPathRemoval.request(() => fetch("/pinokio/delete", {
|
|
450
|
+
const res = await PinokioPathRemoval.request((background) => fetch("/pinokio/delete", {
|
|
451
451
|
method: "post",
|
|
452
452
|
headers: {
|
|
453
453
|
"Content-Type": "application/json"
|
|
454
454
|
},
|
|
455
|
-
body: JSON.stringify({ name: projectName })
|
|
455
|
+
body: JSON.stringify({ name: projectName, background })
|
|
456
456
|
}))
|
|
457
457
|
Swal.close()
|
|
458
458
|
if (res && res.success) {
|
|
@@ -636,12 +636,12 @@ document.addEventListener("click", async (e) => {
|
|
|
636
636
|
actions: "hidden"
|
|
637
637
|
}
|
|
638
638
|
});
|
|
639
|
-
let res = await PinokioPathRemoval.request(() => fetch("/pinokio/delete", {
|
|
639
|
+
let res = await PinokioPathRemoval.request((background) => fetch("/pinokio/delete", {
|
|
640
640
|
method: "post",
|
|
641
641
|
headers: {
|
|
642
642
|
"Content-Type": "application/json"
|
|
643
643
|
},
|
|
644
|
-
body: JSON.stringify({ name })
|
|
644
|
+
body: JSON.stringify({ name, background })
|
|
645
645
|
}))
|
|
646
646
|
console.log("res", res)
|
|
647
647
|
Swal.close()
|
package/server/views/index.ejs
CHANGED
|
@@ -4031,12 +4031,12 @@ document.addEventListener("click", async (e) => {
|
|
|
4031
4031
|
actions: "hidden"
|
|
4032
4032
|
}
|
|
4033
4033
|
});
|
|
4034
|
-
let res = await PinokioPathRemoval.request(() => fetch("/pinokio/delete", {
|
|
4034
|
+
let res = await PinokioPathRemoval.request((background) => fetch("/pinokio/delete", {
|
|
4035
4035
|
method: "post",
|
|
4036
4036
|
headers: {
|
|
4037
4037
|
"Content-Type": "application/json"
|
|
4038
4038
|
},
|
|
4039
|
-
body: JSON.stringify({ name })
|
|
4039
|
+
body: JSON.stringify({ name, background })
|
|
4040
4040
|
}))
|
|
4041
4041
|
console.log("res", res)
|
|
4042
4042
|
Swal.close()
|
package/server/views/install.ejs
CHANGED
|
@@ -540,6 +540,7 @@ body.install-page #terminal .xterm {
|
|
|
540
540
|
</div>
|
|
541
541
|
<input id='requirements' type='hidden' value="<%=requirements%>">
|
|
542
542
|
<input id='callback' type='hidden' value="<%=callback%>">
|
|
543
|
+
<input id='fresh' type='hidden' value="<%=fresh ? '1' : '0'%>">
|
|
543
544
|
<script>
|
|
544
545
|
//
|
|
545
546
|
// /install_bin?type=bin&uri=conda
|
|
@@ -731,6 +732,43 @@ const navigateToCallback = () => {
|
|
|
731
732
|
}
|
|
732
733
|
location.href = href
|
|
733
734
|
}
|
|
735
|
+
const prepareFreshInstall = async () => {
|
|
736
|
+
if (document.querySelector("#fresh").value !== "1") return true
|
|
737
|
+
try {
|
|
738
|
+
const result = await PinokioPathRemoval.request((background) => fetch("/pinokio/delete", {
|
|
739
|
+
method: "post",
|
|
740
|
+
headers: { "Content-Type": "application/json" },
|
|
741
|
+
body: JSON.stringify({ type: "bin", background })
|
|
742
|
+
}))
|
|
743
|
+
if (result.cancelled) {
|
|
744
|
+
navigateToCallback()
|
|
745
|
+
return false
|
|
746
|
+
}
|
|
747
|
+
if (result.error) throw new Error(result.error)
|
|
748
|
+
return true
|
|
749
|
+
} catch (error) {
|
|
750
|
+
ModalInput({
|
|
751
|
+
title: "Error",
|
|
752
|
+
type: "modal",
|
|
753
|
+
description: error && error.message ? error.message : "Unable to reset managed tools."
|
|
754
|
+
})
|
|
755
|
+
return false
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
const resumeInstall = () => {
|
|
759
|
+
const form = document.createElement("form")
|
|
760
|
+
form.method = "post"
|
|
761
|
+
form.action = "/pinokio/install"
|
|
762
|
+
for (const name of ["requirements", "callback"]) {
|
|
763
|
+
const input = document.createElement("input")
|
|
764
|
+
input.type = "hidden"
|
|
765
|
+
input.name = name
|
|
766
|
+
input.value = document.querySelector(`#${name}`).value
|
|
767
|
+
form.appendChild(input)
|
|
768
|
+
}
|
|
769
|
+
document.body.appendChild(form)
|
|
770
|
+
form.submit()
|
|
771
|
+
}
|
|
734
772
|
const createTerm = async (_theme) => {
|
|
735
773
|
//const theme = Object.assign({ }, xtermTheme.Terminal_Basic, {
|
|
736
774
|
const theme = Object.assign({ }, _theme, {
|
|
@@ -789,6 +827,7 @@ const createTerm = async (_theme) => {
|
|
|
789
827
|
}
|
|
790
828
|
document.addEventListener("DOMContentLoaded", async () => {
|
|
791
829
|
applyInstallSpinnerVariant(randomInstallSpinnerVariant())
|
|
830
|
+
if (!await prepareFreshInstall()) return
|
|
792
831
|
const n = new N()
|
|
793
832
|
let socket = new Socket()
|
|
794
833
|
<% if (theme === "dark") { %>
|
|
@@ -854,13 +893,13 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|
|
854
893
|
} else if (packet.type === "path.remove.blocked") {
|
|
855
894
|
const result = await PinokioPathRemoval.show({
|
|
856
895
|
details: packet.data,
|
|
857
|
-
retry: async () => {
|
|
896
|
+
retry: async (manual) => {
|
|
858
897
|
const response = await fetch("/pinokio/delete", {
|
|
859
898
|
method: "post",
|
|
860
899
|
headers: {
|
|
861
900
|
"Content-Type": "application/json"
|
|
862
901
|
},
|
|
863
|
-
body: JSON.stringify({ type: "conda-runtime" })
|
|
902
|
+
body: JSON.stringify({ type: "conda-runtime", background: !manual })
|
|
864
903
|
})
|
|
865
904
|
const payload = await response.json()
|
|
866
905
|
const blocked = PinokioPathRemoval.blockedDetails(payload)
|
|
@@ -872,7 +911,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|
|
872
911
|
}
|
|
873
912
|
})
|
|
874
913
|
if (result && result.success) {
|
|
875
|
-
|
|
914
|
+
resumeInstall()
|
|
876
915
|
} else if (result && result.cancelled) {
|
|
877
916
|
navigateToCallback()
|
|
878
917
|
}
|
package/server/views/net.ejs
CHANGED
|
@@ -2361,12 +2361,12 @@ document.addEventListener("click", async (e) => {
|
|
|
2361
2361
|
actions: "hidden"
|
|
2362
2362
|
}
|
|
2363
2363
|
});
|
|
2364
|
-
let res = await PinokioPathRemoval.request(() => fetch("/pinokio/delete", {
|
|
2364
|
+
let res = await PinokioPathRemoval.request((background) => fetch("/pinokio/delete", {
|
|
2365
2365
|
method: "post",
|
|
2366
2366
|
headers: {
|
|
2367
2367
|
"Content-Type": "application/json"
|
|
2368
2368
|
},
|
|
2369
|
-
body: JSON.stringify({ name })
|
|
2369
|
+
body: JSON.stringify({ name, background })
|
|
2370
2370
|
}))
|
|
2371
2371
|
console.log("res", res)
|
|
2372
2372
|
Swal.close()
|
|
@@ -1537,12 +1537,12 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|
|
1537
1537
|
if (proceed) {
|
|
1538
1538
|
document.querySelector(".reset-bin-loading").classList.remove("hidden")
|
|
1539
1539
|
document.querySelector("#del-bin").classList.add("hidden")
|
|
1540
|
-
let res = await PinokioPathRemoval.request(() => fetch("/pinokio/delete", {
|
|
1540
|
+
let res = await PinokioPathRemoval.request((background) => fetch("/pinokio/delete", {
|
|
1541
1541
|
method: "post",
|
|
1542
1542
|
headers: {
|
|
1543
1543
|
"Content-Type": "application/json"
|
|
1544
1544
|
},
|
|
1545
|
-
body: JSON.stringify({ type: "bin" })
|
|
1545
|
+
body: JSON.stringify({ type: "bin", background })
|
|
1546
1546
|
}))
|
|
1547
1547
|
console.log(res)
|
|
1548
1548
|
document.querySelector(".reset-bin-loading").classList.add("hidden")
|
|
@@ -1561,12 +1561,12 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|
|
1561
1561
|
if (proceed) {
|
|
1562
1562
|
document.querySelector(".reset-cache-loading").classList.remove("hidden")
|
|
1563
1563
|
document.querySelector("#del-cache").classList.add("hidden")
|
|
1564
|
-
let res = await PinokioPathRemoval.request(() => fetch("/pinokio/delete", {
|
|
1564
|
+
let res = await PinokioPathRemoval.request((background) => fetch("/pinokio/delete", {
|
|
1565
1565
|
method: "post",
|
|
1566
1566
|
headers: {
|
|
1567
1567
|
"Content-Type": "application/json"
|
|
1568
1568
|
},
|
|
1569
|
-
body: JSON.stringify({ type: "cache" })
|
|
1569
|
+
body: JSON.stringify({ type: "cache", background })
|
|
1570
1570
|
}))
|
|
1571
1571
|
console.log(res)
|
|
1572
1572
|
document.querySelector(".reset-cache-loading").classList.add("hidden")
|
package/server/views/setup.ejs
CHANGED
|
@@ -177,18 +177,18 @@
|
|
|
177
177
|
<%= install_required ? 'This uses the same install flow as before.' : 'Pinokio can reopen the same destination without reinstalling anything.' %>
|
|
178
178
|
</p>
|
|
179
179
|
|
|
180
|
+
<form class="task-hidden" id="fresh-install-form" method="post" action="/pinokio/install">
|
|
181
|
+
<input type="hidden" name="requirements" value="<%= JSON.stringify(requirementList) %>">
|
|
182
|
+
<input type="hidden" name="callback" value="<%= targetPath %>">
|
|
183
|
+
<input type="hidden" name="fresh" value="1">
|
|
184
|
+
</form>
|
|
180
185
|
<div class="setup-secondary-actions">
|
|
181
186
|
<span class="task-inline-help">Not working?</span>
|
|
182
|
-
<button class="task-button" type="
|
|
187
|
+
<button class="task-button" type="submit" form="fresh-install-form">
|
|
183
188
|
<i class="fa-solid fa-trash" aria-hidden="true"></i>
|
|
184
189
|
<span>Try a fresh install</span>
|
|
185
190
|
</button>
|
|
186
191
|
</div>
|
|
187
|
-
|
|
188
|
-
<div class="reset-bin-loading hidden setup-reset-loading">
|
|
189
|
-
<i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>
|
|
190
|
-
<span>Stand by. Do not close this window.</span>
|
|
191
|
-
</div>
|
|
192
192
|
</section>
|
|
193
193
|
</div>
|
|
194
194
|
</div>
|
|
@@ -383,30 +383,6 @@
|
|
|
383
383
|
showWaitError(error && error.message ? error.message : "Failed to restart Pinokio")
|
|
384
384
|
}
|
|
385
385
|
<% } %>
|
|
386
|
-
if (document.querySelector("#del-bin")) {
|
|
387
|
-
document.querySelector("#del-bin").addEventListener("click", async () => {
|
|
388
|
-
let proceed = confirm("Are you sure you wish to delete the bin folder?")
|
|
389
|
-
if (proceed) {
|
|
390
|
-
document.querySelector(".reset-bin-loading").classList.remove("hidden")
|
|
391
|
-
document.querySelector("#del-bin").classList.add("hidden")
|
|
392
|
-
let res = await PinokioPathRemoval.request(() => fetch("/pinokio/delete", {
|
|
393
|
-
method: "post",
|
|
394
|
-
headers: {
|
|
395
|
-
"Content-Type": "application/json"
|
|
396
|
-
},
|
|
397
|
-
body: JSON.stringify({ type: "bin" })
|
|
398
|
-
}))
|
|
399
|
-
document.querySelector(".reset-bin-loading").classList.add("hidden")
|
|
400
|
-
if (res.cancelled) {
|
|
401
|
-
document.querySelector("#del-bin").classList.remove("hidden")
|
|
402
|
-
} else if (res.error) {
|
|
403
|
-
alert(res.error)
|
|
404
|
-
} else {
|
|
405
|
-
document.querySelector("#install-form").submit()
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
})
|
|
409
|
-
}
|
|
410
386
|
})
|
|
411
387
|
</script>
|
|
412
388
|
<%- include('partials/app_common_scripts') %>
|
|
@@ -3339,12 +3339,12 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|
|
3339
3339
|
if (proceed) {
|
|
3340
3340
|
document.querySelector(".reset-bin-loading").classList.remove("hidden")
|
|
3341
3341
|
document.querySelector("#del-bin").classList.add("hidden")
|
|
3342
|
-
let res = await PinokioPathRemoval.request(() => fetch("/pinokio/delete", {
|
|
3342
|
+
let res = await PinokioPathRemoval.request((background) => fetch("/pinokio/delete", {
|
|
3343
3343
|
method: "post",
|
|
3344
3344
|
headers: {
|
|
3345
3345
|
"Content-Type": "application/json"
|
|
3346
3346
|
},
|
|
3347
|
-
body: JSON.stringify({ type: "bin" })
|
|
3347
|
+
body: JSON.stringify({ type: "bin", background })
|
|
3348
3348
|
}))
|
|
3349
3349
|
console.log(res)
|
|
3350
3350
|
document.querySelector(".reset-bin-loading").classList.add("hidden")
|
package/server/views/tools.ejs
CHANGED
|
@@ -154,6 +154,9 @@
|
|
|
154
154
|
justify-items: end;
|
|
155
155
|
min-width: 118px;
|
|
156
156
|
}
|
|
157
|
+
body.tools-page .tools-reinstall-form {
|
|
158
|
+
margin: 0;
|
|
159
|
+
}
|
|
157
160
|
body.tools-page .tools-bundle-status {
|
|
158
161
|
display: inline-flex;
|
|
159
162
|
align-items: center;
|
|
@@ -494,6 +497,16 @@
|
|
|
494
497
|
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
|
495
498
|
<span>Install</span>
|
|
496
499
|
</a>
|
|
500
|
+
<% } else { %>
|
|
501
|
+
<form class="tools-reinstall-form" method="post" action="/pinokio/install">
|
|
502
|
+
<input type="hidden" name="requirements" value="<%= JSON.stringify(bundle.requirements) %>">
|
|
503
|
+
<input type="hidden" name="callback" value="/tools">
|
|
504
|
+
<input type="hidden" name="fresh" value="1">
|
|
505
|
+
<button class="task-link-button" type="submit">
|
|
506
|
+
<i class="fa-solid fa-rotate" aria-hidden="true"></i>
|
|
507
|
+
<span>Reinstall</span>
|
|
508
|
+
</button>
|
|
509
|
+
</form>
|
|
497
510
|
<% } %>
|
|
498
511
|
</div>
|
|
499
512
|
</article>
|
|
@@ -2,10 +2,12 @@ const assert = require("node:assert/strict")
|
|
|
2
2
|
const fs = require("node:fs/promises")
|
|
3
3
|
const path = require("node:path")
|
|
4
4
|
const test = require("node:test")
|
|
5
|
+
const ejs = require("ejs")
|
|
5
6
|
const { JSDOM, VirtualConsole } = require("jsdom")
|
|
6
7
|
|
|
7
8
|
const repoRoot = path.resolve(__dirname, "..")
|
|
8
9
|
const commonScriptPath = path.join(repoRoot, "server/public/common.js")
|
|
10
|
+
const installViewPath = path.join(repoRoot, "server/views/install.ejs")
|
|
9
11
|
|
|
10
12
|
async function waitFor(predicate, message = "condition") {
|
|
11
13
|
const deadline = Date.now() + 1000
|
|
@@ -196,12 +198,14 @@ test("path-removal request retries automatically without overlapping and resolve
|
|
|
196
198
|
t.after(() => dom.window.close())
|
|
197
199
|
|
|
198
200
|
let requestCount = 0
|
|
201
|
+
const backgroundRequests = []
|
|
199
202
|
let resolveRetry
|
|
200
203
|
const retryResponse = new Promise((resolve) => {
|
|
201
204
|
resolveRetry = resolve
|
|
202
205
|
})
|
|
203
|
-
const resultPromise = api.request(async () => {
|
|
206
|
+
const resultPromise = api.request(async (background) => {
|
|
204
207
|
requestCount += 1
|
|
208
|
+
backgroundRequests.push(background)
|
|
205
209
|
if (requestCount === 1) {
|
|
206
210
|
return response({
|
|
207
211
|
success: false,
|
|
@@ -224,8 +228,7 @@ test("path-removal request retries automatically without overlapping and resolve
|
|
|
224
228
|
state.htmlContainer.querySelector(".pinokio-path-blocker-files").open = true
|
|
225
229
|
|
|
226
230
|
const firstAttempt = state.runInterval()
|
|
227
|
-
assert.equal(state.buttonsDisabled,
|
|
228
|
-
assert.equal(state.dismiss(), false, "Stop must be unavailable during an active removal")
|
|
231
|
+
assert.equal(state.buttonsDisabled, false)
|
|
229
232
|
assert.equal(state.visible, true)
|
|
230
233
|
await state.runInterval()
|
|
231
234
|
assert.equal(requestCount, 2, "a pending retry must suppress overlapping retries")
|
|
@@ -250,11 +253,78 @@ test("path-removal request retries automatically without overlapping and resolve
|
|
|
250
253
|
const result = await resultPromise
|
|
251
254
|
assert.equal(result.ok, true)
|
|
252
255
|
assert.equal(requestCount, 3)
|
|
256
|
+
assert.deepEqual(backgroundRequests, [false, true, true])
|
|
253
257
|
assert.equal(state.closeCount, 1)
|
|
254
258
|
assert.equal(state.intervals.has(intervalId), false)
|
|
255
259
|
assert.ok(state.clearedIntervals.has(intervalId))
|
|
256
260
|
})
|
|
257
261
|
|
|
262
|
+
test("background pending response keeps the current blocker without rerendering", async (t) => {
|
|
263
|
+
const { api, dom, state } = await createHarness()
|
|
264
|
+
t.after(() => dom.window.close())
|
|
265
|
+
|
|
266
|
+
let requestCount = 0
|
|
267
|
+
const shown = api.request(async () => {
|
|
268
|
+
requestCount += 1
|
|
269
|
+
if (requestCount === 1) {
|
|
270
|
+
return response({
|
|
271
|
+
error: {
|
|
272
|
+
code: "PINOKIO_PATH_REMOVE_BLOCKED",
|
|
273
|
+
operation: "Delete app",
|
|
274
|
+
blockers: [{ name: "Original blocker", pid: 10 }],
|
|
275
|
+
remainingCount: 1,
|
|
276
|
+
remaining: ["C:\\app\\locked.dll"],
|
|
277
|
+
},
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
return response({ success: false, pending: true })
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
await waitFor(() => state.fireOptions, "path-removal modal")
|
|
284
|
+
await state.runInterval()
|
|
285
|
+
assert.equal(requestCount, 2)
|
|
286
|
+
assert.equal(state.visible, true)
|
|
287
|
+
assert.match(state.htmlContainer.textContent, /Original blocker/)
|
|
288
|
+
|
|
289
|
+
state.dismiss()
|
|
290
|
+
assert.equal((await shown).cancelled, true)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test("Stop remains available during a background retry and ignores its late response", async (t) => {
|
|
294
|
+
const { api, dom, state } = await createHarness()
|
|
295
|
+
t.after(() => dom.window.close())
|
|
296
|
+
|
|
297
|
+
let resolveRetry
|
|
298
|
+
const retryResponse = new Promise((resolve) => {
|
|
299
|
+
resolveRetry = resolve
|
|
300
|
+
})
|
|
301
|
+
const shown = api.show({
|
|
302
|
+
details: {
|
|
303
|
+
operation: "Delete app",
|
|
304
|
+
blockers: [{ name: "Original blocker", pid: 10 }],
|
|
305
|
+
remainingCount: 1,
|
|
306
|
+
remaining: ["C:\\app\\original.dll"],
|
|
307
|
+
},
|
|
308
|
+
retry: async () => await retryResponse,
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
const attempt = state.runInterval()
|
|
312
|
+
assert.equal(state.buttonsDisabled, false)
|
|
313
|
+
assert.equal(state.dismiss(), true)
|
|
314
|
+
resolveRetry({
|
|
315
|
+
code: "PINOKIO_PATH_REMOVE_BLOCKED",
|
|
316
|
+
operation: "Delete app",
|
|
317
|
+
blockers: [{ name: "Late blocker", pid: 20 }],
|
|
318
|
+
remainingCount: 1,
|
|
319
|
+
remaining: ["C:\\app\\late.dll"],
|
|
320
|
+
})
|
|
321
|
+
await attempt
|
|
322
|
+
|
|
323
|
+
assert.equal((await shown).cancelled, true)
|
|
324
|
+
assert.match(state.htmlContainer.textContent, /Original blocker/)
|
|
325
|
+
assert.doesNotMatch(state.htmlContainer.textContent, /Late blocker/)
|
|
326
|
+
})
|
|
327
|
+
|
|
258
328
|
test("manual retry keeps the modal open when blocked and Stop cancels future retries", async (t) => {
|
|
259
329
|
const { api, dom, state } = await createHarness()
|
|
260
330
|
t.after(() => dom.window.close())
|
|
@@ -316,3 +386,137 @@ test("manual retry escapes unexpected error messages", async (t) => {
|
|
|
316
386
|
state.dismiss()
|
|
317
387
|
assert.equal((await shown).cancelled, true)
|
|
318
388
|
})
|
|
389
|
+
|
|
390
|
+
test("successful runtime cleanup resubmits the interrupted install", async (t) => {
|
|
391
|
+
const source = await fs.readFile(installViewPath, "utf8")
|
|
392
|
+
const requirements = '[{"name":"conda"}]'
|
|
393
|
+
const html = ejs.render(source, {
|
|
394
|
+
agent: "web",
|
|
395
|
+
callback: "/tools",
|
|
396
|
+
fresh: false,
|
|
397
|
+
requirements,
|
|
398
|
+
theme: "dark",
|
|
399
|
+
}, { filename: installViewPath })
|
|
400
|
+
|
|
401
|
+
let rpc
|
|
402
|
+
let submission
|
|
403
|
+
let submitted
|
|
404
|
+
const submittedPromise = new Promise((resolve) => { submitted = resolve })
|
|
405
|
+
const dom = new JSDOM(html, {
|
|
406
|
+
runScripts: "dangerously",
|
|
407
|
+
url: "http://localhost/pinokio/install",
|
|
408
|
+
beforeParse(window) {
|
|
409
|
+
window.fetch = async () => ({ json: async () => ({ config: {} }) })
|
|
410
|
+
window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} })
|
|
411
|
+
window.ResizeObserver = class { observe() {} }
|
|
412
|
+
window.N = class { Noty() {} }
|
|
413
|
+
window.Terminal = class {
|
|
414
|
+
constructor() { this.cols = 80; this.rows = 24 }
|
|
415
|
+
attachCustomKeyEventHandler() {}
|
|
416
|
+
focus() {}
|
|
417
|
+
hasSelection() { return false }
|
|
418
|
+
loadAddon() {}
|
|
419
|
+
onData() {}
|
|
420
|
+
open() {}
|
|
421
|
+
write() {}
|
|
422
|
+
}
|
|
423
|
+
window.FitAddon = { FitAddon: class { fit() {} } }
|
|
424
|
+
window.WebLinksAddon = { WebLinksAddon: class {} }
|
|
425
|
+
window.xtermTheme = { FrontEndDelight: {} }
|
|
426
|
+
window.PinokioTouch = { bindTerminalFocus() {} }
|
|
427
|
+
window.PinokioPathRemoval = {
|
|
428
|
+
blockedDetails: () => null,
|
|
429
|
+
show: async () => ({ success: true }),
|
|
430
|
+
}
|
|
431
|
+
window.Socket = class {
|
|
432
|
+
emit() {}
|
|
433
|
+
run(request, onpacket) {
|
|
434
|
+
rpc = request
|
|
435
|
+
window.setTimeout(() => onpacket({
|
|
436
|
+
type: "path.remove.blocked",
|
|
437
|
+
data: { code: "PINOKIO_PATH_REMOVE_BLOCKED" },
|
|
438
|
+
}), 0)
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
window.HTMLFormElement.prototype.submit = function () {
|
|
442
|
+
submission = {
|
|
443
|
+
method: this.method,
|
|
444
|
+
pathname: new URL(this.action).pathname,
|
|
445
|
+
fields: Object.fromEntries(new window.FormData(this)),
|
|
446
|
+
}
|
|
447
|
+
submitted()
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
})
|
|
451
|
+
t.after(() => dom.window.close())
|
|
452
|
+
|
|
453
|
+
await submittedPromise
|
|
454
|
+
assert.equal(rpc.params, requirements)
|
|
455
|
+
assert.deepEqual(submission, {
|
|
456
|
+
method: "post",
|
|
457
|
+
pathname: "/pinokio/install",
|
|
458
|
+
fields: { requirements, callback: "/tools" },
|
|
459
|
+
})
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
test("fresh install removes bin on the install page before starting the installer", async (t) => {
|
|
463
|
+
const source = await fs.readFile(installViewPath, "utf8")
|
|
464
|
+
const requirements = '[{"name":"conda"}]'
|
|
465
|
+
const html = ejs.render(source, {
|
|
466
|
+
agent: "web",
|
|
467
|
+
callback: "/tools",
|
|
468
|
+
fresh: true,
|
|
469
|
+
requirements,
|
|
470
|
+
theme: "dark",
|
|
471
|
+
}, { filename: installViewPath })
|
|
472
|
+
|
|
473
|
+
let removal
|
|
474
|
+
let started
|
|
475
|
+
const startedPromise = new Promise((resolve) => { started = resolve })
|
|
476
|
+
const dom = new JSDOM(html, {
|
|
477
|
+
runScripts: "dangerously",
|
|
478
|
+
url: "http://localhost/pinokio/install",
|
|
479
|
+
beforeParse(window) {
|
|
480
|
+
window.fetch = async (url, options) => {
|
|
481
|
+
if (url === "/pinokio/delete") {
|
|
482
|
+
removal = { url, options }
|
|
483
|
+
return { ok: true, status: 200, json: async () => ({ success: true }) }
|
|
484
|
+
}
|
|
485
|
+
return { ok: true, status: 200, json: async () => ({ config: {} }) }
|
|
486
|
+
}
|
|
487
|
+
window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} })
|
|
488
|
+
window.ResizeObserver = class { observe() {} }
|
|
489
|
+
window.N = class { Noty() {} }
|
|
490
|
+
window.Terminal = class {
|
|
491
|
+
constructor() { this.cols = 80; this.rows = 24 }
|
|
492
|
+
attachCustomKeyEventHandler() {}
|
|
493
|
+
focus() {}
|
|
494
|
+
hasSelection() { return false }
|
|
495
|
+
loadAddon() {}
|
|
496
|
+
onData() {}
|
|
497
|
+
open() {}
|
|
498
|
+
write() {}
|
|
499
|
+
}
|
|
500
|
+
window.FitAddon = { FitAddon: class { fit() {} } }
|
|
501
|
+
window.WebLinksAddon = { WebLinksAddon: class {} }
|
|
502
|
+
window.xtermTheme = { FrontEndDelight: {} }
|
|
503
|
+
window.PinokioTouch = { bindTerminalFocus() {} }
|
|
504
|
+
window.PinokioPathRemoval = {
|
|
505
|
+
request: async (remove) => (await remove(false)).json(),
|
|
506
|
+
}
|
|
507
|
+
window.Socket = class {
|
|
508
|
+
emit() {}
|
|
509
|
+
run(request) {
|
|
510
|
+
assert.ok(removal, "fresh removal must finish before the installer starts")
|
|
511
|
+
assert.equal(request.params, requirements)
|
|
512
|
+
started()
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
})
|
|
517
|
+
t.after(() => dom.window.close())
|
|
518
|
+
|
|
519
|
+
await startedPromise
|
|
520
|
+
assert.equal(removal.url, "/pinokio/delete")
|
|
521
|
+
assert.deepEqual(JSON.parse(removal.options.body), { type: "bin", background: false })
|
|
522
|
+
})
|
|
@@ -6,6 +6,7 @@ const path = require("path")
|
|
|
6
6
|
|
|
7
7
|
const {
|
|
8
8
|
PATH_REMOVAL_BLOCKED_CODE,
|
|
9
|
+
PATH_REMOVAL_PENDING_CODE,
|
|
9
10
|
collectRemaining,
|
|
10
11
|
createPathRemover,
|
|
11
12
|
} = require("../kernel/path_removal")
|
|
@@ -105,6 +106,30 @@ test("removePath reports Windows blockers after partial recursive deletion and s
|
|
|
105
106
|
assert.equal(await fs.promises.access(root).then(() => true).catch(() => false), false)
|
|
106
107
|
})
|
|
107
108
|
|
|
109
|
+
test("background retry attempts removal without repeating diagnostics", async () => {
|
|
110
|
+
let inspected = false
|
|
111
|
+
let scanned = false
|
|
112
|
+
const removePath = createPathRemover({
|
|
113
|
+
platform: "win32",
|
|
114
|
+
fsPromises: {
|
|
115
|
+
rm: async () => { throw Object.assign(new Error("busy"), { code: "EPERM" }) },
|
|
116
|
+
lstat: async () => { scanned = true },
|
|
117
|
+
readdir: async () => [],
|
|
118
|
+
},
|
|
119
|
+
inspectLocks: async () => {
|
|
120
|
+
inspected = true
|
|
121
|
+
return { blockers: [] }
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
await assert.rejects(
|
|
126
|
+
removePath("C:\\pinokio\\bin", { recursive: true, force: true, diagnose: false }),
|
|
127
|
+
(error) => error.code === PATH_REMOVAL_PENDING_CODE,
|
|
128
|
+
)
|
|
129
|
+
assert.equal(scanned, false)
|
|
130
|
+
assert.equal(inspected, false)
|
|
131
|
+
})
|
|
132
|
+
|
|
108
133
|
test("removePath reports and logs an unattributed Windows removal failure without claiming a file lock", async (t) => {
|
|
109
134
|
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pinokio-remove-denied-"))
|
|
110
135
|
t.after(() => fs.promises.rm(root, { recursive: true, force: true }))
|
|
@@ -420,6 +420,58 @@ if (!serverMode) test('/pinokio/delete refreshes top-level git URI inventory aft
|
|
|
420
420
|
})
|
|
421
421
|
})
|
|
422
422
|
|
|
423
|
+
if (!serverMode) test('/pinokio/delete rejects app paths outside the API root', async () => {
|
|
424
|
+
await withRouteServer(async ({ baseUrl, root, apiRoot }) => {
|
|
425
|
+
const outside = path.join(root, 'outside')
|
|
426
|
+
const outsideFile = path.join(outside, 'keep.txt')
|
|
427
|
+
await fsp.mkdir(outside, { recursive: true })
|
|
428
|
+
await fsp.writeFile(outsideFile, 'keep')
|
|
429
|
+
|
|
430
|
+
for (const name of ['../../outside', outside]) {
|
|
431
|
+
const result = await fetchJson(baseUrl, '/pinokio/delete', {
|
|
432
|
+
method: 'POST',
|
|
433
|
+
body: { name },
|
|
434
|
+
})
|
|
435
|
+
assert.equal(result.response.status, 400)
|
|
436
|
+
assert.equal(result.json.error, 'Invalid app deletion path')
|
|
437
|
+
await fsp.access(outsideFile)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const linked = path.join(apiRoot, 'linked-outside')
|
|
441
|
+
await fsp.symlink(outside, linked, process.platform === 'win32' ? 'junction' : 'dir')
|
|
442
|
+
const linkedResult = await fetchJson(baseUrl, '/pinokio/delete', {
|
|
443
|
+
method: 'POST',
|
|
444
|
+
body: { name: 'linked-outside/keep.txt' },
|
|
445
|
+
})
|
|
446
|
+
assert.equal(linkedResult.response.status, 400)
|
|
447
|
+
await fsp.access(outsideFile)
|
|
448
|
+
|
|
449
|
+
const crossSiteTarget = path.join(apiRoot, 'cross-site-target')
|
|
450
|
+
await fsp.mkdir(crossSiteTarget)
|
|
451
|
+
const crossSiteResult = await fetchJson(baseUrl, '/pinokio/delete', {
|
|
452
|
+
method: 'POST',
|
|
453
|
+
headers: { 'sec-fetch-site': 'cross-site' },
|
|
454
|
+
body: { name: 'cross-site-target' },
|
|
455
|
+
})
|
|
456
|
+
assert.equal(crossSiteResult.response.status, 403)
|
|
457
|
+
await fsp.access(crossSiteTarget)
|
|
458
|
+
|
|
459
|
+
const sameSiteTarget = path.join(apiRoot, 'same-site-target')
|
|
460
|
+
await fsp.mkdir(sameSiteTarget)
|
|
461
|
+
const sameSiteResult = await fetchJson(baseUrl, '/pinokio/delete', {
|
|
462
|
+
method: 'POST',
|
|
463
|
+
headers: {
|
|
464
|
+
host: new URL(baseUrl).host,
|
|
465
|
+
origin: 'http://localhost:5173',
|
|
466
|
+
'sec-fetch-site': 'same-site',
|
|
467
|
+
},
|
|
468
|
+
body: { name: 'same-site-target' },
|
|
469
|
+
})
|
|
470
|
+
assert.equal(sameSiteResult.response.status, 403)
|
|
471
|
+
await fsp.access(sameSiteTarget)
|
|
472
|
+
})
|
|
473
|
+
})
|
|
474
|
+
|
|
423
475
|
if (!serverMode) test('/pinokio/upload move refreshes top-level git URI inventory', async () => {
|
|
424
476
|
await withRouteServer(async ({
|
|
425
477
|
baseUrl,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const assert = require("node:assert/strict")
|
|
2
|
+
const fs = require("node:fs/promises")
|
|
3
|
+
const path = require("node:path")
|
|
4
|
+
const test = require("node:test")
|
|
5
|
+
const ejs = require("ejs")
|
|
6
|
+
const { JSDOM } = require("jsdom")
|
|
7
|
+
|
|
8
|
+
const viewPath = path.resolve(__dirname, "../server/views/tools.ejs")
|
|
9
|
+
const setupViewPath = path.resolve(__dirname, "../server/views/setup.ejs")
|
|
10
|
+
|
|
11
|
+
async function renderTools() {
|
|
12
|
+
const source = await fs.readFile(viewPath, "utf8")
|
|
13
|
+
return ejs.render(source, {
|
|
14
|
+
agent: "web",
|
|
15
|
+
bundles: [{
|
|
16
|
+
name: "dev",
|
|
17
|
+
title: "Coding (Essential)",
|
|
18
|
+
description: "Essential tools",
|
|
19
|
+
install_required: false,
|
|
20
|
+
requirements: [{ name: "conda", type: "bin", installed: true }],
|
|
21
|
+
}],
|
|
22
|
+
installs: [],
|
|
23
|
+
pending: false,
|
|
24
|
+
theme: "dark",
|
|
25
|
+
}, {
|
|
26
|
+
filename: viewPath,
|
|
27
|
+
includer: () => ({ template: "" }),
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test("Tools reinstall immediately submits a fresh install request", async (t) => {
|
|
32
|
+
const html = await renderTools()
|
|
33
|
+
let submission
|
|
34
|
+
const dom = new JSDOM(html, {
|
|
35
|
+
url: "http://localhost/tools",
|
|
36
|
+
})
|
|
37
|
+
t.after(() => dom.window.close())
|
|
38
|
+
|
|
39
|
+
const form = dom.window.document.querySelector(".tools-reinstall-form")
|
|
40
|
+
form.addEventListener("submit", (event) => {
|
|
41
|
+
event.preventDefault()
|
|
42
|
+
submission = {
|
|
43
|
+
action: new URL(form.action).pathname,
|
|
44
|
+
fields: Object.fromEntries(new dom.window.FormData(form)),
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
const button = form.querySelector("button[type=submit]")
|
|
48
|
+
button.click()
|
|
49
|
+
|
|
50
|
+
assert.deepEqual(submission, {
|
|
51
|
+
action: "/pinokio/install",
|
|
52
|
+
fields: {
|
|
53
|
+
requirements: '[{"name":"conda","type":"bin","installed":true}]',
|
|
54
|
+
callback: "/tools",
|
|
55
|
+
fresh: "1",
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test("setup fresh install is a direct form submission", async () => {
|
|
61
|
+
const source = await fs.readFile(setupViewPath, "utf8")
|
|
62
|
+
const start = source.indexOf('<form class="task-hidden" id="fresh-install-form"')
|
|
63
|
+
const end = source.indexOf("</div>", source.indexOf("</button>", start)) + 6
|
|
64
|
+
const fragment = ejs.render(source.slice(start, end), {
|
|
65
|
+
requirementList: [{ name: "conda", installed: true }],
|
|
66
|
+
targetPath: "/tools",
|
|
67
|
+
})
|
|
68
|
+
const dom = new JSDOM(fragment, { url: "http://localhost/setup/dev" })
|
|
69
|
+
const form = dom.window.document.querySelector("#fresh-install-form")
|
|
70
|
+
let submission
|
|
71
|
+
form.addEventListener("submit", (event) => {
|
|
72
|
+
event.preventDefault()
|
|
73
|
+
submission = Object.fromEntries(new dom.window.FormData(form))
|
|
74
|
+
})
|
|
75
|
+
dom.window.document.querySelector('[form="fresh-install-form"]').click()
|
|
76
|
+
|
|
77
|
+
assert.deepEqual(submission, {
|
|
78
|
+
requirements: '[{"name":"conda","installed":true}]',
|
|
79
|
+
callback: "/tools",
|
|
80
|
+
fresh: "1",
|
|
81
|
+
})
|
|
82
|
+
dom.window.close()
|
|
83
|
+
assert.doesNotMatch(source, /id="del-bin"|setup-reset-loading/)
|
|
84
|
+
})
|