pinokiod 8.0.3 → 8.0.5

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.
@@ -8,6 +8,7 @@ const Util = require('../util')
8
8
  const ADMIN_READY_TIMEOUT_MS = 30000
9
9
  const ADMIN_START_TIMEOUT_MS = 5000
10
10
  const ADMIN_POLL_INTERVAL_MS = 250
11
+ const ADMIN_STOP_TIMEOUT_MS = 5000
11
12
 
12
13
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
13
14
 
@@ -29,6 +30,35 @@ class Caddy {
29
30
  return false
30
31
  }
31
32
  }
33
+ async waitForStopped(timeout = ADMIN_STOP_TIMEOUT_MS, interval = ADMIN_POLL_INTERVAL_MS) {
34
+ const started = Date.now()
35
+ while ((Date.now() - started) < timeout) {
36
+ if (!(await this.running())) {
37
+ return true
38
+ }
39
+ await sleep(interval)
40
+ }
41
+ return !(await this.running())
42
+ }
43
+ async stop() {
44
+ if (!(await this.running())) {
45
+ return false
46
+ }
47
+ try {
48
+ await axios.post('http://127.0.0.1:2019/stop', null, {
49
+ timeout: ADMIN_STOP_TIMEOUT_MS
50
+ })
51
+ } catch (error) {
52
+ if (await this.running()) {
53
+ throw error
54
+ }
55
+ }
56
+ const stopped = await this.waitForStopped()
57
+ if (!stopped) {
58
+ throw new Error("Pinokio could not stop Caddy before replacing the managed Conda runtime.")
59
+ }
60
+ return true
61
+ }
32
62
  async start() {
33
63
  // if peer.https_active is true,
34
64
  // 1. kill existing caddy
@@ -170,6 +170,16 @@ report_errors: false`)
170
170
  ondata({ raw: `downloading installer: ${installer_url}...\r\n` })
171
171
  await this.kernel.bin.download(installer_url, installer, ondata)
172
172
 
173
+ const caddy = this.kernel.bin.mod && this.kernel.bin.mod.caddy
174
+ let caddyWasRunning = false
175
+ if (caddy && typeof caddy.running === "function") {
176
+ caddyWasRunning = await caddy.running()
177
+ }
178
+ if (caddyWasRunning) {
179
+ ondata({ raw: `stopping Home Server before replacing Miniforge...\r\n` })
180
+ await caddy.stop()
181
+ }
182
+
173
183
  legacy_path_exists = await this.kernel.exists(`bin/${LEGACY_CONDA_ROOT_DIR}`)
174
184
  if (legacy_path_exists) {
175
185
  console.log("Removing legacy install path...", legacy_path)
@@ -202,8 +212,12 @@ report_errors: false`)
202
212
  await fs.promises.writeFile(this.kernel.path(`bin/${CONDA_ROOT_DIR}/conda-meta/pinned`), this.pinnedPackages())
203
213
  }
204
214
 
