pinokiod 7.0.3 → 7.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "7.0.3",
3
+ "version": "7.0.5",
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.
@@ -52,6 +52,7 @@ Follow these sections in order:
52
52
  2. Only use Registry Fallback if Search App found no suitable installed app and the user approved it.
53
53
  3. Then use Run App.
54
54
  4. Then use API Call Strategy if the app exposes an automatable API.
55
+ 5. Only use Parallel Mode when the user explicitly asks to use multiple apps or multiple machines in parallel.
55
56
 
56
57
  ### 1. Search App
57
58
 
@@ -76,6 +77,8 @@ Follow these sections in order:
76
77
  - relevant apps with `ready=true`
77
78
  - otherwise relevant apps with `running=true`
78
79
  - otherwise relevant offline apps
80
+ - This runtime priority applies across all relevant candidates, not just the same app on different machines.
81
+ - If multiple different apps can satisfy the request, prefer the already-ready one over launching another offline app.
79
82
  - Within the selected runtime tier, rank by user preference:
80
83
  - exact `app_id`/title match (for explicit app requests)
81
84
  - `starred=true`
@@ -86,7 +89,7 @@ Follow these sections in order:
86
89
  - higher `score`
87
90
  - If the top candidate is not clearly better than alternatives, ask user once with top 3 candidates.
88
91
  - 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:
92
+ - Search results may include apps from other reachable Pinokio machines:
90
93
  - remote results use `app_id` in the form `<app_id>@<source.host>`
91
94
  - `source.local=false` means the result is from another machine
92
95
  - treat remote results as separate apps; do not merge them with the local app of the same name
@@ -106,33 +109,39 @@ Follow these sections in order:
106
109
 
107
110
  ### 3. Run App
108
111
 
109
- - Once you have a selected local app, use `pterm status`.
112
+ - Once you have a selected app, use `pterm status`.
110
113
  - Poll every 2s.
111
114
  - Use status fields from pterm output:
112
115
  - `path`: absolute app path to use with `pterm run`
113
116
  - `running`: script is running
114
117
  - `ready`: app is reachable/ready
115
118
  - `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
119
+ - `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
120
  - `state`: `offline | starting | online`
118
- - `source`: machine identity for federated results
121
+ - `source`: machine identity for results from other reachable Pinokio machines
119
122
  - Use `--probe` only for readiness confirmation before first API call (or when status is uncertain).
120
123
  - Use `--timeout=<ms>` only when you need a non-default probe timeout.
121
124
  - Treat `offline` as expected before first run.
122
125
  - If `app_id` contains `@<host>` or `source.local=false`, the app is remote:
123
126
  - treat `path` and `ready_url` as source-local fields, not caller-usable local paths/URLs
124
127
  - 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
128
+ - use `pterm run <app_id>` for remote launch; do not use a remote machine's `path` value as a local path
129
+ - for remote path-based tasks:
130
+ - this applies only when the task expects filesystem paths such as `/path/to/file`
131
+ - do not pass local paths from this machine to the remote app
132
+ - first run `pterm upload <app_id> <file...>`
133
+ - then use the returned remote `path` values
126
134
  - If app is offline or not ready, run it:
127
- - Run `pterm run <app_path>`.
135
+ - For remote apps, run `pterm run <app_id>`.
136
+ - Otherwise run `pterm run <app_path>`.
128
137
  - 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
138
  - Prefer stable launcher selectors such as `run.js?mode=Default`, then broader fallbacks like `run.js`, then installation fallback like `install.js`.
130
139
  - Continue polling with `pterm status <app_id>`.
131
140
  - Default startup timeout: 180s.
132
141
  - Success criteria:
