pinokiod 7.0.2 → 7.0.4
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/package.json +1 -1
- package/prototype/system/SKILL_PINOKIO.md +17 -9
- package/server/routes/apps.js +193 -5
package/package.json
CHANGED
|
@@ -40,7 +40,7 @@ Failure handling:
|
|
|
40
40
|
|
|
41
41
|
Use direct `pterm` commands for control-plane operations:
|
|
42
42
|
|
|
43
|
-
`pterm search`, `pterm status`, `pterm run`, `pterm logs`, `pterm which`, `pterm stars`, `pterm star` / `pterm unstar`, `pterm registry search`, `pterm download`
|
|
43
|
+
`pterm search`, `pterm status`, `pterm run`, `pterm logs`, `pterm upload`, `pterm which`, `pterm stars`, `pterm star` / `pterm unstar`, `pterm registry search`, `pterm download`
|
|
44
44
|
|
|
45
45
|
Do not run update commands from this skill.
|
|
46
46
|
Once a Pinokio-managed app is selected, treat `pterm` and the launcher-managed interfaces as the source of truth for lifecycle and execution. Do not switch to repo-local CLIs or bundled app binaries unless the user explicitly asks for CLI mode.
|
|
@@ -76,6 +76,8 @@ Follow these sections in order:
|
|
|
76
76
|
- relevant apps with `ready=true`
|
|
77
77
|
- otherwise relevant apps with `running=true`
|
|
78
78
|
- otherwise relevant offline apps
|
|
79
|
+
- This runtime priority applies across all relevant candidates, not just the same app on different machines.
|
|
80
|
+
- If multiple different apps can satisfy the request, prefer the already-ready one over launching another offline app.
|
|
79
81
|
- Within the selected runtime tier, rank by user preference:
|
|
80
82
|
- exact `app_id`/title match (for explicit app requests)
|
|
81
83
|
- `starred=true`
|
|
@@ -86,7 +88,7 @@ Follow these sections in order:
|
|
|
86
88
|
- higher `score`
|
|
87
89
|
- If the top candidate is not clearly better than alternatives, ask user once with top 3 candidates.
|
|
88
90
|
- If a suitable installed app is found, select it and continue to Run App.
|
|
89
|
-
-
|
|
91
|
+
- Search results may include apps from other reachable Pinokio machines:
|
|
90
92
|
- remote results use `app_id` in the form `<app_id>@<source.host>`
|
|
91
93
|
- `source.local=false` means the result is from another machine
|
|
92
94
|
- treat remote results as separate apps; do not merge them with the local app of the same name
|
|
@@ -106,33 +108,39 @@ Follow these sections in order:
|
|
|
106
108
|
|
|
107
109
|
### 3. Run App
|
|
108
110
|
|
|
109
|
-
- Once you have a selected
|
|
111
|
+
- Once you have a selected app, use `pterm status`.
|
|
110
112
|
- Poll every 2s.
|
|
111
113
|
- Use status fields from pterm output:
|
|
112
114
|
- `path`: absolute app path to use with `pterm run`
|
|
113
115
|
- `running`: script is running
|
|
114
116
|
- `ready`: app is reachable/ready
|
|
115
117
|
- `ready_url`: default base URL for API calls when available
|
|
116
|
-
- `external_ready_urls`: optional ordered non-loopback app URLs for caller-side access; use them only when `ready_url`
|
|
118
|
+
- `external_ready_urls`: optional ordered non-loopback app URLs for caller-side access; use them only when `ready_url` is missing or unusable due to loopback restrictions
|
|
117
119
|
- `state`: `offline | starting | online`
|
|
118
|
-
- `source`: machine identity for
|
|
120
|
+
- `source`: machine identity for results from other reachable Pinokio machines
|
|
119
121
|
- Use `--probe` only for readiness confirmation before first API call (or when status is uncertain).
|
|
120
122
|
- Use `--timeout=<ms>` only when you need a non-default probe timeout.
|
|
121
123
|
- Treat `offline` as expected before first run.
|
|
122
124
|
- If `app_id` contains `@<host>` or `source.local=false`, the app is remote:
|
|
123
125
|
- treat `path` and `ready_url` as source-local fields, not caller-usable local paths/URLs
|
|
124
126
|
- use `external_ready_urls` in order for caller-side API access when available
|
|
125
|
-
-
|
|
127
|
+
- use `pterm run <app_id>` for remote launch; do not use a remote machine's `path` value as a local path
|
|
128
|
+
- for remote path-based tasks:
|
|
129
|
+
- this applies only when the task expects filesystem paths such as `/path/to/file`
|
|
130
|
+
- do not pass local paths from this machine to the remote app
|
|
131
|
+
- first run `pterm upload <app_id> <file...>`
|
|
132
|
+
- then use the returned remote `path` values
|
|
126
133
|
- If app is offline or not ready, run it:
|
|
127
|
-
-
|
|
134
|
+
- For remote apps, run `pterm run <app_id>`.
|
|
135
|
+
- Otherwise run `pterm run <app_path>`.
|
|
128
136
|
- If the launcher has no explicit default item or the launch action depends on current menu state, infer one or more ordered selectors from the launcher's current menu and pass them via repeated `--default`.
|
|
129
137
|
- Prefer stable launcher selectors such as `run.js?mode=Default`, then broader fallbacks like `run.js`, then installation fallback like `install.js`.
|
|
130
138
|
- Continue polling with `pterm status <app_id>`.
|
|
131
139
|
- Default startup timeout: 180s.
|
|
132
140
|
- Success criteria:
|
|
133
141
|
- `state=online` and `ready=true`
|
|
134
|
-
- use `ready_url` by default
|
|
135
|
-
- if `ready_url` fails because the client cannot access loopback and `external_ready_urls` exists, try those URLs in order
|
|
142
|
+
- use `ready_url` by default when it exists and is caller-usable
|
|
143
|
+
- if `ready_url` is missing, or it fails because the client cannot access loopback, and `external_ready_urls` exists, try those URLs in order
|
|
136
144
|
- missing `external_ready_urls` is normal; it usually means network sharing is off
|
|
137
145
|
- Failure criteria:
|
|
138
146
|
- timeout before success
|
package/server/routes/apps.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
const express = require('express')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const crypto = require('crypto')
|
|
2
5
|
const axios = require('axios')
|
|
6
|
+
const multer = require('multer')
|
|
7
|
+
const FormData = require('form-data')
|
|
8
|
+
const sanitize = require('sanitize-filename')
|
|
3
9
|
|
|
4
10
|
const DEFAULT_PEER_PORT = 42000
|
|
5
11
|
const DEFAULT_PEER_TIMEOUT_MS = 2500
|
|
12
|
+
const DEFAULT_PEER_UPLOAD_TIMEOUT_MS = 30000
|
|
6
13
|
const IPV4_HOST_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/
|
|
7
14
|
|
|
8
15
|
const isQualifiedHost = (value = '') => {
|
|
@@ -55,6 +62,7 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
|
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
const router = express.Router()
|
|
65
|
+
const upload = multer()
|
|
58
66
|
|
|
59
67
|
const asyncHandler = (fn) => (req, res, next) => {
|
|
60
68
|
Promise.resolve(fn(req, res, next)).catch(next)
|
|
@@ -112,8 +120,43 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
|
|
|
112
120
|
}
|
|
113
121
|
return `${normalizedAppId}@${host}`
|
|
114
122
|
}
|
|
115
|
-
const
|
|
123
|
+
const neutralizeRemoteSearchPreferences = (appResult) => {
|
|
116
124
|
const next = appResult && typeof appResult === 'object' ? { ...appResult } : {}
|
|
125
|
+
next.starred = false
|
|
126
|
+
next.starred_at = null
|
|
127
|
+
next.last_launch_at = null
|
|
128
|
+
next.last_launch_source = 'unknown'
|
|
129
|
+
next.launch_count_total = 0
|
|
130
|
+
next.launch_count_pterm = 0
|
|
131
|
+
next.launch_count_ui = 0
|
|
132
|
+
next.preference_boost = 0
|
|
133
|
+
if (typeof next.score === 'number') {
|
|
134
|
+
next.adjusted_score = next.score
|
|
135
|
+
} else {
|
|
136
|
+
next.adjusted_score = null
|
|
137
|
+
}
|
|
138
|
+
return next
|
|
139
|
+
}
|
|
140
|
+
const runtimeRank = (appResult) => {
|
|
141
|
+
if (appResult && appResult.ready) {
|
|
142
|
+
return 2
|
|
143
|
+
}
|
|
144
|
+
if (appResult && appResult.running) {
|
|
145
|
+
return 1
|
|
146
|
+
}
|
|
147
|
+
return 0
|
|
148
|
+
}
|
|
149
|
+
const parseTimestamp = (value) => {
|
|
150
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
151
|
+
return 0
|
|
152
|
+
}
|
|
153
|
+
const parsed = Date.parse(value)
|
|
154
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
155
|
+
}
|
|
156
|
+
const decorateSearchResult = (appResult, source) => {
|
|
157
|
+
const next = source && !source.local
|
|
158
|
+
? neutralizeRemoteSearchPreferences(appResult)
|
|
159
|
+
: (appResult && typeof appResult === 'object' ? { ...appResult } : {})
|
|
117
160
|
next.app_id = qualifyAppId(next.app_id || next.name || '', source.host)
|
|
118
161
|
next.source = source
|
|
119
162
|
if (!source.local) {
|
|
@@ -154,6 +197,60 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
|
|
|
154
197
|
}
|
|
155
198
|
return Object.keys(info).filter((host) => host && host !== localHost)
|
|
156
199
|
}
|
|
200
|
+
const uniqueUploadPath = async (directory, originalName) => {
|
|
201
|
+
const parsed = path.parse(originalName)
|
|
202
|
+
const baseName = parsed.name || 'upload'
|
|
203
|
+
const ext = parsed.ext || ''
|
|
204
|
+
let counter = 0
|
|
205
|
+
while (true) {
|
|
206
|
+
const candidateName = counter === 0
|
|
207
|
+
? `${baseName}${ext}`
|
|
208
|
+
: `${baseName}-${counter}${ext}`
|
|
209
|
+
const candidatePath = path.join(directory, candidateName)
|
|
210
|
+
try {
|
|
211
|
+
await fs.promises.access(candidatePath)
|
|
212
|
+
counter += 1
|
|
213
|
+
} catch (_) {
|
|
214
|
+
return {
|
|
215
|
+
name: candidateName,
|
|
216
|
+
path: candidatePath
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const storeAppUploads = async (appPath, files = []) => {
|
|
222
|
+
const token = crypto.randomBytes(16).toString('hex')
|
|
223
|
+
const uploadDir = path.join(appPath, '.pinokio-temp', 'uploads', token)
|
|
224
|
+
await fs.promises.mkdir(uploadDir, { recursive: true })
|
|
225
|
+
const stored = []
|
|
226
|
+
for (const file of files) {
|
|
227
|
+
if (!file || !file.buffer) {
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
const originalName = path.basename(typeof file.originalname === 'string' && file.originalname.trim()
|
|
231
|
+
? file.originalname.trim()
|
|
232
|
+
: 'upload')
|
|
233
|
+
const safeName = sanitize(originalName) || `upload-${Date.now()}`
|
|
234
|
+
const target = await uniqueUploadPath(uploadDir, safeName)
|
|
235
|
+
await fs.promises.writeFile(target.path, file.buffer)
|
|
236
|
+
stored.push({
|
|
237
|
+
name: originalName,
|
|
238
|
+
path: target.path,
|
|
239
|
+
size: typeof file.size === 'number' ? file.size : file.buffer.length,
|
|
240
|
+
mimeType: typeof file.mimetype === 'string' ? file.mimetype : ''
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
token,
|
|
245
|
+
files: stored
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const decorateUploadResult = (uploadResult, source, appId) => {
|
|
249
|
+
const next = uploadResult && typeof uploadResult === 'object' ? { ...uploadResult } : {}
|
|
250
|
+
next.app_id = qualifyAppId(appId || next.app_id || '', source.host)
|
|
251
|
+
next.source = source
|
|
252
|
+
return next
|
|
253
|
+
}
|
|
157
254
|
const mergeSearchApps = (localApps, remoteApps, query = '') => {
|
|
158
255
|
const merged = []
|
|
159
256
|
const seen = new Set()
|
|
@@ -171,11 +268,31 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
|
|
|
171
268
|
}
|
|
172
269
|
push(localApps)
|
|
173
270
|
push(remoteApps)
|
|
174
|
-
const normalizedQuery = typeof query === 'string' ? query.trim() : ''
|
|
175
|
-
if (!normalizedQuery) {
|
|
176
|
-
return merged
|
|
177
|
-
}
|
|
178
271
|
return merged.sort((a, b) => {
|
|
272
|
+
const aRuntimeRank = runtimeRank(a)
|
|
273
|
+
const bRuntimeRank = runtimeRank(b)
|
|
274
|
+
if (aRuntimeRank !== bRuntimeRank) {
|
|
275
|
+
return bRuntimeRank - aRuntimeRank
|
|
276
|
+
}
|
|
277
|
+
const normalizedQuery = typeof query === 'string' ? query.trim() : ''
|
|
278
|
+
if (!normalizedQuery) {
|
|
279
|
+
const aStarred = a && a.starred ? 1 : 0
|
|
280
|
+
const bStarred = b && b.starred ? 1 : 0
|
|
281
|
+
if (aStarred !== bStarred) {
|
|
282
|
+
return bStarred - aStarred
|
|
283
|
+
}
|
|
284
|
+
const aLaunchCount = Math.max(0, Number.parseInt(String(a && a.launch_count_total || 0), 10) || 0)
|
|
285
|
+
const bLaunchCount = Math.max(0, Number.parseInt(String(b && b.launch_count_total || 0), 10) || 0)
|
|
286
|
+
if (aLaunchCount !== bLaunchCount) {
|
|
287
|
+
return bLaunchCount - aLaunchCount
|
|
288
|
+
}
|
|
289
|
+
const aLastLaunch = parseTimestamp(a && a.last_launch_at)
|
|
290
|
+
const bLastLaunch = parseTimestamp(b && b.last_launch_at)
|
|
291
|
+
if (aLastLaunch !== bLastLaunch) {
|
|
292
|
+
return bLastLaunch - aLastLaunch
|
|
293
|
+
}
|
|
294
|
+
return String(a.app_id || '').localeCompare(String(b.app_id || ''))
|
|
295
|
+
}
|
|
179
296
|
const aAdjusted = typeof a.adjusted_score === 'number' ? a.adjusted_score : -Infinity
|
|
180
297
|
const bAdjusted = typeof b.adjusted_score === 'number' ? b.adjusted_score : -Infinity
|
|
181
298
|
if (aAdjusted !== bAdjusted) {
|
|
@@ -259,6 +376,11 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
|
|
|
259
376
|
res.status(400).json({ error: 'Invalid app_id' })
|
|
260
377
|
return
|
|
261
378
|
}
|
|
379
|
+
const parsedAppId = parseQualifiedAppId(appId)
|
|
380
|
+
if (parsedAppId.qualified && parsedAppId.host !== currentPeerHost()) {
|
|
381
|
+
res.status(400).json({ error: 'Remote app preferences are not supported' })
|
|
382
|
+
return
|
|
383
|
+
}
|
|
262
384
|
const body = req.body && typeof req.body === 'object' ? req.body : {}
|
|
263
385
|
const hasStarred = Object.prototype.hasOwnProperty.call(body, 'starred')
|
|
264
386
|
if (!hasStarred) {
|
|
@@ -333,6 +455,72 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
|
|
|
333
455
|
})
|
|
334
456
|
}))
|
|
335
457
|
|
|
458
|
+
router.post('/apps/:app_id/upload', upload.any(), asyncHandler(async (req, res) => {
|
|
459
|
+
const parsedAppId = parseQualifiedAppId(req.params.app_id)
|
|
460
|
+
const requestedAppId = parsedAppId.app_id || req.params.app_id
|
|
461
|
+
const remoteHost = parsedAppId.qualified ? parsedAppId.host : null
|
|
462
|
+
const files = Array.isArray(req.files) ? req.files : []
|
|
463
|
+
if (files.length === 0) {
|
|
464
|
+
res.status(400).json({ error: 'No files provided' })
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
if (remoteHost && remoteHost !== currentPeerHost()) {
|
|
468
|
+
try {
|
|
469
|
+
const form = new FormData()
|
|
470
|
+
for (const file of files) {
|
|
471
|
+
if (!file || !file.buffer) {
|
|
472
|
+
continue
|
|
473
|
+
}
|
|
474
|
+
form.append('files', file.buffer, {
|
|
475
|
+
filename: path.basename(file.originalname || 'upload'),
|
|
476
|
+
contentType: file.mimetype || 'application/octet-stream',
|
|
477
|
+
knownLength: typeof file.size === 'number' ? file.size : file.buffer.length
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
const response = await axios.post(`http://${remoteHost}:${peerPort()}/apps/${encodeURIComponent(requestedAppId)}/upload`, form, {
|
|
481
|
+
timeout: DEFAULT_PEER_UPLOAD_TIMEOUT_MS,
|
|
482
|
+
maxBodyLength: Infinity,
|
|
483
|
+
maxContentLength: Infinity,
|
|
484
|
+
headers: {
|
|
485
|
+
...peerRequestHeaders(req),
|
|
486
|
+
...form.getHeaders()
|
|
487
|
+
}
|
|
488
|
+
})
|
|
489
|
+
res.json(decorateUploadResult(response.data, buildSource(remoteHost, false), requestedAppId))
|
|
490
|
+
return
|
|
491
|
+
} catch (error) {
|
|
492
|
+
if (error && error.response) {
|
|
493
|
+
res.status(error.response.status).json(error.response.data)
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
res.status(502).json({
|
|
497
|
+
error: 'Peer upload unavailable',
|
|
498
|
+
app_id: qualifyAppId(requestedAppId, remoteHost),
|
|
499
|
+
source: buildSource(remoteHost, false)
|
|
500
|
+
})
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const appId = registry.normalizeAppId(requestedAppId)
|
|
505
|
+
if (!appId) {
|
|
506
|
+
res.status(400).json({ error: 'Invalid app_id' })
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
const status = await registry.buildAppStatus(appId, {
|
|
510
|
+
source: req.$source || null
|
|
511
|
+
})
|
|
512
|
+
if (!status || !status.path) {
|
|
513
|
+
res.status(404).json({ error: 'App not found', app_id: appId })
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
const payload = await storeAppUploads(status.path, files)
|
|
517
|
+
if (!Array.isArray(payload.files) || payload.files.length === 0) {
|
|
518
|
+
res.status(400).json({ error: 'No valid files provided', app_id: appId })
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
res.json(decorateUploadResult(payload, buildSource(currentPeerHost(), true), appId))
|
|
522
|
+
}))
|
|
523
|
+
|
|
336
524
|
router.get('/apps/status/:app_id', asyncHandler(async (req, res) => {
|
|
337
525
|
const parsedAppId = parseQualifiedAppId(req.params.app_id)
|
|
338
526
|
const requestedAppId = parsedAppId.app_id || req.params.app_id
|