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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "7.0.2",
3
+ "version": "7.0.4",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -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
- - Federated search may return apps from other Pinokio machines on the LAN:
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 local app, use `pterm status`.
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` fails due to loopback restrictions
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 federated results
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
- - do not assume `pterm run <path>` can launch a remote app from the current machine
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
- - Run `pterm run <app_path>`.
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
@@ -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 decorateSearchResult = (appResult, source) => {
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