pinokiod 8.0.4 → 8.0.6

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.
@@ -168,6 +168,13 @@ class HF {
168
168
  }
169
169
  await this.htmlModal.close(this.modalRequest(req, this.modalId(req)), ondata, kernel)
170
170
  }
171
+ authContext(req = {}, params = {}) {
172
+ return {
173
+ parentPath: req.parent && req.parent.path,
174
+ cwd: req.cwd,
175
+ env: params.env,
176
+ }
177
+ }
171
178
  async cancelLogin(connect, params) {
172
179
  if (connect && typeof connect.cancelLogin === "function") {
173
180
  await connect.cancelLogin("huggingface", params || {})
@@ -201,9 +208,10 @@ class HF {
201
208
  const shouldWait = params.wait !== false
202
209
  const timeout = positiveNumber(params.timeout, 120000)
203
210
  const interval = positiveNumber(params.interval, 2000)
211
+ const authContext = this.authContext(req, params)
204
212
 
205
213
  if (!force) {
206
- const existing = await connect.keys("huggingface")
214
+ const existing = await connect.keys("huggingface", authContext)
207
215
  if (existing && existing.access_token) {
208
216
  return {
209
217
  status: "success",
@@ -216,7 +224,7 @@ class HF {
216
224
  if (typeof connect.login !== "function") {
217
225
  throw new Error("Hugging Face connect login is not available")
218
226
  }
219
- const response = await connect.login("huggingface", params)
227
+ const response = await connect.login("huggingface", params, authContext)
220
228
  if (!response || response.status === "error") {
221
229
  return response
222
230
  }
@@ -262,7 +270,7 @@ class HF {
262
270
 
263
271
  const startedAt = Date.now()
264
272
  while (Date.now() - startedAt < timeout) {
265
- const keys = await connect.keys("huggingface")
273
+ const keys = await connect.keys("huggingface", authContext)
266
274
  if (keys && keys.access_token) {
267
275
  if (useModal) {
268
276
  await this.closeLoginModal(req, ondata, kernel)
@@ -293,7 +301,8 @@ class HF {
293
301
  */
294
302
  async logout(req, ondata, kernel) {
295
303
  const connect = this.connect(kernel, "logout")
296
- await connect.logout("huggingface", req.params || {})
304
+ const params = req.params || {}
305
+ await connect.logout("huggingface", params, this.authContext(req, params))
297
306
  return { status: "success" }
298
307
  }
299
308
  /*
@@ -24,12 +24,12 @@ class Connect {
24
24
  return null
25
25
  }
26
26
  }
27
- async login(provider, req) {
28
- let res = await this.clients[provider].login(req)
27
+ async login(provider, params, context) {
28
+ let res = await this.clients[provider].login(params, context)
29
29
  return res
30
30
  }
31
- async logout(provider, req) {
32
- let res = await this.clients[provider].logout(req)
31
+ async logout(provider, params, context) {
32
+ let res = await this.clients[provider].logout(params, context)
33
33
  return res
34
34
  }
35
35
  async cancelLogin(provider, req) {
@@ -38,9 +38,15 @@ class Connect {
38
38
  }
39
39
  return null
40
40
  }
41
- async keys(provider) {
42
- let res = await this.clients[provider].keys()
41
+ async keys(provider, context) {
42
+ let res = await this.clients[provider].keys(context)
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
@@ -18,23 +18,55 @@ class Huggingface {
18
18
  defaultTokenPath() {
19
19
  return this.kernel.path("cache", "HF_AUTH", "token")
20
20
  }
21
- async authEnv() {
22
- let systemEnv = {}
21
+ requestCwd(context = {}) {
22
+ if (context.cwd) {
23
+ return path.resolve(context.cwd)
24
+ }
25
+ if (context.parentPath) {
26
+ return path.dirname(path.resolve(context.parentPath))
27
+ }
28
+ return this.kernel.homedir
29
+ }
30
+ async repairTokenPath(tokenPath) {
23
31
  try {
24
- systemEnv = await Environment.get(this.kernel.homedir, this.kernel)
25
- } catch (_) {
26
- systemEnv = {}
32
+ const stat = await fs.promises.lstat(tokenPath)
33
+ if (stat.isDirectory()) {
34
+ await fs.promises.rm(tokenPath, { recursive: true, force: true })
35
+ }
36
+ } catch (error) {
37
+ if (!error || error.code !== "ENOENT") {
38
+ throw error
39
+ }
40
+ }
41
+ await fs.promises.mkdir(path.dirname(tokenPath), { recursive: true })
42
+ }
43
+ async authEnv(context = {}) {
44
+ let env
45
+ if (context.parentPath) {
46
+ env = await Environment.get2(context.parentPath, this.kernel)
47
+ } else {
48
+ let systemEnv = {}
49
+ try {
50
+ systemEnv = await Environment.get(this.kernel.homedir, this.kernel)
51
+ } catch (_) {
52
+ systemEnv = {}
53
+ }
54
+ env = Object.assign({}, process.env, systemEnv)
55
+ }
56
+ if (context.env && typeof context.env === "object") {
57
+ env = Object.assign(env, context.env)
27
58
  }
28
- const env = Object.assign({}, process.env, systemEnv)
29
59
  if (!env.HF_TOKEN_PATH) {
30
60
  env.HF_TOKEN_PATH = this.defaultTokenPath()
61
+ } else if (!path.isAbsolute(env.HF_TOKEN_PATH)) {
62
+ env.HF_TOKEN_PATH = path.resolve(this.requestCwd(context), env.HF_TOKEN_PATH)
31
63
  }
32
64
  if (!env.HF_HUB_DISABLE_UPDATE_CHECK) {
33
65
  env.HF_HUB_DISABLE_UPDATE_CHECK = "1"
34
66
  }
35
67
  delete env.HF_TOKEN
36
68
  delete env.HUGGING_FACE_HUB_TOKEN
37
- await fs.promises.mkdir(path.dirname(env.HF_TOKEN_PATH), { recursive: true }).catch(() => {})
69
+ await this.repairTokenPath(env.HF_TOKEN_PATH)
38
70
  return env
39
71
  }
40
72
  hfPath() {
@@ -52,12 +84,13 @@ class Huggingface {
52
84
  }
53
85
  return candidates[0]
54
86
  }
55
- async runHf(args) {
56
- const env = await this.authEnv()
87
+ async runHf(args, options = {}, context = {}) {
88
+ const env = await this.authEnv(context)
57
89
  return new Promise((resolve, reject) => {
58
90
  execFile(this.hfPath(), args, {
59
- cwd: this.kernel.homedir,
91
+ cwd: this.requestCwd(context),
60
92
  env,
93
+ timeout: Number.isFinite(options.timeout) ? options.timeout : undefined,
61
94
  maxBuffer: 1024 * 1024,
62
95
  }, (error, stdout, stderr) => {
63
96
  if (error) {
@@ -114,11 +147,18 @@ class Huggingface {
114
147
  expires_in: expiresMatch ? Number(expiresMatch[1]) : null,
115
148
  }
116
149
  }
117
- async login() {
150
+ async login(_params = {}, context = {}) {
151
+ const env = await this.authEnv(context)
118
152
  if (this.loginSession && this.loginSession.status === "pending") {
153
+ if (this.loginSession.tokenPath !== env.HF_TOKEN_PATH) {
154
+ return {
155
+ status: "error",
156
+ error: `A Hugging Face login is already pending for ${this.loginSession.tokenPath}`,
157
+ token_path: env.HF_TOKEN_PATH,
158
+ }
159
+ }
119
160
  return this.serializeLoginSession(this.loginSession)
120
161
  }
121
- const env = await this.authEnv()
122
162
  const session = {
123
163
  status: "pending",
124
164
  login: null,
@@ -148,7 +188,7 @@ class Huggingface {
148
188
  }
149
189
  }
150
190
  const child = spawn(this.hfPath(), ["auth", "login", "--format", "agent", "--force"], {
151
- cwd: this.kernel.homedir,
191
+ cwd: this.requestCwd(context),
152
192
  env,
153
193
  windowsHide: false,
154
194
  })
@@ -184,12 +224,15 @@ class Huggingface {
184
224
  }
185
225
  return this.serializeLoginSession(session)
186
226
  }
187
- async keys() {
227
+ async keys(context = {}) {
188
228
  if (this.loginSession && this.loginSession.status === "pending") {
189
- return this.serializeLoginSession(this.loginSession)
229
+ const env = await this.authEnv(context)
230
+ if (this.loginSession.tokenPath === env.HF_TOKEN_PATH) {
231
+ return this.serializeLoginSession(this.loginSession)
232
+ }
190
233
  }
191
234
  try {
192
- const { stdout, env } = await this.runHf(["auth", "token", "--format", "quiet"])
235
+ const { stdout, env } = await this.runHf(["auth", "token", "--format", "quiet"], {}, context)
193
236
  const access_token = stripAnsi(stdout).trim().split(/\r?\n/).find(Boolean)
194
237
  if (!access_token) {
195
238
  return null
@@ -199,6 +242,14 @@ class Huggingface {
199
242
  return null
200
243
  }
201
244
  }
245
+ async connected(options = {}) {
246
+ try {
247
+ await this.runHf(["auth", "whoami", "--format", "quiet"], options)
248
+ return true
249
+ } catch (_) {
250
+ return false
251
+ }
252
+ }
202
253
  async profile() {
203
254
  const keys = await this.keys()
204
255
  if (!keys || !keys.access_token) {
@@ -219,12 +270,12 @@ class Huggingface {
219
270
  await this.config.profile.cache(response, connectPath).catch(() => {})
220
271
  return this.config.profile.render(response)
221
272
  }
222
- async destroy() {
273
+ async destroy(context = {}) {
223
274
  if (this.loginSession && this.loginSession.child && this.loginSession.status === "pending") {
224
275
  this.loginSession.child.kill()
225
276
  }
226
277
  this.loginSession = null
227
- await this.runHf(["auth", "logout"]).catch(() => {})
278
+ await this.runHf(["auth", "logout"], {}, context).catch(() => {})
228
279
  await fs.promises.rm(this.kernel.path("connect", "huggingface"), { recursive: true, force: true }).catch(() => {})
229
280
  }
230
281
  async cancelLogin() {
@@ -241,8 +292,8 @@ class Huggingface {
241
292
  this.loginSession = null
242
293
  }
243
294
  }
244
- async logout() {
245
- await this.destroy()
295
+ async logout(_params = {}, context = {}) {
296
+ await this.destroy(context)
246
297
  }
247
298
  }
248
299
 
@@ -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.4",
3
+ "version": "8.0.6",
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;
@@ -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
 
@@ -17,12 +17,14 @@ test('hf.login shows a modal, waits for the user to open Hugging Face, then clos
17
17
  const originalOpenURI = Util.openURI
18
18
  const originalClipboard = Util.clipboard
19
19
  let keyReads = 0
20
+ const keyContexts = []
20
21
  const loginCalls = []
21
22
  const waitCalls = []
22
23
  const kernel = {
23
24
  connect: {
24
- async keys(provider) {
25
+ async keys(provider, context) {
25
26
  assert.equal(provider, 'huggingface')
27
+ keyContexts.push(context)
26
28
  keyReads += 1
27
29
  if (keyReads < 3) {
28
30
  return null
@@ -32,8 +34,8 @@ test('hf.login shows a modal, waits for the user to open Hugging Face, then clos
32
34
  token_path: '/tmp/pinokio/hf-token'
33
35
  }
34
36
  },
35
- async login(provider, params) {
36
- loginCalls.push({ provider, params })
37
+ async login(provider, params, context) {
38
+ loginCalls.push({ provider, params, context })
37
39
  return {
38
40
  status: 'pending',
39
41
  login,
@@ -58,15 +60,28 @@ test('hf.login shows a modal, waits for the user to open Hugging Face, then clos
58
60
  }
59
61
 
60
62
  try {
61
- const result = await hf.login({
63
+ const request = {
62
64
  parent: { id: 'test-script', path: '/pinokio/api/test/hf-login.js' },
63
65
  params: {
64
66
  timeout: 100,
65
67
  interval: 1
66
68
  }
67
- }, (stream, type) => packets.push({ type, stream }), kernel)
69
+ }
70
+ const result = await hf.login(request, (stream, type) => packets.push({ type, stream }), kernel)
68
71
 
69
- assert.deepEqual(loginCalls, [{ provider: 'huggingface', params: { timeout: 100, interval: 1 } }])
72
+ const authContext = {
73
+ parentPath: '/pinokio/api/test/hf-login.js',
74
+ cwd: undefined,
75
+ env: undefined
76
+ }
77
+ assert.deepEqual(loginCalls, [{
78
+ provider: 'huggingface',
79
+ params: request.params,
80
+ context: authContext
81
+ }])
82
+ assert.equal(loginCalls[0].params, request.params)
83
+ assert.equal(keyContexts.length, 3)
84
+ assert.equal(keyContexts.every((context) => context === loginCalls[0].context), true)
70
85
  assert.deepEqual(waitCalls, ['/pinokio/api/test/hf-login.js'])
71
86
  assert.deepEqual(actions, [{
72
87
  type: 'clipboard',
@@ -185,9 +200,14 @@ test('hf.login can use the non-modal browser fallback when modal is disabled', a
185
200
  assert.equal(provider, 'huggingface')
186
201
  return null
187
202
  },
188
- async login(provider, params) {
203
+ async login(provider, params, context) {
189
204
  assert.equal(provider, 'huggingface')
190
205
  assert.deepEqual(params, { modal: false, wait: false })
206
+ assert.deepEqual(context, {
207
+ parentPath: undefined,
208
+ cwd: undefined,
209
+ env: undefined
210
+ })
191
211
  return {
192
212
  status: 'pending',
193
213
  login,
@@ -396,8 +416,8 @@ test('hf.logout delegates to the Hugging Face connect provider and returns succe
396
416
  const calls = []
397
417
  const kernel = {
398
418
  connect: {
399
- async logout(provider, params) {
400
- calls.push({ method: 'logout', provider, params })
419
+ async logout(provider, params, context) {
420
+ calls.push({ method: 'logout', provider, params, context })
401
421
  }
402
422
  }
403
423
  }
@@ -405,7 +425,16 @@ test('hf.logout delegates to the Hugging Face connect provider and returns succe
405
425
  const result = await hf.logout(req, () => {}, kernel)
406
426
 
407
427
  assert.deepEqual(result, { status: 'success' })
408
- assert.deepEqual(calls, [{ method: 'logout', provider: 'huggingface', params: req.params }])
428
+ assert.deepEqual(calls, [{
429
+ method: 'logout',
430
+ provider: 'huggingface',
431
+ params: req.params,
432
+ context: {
433
+ parentPath: undefined,
434
+ cwd: undefined,
435
+ env: undefined
436
+ }
437
+ }])
409
438
  })
410
439
 
411
440
  test('hf.upload runs hf upload through shell.run with Hugging Face CLI args', async () => {
@@ -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(
@@ -50,15 +56,16 @@ test('Hugging Face connect parses managed hf agent login instructions', () => {
50
56
  })
51
57
  })
52
58
 
53
- test('Hugging Face connect uses shared HF_TOKEN_PATH and ignores ambient HF_TOKEN', async () => {
59
+ test('Hugging Face connect preserves configured HF paths and ignores ambient HF_TOKEN', async () => {
54
60
  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-hf-connect-'))
55
- await fs.writeFile(path.join(root, 'ENVIRONMENT'), 'HF_TOKEN_PATH=./custom/hf-token\n')
61
+ await fs.writeFile(path.join(root, 'ENVIRONMENT'), 'HF_HOME=./custom/hf-home\nHF_TOKEN_PATH=./custom/hf-token\n')
56
62
  const oldToken = process.env.HF_TOKEN
57
63
  process.env.HF_TOKEN = 'hf_should_not_win'
58
64
  try {
59
65
  const provider = new HuggingfaceConnect(createKernel(root), {})
60
66
  const env = await provider.authEnv()
61
67
 
68
+ assert.equal(env.HF_HOME, path.join(root, 'custom', 'hf-home'))
62
69
  assert.equal(env.HF_TOKEN_PATH, path.join(root, 'custom', 'hf-token'))
63
70
  assert.equal(env.HF_TOKEN, undefined)
64
71
  assert.equal(env.HF_HUB_DISABLE_UPDATE_CHECK, '1')
@@ -72,6 +79,156 @@ test('Hugging Face connect uses shared HF_TOKEN_PATH and ignores ambient HF_TOKE
72
79
  }
73
80
  })
74
81
 
82
+ test('Hugging Face connect uses the app token path for its managed CLI', async () => {
83
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-hf-connect-app-env-'))
84
+ const appDir = path.join(root, 'api', 'demo')
85
+ const scriptPath = path.join(appDir, 'start.js')
86
+ const tokenPath = path.join(appDir, 'auth', 'token')
87
+ await fs.mkdir(appDir, { recursive: true })
88
+ await fs.writeFile(scriptPath, '')
89
+ await fs.writeFile(
90
+ path.join(appDir, 'ENVIRONMENT'),
91
+ 'HF_HOME=./cache/huggingface\n'
92
+ )
93
+ try {
94
+ const provider = new HuggingfaceConnect(createKernel(root), {})
95
+ const context = {
96
+ parentPath: scriptPath,
97
+ cwd: appDir,
98
+ }
99
+ const sharedEnv = await provider.authEnv(context)
100
+ assert.equal(sharedEnv.HF_HOME, path.join(appDir, 'cache', 'huggingface'))
101
+ assert.equal(sharedEnv.HF_TOKEN_PATH, path.join(root, 'cache', 'HF_AUTH', 'token'))
102
+
103
+ await fs.writeFile(
104
+ path.join(appDir, 'ENVIRONMENT'),
105
+ 'HF_HOME=./cache/huggingface\nHF_TOKEN_PATH=./auth/token\n'
106
+ )
107
+ provider.hfPath = () => process.execPath
108
+ const { env } = await provider.runHf([
109
+ '-e',
110
+ 'require("node:fs").writeFileSync(process.env.HF_TOKEN_PATH, "hf_app_token")'
111
+ ], {}, context)
112
+
113
+ assert.equal(env.HF_HOME, path.join(appDir, 'cache', 'huggingface'))
114
+ assert.equal(env.HF_TOKEN_PATH, tokenPath)
115
+ assert.equal(await fs.readFile(tokenPath, 'utf8'), 'hf_app_token')
116
+ await assert.rejects(
117
+ fs.access(path.join(root, 'cache', 'HF_AUTH', 'token')),
118
+ { code: 'ENOENT' }
119
+ )
120
+
121
+ const commandTokenPath = path.join(appDir, 'command-auth', 'token')
122
+ const commandContext = {
123
+ parentPath: scriptPath,
124
+ cwd: appDir,
125
+ env: { HF_TOKEN_PATH: './command-auth/token' }
126
+ }
127
+ const commandResult = await provider.runHf([
128
+ '-e',
129
+ 'require("node:fs").writeFileSync(process.env.HF_TOKEN_PATH, "hf_command_token")'
130
+ ], {}, commandContext)
131
+
132
+ assert.equal(commandResult.env.HF_TOKEN_PATH, commandTokenPath)
133
+ assert.equal(await fs.readFile(commandTokenPath, 'utf8'), 'hf_command_token')
134
+ } finally {
135
+ await fs.rm(root, { recursive: true, force: true })
136
+ }
137
+ })
138
+
139
+ test('Hugging Face connect verifies status with bounded whoami', async () => {
140
+ const provider = new HuggingfaceConnect(createKernel('/tmp/pinokio'), {})
141
+ let receivedArgs
142
+ let receivedOptions
143
+ provider.runHf = async (args, options) => {
144
+ receivedArgs = args
145
+ receivedOptions = options
146
+ }
147
+
148
+ const connected = await provider.connected({ timeout: 2500 })
149
+
150
+ assert.deepEqual(receivedArgs, ['auth', 'whoami', '--format', 'quiet'])
151
+ assert.deepEqual(receivedOptions, { timeout: 2500 })
152
+ assert.equal(connected, true)
153
+
154
+ provider.runHf = async () => {
155
+ throw new Error('not logged in')
156
+ }
157
+ assert.equal(await provider.connected({ timeout: 2500 }), false)
158
+ })
159
+
160
+ test('Hugging Face connect supplies the shared token path when none is configured', async () => {
161
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-hf-connect-default-'))
162
+ await fs.writeFile(path.join(root, 'ENVIRONMENT'), 'OTHER=value\n')
163
+ try {
164
+ const provider = new HuggingfaceConnect(createKernel(root), {})
165
+ const env = await provider.authEnv()
166
+
167
+ assert.equal(env.HF_TOKEN_PATH, path.join(root, 'cache', 'HF_AUTH', 'token'))
168
+ await assert.doesNotReject(fs.access(path.join(root, 'cache', 'HF_AUTH')))
169
+ await assert.rejects(fs.access(env.HF_TOKEN_PATH), { code: 'ENOENT' })
170
+ } finally {
171
+ await fs.rm(root, { recursive: true, force: true })
172
+ }
173
+ })
174
+
175
+ test('Hugging Face connect repairs an empty directory at the token file path', async () => {
176
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-hf-connect-repair-'))
177
+ const tokenPath = path.join(root, 'cache', 'HF_AUTH', 'token')
178
+ await fs.mkdir(tokenPath, { recursive: true })
179
+ try {
180
+ const provider = new HuggingfaceConnect(createKernel(root), {})
181
+ const env = await provider.authEnv()
182
+
183
+ assert.equal(env.HF_TOKEN_PATH, tokenPath)
184
+ await assert.rejects(fs.access(tokenPath), { code: 'ENOENT' })
185
+ } finally {
186
+ await fs.rm(root, { recursive: true, force: true })
187
+ }
188
+ })
189
+
190
+ test('Hugging Face connect leaves existing token and refresh files unchanged', async () => {
191
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-hf-connect-existing-login-'))
192
+ const authDir = path.join(root, 'cache', 'HF_AUTH')
193
+ await fs.mkdir(authDir, { recursive: true })
194
+ await fs.writeFile(path.join(authDir, 'token'), 'hf_existing_token')
195
+ await fs.writeFile(path.join(authDir, 'stored_tokens'), 'existing stored tokens')
196
+ try {
197
+ const provider = new HuggingfaceConnect(createKernel(root), {})
198
+ const env = await provider.authEnv()
199
+
200
+ assert.equal(env.HF_TOKEN_PATH, path.join(authDir, 'token'))
201
+ assert.equal(await fs.readFile(path.join(authDir, 'token'), 'utf8'), 'hf_existing_token')
202
+ assert.equal(await fs.readFile(path.join(authDir, 'stored_tokens'), 'utf8'), 'existing stored tokens')
203
+ } finally {
204
+ await fs.rm(root, { recursive: true, force: true })
205
+ }
206
+ })
207
+
208
+ test('Hugging Face connect removes a non-empty token directory', async () => {
209
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-hf-connect-invalid-dir-'))
210
+ const authDir = path.join(root, 'cache', 'HF_AUTH')
211
+ const tokenPath = path.join(authDir, 'token')
212
+ await fs.mkdir(tokenPath, { recursive: true })
213
+ await fs.writeFile(path.join(tokenPath, 'leftover.txt'), 'leftover')
214
+ await fs.writeFile(path.join(authDir, 'stored_tokens'), 'refresh state')
215
+ try {
216
+ const provider = new HuggingfaceConnect(createKernel(root), {})
217
+ await Promise.all([provider.authEnv(), provider.authEnv()])
218
+ provider.hfPath = () => process.execPath
219
+ const { env } = await provider.runHf([
220
+ '-e',
221
+ 'require("node:fs").writeFileSync(process.env.HF_TOKEN_PATH, "hf_replacement_token")'
222
+ ])
223
+
224
+ assert.equal(env.HF_TOKEN_PATH, tokenPath)
225
+ assert.equal(await fs.readFile(tokenPath, 'utf8'), 'hf_replacement_token')
226
+ assert.equal(await fs.readFile(path.join(authDir, 'stored_tokens'), 'utf8'), 'refresh state')
227
+ } finally {
228
+ await fs.rm(root, { recursive: true, force: true })
229
+ }
230
+ })
231
+
75
232
  test('Hugging Face connect cancelLogin only stops a pending login session', async () => {
76
233
  const provider = new HuggingfaceConnect(createKernel('/tmp/pinokio'), {})
77
234
  let killed = false
@@ -172,6 +172,39 @@ test('Shell.init_env disables Hugging Face hub update checks by default without
172
172
  assert.equal(overrideShell.env.HF_TOKEN_PATH, path.join(root, 'custom', 'hf-token'))
173
173
  })
174
174
 
175
+ test('Shell.init_env preserves an app HF_HOME without changing the shared token default', async () => {
176
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-shell-hf-app-env-'))
177
+ const appDir = path.join(root, 'api', 'demo')
178
+ const scriptPath = path.join(appDir, 'start.js')
179
+ await fs.mkdir(appDir, { recursive: true })
180
+ await fs.writeFile(scriptPath, '')
181
+ await fs.writeFile(path.join(appDir, 'ENVIRONMENT'), 'HF_HOME=./cache/huggingface\n')
182
+
183
+ const shell = createShell(createKernel(root))
184
+ await shell.init_env({
185
+ path: appDir,
186
+ $parent: { path: scriptPath },
187
+ env: {}
188
+ })
189
+
190
+ assert.equal(shell.env.HF_HOME, path.join(appDir, 'cache', 'huggingface'))
191
+ assert.equal(shell.env.HF_TOKEN_PATH, path.join(root, 'cache', 'HF_AUTH', 'token'))
192
+
193
+ await fs.writeFile(
194
+ path.join(appDir, 'ENVIRONMENT'),
195
+ 'HF_HOME=./cache/huggingface\nHF_TOKEN_PATH=./auth/token\n'
196
+ )
197
+ const overrideShell = createShell(createKernel(root))
198
+ await overrideShell.init_env({
199
+ path: appDir,
200
+ $parent: { path: scriptPath },
201
+ env: {}
202
+ })
203
+
204
+ assert.equal(overrideShell.env.HF_HOME, path.join(appDir, 'cache', 'huggingface'))
205
+ assert.equal(overrideShell.env.HF_TOKEN_PATH, path.join(appDir, 'auth', 'token'))
206
+ })
207
+
175
208
  test('Shell.init_env keeps Windows Hugging Face symlink defaults scoped to win32', async () => {
176
209
  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-shell-hf-win-'))
177
210
  await fs.mkdir(path.join(root, 'api'), { recursive: true })