215
+ const dependencies = new Set(Array.isArray(req.dependencies) ? req.dependencies : [])
216
+ if (caddyWasRunning) {
217
+ dependencies.add("caddy")
218
+ }
205
219
  let mods = this.kernel.bin.mods.filter((m) => {
206
- return req.dependencies.includes(m.name)
220
+ return dependencies.has(m.name)
207
221
  }).map((m) => {
208
222
  if (m.mod.cmd) {
209
223
  return m.mod.cmd()
@@ -241,6 +255,10 @@ report_errors: false`)
241
255
  await this.ensureCompatibilityAlias()
242
256
  ondata({ raw: `Install finished\r\n` })
243
257
  await this.kernel.bin.rm(installer, ondata)
258
+ if (caddyWasRunning) {
259
+ ondata({ raw: `restarting Home Server after replacing Miniforge...\r\n` })
260
+ await caddy.start()
261
+ }
244
262
  }
245
263
  async removeInstallPath(target) {
246
264
  try {
@@ -42,5 +42,11 @@ class Connect {
42
42
  let res = await this.clients[provider].keys()
43
43
  return res
44
44
  }
45
+ async connected(provider, options) {
46
+ if (this.clients[provider] && this.clients[provider].connected) {
47
+ return await this.clients[provider].connected(options)
48
+ }
49
+ return false
50
+ }
45
51
  }
46
52
  module.exports = Connect
@@ -34,6 +34,13 @@ class Huggingface {
34
34
  }
35
35
  delete env.HF_TOKEN
36
36
  delete env.HUGGING_FACE_HUB_TOKEN
37
+ try {
38
+ await fs.promises.rmdir(env.HF_TOKEN_PATH)
39
+ } catch (error) {
40
+ if (!error || !["ENOENT", "ENOTDIR"].includes(error.code)) {
41
+ throw new Error(`Hugging Face token path must be a file: ${env.HF_TOKEN_PATH}`)
42
+ }
43
+ }
37
44
  await fs.promises.mkdir(path.dirname(env.HF_TOKEN_PATH), { recursive: true }).catch(() => {})
38
45
  return env
39
46
  }
@@ -52,12 +59,13 @@ class Huggingface {
52
59
  }
53
60
  return candidates[0]
54
61
  }
55
- async runHf(args) {
62
+ async runHf(args, options = {}) {
56
63
  const env = await this.authEnv()
57
64
  return new Promise((resolve, reject) => {
58
65
  execFile(this.hfPath(), args, {
59
66
  cwd: this.kernel.homedir,
60
67
  env,
68
+ timeout: Number.isFinite(options.timeout) ? options.timeout : undefined,
61
69
  maxBuffer: 1024 * 1024,
62
70
  }, (error, stdout, stderr) => {
63
71
  if (error) {
@@ -199,6 +207,14 @@ class Huggingface {
199
207
  return null
200
208
  }
201
209
  }
210
+ async connected(options = {}) {
211
+ try {
212
+ await this.runHf(["auth", "whoami", "--format", "quiet"], options)
213
+ return true
214
+ } catch (_) {
215
+ return false
216
+ }
217
+ }
202
218
  async profile() {
203
219
  const keys = await this.keys()
204
220
  if (!keys || !keys.access_token) {
@@ -489,21 +489,6 @@ const ENVS = async () => {
489
489
  "#",
490
490
  "##########################################################################",
491
491
  ],
492
- }, {
493
- type: ["system"],
494
- key: "HF_TOKEN_PATH",
495
- val: "./cache/HF_AUTH/token",
496
- comment: [
497
- "##########################################################################",
498
- "#",
499
- "# HF_TOKEN_PATH",
500
- "#",
501
- "# Hugging Face authentication token file",
502
- "# Pinokio keeps this shared across apps so each app can customize HF_HOME",
503
- "# for model/cache files without changing the logged-in Hugging Face account.",
504
- "#",
505
- "##########################################################################",
506
- ],
507
492
  }, {
508
493
  type: ["system", "app"],
509
494
  key: "TORCH_HOME",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.3",
3
+ "version": "8.0.5",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -28,6 +28,7 @@ const system = require('systeminformation')
28
28
  const serveIndex = require('./serveIndex')
29
29
  const registerFileRoutes = require('./routes/files')
30
30
  const registerAppRoutes = require('./routes/apps')
31
+ const registerConnectRoutes = require('./routes/connect')
31
32
  const ServerAutolaunch = require('./autolaunch')
32
33
  const Git = require("../kernel/git")
33
34
  const TerminalApi = require('../kernel/api/terminal')
@@ -1500,7 +1501,7 @@ class Server {
1500
1501
  )
1501
1502
  })
1502
1503
  }
1503
- async get_github_connection() {
1504
+ async get_github_connection(options = {}) {
1504
1505
  if (this._githubConnectionPromise) {
1505
1506
  return this._githubConnectionPromise
1506
1507
  }
@@ -1511,7 +1512,9 @@ class Server {
1511
1512
  let gcmError = null
1512
1513
 
1513
1514
  try {
1514
- const stdout = await this.github_gcm(['list'], { timeout: 10000 })
1515
+ const stdout = await this.github_gcm(['list'], {
1516
+ timeout: Number.isFinite(options.timeout) ? options.timeout : 10000
1517
+ })
1515
1518
  accounts = stdout
1516
1519
  .split(/\r?\n/)
1517
1520
  .map((line) => line.trim())
@@ -1550,7 +1553,7 @@ class Server {
1550
1553
  ? "echo P^INOKIO_GITHUB_LOGIN_DONE"
1551
1554
  : "(GCM_DONE=INOKIO_GITHUB_LOGIN_DONE; printf 'P%s\\n' \"$GCM_DONE\")"
1552
1555
  const loginCommand = [
1553
- "git credential-manager github login --web --force",
1556
+ "git credential-manager github login --device --force",
1554
1557
  verifyCommand,
1555
1558
  doneCommand
1556
1559
  ].join(" && ")
@@ -12668,80 +12671,19 @@ class Server {
12668
12671
 
12669
12672
  /*
12670
12673
  GET /connect => display connection options
12674
+ - huggingface
12671
12675
  - github
12672
- - x
12673
12676
  */
12674
- this.app.get("/connect", ex(async (req, res) => {
12675
- let list = this.getPeers()
12676
- let current_urls = await this.current_urls(req.originalUrl.slice(1))
12677
- let items = [{
12678
- // image: "/pinokio-black.png",
12679
- // name: "pinokio",
12680
- // title: "pinokio.co",
12681
- // description: "Connect with pinokio.co",
12682
- // url: "/connect/pinokio"
12683
- // }, {
12684
- icon: "fa-brands fa-hugging-face",
12685
- name: "huggingface",
12686
- title: "huggingface.co",
12687
- description: "Connect with huggingface.co",
12688
- url: "/connect/huggingface"
12689
- }, {
12690
- icon: "fa-brands fa-github",
12691
- name: "github",
12692
- title: "github.com",
12693
- description: "Connect with GitHub.com",
12694
- url: "/github"
12695
- }, {
12696
- icon: "fa-brands fa-square-x-twitter",
12697
- name: "x",
12698
- title: "x.com",
12699
- description: "Connect with X.com",
12700
- url: "/connect/x",
12701
- hidden: true
12702
- }]
12703
- let github_hosts = await this.get_github_hosts()
12704
- for(let i=0; i<items.length; i++) {
12705
- try {
12706
- if (items[i].name === "github") {
12707
- if (github_hosts.length > 0) {
12708
- items[i].profile = {
12709
- icon: "fa-brands fa-github",
12710
- items: [{
12711
- key: "config",
12712
- val: github_hosts
12713
- }]
12714
- }
12715
- items[i].description = `<i class="fa-solid fa-circle-check"></i> Connected with ${items[i].title}`
12716
- items[i].connected = true
12717
- }
12718
- } else {
12719
- const config = this.kernel.connect.config[items[i].name]
12720
- if (config) {
12721
- let profile = await this.kernel.connect.profile(items[i].name)
12722
- if (profile) {
12723
- items[i].profile = profile
12724
- items[i].description = `<i class="fa-solid fa-circle-check"></i> Connected with ${items[i].title}`
12725
- items[i].connected = true
12726
- }
12727
- }
12728
- }
12729
- } catch (e) {
12730
- }
12731
- }
12732
- const peerAccess = await this.composePeerAccessPayload()
12733
- res.render(`connect`, {
12734
- current_urls,
12735
- current_host: this.kernel.peer.host,
12736
- ...peerAccess,
12737
- list,
12677
+ registerConnectRoutes(this.app, {
12678
+ getPageContext: (req) => ({
12738
12679
  portal: this.portal,
12739
12680
  logo: this.logo,
12740
12681
  theme: this.theme,
12741
- agent: req.agent,
12742
- items: items.filter((item) => !item.hidden),
12743
- })
12744
- }))
12682
+ agent: req.agent
12683
+ }),
12684
+ getGithubConnection: (options) => this.get_github_connection(options),
12685
+ getProviderConnection: (provider, options) => this.kernel.connect.connected(provider, options)
12686
+ })
12745
12687
  /*
12746
12688
  * GET /connect/x
12747
12689
  * GET /connect/discord
@@ -13514,6 +13456,7 @@ class Server {
13514
13456
  }))
13515
13457
  this.app.get("/github/login", ex(async (req, res) => {
13516
13458
  let id = "gh_login"
13459
+ this.kernel.shell.kill({ id: `shell/${id}` })
13517
13460
  let params = new URLSearchParams()
13518
13461
  const { doneMarker, message } = this.github_login_params()
13519
13462
  params.set("message", encodeURIComponent(message))
@@ -2618,6 +2618,10 @@ body.connect-page .connect-service-status {
2618
2618
  font-weight: 700;
2619
2619
  }
2620
2620
 
2621
+ body.connect-page .connect-service-status[hidden] {
2622
+ display: none;
2623
+ }
2624
+
2621
2625
  body.connect-page .connect-service-open {
2622
2626
  color: var(--task-muted);
2623
2627
  font-size: 15px;
@@ -0,0 +1,49 @@
1
+ const CONNECT_STATUS_TIMEOUT_MS = 3000
2
+
3
+ const CONNECT_ITEMS = [{
4
+ icon: "fa-brands fa-hugging-face",
5
+ name: "huggingface",
6
+ title: "huggingface.co",
7
+ description: "Connect with huggingface.co",
8
+ url: "/connect/huggingface"
9
+ }, {
10
+ icon: "fa-brands fa-github",
11
+ name: "github",
12
+ title: "github.com",
13
+ description: "Connect with GitHub.com",
14
+ url: "/github"
15
+ }]
16
+
17
+ module.exports = function registerConnectRoutes(app, options = {}) {
18
+ const getPageContext = options.getPageContext || (() => ({}))
19
+ const getGithubConnection = options.getGithubConnection
20
+ const getProviderConnection = options.getProviderConnection
21
+ const statusTimeoutMs = Number.isFinite(options.statusTimeoutMs)
22
+ ? options.statusTimeoutMs
23
+ : CONNECT_STATUS_TIMEOUT_MS
24
+
25
+ app.get("/connect", (req, res) => {
26
+ res.render("connect", {
27
+ ...getPageContext(req),
28
+ items: CONNECT_ITEMS
29
+ })
30
+ })
31
+
32
+ app.get("/connect/status/:provider", async (req, res) => {
33
+ const provider = String(req.params.provider || "")
34
+ let connected = false
35
+ try {
36
+ if (provider === "github" && typeof getGithubConnection === "function") {
37
+ const connection = await getGithubConnection({ timeout: statusTimeoutMs })
38
+ connected = Boolean(connection && connection.connected)
39
+ } else if (provider === "huggingface" && typeof getProviderConnection === "function") {
40
+ connected = await getProviderConnection(provider, { timeout: statusTimeoutMs })
41
+ } else {
42
+ res.status(404).json({ provider, error: "Unknown provider" })
43
+ return
44
+ }
45
+ } catch (_) {}
46
+ res.set("Cache-Control", "no-store")
47
+ res.json({ provider, connected: Boolean(connected) })
48
+ })
49
+ }
@@ -818,56 +818,31 @@ body.dark .profile td {
818
818
  <% } else { %>
819
819
  <div class='connect-service-list'>
820
820
  <% items.forEach((item) => { %>
821
- <% if (item.profile) { %>
822
- <a href="<%=item.url%>" class="connect-service-card connect-service-card-connected">
823
- <div class='connect-service-head'>
824
- <div class='connect-service-icon'>
825
- <% if (item.image) { %>
826
- <img class="connect-service-icon-image" src="<%=item.image%>" alt="">
827
- <% } else if (item.icon) { %>
828
- <i class="<%=item.icon%>"></i>
829
- <% } else if (item.emoji) { %>
830
- <span><%=item.emoji%></span>
831
- <% } else { %>
832
- <i class="fa-solid fa-plug"></i>
833
- <% } %>
834
- </div>
835
- <div class='connect-service-copy'>
836
- <div class='connect-service-title-row'>
837
- <h2 class='connect-service-title'><%=item.title%></h2>
838
- <span class='connect-service-status'>Connected</span>
839
- </div>
840
- <div class='connect-service-description'><%- item.description %></div>
841
- </div>
842
- <div class='connect-service-open' aria-hidden='true'>
843
- <i class="fa-solid fa-chevron-right"></i>
844
- </div>
821
+ <a href="<%=item.url%>" class="connect-service-card" data-connect-provider="<%=item.name%>">
822
+ <div class='connect-service-head'>
823
+ <div class='connect-service-icon'>
824
+ <% if (item.image) { %>
825
+ <img class="connect-service-icon-image" src="<%=item.image%>" alt="">
826
+ <% } else if (item.icon) { %>
827
+ <i class="<%=item.icon%>"></i>
828
+ <% } else if (item.emoji) { %>
829
+ <span><%=item.emoji%></span>
830
+ <% } else { %>
831
+ <i class="fa-solid fa-plug"></i>
832
+ <% } %>
845
833
  </div>
846
- </a>
847
- <% } else { %>
848
- <a href="<%=item.url%>" class="connect-service-card">
849
- <div class='connect-service-head'>
850
- <div class='connect-service-icon'>
851
- <% if (item.image) { %>
852
- <img class="connect-service-icon-image" src="<%=item.image%>" alt="">
853
- <% } else if (item.icon) { %>
854
- <i class="<%=item.icon%>"></i>
855
- <% } else if (item.emoji) { %>
856
- <span><%=item.emoji%></span>
857
- <% } else { %>
858
- <i class="fa-solid fa-plug"></i>
859
- <% } %>
860
- </div>
861
- <div class='connect-service-copy'>
834
+ <div class='connect-service-copy'>
835
+ <div class='connect-service-title-row'>
862
836
  <h2 class='connect-service-title'><%=item.title%></h2>
863
- <div class="connect-service-description"><%=item.description%></div>
864
- </div>
865
- <div class="connect-service-open" aria-hidden='true'>
866
- <i class="fa-solid fa-chevron-right"></i>
837
+ <span class='connect-service-status' data-connect-status aria-live="polite">Checking...</span>
867
838
  </div>
839
+ <div class="connect-service-description" data-connect-description><%=item.description%></div>
868
840
  </div>
869
- </a>
870
- <% } %>
841
+ <div class="connect-service-open" aria-hidden='true'>
842
+ <i class="fa-solid fa-chevron-right"></i>
843
+ </div>
844
+ </div>
845
+ </a>
871
846
  <% }) %>
872
847
  </div>
873
848
  <% } %>
@@ -878,5 +853,50 @@ body.dark .profile td {
878
853
  <%- include('partials/main_sidebar', { selected: 'connect' }) %>
879
854
  </main>
880
855
  <%- include('partials/app_common_scripts') %>
856
+ <script>
857
+ (() => {
858
+ const cards = document.querySelectorAll("[data-connect-provider]")
859
+ cards.forEach((card) => {
860
+ const provider = card.dataset.connectProvider
861
+ const status = card.querySelector("[data-connect-status]")
862
+ const description = card.querySelector("[data-connect-description]")
863
+ const title = card.querySelector(".connect-service-title")
864
+ const controller = new AbortController()
865
+ const timer = window.setTimeout(() => controller.abort(), 4000)
866
+
867
+ fetch(`/connect/status/${encodeURIComponent(provider)}`, {
868
+ cache: "no-store",
869
+ headers: { "Accept": "application/json" },
870
+ signal: controller.signal
871
+ }).then((response) => {
872
+ if (!response.ok) {
873
+ throw new Error(`Status request failed: ${response.status}`)
874
+ }
875
+ return response.json()
876
+ }).then((result) => {
877
+ const connected = Boolean(result && result.connected)
878
+ if (!connected) {
879
+ if (status) {
880
+ status.hidden = true
881
+ }
882
+ return
883
+ }
884
+ if (status) {
885
+ status.textContent = "Connected"
886
+ status.hidden = false
887
+ }
888
+ if (description && title) {
889
+ description.textContent = `Connected with ${title.textContent}`
890
+ }
891
+ }).catch(() => {
892
+ if (status) {
893
+ status.hidden = true
894
+ }
895
+ }).finally(() => {
896
+ window.clearTimeout(timer)
897
+ })
898
+ })
899
+ })()
900
+ </script>
881
901
  </body>
882
902
  </html>
@@ -64,6 +64,9 @@ body.install-page > .terminal-container {
64
64
  height: 100%;
65
65
  min-height: 0;
66
66
  }
67
+ body.install-page #terminal .xterm {
68
+ padding-top: 10px !important;
69
+ }
67
70
  .line2 {
68
71
  display: flex;
69
72
  align-items: center;
@@ -51,3 +51,68 @@ test('caddy admin wait returns true when admin responds', async () => {
51
51
  assert.equal(ready, true)
52
52
  assert.equal(checks, 2)
53
53
  })
54
+
55
+ test('caddy start uses the normal shell execution contract', async () => {
56
+ const caddy = new Caddy()
57
+ let running = false
58
+ let launch
59
+ caddy.kernel = {
60
+ homedir: '/tmp/pinokio',
61
+ peer: {
62
+ https_active: true,
63
+ announce() {}
64
+ },
65
+ processes: {},
66
+ exec(params, ondata) {
67
+ launch = params
68
+ running = true
69
+ ondata({ raw: '', cleaned: 'admin endpoint started' })
70
+ }
71
+ }
72
+ caddy.running = async () => running
73
+ caddy.installed = async () => true
74
+
75
+ await caddy.start()
76
+
77
+ assert.equal(launch.message, 'caddy run --watch')
78
+ assert.deepEqual(Object.keys(launch).sort(), ['message', 'path'])
79
+ })
80
+
81
+ test('caddy stop uses the native admin API only when Caddy is running', async () => {
82
+ const caddy = new Caddy()
83
+ let running = true
84
+ caddy.running = async () => running
85
+ const originalPost = require('axios').post
86
+ const calls = []
87
+ require('axios').post = async (...args) => {
88
+ calls.push(args)
89
+ running = false
90
+ }
91
+
92
+ try {
93
+ assert.equal(await caddy.stop(), true)
94
+ assert.equal(await caddy.stop(), false)
95
+ } finally {
96
+ require('axios').post = originalPost
97
+ }
98
+
99
+ assert.equal(calls.length, 1)
100
+ assert.equal(calls[0][0], 'http://127.0.0.1:2019/stop')
101
+ })
102
+
103
+ test('caddy stop refuses to continue when native shutdown is not confirmed', async () => {
104
+ const caddy = new Caddy()
105
+ caddy.running = async () => true
106
+ caddy.waitForStopped = async () => false
107
+ const originalPost = require('axios').post
108
+ require('axios').post = async () => {}
109
+
110
+ try {
111
+ await assert.rejects(
112
+ caddy.stop(),
113
+ /could not stop Caddy/
114
+ )
115
+ } finally {
116
+ require('axios').post = originalPost
117
+ }
118
+ })
@@ -262,6 +262,60 @@ test('Conda install replaces the stale runtime and rebuilds declared Conda modul
262
262
  )
263
263
  })
264
264
 
265
+ test('Conda replacement stops, preserves, and restarts a running managed Caddy', async () => {
266
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-conda-caddy-lifecycle-'))
267
+ await createMiniforge(root)
268
+ const kernel = createKernel(root, 'win32')
269
+ const events = []
270
+ let caddyRunning = true
271
+ let condaInstall
272
+
273
+ const caddy = {
274
+ cmd: () => 'caddy=2.9.1',
275
+ running: async () => caddyRunning,
276
+ stop: async () => {
277
+ events.push('caddy-stop')
278
+ caddyRunning = false
279
+ },
280
+ start: async () => {
281
+ events.push('caddy-start')
282
+ caddyRunning = true
283
+ },
284
+ }
285
+ kernel.bin.installed.conda = new Set(['caddy'])
286
+ kernel.bin.mod = { caddy }
287
+ kernel.bin.mods = [{ name: 'caddy', mod: caddy }]
288
+ kernel.bin.download = async () => events.push('download')
289
+ kernel.bin.rm = async () => events.push('installer-cleanup')
290
+ kernel.bin.exec = async (payload) => {
291
+ if (payload && payload.conda && payload.conda.skip) {
292
+ events.push('bootstrap')
293
+ const miniforge = path.join(root, 'bin', 'miniforge')
294
+ await fs.mkdir(path.join(miniforge, 'conda-meta'), { recursive: true })
295
+ await fs.writeFile(path.join(miniforge, 'python.exe'), 'fake python\n')
296
+ return
297
+ }
298
+ events.push('conda-install')
299
+ condaInstall = payload
300
+ }
301
+
302
+ const conda = createConda(kernel)
303
+ const removeInstallPath = conda.removeInstallPath.bind(conda)
304
+ conda.removeInstallPath = async (target) => {
305
+ assert.equal(caddyRunning, false)
306
+ events.push('remove-miniforge')
307
+ await removeInstallPath(target)
308
+ }
309
+
310
+ await conda._install({ dependencies: [] }, () => {})
311
+
312
+ assert.ok(events.indexOf('caddy-stop') < events.indexOf('remove-miniforge'))
313
+ assert.ok(events.indexOf('remove-miniforge') < events.indexOf('bootstrap'))
314
+ assert.ok(events.indexOf('conda-install') < events.indexOf('caddy-start'))
315
+ assert.match(condaInstall.message[1], / caddy=2\.9\.1$/)
316
+ assert.equal(caddyRunning, true)
317
+ })
318
+
265
319
  test('Conda install replaces legacy miniconda with a compatibility alias to miniforge', async () => {
266
320
  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-conda-miniforge-alias-'))
267
321
  const legacy = await createLegacyMiniconda(root)
@@ -0,0 +1,143 @@
1
+ const assert = require('node:assert/strict')
2
+ const http = require('node:http')
3
+ const path = require('node:path')
4
+ const test = require('node:test')
5
+ const express = require('express')
6
+ const registerConnectRoutes = require('../server/routes/connect')
7
+
8
+ const viewsPath = path.resolve(__dirname, '..', 'server', 'views')
9
+
10
+ async function startConnectServer(options = {}) {
11
+ const app = express()
12
+ app.set('views', viewsPath)
13
+ app.set('view engine', 'ejs')
14
+ registerConnectRoutes(app, {
15
+ getPageContext: () => ({
16
+ portal: '',
17
+ logo: '',
18
+ theme: 'light',
19
+ agent: 'test'
20
+ }),
21
+ getGithubConnection: options.getGithubConnection,
22
+ getProviderConnection: options.getProviderConnection,
23
+ statusTimeoutMs: options.statusTimeoutMs
24
+ })
25
+ app.use((error, req, res, next) => {
26
+ res.status(500).send(error.message)
27
+ })
28
+
29
+ const server = http.createServer(app)
30
+ await new Promise((resolve, reject) => {
31
+ server.once('error', reject)
32
+ server.listen(0, '127.0.0.1', resolve)
33
+ })
34
+ const address = server.address()
35
+ return {
36
+ baseUrl: `http://127.0.0.1:${address.port}`,
37
+ close: () => new Promise((resolve, reject) => {
38
+ server.close((error) => error ? reject(error) : resolve())
39
+ })
40
+ }
41
+ }
42
+
43
+ async function fetchWithin(url, timeoutMs = 500) {
44
+ const controller = new AbortController()
45
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
46
+ try {
47
+ return await fetch(url, { signal: controller.signal })
48
+ } finally {
49
+ clearTimeout(timer)
50
+ }
51
+ }
52
+
53
+ test('/connect renders without invoking provider status checks', async () => {
54
+ let githubCalls = 0
55
+ let huggingfaceCalls = 0
56
+ const fixture = await startConnectServer({
57
+ getGithubConnection: async () => {
58
+ githubCalls += 1
59
+ return await new Promise(() => {})
60
+ },
61
+ getProviderConnection: async () => {
62
+ huggingfaceCalls += 1
63
+ return await new Promise(() => {})
64
+ }
65
+ })
66
+
67
+ try {
68
+ const response = await fetchWithin(`${fixture.baseUrl}/connect`)
69
+ const html = await response.text()
70
+
71
+ assert.equal(response.status, 200)
72
+ assert.equal(githubCalls, 0)
73
+ assert.equal(huggingfaceCalls, 0)
74
+ assert.match(html, /data-connect-provider="huggingface"/)
75
+ assert.match(html, /data-connect-provider="github"/)
76
+ assert.match(html, /Checking\.\.\./)
77
+ } finally {
78
+ await fixture.close()
79
+ }
80
+ })
81
+
82
+ test('/connect status endpoints check providers independently with bounded options', async () => {
83
+ const calls = []
84
+ const fixture = await startConnectServer({
85
+ statusTimeoutMs: 75,
86
+ getGithubConnection: async (options) => {
87
+ calls.push({ provider: 'github', options })
88
+ return { connected: true }
89
+ },
90
+ getProviderConnection: async (provider, options) => {
91
+ calls.push({ provider, options })
92
+ return false
93
+ }
94
+ })
95
+
96
+ try {
97
+ const [githubResponse, huggingfaceResponse] = await Promise.all([
98
+ fetch(`${fixture.baseUrl}/connect/status/github`),
99
+ fetch(`${fixture.baseUrl}/connect/status/huggingface`)
100
+ ])
101
+ const github = await githubResponse.json()
102
+ const huggingface = await huggingfaceResponse.json()
103
+
104
+ assert.deepEqual(github, { provider: 'github', connected: true })
105
+ assert.deepEqual(huggingface, { provider: 'huggingface', connected: false })
106
+ assert.deepEqual(calls, [{
107
+ provider: 'github',
108
+ options: { timeout: 75 }
109
+ }, {
110
+ provider: 'huggingface',
111
+ options: { timeout: 75 }
112
+ }])
113
+ assert.match(githubResponse.headers.get('cache-control'), /no-store/)
114
+ } finally {
115
+ await fixture.close()
116
+ }
117
+ })
118
+
119
+ test('/connect status endpoints degrade provider errors to disconnected', async () => {
120
+ const fixture = await startConnectServer({
121
+ statusTimeoutMs: 30,
122
+ getGithubConnection: async () => {
123
+ throw new Error('github unavailable')
124
+ },
125
+ getProviderConnection: async () => {
126
+ throw new Error('huggingface unavailable')
127
+ }
128
+ })
129
+
130
+ try {
131
+ const [githubResponse, huggingfaceResponse] = await Promise.all([
132
+ fetch(`${fixture.baseUrl}/connect/status/github`),
133
+ fetch(`${fixture.baseUrl}/connect/status/huggingface`)
134
+ ])
135
+
136
+ assert.equal(githubResponse.status, 200)
137
+ assert.equal(huggingfaceResponse.status, 200)
138
+ assert.deepEqual(await githubResponse.json(), { provider: 'github', connected: false })
139
+ assert.deepEqual(await huggingfaceResponse.json(), { provider: 'huggingface', connected: false })
140
+ } finally {
141
+ await fixture.close()
142
+ }
143
+ })
@@ -36,6 +36,19 @@ test('GitHub connection reports GCM accounts as connected', async () => {
36
36
  assert.equal(connection.display, 'github.com: octocat')
37
37
  })
38
38
 
39
+ test('GitHub connection forwards a bounded timeout to GCM status checks', async () => {
40
+ let receivedOptions
41
+ await Server.prototype.get_github_connection.call({
42
+ get_legacy_github_hosts: async () => '',
43
+ github_gcm: async (args, options) => {
44
+ receivedOptions = options
45
+ return ''
46
+ },
47
+ }, { timeout: 2500 })
48
+
49
+ assert.deepEqual(receivedOptions, { timeout: 2500 })
50
+ })
51
+
39
52
  test('GitHub connection coalesces only concurrent GCM checks', async () => {
40
53
  let calls = 0
41
54
  const context = {
@@ -66,8 +79,9 @@ test('GitHub login only emits completion marker after credential verification',
66
79
  })
67
80
 
68
81
  assert.equal(doneMarker, 'PINOKIO_GITHUB_LOGIN_DONE')
69
- assert.match(message, /git credential-manager github login --web --force && printf 'protocol=https\\nhost=github\.com\\n\\n' \| GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never git credential fill >\/dev\/null && \(GCM_DONE=INOKIO_GITHUB_LOGIN_DONE; printf 'P%s\\n' "\$GCM_DONE"\)/)
70
- assert.doesNotMatch(message, /git credential-manager github login --web --force\s*;/)
82
+ assert.match(message, /git credential-manager github login --device --force && printf 'protocol=https\\nhost=github\.com\\n\\n' \| GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never git credential fill >\/dev\/null && \(GCM_DONE=INOKIO_GITHUB_LOGIN_DONE; printf 'P%s\\n' "\$GCM_DONE"\)/)
83
+ assert.doesNotMatch(message, /git credential-manager github login --device --force\s*;/)
84
+ assert.doesNotMatch(message, /--web/)
71
85
  assert.doesNotMatch(message, /PINOKIO_GITHUB_LOGIN_DONE/)
72
86
  })
73
87
 
@@ -76,7 +90,8 @@ test('GitHub login uses success-only credential verification on Windows', () =>
76
90
  kernel: { platform: 'win32' }
77
91
  })
