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.
- package/kernel/api/hf/index.js +13 -4
- package/kernel/connect/index.js +12 -6
- package/kernel/connect/providers/huggingface/index.js +71 -20
- package/kernel/environment.js +0 -15
- package/package.json +1 -1
- package/server/index.js +15 -72
- package/server/public/task-launcher.css +4 -0
- package/server/routes/connect.js +49 -0
- package/server/views/connect.ejs +66 -46
- package/server/views/install.ejs +3 -0
- package/test/connect-page-route.test.js +143 -0
- package/test/github-connection.test.js +18 -3
- package/test/hf-api.test.js +39 -10
- package/test/huggingface-connect.test.js +159 -2
- package/test/shell-run-template.test.js +33 -0
package/kernel/api/hf/index.js
CHANGED
|
@@ -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
|
-
|
|
304
|
+
const params = req.params || {}
|
|
305
|
+
await connect.logout("huggingface", params, this.authContext(req, params))
|
|
297
306
|
return { status: "success" }
|
|
298
307
|
}
|
|
299
308
|
/*
|
package/kernel/connect/index.js
CHANGED
|
@@ -24,12 +24,12 @@ class Connect {
|
|
|
24
24
|
return null
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
-
async login(provider,
|
|
28
|
-
let res = await this.clients[provider].login(
|
|
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,
|
|
32
|
-
let res = await this.clients[provider].logout(
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
package/kernel/environment.js
CHANGED
|
@@ -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
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'], {
|
|
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 --
|
|
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
|
|
12675
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/server/views/connect.ejs
CHANGED
|
@@ -818,56 +818,31 @@ body.dark .profile td {
|
|
|
818
818
|
<% } else { %>
|
|
819
819
|
<div class='connect-service-list'>
|
|
820
820
|
<% items.forEach((item) => { %>
|
|
821
|
-
|
|
822
|
-
<
|
|
823
|
-
<div class='connect-service-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
847
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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>
|
package/server/views/install.ejs
CHANGED
|
@@ -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 --
|
|
70
|
-
assert.doesNotMatch(message, /git credential-manager github login --
|
|
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 --
|
|
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
|
|
package/test/hf-api.test.js
CHANGED
|
@@ -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
|
|
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
|
-
}
|
|
69
|
+
}
|
|
70
|
+
const result = await hf.login(request, (stream, type) => packets.push({ type, stream }), kernel)
|
|
68
71
|
|
|
69
|
-
|
|
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, [{
|
|
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
|
|
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'), '
|
|
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 })
|