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.
@@ -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.14",
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
@@ -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) => {
@@ -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;
@@ -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()
@@ -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
- setTimeout(() => window.location.reload(), 0)
914
+ resumeInstall()
876
915
  } else if (result && result.cancelled) {
877
916
  navigateToCallback()
878
917
  }
@@ -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")
@@ -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="button" id="del-bin">
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")
@@ -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, 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,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
+ })