78
92
 
79
- assert.match(message, /git credential-manager github login --web --force && powershell\.exe -NoProfile -Command "\$env:GIT_TERMINAL_PROMPT='0'; \$env:GCM_INTERACTIVE='never'; @\('protocol=https','host=github\.com',''\) \| git credential fill > \$null; exit \$LASTEXITCODE" && echo P\^INOKIO_GITHUB_LOGIN_DONE/)
93
+ assert.match(message, /git credential-manager github login --device --force && powershell\.exe -NoProfile -Command "\$env:GIT_TERMINAL_PROMPT='0'; \$env:GCM_INTERACTIVE='never'; @\('protocol=https','host=github\.com',''\) \| git credential fill > \$null; exit \$LASTEXITCODE" && echo P\^INOKIO_GITHUB_LOGIN_DONE/)
94
+ assert.doesNotMatch(message, /--web/)
80
95
  assert.doesNotMatch(message, /PINOKIO_GITHUB_LOGIN_DONE/)
81
96
  })
82
97
 
@@ -5,6 +5,7 @@ const path = require('node:path')
5
5
  const test = require('node:test')
6
6
 
7
7
  const HuggingfaceConnect = require('../kernel/connect/providers/huggingface')
8
+ const Environment = require('../kernel/environment')
8
9
 
