pinokiod 8.0.11 → 8.0.13
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/kernel/windows_restart_manager.js +3 -3
- package/package.json +1 -1
- package/server/index.js +53 -2
- package/server/public/common.js +16 -7
- 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 +17 -3
- package/server/views/net.ejs +2 -2
- package/server/views/settings.ejs +4 -4
- package/server/views/setup.ejs +2 -2
- package/server/views/terminal.ejs +2 -2
- package/server/views/tools.ejs +53 -0
- package/test/path-removal-ui.test.js +146 -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 +104 -0
- package/test/windows-path-removal.integration.test.js +6 -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
|
}
|
|
@@ -17,15 +17,15 @@ const initialize = () => {
|
|
|
17
17
|
const koffi = require("koffi")
|
|
18
18
|
const library = koffi.load("rstrtmgr.dll")
|
|
19
19
|
const kernel32 = koffi.load("kernel32.dll")
|
|
20
|
-
const fileTime = koffi.struct(
|
|
20
|
+
const fileTime = koffi.struct({
|
|
21
21
|
dwLowDateTime: "uint32_t",
|
|
22
22
|
dwHighDateTime: "uint32_t",
|
|
23
23
|
})
|
|
24
|
-
const uniqueProcess = koffi.struct(
|
|
24
|
+
const uniqueProcess = koffi.struct({
|
|
25
25
|
dwProcessId: "uint32_t",
|
|
26
26
|
ProcessStartTime: fileTime,
|
|
27
27
|
})
|
|
28
|
-
const processInfo = koffi.struct(
|
|
28
|
+
const processInfo = koffi.struct({
|
|
29
29
|
Process: uniqueProcess,
|
|
30
30
|
strAppName: koffi.array("char16_t", 256, "String"),
|
|
31
31
|
strServiceShortName: koffi.array("char16_t", 64, "String"),
|
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
|
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
|
}
|
|
@@ -110,15 +110,24 @@ const PinokioPathRemoval = (() => {
|
|
|
110
110
|
|
|
111
111
|
const update = () => {
|
|
112
112
|
const container = Swal.getHtmlContainer()
|
|
113
|
-
if (container)
|
|
113
|
+
if (container) {
|
|
114
|
+
const filesOpen = !!container.querySelector(".pinokio-path-blocker-files")?.open
|
|
115
|
+
container.innerHTML = render(current)
|
|
116
|
+
const files = container.querySelector(".pinokio-path-blocker-files")
|
|
117
|
+
if (files) files.open = filesOpen
|
|
118
|
+
}
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
const attempt = async (manual = false) => {
|
|
117
122
|
if (retrying || settled || typeof retry !== "function") return false
|
|
118
123
|
retrying = true
|
|
119
|
-
if (typeof Swal.disableButtons === "function") Swal.disableButtons()
|
|
124
|
+
if (manual && typeof Swal.disableButtons === "function") Swal.disableButtons()
|
|
120
125
|
try {
|
|
121
|
-
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
|
+
}
|
|
122
131
|
const nextBlocked = blockedDetails(result)
|
|
123
132
|
if (nextBlocked) {
|
|
124
133
|
current = nextBlocked
|
|
@@ -133,7 +142,7 @@ const PinokioPathRemoval = (() => {
|
|
|
133
142
|
return false
|
|
134
143
|
} finally {
|
|
135
144
|
retrying = false
|
|
136
|
-
if (!settled && typeof Swal.enableButtons === "function") Swal.enableButtons()
|
|
145
|
+
if (manual && !settled && typeof Swal.enableButtons === "function") Swal.enableButtons()
|
|
137
146
|
}
|
|
138
147
|
}
|
|
139
148
|
|
|
@@ -164,7 +173,7 @@ const PinokioPathRemoval = (() => {
|
|
|
164
173
|
}
|
|
165
174
|
|
|
166
175
|
const request = async (requestFactory) => {
|
|
167
|
-
const execute = async () => parseResponse(await requestFactory())
|
|
176
|
+
const execute = async (background = false) => parseResponse(await requestFactory(background))
|
|
168
177
|
const initial = await execute()
|
|
169
178
|
const details = blockedDetails(initial)
|
|
170
179
|
if (!details) {
|
|
@@ -175,7 +184,7 @@ const PinokioPathRemoval = (() => {
|
|
|
175
184
|
}
|
|
176
185
|
return show({
|
|
177
186
|
details,
|
|
178
|
-
retry: execute,
|
|
187
|
+
retry: (manual) => execute(!manual),
|
|
179
188
|
})
|
|
180
189
|
}
|
|
181
190
|
|
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
|
@@ -731,6 +731,20 @@ const navigateToCallback = () => {
|
|
|
731
731
|
}
|
|
732
732
|
location.href = href
|
|
733
733
|
}
|
|
734
|
+
const resumeInstall = () => {
|
|
735
|
+
const form = document.createElement("form")
|
|
736
|
+
form.method = "post"
|
|
737
|
+
form.action = "/pinokio/install"
|
|
738
|
+
for (const name of ["requirements", "callback"]) {
|
|
739
|
+
const input = document.createElement("input")
|
|
740
|
+
input.type = "hidden"
|
|
741
|
+
input.name = name
|
|
742
|
+
input.value = document.querySelector(`#${name}`).value
|
|
743
|
+
form.appendChild(input)
|
|
744
|
+
}
|
|
745
|
+
document.body.appendChild(form)
|
|
746
|
+
form.submit()
|
|
747
|
+
}
|
|
734
748
|
const createTerm = async (_theme) => {
|
|
735
749
|
//const theme = Object.assign({ }, xtermTheme.Terminal_Basic, {
|
|
736
750
|
const theme = Object.assign({ }, _theme, {
|
|
@@ -854,13 +868,13 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|
|
854
868
|
} else if (packet.type === "path.remove.blocked") {
|
|
855
869
|
const result = await PinokioPathRemoval.show({
|
|
856
870
|
details: packet.data,
|
|
857
|
-
retry: async () => {
|
|
871
|
+
retry: async (manual) => {
|
|
858
872
|
const response = await fetch("/pinokio/delete", {
|
|
859
873
|
method: "post",
|
|
860
874
|
headers: {
|
|
861
875
|
"Content-Type": "application/json"
|
|
862
876
|
},
|
|
863
|
-
body: JSON.stringify({ type: "conda-runtime" })
|
|
877
|
+
body: JSON.stringify({ type: "conda-runtime", background: !manual })
|
|
864
878
|
})
|
|
865
879
|
const payload = await response.json()
|
|
866
880
|
const blocked = PinokioPathRemoval.blockedDetails(payload)
|
|
@@ -872,7 +886,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|
|
872
886
|
}
|
|
873
887
|
})
|
|
874
888
|
if (result && result.success) {
|
|
875
|
-
|
|
889
|
+
resumeInstall()
|
|
876
890
|
} else if (result && result.cancelled) {
|
|
877
891
|
navigateToCallback()
|
|
878
892
|
}
|
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
|
@@ -389,12 +389,12 @@
|
|
|
389
389
|
if (proceed) {
|
|
390
390
|
document.querySelector(".reset-bin-loading").classList.remove("hidden")
|
|
391
391
|
document.querySelector("#del-bin").classList.add("hidden")
|
|
392
|
-
let res = await PinokioPathRemoval.request(() => fetch("/pinokio/delete", {
|
|
392
|
+
let res = await PinokioPathRemoval.request((background) => fetch("/pinokio/delete", {
|
|
393
393
|
method: "post",
|
|
394
394
|
headers: {
|
|
395
395
|
"Content-Type": "application/json"
|
|
396
396
|
},
|
|
397
|
-
body: JSON.stringify({ type: "bin" })
|
|
397
|
+
body: JSON.stringify({ type: "bin", background })
|
|
398
398
|
}))
|
|
399
399
|
document.querySelector(".reset-bin-loading").classList.add("hidden")
|
|
400
400
|
if (res.cancelled) {
|
|
@@ -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,15 @@
|
|
|
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
|
+
<button class="task-link-button" type="button" data-tools-reinstall data-bundle-title="<%= bundle.title %>">
|
|
505
|
+
<i class="fa-solid fa-rotate" aria-hidden="true"></i>
|
|
506
|
+
<span>Reinstall</span>
|
|
507
|
+
</button>
|
|
508
|
+
</form>
|
|
497
509
|
<% } %>
|
|
498
510
|
</div>
|
|
499
511
|
</article>
|
|
@@ -828,6 +840,46 @@
|
|
|
828
840
|
});
|
|
829
841
|
};
|
|
830
842
|
|
|
843
|
+
const registerToolsReinstall = () => {
|
|
844
|
+
document.querySelectorAll("[data-tools-reinstall]").forEach((button) => {
|
|
845
|
+
button.addEventListener("click", async () => {
|
|
846
|
+
const bundleTitle = button.dataset.bundleTitle || "this bundle";
|
|
847
|
+
const proceed = confirm(`Reinstall ${bundleTitle} from scratch? This removes the entire managed tools folder before reinstalling the selected bundle.`);
|
|
848
|
+
if (!proceed) return;
|
|
849
|
+
|
|
850
|
+
const form = button.closest("form");
|
|
851
|
+
const icon = button.querySelector("i");
|
|
852
|
+
const restoreButton = () => {
|
|
853
|
+
button.disabled = false;
|
|
854
|
+
button.removeAttribute("aria-busy");
|
|
855
|
+
if (icon) icon.classList.remove("fa-spin");
|
|
856
|
+
};
|
|
857
|
+
button.disabled = true;
|
|
858
|
+
button.setAttribute("aria-busy", "true");
|
|
859
|
+
if (icon) icon.classList.add("fa-spin");
|
|
860
|
+
let result;
|
|
861
|
+
try {
|
|
862
|
+
result = await PinokioPathRemoval.request((background) => fetch("/pinokio/delete", {
|
|
863
|
+
method: "post",
|
|
864
|
+
headers: { "Content-Type": "application/json" },
|
|
865
|
+
body: JSON.stringify({ type: "bin", background })
|
|
866
|
+
}));
|
|
867
|
+
} catch (error) {
|
|
868
|
+
restoreButton();
|
|
869
|
+
alert(error && error.message ? error.message : "Unable to reset managed tools.");
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (result.cancelled || result.error) {
|
|
874
|
+
restoreButton();
|
|
875
|
+
if (result.error) alert(result.error);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
form.submit();
|
|
879
|
+
});
|
|
880
|
+
});
|
|
881
|
+
};
|
|
882
|
+
|
|
831
883
|
document.addEventListener("DOMContentLoaded", function() {
|
|
832
884
|
const isPending = <%= pending ? 'true' : 'false' %>;
|
|
833
885
|
|
|
@@ -840,6 +892,7 @@
|
|
|
840
892
|
|
|
841
893
|
registerPackageInstallSheets();
|
|
842
894
|
registerPackageInstallForms();
|
|
895
|
+
registerToolsReinstall();
|
|
843
896
|
|
|
844
897
|
const tabs = document.querySelectorAll(".tools-tab-button");
|
|
845
898
|
const panels = document.querySelectorAll(".tools-package-panel");
|
|
@@ -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,
|
|
@@ -221,10 +225,10 @@ test("path-removal request retries automatically without overlapping and resolve
|
|
|
221
225
|
await waitFor(() => state.fireOptions, "path-removal modal")
|
|
222
226
|
const intervalId = state.getRetryIntervalId()
|
|
223
227
|
assert.equal(state.intervals.get(intervalId).delay, 2000)
|
|
228
|
+
state.htmlContainer.querySelector(".pinokio-path-blocker-files").open = true
|
|
224
229
|
|
|
225
230
|
const firstAttempt = state.runInterval()
|
|
226
|
-
assert.equal(state.buttonsDisabled,
|
|
227
|
-
assert.equal(state.dismiss(), false, "Stop must be unavailable during an active removal")
|
|
231
|
+
assert.equal(state.buttonsDisabled, false)
|
|
228
232
|
assert.equal(state.visible, true)
|
|
229
233
|
await state.runInterval()
|
|
230
234
|
assert.equal(requestCount, 2, "a pending retry must suppress overlapping retries")
|
|
@@ -241,6 +245,7 @@ test("path-removal request retries automatically without overlapping and resolve
|
|
|
241
245
|
}))
|
|
242
246
|
await firstAttempt
|
|
243
247
|
assert.equal(state.buttonsDisabled, false)
|
|
248
|
+
assert.equal(state.htmlContainer.querySelector(".pinokio-path-blocker-files").open, true)
|
|
244
249
|
assert.match(state.htmlContainer.textContent, /Second blocker/)
|
|
245
250
|
assert.doesNotMatch(state.htmlContainer.textContent, /First blocker/)
|
|
246
251
|
|
|
@@ -248,11 +253,78 @@ test("path-removal request retries automatically without overlapping and resolve
|
|
|
248
253
|
const result = await resultPromise
|
|
249
254
|
assert.equal(result.ok, true)
|
|
250
255
|
assert.equal(requestCount, 3)
|
|
256
|
+
assert.deepEqual(backgroundRequests, [false, true, true])
|
|
251
257
|
assert.equal(state.closeCount, 1)
|
|
252
258
|
assert.equal(state.intervals.has(intervalId), false)
|
|
253
259
|
assert.ok(state.clearedIntervals.has(intervalId))
|
|
254
260
|
})
|
|
255
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
|
+
|
|
256
328
|
test("manual retry keeps the modal open when blocked and Stop cancels future retries", async (t) => {
|
|
257
329
|
const { api, dom, state } = await createHarness()
|
|
258
330
|
t.after(() => dom.window.close())
|
|
@@ -314,3 +386,74 @@ test("manual retry escapes unexpected error messages", async (t) => {
|
|
|
314
386
|
state.dismiss()
|
|
315
387
|
assert.equal((await shown).cancelled, true)
|
|
316
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
|
+
requirements,
|
|
397
|
+
theme: "dark",
|
|
398
|
+
}, { filename: installViewPath })
|
|
399
|
+
|
|
400
|
+
let rpc
|
|
401
|
+
let submission
|
|
402
|
+
let submitted
|
|
403
|
+
const submittedPromise = new Promise((resolve) => { submitted = resolve })
|
|
404
|
+
const dom = new JSDOM(html, {
|
|
405
|
+
runScripts: "dangerously",
|
|
406
|
+
url: "http://localhost/pinokio/install",
|
|
407
|
+
beforeParse(window) {
|
|
408
|
+
window.fetch = async () => ({ json: async () => ({ config: {} }) })
|
|
409
|
+
window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} })
|
|
410
|
+
window.ResizeObserver = class { observe() {} }
|
|
411
|
+
window.N = class { Noty() {} }
|
|
412
|
+
window.Terminal = class {
|
|
413
|
+
constructor() { this.cols = 80; this.rows = 24 }
|
|
414
|
+
attachCustomKeyEventHandler() {}
|
|
415
|
+
focus() {}
|
|
416
|
+
hasSelection() { return false }
|
|
417
|
+
loadAddon() {}
|
|
418
|
+
onData() {}
|
|
419
|
+
open() {}
|
|
420
|
+
write() {}
|
|
421
|
+
}
|
|
422
|
+
window.FitAddon = { FitAddon: class { fit() {} } }
|
|
423
|
+
window.WebLinksAddon = { WebLinksAddon: class {} }
|
|
424
|
+
window.xtermTheme = { FrontEndDelight: {} }
|
|
425
|
+
window.PinokioTouch = { bindTerminalFocus() {} }
|
|
426
|
+
window.PinokioPathRemoval = {
|
|
427
|
+
blockedDetails: () => null,
|
|
428
|
+
show: async () => ({ success: true }),
|
|
429
|
+
}
|
|
430
|
+
window.Socket = class {
|
|
431
|
+
emit() {}
|
|
432
|
+
run(request, onpacket) {
|
|
433
|
+
rpc = request
|
|
434
|
+
window.setTimeout(() => onpacket({
|
|
435
|
+
type: "path.remove.blocked",
|
|
436
|
+
data: { code: "PINOKIO_PATH_REMOVE_BLOCKED" },
|
|
437
|
+
}), 0)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
window.HTMLFormElement.prototype.submit = function () {
|
|
441
|
+
submission = {
|
|
442
|
+
method: this.method,
|
|
443
|
+
pathname: new URL(this.action).pathname,
|
|
444
|
+
fields: Object.fromEntries(new window.FormData(this)),
|
|
445
|
+
}
|
|
446
|
+
submitted()
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
})
|
|
450
|
+
t.after(() => dom.window.close())
|
|
451
|
+
|
|
452
|
+
await submittedPromise
|
|
453
|
+
assert.equal(rpc.params, requirements)
|
|
454
|
+
assert.deepEqual(submission, {
|
|
455
|
+
method: "post",
|
|
456
|
+
pathname: "/pinokio/install",
|
|
457
|
+
fields: { requirements, callback: "/tools" },
|
|
458
|
+
})
|
|
459
|
+
})
|
|
@@ -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,104 @@
|
|
|
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
|
+
|
|
10
|
+
async function renderTools() {
|
|
11
|
+
const source = await fs.readFile(viewPath, "utf8")
|
|
12
|
+
return ejs.render(source, {
|
|
13
|
+
agent: "web",
|
|
14
|
+
bundles: [{
|
|
15
|
+
name: "dev",
|
|
16
|
+
title: "Coding (Essential)",
|
|
17
|
+
description: "Essential tools",
|
|
18
|
+
install_required: false,
|
|
19
|
+
requirements: [{ name: "conda", type: "bin", installed: true }],
|
|
20
|
+
}],
|
|
21
|
+
installs: [],
|
|
22
|
+
pending: false,
|
|
23
|
+
theme: "dark",
|
|
24
|
+
}, {
|
|
25
|
+
filename: viewPath,
|
|
26
|
+
includer: () => ({ template: "" }),
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("Tools reinstall removes bin and submits the selected bundle", async (t) => {
|
|
31
|
+
const html = await renderTools()
|
|
32
|
+
let request
|
|
33
|
+
let submission
|
|
34
|
+
const dom = new JSDOM(html, {
|
|
35
|
+
runScripts: "dangerously",
|
|
36
|
+
url: "http://localhost/tools",
|
|
37
|
+
beforeParse(window) {
|
|
38
|
+
window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} })
|
|
39
|
+
window.requestAnimationFrame = (callback) => window.setTimeout(callback, 0)
|
|
40
|
+
window.confirm = () => true
|
|
41
|
+
window.PinokioPathRemoval = {
|
|
42
|
+
request: async (remove) => {
|
|
43
|
+
request = await remove(false)
|
|
44
|
+
return { success: true }
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
window.fetch = async (url, options) => ({ url, options })
|
|
48
|
+
window.HTMLFormElement.prototype.submit = function () {
|
|
49
|
+
submission = {
|
|
50
|
+
action: new URL(this.action).pathname,
|
|
51
|
+
fields: Object.fromEntries(new window.FormData(this)),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
t.after(() => dom.window.close())
|
|
57
|
+
|
|
58
|
+
await new Promise((resolve) => dom.window.setTimeout(resolve, 0))
|
|
59
|
+
const button = dom.window.document.querySelector("[data-tools-reinstall]")
|
|
60
|
+
assert.ok(button)
|
|
61
|
+
button.click()
|
|
62
|
+
await new Promise((resolve) => dom.window.setTimeout(resolve, 0))
|
|
63
|
+
|
|
64
|
+
assert.equal(request.url, "/pinokio/delete")
|
|
65
|
+
assert.equal(request.options.method, "post")
|
|
66
|
+
assert.deepEqual(JSON.parse(request.options.body), { type: "bin", background: false })
|
|
67
|
+
assert.deepEqual(submission, {
|
|
68
|
+
action: "/pinokio/install",
|
|
69
|
+
fields: {
|
|
70
|
+
requirements: '[{"name":"conda","type":"bin","installed":true}]',
|
|
71
|
+
callback: "/tools",
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("Tools reinstall keeps the bundle intact when the user cancels", async (t) => {
|
|
77
|
+
const html = await renderTools()
|
|
78
|
+
let removed = false
|
|
79
|
+
let submitted = false
|
|
80
|
+
const dom = new JSDOM(html, {
|
|
81
|
+
runScripts: "dangerously",
|
|
82
|
+
url: "http://localhost/tools",
|
|
83
|
+
beforeParse(window) {
|
|
84
|
+
window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} })
|
|
85
|
+
window.requestAnimationFrame = (callback) => window.setTimeout(callback, 0)
|
|
86
|
+
window.fetch = async () => ({ ok: true, json: async () => ({ status: "off" }) })
|
|
87
|
+
window.confirm = () => false
|
|
88
|
+
window.PinokioPathRemoval = {
|
|
89
|
+
request: async () => {
|
|
90
|
+
removed = true
|
|
91
|
+
return { success: true }
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
window.HTMLFormElement.prototype.submit = () => { submitted = true }
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
t.after(() => dom.window.close())
|
|
98
|
+
|
|
99
|
+
await new Promise((resolve) => dom.window.setTimeout(resolve, 0))
|
|
100
|
+
dom.window.document.querySelector("[data-tools-reinstall]").click()
|
|
101
|
+
await new Promise((resolve) => dom.window.setTimeout(resolve, 0))
|
|
102
|
+
assert.equal(removed, false)
|
|
103
|
+
assert.equal(submitted, false)
|
|
104
|
+
})
|
|
@@ -68,6 +68,12 @@ test("Windows integration: loaded DLL is diagnosed and removable after release",
|
|
|
68
68
|
)
|
|
69
69
|
assert.equal(await fs.promises.access(ordinaryFile).then(() => true).catch(() => false), false)
|
|
70
70
|
|
|
71
|
+
const restartManagerPath = require.resolve("../kernel/windows_restart_manager")
|
|
72
|
+
delete require.cache[restartManagerPath]
|
|
73
|
+
const reloadedRestartManager = require(restartManagerPath)
|
|
74
|
+
const reloadedInspection = await reloadedRestartManager.inspectWindowsFileLocks([dllPath])
|
|
75
|
+
assert.equal(reloadedInspection.blockers.some((blocker) => blocker.pid === child.pid), true)
|
|
76
|
+
|
|
71
77
|
const exited = new Promise((resolve) => child.once("exit", resolve))
|
|
72
78
|
child.stdin.write("release\n")
|
|
73
79
|
await exited
|