133
142
  - `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
143
+ - use `ready_url` by default when it exists and is caller-usable
144
+ - 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
145
  - missing `external_ready_urls` is normal; it usually means network sharing is off
137
146
  - Failure criteria:
138
147
  - timeout before success
@@ -165,6 +174,27 @@ Follow these sections in order:
165
174
  - Do not execute the app's internal Python/Node/bundled CLI as a fallback when `pterm` has already selected a launcher-managed app.
166
175
  - If no automatable API exists after the app is running, report that clearly instead of bypassing the launcher with an internal CLI.
167
176
 
177
+ ### 5. Parallel Mode (explicit only)
178
+
179
+ - Use this section only when the user explicitly asks to:
180
+ - run on multiple machines
181
+ - use multiple apps in parallel
182
+ - compare multiple relevant apps side by side
183
+ - generate multiple outputs concurrently
184
+ - Do not use this mode by default.
185
+ - Keep each selected app as a separate target, including remote results like `<app_id>@<source.host>`.
186
+ - Selection rules:
187
+ - if the user asks for all relevant apps, use all relevant search results that can perform the task
188
+ - if the user asks for a specific count, use the top N relevant search results after normal search ranking
189
+ - if the user asks for parallel use but does not specify how many apps or machines to use, ask once
190
+ - Ranking still applies in this mode:
191
+ - prefer `ready` apps first
192
+ - then `running` apps
193
+ - then offline apps if more targets are still needed
194
+ - Run and monitor each selected target independently.
195
+ - Keep outputs labeled by target `app_id`.
196
+ - For remote path-based tasks, run `pterm upload <app_id> <file...>` separately for each remote target and use that target's returned remote `path` values.
197
+
168
198
  ## Behavior Rules
169
199
 
170
200
  - Do not add app-specific hardcoding when user gave only capability (for example "tts").
@@ -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)
@@ -189,6 +197,60 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
189
197
  }
190
198
  return Object.keys(info).filter((host) => host && host !== localHost)
191
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
+ }
192
254
  const mergeSearchApps = (localApps, remoteApps, query = '') => {
193
255
  const merged = []
194
256
  const seen = new Set()
@@ -393,6 +455,72 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
393
455
  })
394
456
  }))
395
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
+
396
524
  router.get('/apps/status/:app_id', asyncHandler(async (req, res) => {
397
525
  const parsedAppId = parseQualifiedAppId(req.params.app_id)
398
526
  const requestedAppId = parsedAppId.app_id || req.params.app_id
@@ -449,7 +577,45 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
449
577
  }))
450
578
 
451
579
  router.get('/apps/logs/:app_id', asyncHandler(async (req, res) => {
452
- const appId = registry.normalizeAppId(req.params.app_id)
580
+ const parsedAppId = parseQualifiedAppId(req.params.app_id)
581
+ const requestedAppId = parsedAppId.app_id || req.params.app_id
582
+ const remoteHost = parsedAppId.qualified ? parsedAppId.host : null
583
+ if (remoteHost && remoteHost !== currentPeerHost()) {
584
+ try {
585
+ const params = {}
586
+ if (typeof req.query.script === 'string' && req.query.script.trim()) {
587
+ params.script = req.query.script
588
+ }
589
+ const tail = registry.parseTailCount(req.query.tail, 200)
590
+ if (Number.isFinite(tail) && tail > 0) {
591
+ params.tail = String(tail)
592
+ }
593
+ const response = await axios.get(`http://${remoteHost}:${peerPort()}/apps/logs/${encodeURIComponent(requestedAppId)}`, {
594
+ timeout: DEFAULT_PEER_TIMEOUT_MS,
595
+ headers: peerRequestHeaders(req),
596
+ params
597
+ })
598
+ const payload = response && response.data && typeof response.data === 'object'
599
+ ? { ...response.data }
600
+ : {}
601
+ payload.app_id = qualifyAppId(requestedAppId, remoteHost)
602
+ payload.source = buildSource(remoteHost, false)
603
+ res.json(payload)
604
+ return
605
+ } catch (error) {
606
+ if (error && error.response) {
607
+ res.status(error.response.status).json(error.response.data)
608
+ return
609
+ }
610
+ res.status(502).json({
611
+ error: 'Peer logs unavailable',
612
+ app_id: qualifyAppId(requestedAppId, remoteHost),
613
+ source: buildSource(remoteHost, false)
614
+ })
615
+ return
616
+ }
617
+ }
618
+ const appId = registry.normalizeAppId(requestedAppId)
453
619
  if (!appId) {
454
620
  res.status(400).json({ error: 'Invalid app_id' })
455
621
  return