9
10
  function createKernel(root) {
10
11
  return {
@@ -23,6 +24,11 @@ function createKernel(root) {
23
24
  }
24
25
  }
25
26
 
27
+ test('system environment does not persist the default Hugging Face token path', async () => {
28
+ const content = await Environment.ENV('system', '/tmp/pinokio', {})
29
+ assert.doesNotMatch(content, /^HF_TOKEN_PATH=/m)
30
+ })
31
+
26
32
  test('Hugging Face connect parses managed hf JSON device login events', () => {
27
33
  const provider = new HuggingfaceConnect(createKernel('/tmp/pinokio'), {})
28
34
  const parsed = provider.parseDeviceLogin(
@@ -72,6 +78,72 @@ test('Hugging Face connect uses shared HF_TOKEN_PATH and ignores ambient HF_TOKE
72
78
  }
73
79
  })
74
80
 
81
+ test('Hugging Face connect verifies status with bounded whoami', async () => {
82
+ const provider = new HuggingfaceConnect(createKernel('/tmp/pinokio'), {})
83
+ let receivedArgs
84
+ let receivedOptions
85
+ provider.runHf = async (args, options) => {
86
+ receivedArgs = args
87
+ receivedOptions = options
88
+ }
89
+
90
+ const connected = await provider.connected({ timeout: 2500 })
91
+
92
+ assert.deepEqual(receivedArgs, ['auth', 'whoami', '--format', 'quiet'])
93
+ assert.deepEqual(receivedOptions, { timeout: 2500 })
94
+ assert.equal(connected, true)
95
+
96
+ provider.runHf = async () => {
97
+ throw new Error('not logged in')
98
+ }
99
+ assert.equal(await provider.connected({ timeout: 2500 }), false)
100
+ })
101
+
102
+ test('Hugging Face connect supplies the shared token path at runtime when it is not persisted', async () => {
103
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-hf-connect-default-'))
104
+ await fs.writeFile(path.join(root, 'ENVIRONMENT'), 'OTHER=value\n')
105
+ try {
106
+ const provider = new HuggingfaceConnect(createKernel(root), {})
107
+ const env = await provider.authEnv()
108
+
109
+ assert.equal(env.HF_TOKEN_PATH, path.join(root, 'cache', 'HF_AUTH', 'token'))
110
+ await assert.doesNotReject(fs.access(path.join(root, 'cache', 'HF_AUTH')))
111
+ await assert.rejects(fs.access(env.HF_TOKEN_PATH), { code: 'ENOENT' })
112
+ } finally {
113
+ await fs.rm(root, { recursive: true, force: true })
114
+ }
115
+ })
116
+
117
+ test('Hugging Face connect repairs an empty directory at the token file path', async () => {
118
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-hf-connect-repair-'))
119
+ const tokenPath = path.join(root, 'cache', 'HF_AUTH', 'token')
120
+ await fs.mkdir(tokenPath, { recursive: true })
121
+ try {
122
+ const provider = new HuggingfaceConnect(createKernel(root), {})
123
+ const env = await provider.authEnv()
124
+
125
+ assert.equal(env.HF_TOKEN_PATH, tokenPath)
126
+ await assert.rejects(fs.access(tokenPath), { code: 'ENOENT' })
127
+ } finally {
128
+ await fs.rm(root, { recursive: true, force: true })
129
+ }
130
+ })
131
+
132
+ test('Hugging Face connect preserves and rejects a non-empty token directory', async () => {
133
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-hf-connect-invalid-'))
134
+ const tokenPath = path.join(root, 'cache', 'HF_AUTH', 'token')
135
+ await fs.mkdir(tokenPath, { recursive: true })
136
+ await fs.writeFile(path.join(tokenPath, 'keep.txt'), 'keep')
137
+ try {
138
+ const provider = new HuggingfaceConnect(createKernel(root), {})
139
+
140
+ await assert.rejects(provider.authEnv(), /Hugging Face token path must be a file/)
141
+ assert.equal(await fs.readFile(path.join(tokenPath, 'keep.txt'), 'utf8'), 'keep')
142
+ } finally {
143
+ await fs.rm(root, { recursive: true, force: true })
144
+ }
145
+ })
146
+
75
147
  test('Hugging Face connect cancelLogin only stops a pending login session', async () => {
76
148
  const provider = new HuggingfaceConnect(createKernel('/tmp/pinokio'), {})
77
149
  let killed = false