pinokiod 7.0.1 → 7.0.3

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.1",
3
+ "version": "7.0.3",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -113,7 +113,7 @@ Follow these sections in order:
113
113
  - `running`: script is running
114
114
  - `ready`: app is reachable/ready
115
115
  - `ready_url`: default base URL for API calls when available
116
- - `external_ready_url`: optional non-loopback app URL, usually a LAN-accessible or otherwise externally reachable address exposed by Pinokio; use it only when `ready_url` fails due to loopback restrictions
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
117
117
  - `state`: `offline | starting | online`
118
118
  - `source`: machine identity for federated results
119
119
  - Use `--probe` only for readiness confirmation before first API call (or when status is uncertain).
@@ -121,7 +121,7 @@ Follow these sections in order:
121
121
  - Treat `offline` as expected before first run.
122
122
  - If `app_id` contains `@<host>` or `source.local=false`, the app is remote:
123
123
  - treat `path` and `ready_url` as source-local fields, not caller-usable local paths/URLs
124
- - use `external_ready_url` for caller-side API access when available
124
+ - use `external_ready_urls` in order for caller-side API access when available
125
125
  - do not assume `pterm run <path>` can launch a remote app from the current machine
126
126
  - If app is offline or not ready, run it:
127
127
  - Run `pterm run <app_path>`.
@@ -132,8 +132,8 @@ Follow these sections in order:
132
132
  - Success criteria:
133
133
  - `state=online` and `ready=true`
134
134
  - use `ready_url` by default
135
- - if `ready_url` fails because the client cannot access loopback and `external_ready_url` exists, use `external_ready_url`
136
- - missing `external_ready_url` is normal; it usually means network sharing is off
135
+ - if `ready_url` fails because the client cannot access loopback and `external_ready_urls` exists, try those URLs in order
136
+ - missing `external_ready_urls` is normal; it usually means network sharing is off
137
137
  - Failure criteria:
138
138
  - timeout before success
139
139
  - app drops back to `offline` during startup after a run attempt
@@ -174,7 +174,7 @@ Follow these sections in order:
174
174
  - Prefer returning full logs over brittle deterministic error parsing.
175
175
  - REST endpoints may be used for diagnostics only when pterm is unavailable; do not claim full install/launch lifecycle completion without compatible pterm commands.
176
176
  - Do not keep searching after app selection; move to status/run.
177
- - Do not assume `external_ready_url` exists; localhost-only apps are normal.
177
+ - Do not assume `external_ready_urls` exists; localhost-only apps are normal.
178
178
  - Do not conflate loopback access failure, sandbox denial, or missing permission with "Pinokio is not running" or "`pterm` is not installed."
179
179
  - On `pterm` permission failure, prefer asking for permission over asking the user to manually run commands.
180
180
  - If `pterm` exists locally but cannot reach the control plane, explicitly tell the user this looks like a client permission/sandbox issue.
@@ -70,7 +70,9 @@ class AppRegistryService {
70
70
  seen.add(signature)
71
71
  entries.push({
72
72
  host: String(externalHost.host).trim(),
73
- port: Number.parseInt(String(externalHost.port), 10)
73
+ port: Number.parseInt(String(externalHost.port), 10),
74
+ scope: externalHost.scope || null,
75
+ interface: externalHost.interface || null
74
76
  })
75
77
  }
76
78
  if (item.external_ip && typeof item.external_ip === 'string') {
@@ -81,7 +83,9 @@ class AppRegistryService {
81
83
  seen.add(signature)
82
84
  entries.push({
83
85
  host: parsed.hostname,
84
- port: Number.parseInt(parsed.port, 10)
86
+ port: Number.parseInt(parsed.port, 10),
87
+ scope: null,
88
+ interface: null
85
89
  })
86
90
  }
87
91
  } catch (_) {}
@@ -90,68 +94,88 @@ class AppRegistryService {
90
94
  return entries
91
95
  }
92
96
 
