pinokiod 8.0.12 → 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.
@@ -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
- trace("rm:failed", `elapsedMs=${Date.now() - removeStartedAt} code=${error && error.code ? error.code : "unknown"}`)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.12",
3
+ "version": "8.0.13",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
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
- let folderPath = this.kernel.path("api", req.body.name)
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
@@ -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
 
@@ -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")
@@ -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()
@@ -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()
@@ -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
- setTimeout(() => window.location.reload(), 0)
889
+ resumeInstall()
876
890
  } else if (result && result.cancelled) {
877
891
  navigateToCallback()
878
892
  }
@@ -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")
@@ -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")
@@ -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,
@@ -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, true)
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,74 @@ 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
+ 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
+ })