93
- buildExternalReadyUrl(url, source = null) {
97
+ sortExternalHostEntries(entries = [], source = null) {
98
+ const sourceInfo = this.normalizeSource(source)
99
+ const scopedEntries = Array.isArray(entries) ? entries.slice() : []
100
+ const scopeRank = (scope = '') => {
101
+ switch (String(scope || '').toLowerCase()) {
102
+ case 'lan':
103
+ return 0
104
+ case 'cgnat':
105
+ return 1
106
+ default:
107
+ return 2
108
+ }
109
+ }
110
+ return scopedEntries.sort((a, b) => {
111
+ const aHost = String(a && a.host ? a.host : '').toLowerCase()
112
+ const bHost = String(b && b.host ? b.host : '').toLowerCase()
113
+ const aMatchesCaller = sourceInfo.hostname && aHost === sourceInfo.hostname ? 1 : 0
114
+ const bMatchesCaller = sourceInfo.hostname && bHost === sourceInfo.hostname ? 1 : 0
115
+ if (aMatchesCaller !== bMatchesCaller) {
116
+ return bMatchesCaller - aMatchesCaller
117
+ }
118
+ const aScope = scopeRank(a && a.scope)
119
+ const bScope = scopeRank(b && b.scope)
120
+ if (aScope !== bScope) {
121
+ return aScope - bScope
122
+ }
123
+ return `${aHost}:${a && a.port ? a.port : ''}`.localeCompare(`${bHost}:${b && b.port ? b.port : ''}`)
124
+ })
125
+ }
126
+
127
+ buildExternalReadyUrls(url, source = null) {
94
128
  if (!url || typeof url !== 'string') {
95
- return null
129
+ return []
96
130
  }
97
131
  const originalUrl = String(url)
98
132
  let parsed
99
133
  try {
100
134
  parsed = new URL(originalUrl)
101
135
  } catch (_) {
102
- return null
136
+ return []
103
137
  }
104
138
  const originalProtocol = parsed.protocol === 'https:' ? 'https:' : 'http:'
105
139
  const hostname = (parsed.hostname || '').trim().toLowerCase()
106
- if (!hostname) {
107
- return null
108
- }
109
- if (!this.isLoopbackHostname(hostname)) {
110
- let result = parsed.toString()
111
- if (/^https?:\/\/[^/?#]+$/i.test(originalUrl) && parsed.pathname === '/' && !parsed.search && !parsed.hash) {
112
- result = result.replace(/\/$/, '')
113
- }
114
- return result
140
+ if (!hostname || !this.isLoopbackHostname(hostname)) {
141
+ return []
115
142
  }
116
143
 
117
- const sourceInfo = this.normalizeSource(source)
118
- const externalEntries = this.findExternalHostEntries(parsed.port)
119
- let nextHost = ''
120
- let nextPort = Number.parseInt(parsed.port, 10)
121
-
122
- if (externalEntries.length > 0) {
123
- const matchingEntry = sourceInfo.hostname
124
- ? externalEntries.find((entry) => entry.host === sourceInfo.hostname)
125
- : null
126
- const selectedEntry = matchingEntry || externalEntries[0]
127
- nextHost = selectedEntry.host
128
- nextPort = selectedEntry.port
129
- } else {
130
- const mappedPort = this.kernel && this.kernel.router && this.kernel.router.port_mapping
131
- ? this.kernel.router.port_mapping[String(parsed.port)]
132
- : null
133
- if (sourceInfo.hostname && !this.isLoopbackHostname(sourceInfo.hostname)) {
134
- nextHost = sourceInfo.hostname
135
- } else if (this.kernel && this.kernel.peer && this.kernel.peer.host) {
136
- nextHost = String(this.kernel.peer.host).trim()
144
+ const externalEntries = this.sortExternalHostEntries(this.findExternalHostEntries(parsed.port), source)
145
+ const results = []
146
+ const seen = new Set()
147
+ for (const entry of externalEntries) {
148
+ if (!entry || !entry.host || !entry.port) {
149
+ continue
137
150
  }
138
- if (mappedPort) {
139
- nextPort = Number.parseInt(String(mappedPort), 10)
151
+ const next = new URL(parsed.toString())
152
+ next.protocol = originalProtocol
153
+ next.hostname = entry.host
154
+ next.port = String(entry.port)
155
+ let nextUrl = next.toString()
156
+ if (/^https?:\/\/[^/?#]+$/i.test(originalUrl) && next.pathname === '/' && !next.search && !next.hash) {
157
+ nextUrl = nextUrl.replace(/\/$/, '')
140
158
  }
159
+ if (seen.has(nextUrl)) {
160
+ continue
161
+ }
162
+ seen.add(nextUrl)
163
+ const item = {
164
+ url: nextUrl,
165
+ transport: 'ip',
166
+ scope: entry.scope || 'unknown'
167
+ }
168
+ if (entry.interface) {
169
+ item.interface = entry.interface
170
+ }
171
+ results.push(item)
141
172
  }
173
+ return results
174
+ }
142
175
 
143
- if (!nextHost || !Number.isFinite(nextPort) || nextPort <= 0) {
144
- return null
145
- }
146
-
147
- parsed.protocol = originalProtocol
148
- parsed.hostname = nextHost
149
- parsed.port = String(nextPort)
150
- let result = parsed.toString()
151
- if (/^https?:\/\/[^/?#]+$/i.test(originalUrl) && parsed.pathname === '/' && !parsed.search && !parsed.hash) {
152
- result = result.replace(/\/$/, '')
153
- }
154
- return result
176
+ buildExternalReadyUrl(url, source = null) {
177
+ const externalReadyUrls = this.buildExternalReadyUrls(url, source)
178
+ return externalReadyUrls.length > 0 ? externalReadyUrls[0].url : null
155
179
  }
156
180
 
157
181
  isPathWithin(parentPath, childPath) {
@@ -241,7 +265,7 @@ class AppRegistryService {
241
265
  ready: false,
242
266
  state: 'offline',
243
267
  ready_url: null,
244
- external_ready_url: null,
268
+ external_ready_urls: [],
245
269
  ready_script: null,
246
270
  running_scripts: [],
247
271
  local_entries: []
@@ -419,7 +443,7 @@ class AppRegistryService {
419
443
  }
420
444
 
421
445
  const runtime = this.collectAppRuntime(appRoot)
422
- runtime.external_ready_url = this.buildExternalReadyUrl(runtime.ready_url, options.source || null)
446
+ runtime.external_ready_urls = this.buildExternalReadyUrls(runtime.ready_url, options.source || null)
423
447
  const installScript = await this.firstExistingScript(appRoot, ['install.js', 'install.json'])
424
448
  const startScript = await this.firstExistingScript(appRoot, ['start.js', 'start.json'])
425
449
  let defaultTarget = null
@@ -457,7 +481,7 @@ class AppRegistryService {
457
481
  running: runtime.running,
458
482
  ready,
459
483
  ready_url: runtime.ready_url,
460
- external_ready_url: runtime.external_ready_url,
484
+ external_ready_urls: runtime.external_ready_urls,
461
485
  state,
462
486
  running_scripts: runtime.running_scripts,
463
487
  ready_script: runtime.ready_script,
@@ -515,14 +515,14 @@ class AppSearchService {
515
515
  decorateAppWithRuntime(app, extras = {}, source = null) {
516
516
  const appRoot = this.kernel.path('api', app.name)
517
517
  const runtime = this.registry.collectAppRuntime(appRoot)
518
- const externalReadyUrl = this.registry.buildExternalReadyUrl(runtime.ready_url, source)
518
+ const externalReadyUrls = this.registry.buildExternalReadyUrls(runtime.ready_url, source)
519
519
  return {
520
520
  app_id: app.name,
521
521
  ...app,
522
522
  running: runtime.running,
523
523
  ready: runtime.ready,
524
524
  ready_url: runtime.ready_url,
525
- external_ready_url: externalReadyUrl,
525
+ external_ready_urls: externalReadyUrls,
526
526
  state: runtime.state,
527
527
  ...extras
528
528
  }
@@ -112,8 +112,43 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
112
112
  }
113
113
  return `${normalizedAppId}@${host}`
114
114
  }
115
- const decorateSearchResult = (appResult, source) => {
115
+ const neutralizeRemoteSearchPreferences = (appResult) => {
116
116
  const next = appResult && typeof appResult === 'object' ? { ...appResult } : {}
117
+ next.starred = false
118
+ next.starred_at = null
119
+ next.last_launch_at = null
120
+ next.last_launch_source = 'unknown'
121
+ next.launch_count_total = 0
122
+ next.launch_count_pterm = 0
123
+ next.launch_count_ui = 0
124
+ next.preference_boost = 0
125
+ if (typeof next.score === 'number') {
126
+ next.adjusted_score = next.score
127
+ } else {
128
+ next.adjusted_score = null
129
+ }
130
+ return next
131
+ }
132
+ const runtimeRank = (appResult) => {
133
+ if (appResult && appResult.ready) {
134
+ return 2
135
+ }
136
+ if (appResult && appResult.running) {
137
+ return 1
138
+ }
139
+ return 0
140
+ }
141
+ const parseTimestamp = (value) => {
142
+ if (typeof value !== 'string' || !value.trim()) {
143
+ return 0
144
+ }
145
+ const parsed = Date.parse(value)
146
+ return Number.isFinite(parsed) ? parsed : 0
147
+ }
148
+ const decorateSearchResult = (appResult, source) => {
149
+ const next = source && !source.local
150
+ ? neutralizeRemoteSearchPreferences(appResult)
151
+ : (appResult && typeof appResult === 'object' ? { ...appResult } : {})
117
152
  next.app_id = qualifyAppId(next.app_id || next.name || '', source.host)
118
153
  next.source = source
119
154
  if (!source.local) {
@@ -171,11 +206,31 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
171
206
  }
172
207
  push(localApps)
173
208
  push(remoteApps)
174
- const normalizedQuery = typeof query === 'string' ? query.trim() : ''
175
- if (!normalizedQuery) {
176
- return merged
177
- }
178
209
  return merged.sort((a, b) => {
210
+ const aRuntimeRank = runtimeRank(a)
211
+ const bRuntimeRank = runtimeRank(b)
212
+ if (aRuntimeRank !== bRuntimeRank) {
213
+ return bRuntimeRank - aRuntimeRank
214
+ }
215
+ const normalizedQuery = typeof query === 'string' ? query.trim() : ''
216
+ if (!normalizedQuery) {
217
+ const aStarred = a && a.starred ? 1 : 0
218
+ const bStarred = b && b.starred ? 1 : 0
219
+ if (aStarred !== bStarred) {
220
+ return bStarred - aStarred
221
+ }
222
+ const aLaunchCount = Math.max(0, Number.parseInt(String(a && a.launch_count_total || 0), 10) || 0)
223
+ const bLaunchCount = Math.max(0, Number.parseInt(String(b && b.launch_count_total || 0), 10) || 0)
224
+ if (aLaunchCount !== bLaunchCount) {
225
+ return bLaunchCount - aLaunchCount
226
+ }
227
+ const aLastLaunch = parseTimestamp(a && a.last_launch_at)
228
+ const bLastLaunch = parseTimestamp(b && b.last_launch_at)
229
+ if (aLastLaunch !== bLastLaunch) {
230
+ return bLastLaunch - aLastLaunch
231
+ }
232
+ return String(a.app_id || '').localeCompare(String(b.app_id || ''))
233
+ }
179
234
  const aAdjusted = typeof a.adjusted_score === 'number' ? a.adjusted_score : -Infinity
180
235
  const bAdjusted = typeof b.adjusted_score === 'number' ? b.adjusted_score : -Infinity
181
236
  if (aAdjusted !== bAdjusted) {
@@ -259,6 +314,11 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
259
314
  res.status(400).json({ error: 'Invalid app_id' })
260
315
  return
261
316
  }
317
+ const parsedAppId = parseQualifiedAppId(appId)
318
+ if (parsedAppId.qualified && parsedAppId.host !== currentPeerHost()) {
319
+ res.status(400).json({ error: 'Remote app preferences are not supported' })
320
+ return
321
+ }
262
322
  const body = req.body && typeof req.body === 'object' ? req.body : {}
263
323
  const hasStarred = Object.prototype.hasOwnProperty.call(body, 'starred')
264
324
  if (!hasStarred) {