pinokiod 8.1.0 → 8.1.1

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.
@@ -15,7 +15,7 @@ class Env {
15
15
  }
16
16
  */
17
17
  // write to current app folder's ENVIRONMENT
18
- let api_path = Util.api_path(req.parent.path, kernel)
18
+ let api_path = await kernel.api.launcher_path(Util.api_path(req.parent.path, kernel))
19
19
  let env_path = path.resolve(api_path, "ENVIRONMENT")
20
20
  await Util.update_env(env_path, req.params)
21
21
  }
@@ -35,7 +35,7 @@ class Env {
35
35
  }
36
36
  }
37
37
  */
38
- let api_path = Util.api_path(req.parent.path, kernel)
38
+ let api_path = await kernel.api.launcher_path(Util.api_path(req.parent.path, kernel))
39
39
  let env_path = path.resolve(api_path, "ENVIRONMENT")
40
40
  let env = await Environment.get2(req.parent.path, kernel)
41
41
  // does the key exist?
@@ -131,7 +131,7 @@ class Api {
131
131
  if (formData.icon_dirty) {
132
132
  //
133
133
  // write icon file
134
- let icon_path = this.kernel.path("api", formData.new_path, formData.icon_path)
134
+ let icon_path = path.resolve(launcher_path, formData.icon_path)
135
135
  await fs.promises.writeFile(icon_path, formData.avatar)
136
136
  meta.icon = formData.icon_path
137
137
  dirty = true
@@ -232,7 +232,7 @@ class Api {
232
232
  meta.ui = `/p/${api_name}`
233
233
  meta.browse = `/p/${api_name}/dev`
234
234
  } else {
235
- meta.icon = meta.icon ? `/asset/api/${api_name}/${meta.icon}` : "/pinokio-black.png"
235
+ Util.NestedLayout.setMetaIcon(meta, api_name, api_root_path, relpath, isWithinApiRoot)
236
236
  meta.link = `/p/${api_name}/${relpath}/dev#n1`
237
237
  meta.web_path = `/api/${api_name}/${relpath}`
238
238
  meta.ui = `/p/${api_name}/${relpath}`
@@ -1791,6 +1791,7 @@ class Api {
1791
1791
  })
1792
1792
  }
1793
1793
  async get_default(repo_path) {
1794
+ repo_path = await this.launcher_path(repo_path)
1794
1795
  let launcher = await this.launcher({
1795
1796
  path: repo_path
1796
1797
  })
@@ -1882,7 +1883,7 @@ class Api {
1882
1883
  if (chunks.length == 2) {
1883
1884
  // the script is requesting a uri of the git repo
1884
1885
  // look for pinokio.js
1885
- let p = path.resolve(request.path, "pinokio.js")
1886
+ let p = path.resolve(await this.launcher_path(request.path), "pinokio.js")
1886
1887
  let exists = await this.exists(p)
1887
1888
  if (exists) {
1888
1889
  await this.launch(request, p)
@@ -109,6 +109,7 @@ const loadMenu = async (api, repoPath) => {
109
109
  }
110
110
 
111
111
  module.exports = async (api, repoPath, preferred = []) => {
112
+ repoPath = await api.launcher_path(repoPath)
112
113
  let defaultTarget = await api.get_default(repoPath)
113
114
  if (defaultTarget) {
114
115
  return {
@@ -3,6 +3,7 @@ const path = require('path')
3
3
  const crypto = require('crypto')
4
4
  const { AsyncLocalStorage } = require('async_hooks')
5
5
  const Environment = require('./environment')
6
+ const NestedLayout = require('./nested_layout')
6
7
 
7
8
  class AppLogSessions {
8
9
  constructor({ kernel, now = () => new Date().toISOString(), randomHex = () => crypto.randomBytes(3).toString('hex') }) {
@@ -90,7 +91,7 @@ class AppLogSessions {
90
91
  return {
91
92
  appRoot,
92
93
  scriptPath: absolute,
93
- script: this.toPosix(parts.slice(1).join(path.sep))
94
+ script: this.toPosix(await NestedLayout.sessionScript(this, apiRoot, appRoot, absolute, parts.slice(1).join(path.sep)))
94
95
  }
95
96
  }
96
97
 
@@ -660,7 +660,7 @@ const get = async (homedir, kernel) => {
660
660
  for(let key in current_env) {
661
661
  let val = current_env[key]
662
662
  if (val.startsWith("./")) {
663
- let full_path = path.resolve(homedir, val)
663
+ let full_path = await Util.NestedLayout.environmentPath(kernel, homedir, val, got_root)
664
664
  current_env[key] = full_path
665
665
  }
666
666
  if (val.trim() === "") {
@@ -1013,6 +1013,7 @@ const init = async (options, kernel) => {
1013
1013
  await fs.promises.appendFile(excludePath, appendContent)
1014
1014
  }
1015
1015
  }
1016
+ await Util.NestedLayout.ensureGitExclude(root, relpath, kernel)
1016
1017
  return {
1017
1018
  relpath,
1018
1019
  root_path: root,
@@ -0,0 +1,140 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+
4
+ const samePath = (left, right) => path.resolve(left) === path.resolve(right)
5
+
6
+ const launcherPath = async (kernel, workspace) => {
7
+ if (!kernel.api || typeof kernel.api.launcher_path !== 'function') return workspace
8
+ return kernel.api.launcher_path(workspace)
9
+ }
10
+
11
+ const relativeRoot = async (kernel, workspace) => path.relative(workspace, await launcherPath(kernel, workspace))
12
+
13
+ const menuRoot = async (kernel, workspace, legacyRoot) => {
14
+ if (!legacyRoot) return ''
15
+ return samePath(await launcherPath(kernel, workspace), path.resolve(workspace, legacyRoot)) ? legacyRoot : ''
16
+ }
17
+
18
+ const environmentPath = async (kernel, homedir, value, root) => {
19
+ const base = root.relpath ? await launcherPath(kernel, homedir) : homedir
20
+ return path.resolve(base, value)
21
+ }
22
+
23
+ const sessionScript = async (sessions, apiRoot, appRoot, scriptPath, fallback) => {
24
+ const workspace = path.resolve(apiRoot, path.relative(apiRoot, scriptPath).split(path.sep)[0])
25
+ if (samePath(appRoot, workspace)) return fallback
26
+ const root = await launcherPath(sessions.kernel, workspace)
27
+ return sessions.isPathWithin(root, scriptPath) ? path.relative(root, scriptPath) : fallback
28
+ }
29
+
30
+ const logScript = async (kernel, scriptPath, relativeParts) => {
31
+ const fallback = relativeParts.slice(2)
32
+ if (relativeParts[2] !== 'pinokio') return fallback
33
+ const root = await launcherPath(kernel, kernel.path(...relativeParts.slice(0, 2)))
34
+ const relative = path.relative(root, scriptPath)
35
+ return relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative) ? fallback : relative.split(path.sep).filter(Boolean)
36
+ }
37
+
38
+ const appLogValues = async (registry, appRoot, scriptQuery, runtimeScripts) => {
39
+ const root = await launcherPath(registry.kernel || {}, appRoot)
40
+ if (samePath(root, appRoot)) return { appRoot, scriptQuery, runtimeScripts }
41
+ const strip = value => typeof value === 'string' ? value.replace(/^pinokio[\\/]/, '') : value
42
+ return { appRoot: root, scriptQuery: strip(scriptQuery), runtimeScripts: runtimeScripts.map(strip) }
43
+ }
44
+
45
+ const appScripts = async (registry, appRoot) => {
46
+ const root = await launcherPath(registry.kernel, appRoot)
47
+ const prefix = path.relative(appRoot, root).split(path.sep).join('/')
48
+ const candidates = files => files.map(file => path.posix.join(prefix, file))
49
+ return {
50
+ installScript: await registry.firstExistingScript(appRoot, candidates(['install.js', 'install.json'])),
51
+ startScript: await registry.firstExistingScript(appRoot, candidates(['start.js', 'start.json']))
52
+ }
53
+ }
54
+
55
+ const addSearchCandidates = async (search, appRoot, candidates) => {
56
+ const root = await launcherPath(search.kernel, appRoot)
57
+ if (samePath(root, appRoot)) return
58
+ const prefix = path.relative(appRoot, root).split(path.sep).join('/')
59
+ const seen = new Set(candidates.map(candidate => candidate.relativePath))
60
+ for (const candidate of await search.collectAppSearchCandidates(root)) {
61
+ const relativePath = `${prefix}/${candidate.relativePath}`
62
+ if (!seen.has(relativePath)) candidates.push({ ...candidate, relativePath })
63
+ }
64
+ }
65
+
66
+ const setMetaIcon = (meta, apiName, apiRoot, relpath, isWithinApiRoot) => {
67
+ const prefix = relpath.split(path.sep).join('/') === 'pinokio' && isWithinApiRoot(apiRoot) ? 'pinokio/' : ''
68
+ meta.icon = meta.icon ? `/asset/api/${apiName}/${prefix}${meta.icon}` : '/pinokio-black.png'
69
+ if (prefix && meta.iconpath) meta.iconpath = `${prefix}${meta.iconpath}`
70
+ }
71
+
72
+ const preLauncher = async (kernel, launcher) => {
73
+ const root = await launcherPath(kernel, launcher.root)
74
+ if (samePath(root, launcher.root) || !launcher.script || !Array.isArray(launcher.script.pre)) return launcher
75
+ const prefix = path.relative(launcher.root, root).split(path.sep).join('/')
76
+ const pre = launcher.script.pre.map(item => {
77
+ if (!item || typeof item !== 'object') return item
78
+ const copy = { ...item }
79
+ if (typeof copy.icon === 'string' && copy.icon) copy.icon = `${prefix}/${copy.icon}`
80
+ if (typeof copy.href === 'string' && copy.href && !copy.href.startsWith('http')) copy.href = path.resolve(root, copy.href)
81
+ return copy
82
+ })
83
+ return { ...launcher, root, script: { ...launcher.script, pre } }
84
+ }
85
+
86
+ const addAiFiles = async (kernel, workspace, filenames, files, exists) => {
87
+ const root = await launcherPath(kernel, workspace)
88
+ if (samePath(root, workspace)) return files
89
+ for (const filename of filenames) {
90
+ const candidate = path.resolve(root, filename)
91
+ const relative = path.relative(workspace, candidate).split(path.sep).join('/')
92
+ if (!files.includes(relative) && await exists(candidate)) files.push(relative)
93
+ }
94
+ return files
95
+ }
96
+
97
+ // Keep in sync with the legacy list in Environment.init.
98
+ const excludeEntries = [
99
+ 'ENVIRONMENT', '/.pinokio-temp', '/logs', '/cache', '/AGENTS.md',
100
+ '/CLAUDE.md', '/GEMINI.md', '/QWEN.md', '/.geminiignore',
101
+ '.clinerules', '.cursorrules', '.windsurfrules'
102
+ ]
103
+
104
+ const ensureGitExclude = async (root, relpath, kernel) => {
105
+ if (!relpath || !samePath(await launcherPath(kernel, path.dirname(root)), root)) return
106
+ const workspace = path.dirname(root)
107
+ const gitDir = path.resolve(workspace, '.git')
108
+ try {
109
+ if (!(await fs.promises.stat(gitDir)).isDirectory()) return
110
+ const excludePath = path.resolve(gitDir, 'info', 'exclude')
111
+ let content = ''
112
+ try {
113
+ content = await fs.promises.readFile(excludePath, 'utf8')
114
+ } catch (error) {
115
+ if (error.code !== 'ENOENT') return
116
+ }
117
+ const existing = new Set(content.split(/\r?\n/).map(line => line.trim()).filter(Boolean))
118
+ const prefix = path.relative(workspace, root).split(path.sep).join('/')
119
+ const missing = excludeEntries.map(entry => `/${prefix}/${entry.replace(/^\//, '')}`).filter(entry => !existing.has(entry))
120
+ if (!missing.length) return
121
+ await fs.promises.mkdir(path.dirname(excludePath), { recursive: true })
122
+ await fs.promises.appendFile(excludePath, `${content && !content.endsWith('\n') ? '\n' : ''}${missing.join('\n')}\n`)
123
+ } catch (_) {
124
+ }
125
+ }
126
+
127
+ module.exports = {
128
+ addAiFiles,
129
+ addSearchCandidates,
130
+ appLogValues,
131
+ appScripts,
132
+ ensureGitExclude,
133
+ environmentPath,
134
+ logScript,
135
+ menuRoot,
136
+ preLauncher,
137
+ relativeRoot,
138
+ sessionScript,
139
+ setMetaIcon
140
+ }
package/kernel/util.js CHANGED
@@ -1246,6 +1246,7 @@ const rewrite_localhost= (kernel, obj, source) => {
1246
1246
 
1247
1247
 
1248
1248
  module.exports = {
1249
+ NestedLayout: require('./nested_layout'),
1249
1250
  parse_env,
1250
1251
  log_path,
1251
1252
  api_path,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.1.0",
3
+ "version": "8.1.1",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -406,7 +406,7 @@ class ServerAutolaunch {
406
406
  if (!localScript) {
407
407
  continue
408
408
  }
409
- const scriptPath = path.resolve(launcherRoot, localScript)
409
+ const scriptPath = path.resolve(app.launcher_root && menuitem.href.trim().startsWith(apiPrefix) ? appRoot : launcherRoot, localScript)
410
410
  if (!this.server.is_subpath(appRoot, scriptPath)) {
411
411
  continue
412
412
  }
package/server/index.js CHANGED
@@ -3861,7 +3861,7 @@ class Server {
3861
3861
  config.shortcuts = config.shortcuts(this.kernel, this.kernel.info)
3862
3862
  }
3863
3863
  }
3864
- await this.renderShortcuts(uri, item.name, config, pathComponents)
3864
+ await this.renderShortcuts(uri, item.name, config, pathComponents, await Util.NestedLayout.relativeRoot(this.kernel, this.kernel.path("api", item.name)))
3865
3865
  items[i].shortcuts = config.shortcuts
3866
3866
  }
3867
3867
  }
@@ -4369,14 +4369,14 @@ class Server {
4369
4369
 
4370
4370
  }
4371
4371
  }
4372
- async renderShortcuts(uri, name, config, pathComponents) {
4372
+ async renderShortcuts(uri, name, config, pathComponents, launcher_root = "") {
4373
4373
  if (config.shortcuts) {
4374
4374
  for(let i=0; i<config.shortcuts.length; i++) {
4375
4375
  let shortcut = config.shortcuts[i]
4376
4376
  if (shortcut.action) {
4377
4377
  if (shortcut.action.method === "stop") {
4378
4378
  if (shortcut.action.uri) {
4379
- let absolute = path.resolve(__dirname, ...pathComponents, shortcut.action.uri)
4379
+ let absolute = path.resolve(__dirname, launcher_root, ...pathComponents, shortcut.action.uri)
4380
4380
  let seed = path.resolve(__dirname)
4381
4381
  let p = absolute.replace(seed, "")
4382
4382
  let link = p.split(/[\/\\]/).filter((x) => { return x }).join("/")
@@ -4584,6 +4584,7 @@ class Server {
4584
4584
  // }].concat(config.menu)
4585
4585
 
4586
4586
  let launcher_root = req.launcher_root || ""
4587
+ let nested_root = await Util.NestedLayout.menuRoot(this.kernel, path.resolve(uri, name), launcher_root)
4587
4588
 
4588
4589
  for(let i=0; i<config.menu.length; i++) {
4589
4590
  let menuitem = config.menu[i]
@@ -4640,7 +4641,7 @@ class Server {
4640
4641
  }
4641
4642
  } else {
4642
4643
  config.menu[i].run = rendered.run
4643
- config.menu[i].cwd = path.resolve(this.kernel.homedir, "api", launcher_root, name)
4644
+ config.menu[i].cwd = nested_root ? path.resolve(this.kernel.homedir, "api", name, nested_root) : path.resolve(this.kernel.homedir, "api", launcher_root, name)
4644
4645
  if (launcher_root) {
4645
4646
  config.menu[i].href = "/api/" + name + "/" + launcher_root
4646
4647
  } else {
@@ -4749,7 +4750,7 @@ class Server {
4749
4750
  if (menuitem.action) {
4750
4751
  if (menuitem.action.method === "stop") {
4751
4752
  if (menuitem.action.uri) {
4752
- let absolute = path.resolve(__dirname, ...pathComponents, menuitem.action.uri)
4753
+ let absolute = path.resolve(__dirname, nested_root, ...pathComponents, menuitem.action.uri)
4753
4754
  let seed = path.resolve(__dirname)
4754
4755
  let p = absolute.replace(seed, "")
4755
4756
  let link = p.split(/[\/\\]/).filter((x) => { return x }).join("/")
@@ -4773,7 +4774,7 @@ class Server {
4773
4774
  // check on/off: if on/off exists => assume that it's a script
4774
4775
  // 1. check if the script is running
4775
4776
  if (menuitem.when) {
4776
- let scriptPath = path.resolve(uri, name, menuitem.when)
4777
+ let scriptPath = path.resolve(uri, name, nested_root, menuitem.when)
4777
4778
  let filepath = scriptPath.replace(/\?.+/, "")
4778
4779
  let check = this.kernel.status(filepath)
4779
4780
  if (check) {
@@ -5227,7 +5228,7 @@ class Server {
5227
5228
  if (!workspaceStats.isDirectory()) {
5228
5229
  throw new Error('Workspace path is not a directory')
5229
5230
  }
5230
- const candidate = path.resolve(workspacePath, 'logs')
5231
+ const candidate = path.resolve(await this.kernel.api.launcher_path(workspacePath), 'logs')
5231
5232
  await fs.promises.mkdir(candidate, { recursive: true })
5232
5233
  return {
5233
5234
  logsRoot: candidate,
@@ -14677,7 +14678,7 @@ class Server {
14677
14678
  // }
14678
14679
  // }))
14679
14680
  this.app.post("/env", ex(async (req, res) => {
14680
- let fullpath = path.resolve(this.kernel.homedir, req.body.filepath, "ENVIRONMENT")
14681
+ let fullpath = path.resolve(await this.kernel.api.launcher_path(path.resolve(this.kernel.homedir, req.body.filepath)), "ENVIRONMENT")
14681
14682
  let updated = req.body.vals
14682
14683
  let hosts = req.body.hosts
14683
14684
  await Util.update_env(fullpath, updated)
@@ -14830,7 +14831,7 @@ class Server {
14830
14831
  //})
14831
14832
  }))
14832
14833
  this.app.get("/pre/api/:name", ex(async (req, res) => {
14833
- let launcher = await this.kernel.api.launcher(req.params.name)
14834
+ let launcher = await Util.NestedLayout.preLauncher(this.kernel, await this.kernel.api.launcher(req.params.name))
14834
14835
  let config = launcher.script
14835
14836
  if (config && Array.isArray(config.pre)) {
14836
14837
  const items = config.pre.filter((item) => item && typeof item === "object")
@@ -14888,7 +14889,7 @@ class Server {
14888
14889
  }
14889
14890
  }))
14890
14891
  this.app.get("/share/:name", ex(async (req, res) => {
14891
- let filepath = path.resolve(this.kernel.homedir, "api", req.params.name, "ENVIRONMENT")
14892
+ let filepath = path.resolve(await this.kernel.api.launcher_path(req.params.name), "ENVIRONMENT")
14892
14893
  //let filepath = path.resolve(this.kernel.homedir, req.params[0])
14893
14894
  const config = await Util.parse_env(filepath)
14894
14895
  const keys = [
@@ -15640,10 +15641,11 @@ class Server {
15640
15641
  files.push(filename)
15641
15642
  }
15642
15643
  }
15644
+ files = await Util.NestedLayout.addAiFiles(this.kernel, this.kernel.path("api", req.params.name), filenames, files, this.exists.bind(this))
15643
15645
 
15644
15646
  let items = files.map((item) => {
15645
15647
  return {
15646
- text: item,
15648
+ text: path.basename(item),
15647
15649
  href: `/_api/${req.params.name}/${item}`
15648
15650
  }
15649
15651
  })
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs')
2
2
  const path = require('path')
3
+ const NestedLayout = require('../../kernel/nested_layout')
3
4
 
4
5
  class AppLogService {
5
6
  constructor({ registry }) {
@@ -38,6 +39,7 @@ class AppLogService {
38
39
  }
39
40
 
40
41
  async resolveAppLogFile(appRoot, scriptQuery = '', runtimeScripts = []) {
42
+ ({ appRoot, scriptQuery, runtimeScripts } = await NestedLayout.appLogValues(this.registry, appRoot, scriptQuery, runtimeScripts))
41
43
  const apiLogsRoot = path.resolve(appRoot, 'logs', 'api')
42
44
  const candidates = []
43
45
  const addCandidate = (value) => {
@@ -412,8 +412,7 @@ class AppRegistryService {
412
412
 
413
413
  const runtime = this.collectAppRuntime(appRoot)
414
414
  runtime.external_ready_urls = this.buildExternalReadyUrls(runtime.ready_url, options.source || null)
415
- const installScript = await this.firstExistingScript(appRoot, ['install.js', 'install.json'])
416
- const startScript = await this.firstExistingScript(appRoot, ['start.js', 'start.json'])
415
+ const { installScript, startScript } = await Util.NestedLayout.appScripts(this, appRoot)
417
416
  let defaultTarget = null
418
417
  try {
419
418
  defaultTarget = await this.kernel.api.get_default(appRoot)
@@ -2,6 +2,7 @@ const fs = require('fs')
2
2
  const path = require('path')
3
3
  const crypto = require('crypto')
4
4
  const MiniSearch = require('minisearch')
5
+ const NestedLayout = require('../../kernel/nested_layout')
5
6
 
6
7
  const APP_SEARCH_CACHE_TTL_MS = 15000
7
8
  const APP_SEARCH_MAX_FILE_BYTES = 1024 * 1024
@@ -265,6 +266,7 @@ class AppSearchService {
265
266
  for (const app of apps) {
266
267
  const appRoot = this.kernel.path('api', app.name)
267
268
  const candidates = await this.collectAppSearchCandidates(appRoot)
269
+ await NestedLayout.addSearchCandidates(this, appRoot, candidates)
268
270
  const fingerprint = this.computeAppSearchFingerprint(app, candidates)
269
271
  const previous = previousPerApp.get(app.name)
270
272
  let appDocs
package/server/socket.js CHANGED
@@ -729,8 +729,8 @@ class Socket {
729
729
  if (relative === null) return null
730
730
  let relative_parts = relative.split(path.sep).filter(Boolean)
731
731
  if (relative_parts[0] === "api" && relative_parts[1]) {
732
- let filepath_chunks = relative_parts.slice(2)
733
732
  let cwd = this.parent.kernel.path(...relative_parts.slice(0, 2))
733
+ let filepath_chunks = await Util.NestedLayout.logScript(this.parent.kernel, p, relative_parts)
734
734
  let root = await Environment.get_root({ path: cwd }, this.parent.kernel)
735
735
  cwd = root.root
736
736
  return path.resolve(cwd, "logs/api", ...filepath_chunks)
@@ -0,0 +1,342 @@
1
+ const assert = require('node:assert/strict')
2
+ const fs = require('node:fs/promises')
3
+ const os = require('node:os')
4
+ const path = require('node:path')
5
+ const test = require('node:test')
6
+
7
+ const Api = require('../kernel/api')
8
+ const Env = require('../kernel/api/env')
9
+ const Environment = require('../kernel/environment')
10
+ const NestedLayout = require('../kernel/nested_layout')
11
+ const getLauncherTarget = require('../kernel/api/launcher_target')
12
+ const AppLogSessions = require('../kernel/app_log_sessions')
13
+ const Server = require('../server')
14
+ const ServerAutolaunch = require('../server/autolaunch')
15
+ const Socket = require('../server/socket')
16
+ const AppLogService = require('../server/lib/app_logs')
17
+ const AppLogReportService = require('../server/lib/app_log_report')
18
+ const AppRegistryService = require('../server/lib/app_registry')
19
+ const AppSearchService = require('../server/lib/app_search')
20
+
21
+ const exists = target => fs.access(target).then(() => true, () => false)
22
+
23
+ const kernelFor = home => {
24
+ const loaded = new Map()
25
+ const kernel = {
26
+ homedir: home,
27
+ path: (...parts) => path.resolve(home, ...parts),
28
+ exists,
29
+ status: () => false,
30
+ template: { render: value => value },
31
+ memory: { local: {} },
32
+ loader: { load: async target => ({ resolved: loaded.get(path.resolve(target)) || null }) }
33
+ }
34
+ kernel.api = new Api(kernel)
35
+ kernel.api.userdir = path.resolve(home, 'api')
36
+ kernel.api.exists = exists
37
+ kernel.api.running = {}
38
+ kernel.loaded = loaded
39
+ return kernel
40
+ }
41
+
42
+ const withHome = async callback => {
43
+ const home = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-nested-parity-'))
44
+ try {
45
+ await callback(home)
46
+ } finally {
47
+ await fs.rm(home, { recursive: true, force: true })
48
+ }
49
+ }
50
+
51
+ const write = async (root, relative, content = '') => {
52
+ const target = path.resolve(root, relative)
53
+ await fs.mkdir(path.dirname(target), { recursive: true })
54
+ await fs.writeFile(target, content)
55
+ return target
56
+ }
57
+
58
+ test('root launcher precedence and environment writes remain unchanged', async () => {
59
+ await withHome(async home => {
60
+ const kernel = kernelFor(home)
61
+ const env = new Env()
62
+ const layouts = [
63
+ ['classic', ['pinokio.js']],
64
+ ['root-with-folder', ['pinokio.js', 'pinokio/note.txt']],
65
+ ['both', ['pinokio.js', 'pinokio/pinokio.js']],
66
+ ['empty-folder', ['pinokio/note.txt']],
67
+ ['no-manifest', []]
68
+ ]
69
+
70
+ for (const [name, files] of layouts) {
71
+ const workspace = path.resolve(home, 'api', name)
72
+ await fs.mkdir(workspace, { recursive: true })
73
+ for (const file of files) await write(workspace, file)
74
+ await write(workspace, 'ENVIRONMENT')
75
+ assert.equal(await kernel.api.launcher_path(workspace), workspace, name)
76
+ if (files.includes('pinokio.js')) kernel.loaded.set(path.resolve(workspace, 'pinokio.js'), { icon: 'icon.png' })
77
+ const meta = await kernel.api.meta(name)
78
+ if (files.includes('pinokio.js')) {
79
+ assert.equal(meta.icon, `/asset/api/${name}/icon.png`, name)
80
+ assert.equal(meta.iconpath, 'icon.png', name)
81
+ }
82
+ await env.set({ parent: { path: path.resolve(workspace, 'start.js') }, params: { VALUE: name } }, null, kernel)
83
+ assert.match(await fs.readFile(path.resolve(workspace, 'ENVIRONMENT'), 'utf8'), new RegExp(`VALUE=${name}`), name)
84
+ }
85
+
86
+ const pinokioEntry = path.resolve(home, 'api', 'pinokio-entry')
87
+ await fs.mkdir(pinokioEntry, { recursive: true })
88
+ await write(pinokioEntry, 'pinokio')
89
+ await write(pinokioEntry, 'ENVIRONMENT')
90
+ assert.equal(await kernel.api.launcher_path(pinokioEntry), pinokioEntry)
91
+ await env.set({ parent: { path: path.resolve(pinokioEntry, 'start.js') }, params: { VALUE: 'file' } }, null, kernel)
92
+ assert.match(await fs.readFile(path.resolve(pinokioEntry, 'ENVIRONMENT'), 'utf8'), /VALUE=file/)
93
+ })
94
+ })
95
+
96
+ test('nested launcher environment, metadata, targets, registry, and search use pinokio/', async () => {
97
+ await withHome(async home => {
98
+ const kernel = kernelFor(home)
99
+ const workspace = path.resolve(home, 'api', 'nested')
100
+ const launcher = path.resolve(workspace, 'pinokio')
101
+ const launcherConfig = { title: 'Nested', icon: 'icon.png', menu: [
102
+ { text: 'Start', href: 'start.js', default: true },
103
+ { text: 'Nested child', href: 'pinokio/tool.js' },
104
+ { text: 'Absolute nested', href: '/api/nested/pinokio/absolute.js' },
105
+ { text: 'Absolute workspace', href: '/api/nested/workspace.js' }
106
+ ] }
107
+ await write(workspace, 'README.md', 'workspace documentation')
108
+ await write(launcher, 'README.md', 'launcher documentation')
109
+ await write(launcher, 'pinokio.js', 'nested launcher token')
110
+ await write(launcher, 'install.js', 'nested install token')
111
+ await write(launcher, 'start.js', 'nested start token')
112
+ await write(launcher, 'pinokio/tool.js', 'nested child token')
113
+ await write(launcher, 'absolute.js', 'absolute nested token')
114
+ await write(workspace, 'workspace.js', 'absolute workspace token')
115
+ await write(launcher, 'ENVIRONMENT', 'DATA=./data\nVALUE=old\n')
116
+ kernel.loaded.set(path.resolve(launcher, 'pinokio.js'), launcherConfig)
117
+
118
+ assert.equal(await kernel.api.launcher_path(workspace), launcher)
119
+ assert.equal((await Environment.get(workspace, kernel)).DATA, path.resolve(launcher, 'data'))
120
+
121
+ await new Env().set({ parent: { path: path.resolve(launcher, 'start.js') }, params: { VALUE: 'nested' } }, null, kernel)
122
+ assert.match(await fs.readFile(path.resolve(launcher, 'ENVIRONMENT'), 'utf8'), /VALUE=nested/)
123
+
124
+ const meta = await kernel.api.meta('nested')
125
+ assert.equal(meta.icon, '/asset/api/nested/pinokio/icon.png')
126
+ assert.equal(meta.iconpath, 'pinokio/icon.png')
127
+ assert.equal(await kernel.api.get_default(workspace), path.resolve(launcher, 'start.js'))
128
+ assert.equal((await getLauncherTarget(kernel.api, workspace, ['start.js'])).uri, path.resolve(launcher, 'start.js'))
129
+
130
+ const registry = new AppRegistryService({ kernel })
131
+ const status = await registry.buildAppStatus('nested')
132
+ assert.equal(status.install_script, 'pinokio/install.js')
133
+ assert.equal(status.start_script, 'pinokio/start.js')
134
+ assert.equal(status.default_script, 'pinokio/start.js')
135
+
136
+ const searchRegistry = {
137
+ listInfoApps: async () => [{ name: 'nested', title: 'Nested', description: '', icon: '' }],
138
+ isPathWithin: registry.isPathWithin.bind(registry)
139
+ }
140
+ const state = await new AppSearchService({ kernel, registry: searchRegistry }).ensureSearchState(true)
141
+ const files = state.docs.map(doc => doc.file)
142
+ assert(files.includes('README.md'))
143
+ assert(files.includes('pinokio/pinokio.js'))
144
+ assert(files.includes('pinokio/install.js'))
145
+ assert(files.includes('pinokio/start.js'))
146
+ assert.equal(files.filter(file => file === 'pinokio/README.md').length, 1)
147
+
148
+ const server = Object.assign(Object.create(Server.prototype), { kernel })
149
+ const app = (await kernel.api.listApps()).find(item => item.id === 'nested')
150
+ const candidates = await new ServerAutolaunch(server).buildCandidates(app)
151
+ assert(candidates.menu.some(item => item.script === 'pinokio/start.js'))
152
+ assert(candidates.menu.some(item => item.script === 'pinokio/pinokio/tool.js'))
153
+ assert(candidates.menu.some(item => item.script === 'pinokio/absolute.js'))
154
+ assert(candidates.menu.some(item => item.script === 'workspace.js'))
155
+ assert(candidates.other.some(item => item.script === 'pinokio/install.js'))
156
+
157
+ await kernel.api.updateMeta({ icon_dirty: true, icon_path: 'uploaded.png', avatar: Buffer.from('icon') }, 'nested')
158
+ assert.equal(await fs.readFile(path.resolve(launcher, 'uploaded.png'), 'utf8'), 'icon')
159
+ assert.equal(await exists(path.resolve(workspace, 'uploaded.png')), false)
160
+ })
161
+ })
162
+
163
+ test('nested preflight assets and AI documents use pinokio/ without changing root launchers', async () => {
164
+ await withHome(async home => {
165
+ const kernel = kernelFor(home)
166
+ const nested = path.resolve(home, 'api', 'nested')
167
+ const nestedRoot = path.resolve(nested, 'pinokio')
168
+ await write(nestedRoot, 'pinokio.js')
169
+ await write(nestedRoot, 'CLAUDE.md')
170
+ await write(nested, 'CLAUDE.md')
171
+
172
+ const launcher = {
173
+ root: nested,
174
+ script: { pre: [{ icon: 'icon.png', href: 'setup.js' }, { href: 'https://example.com' }] }
175
+ }
176
+ const adapted = await NestedLayout.preLauncher(kernel, launcher)
177
+ assert.equal(adapted.root, nestedRoot)
178
+ assert.equal(adapted.script.pre[0].icon, 'pinokio/icon.png')
179
+ assert.equal(adapted.script.pre[0].href, path.resolve(nestedRoot, 'setup.js'))
180
+ assert.equal(adapted.script.pre[1].href, 'https://example.com')
181
+ assert.equal(launcher.script.pre[0].icon, 'icon.png')
182
+
183
+ const files = await NestedLayout.addAiFiles(kernel, nested, ['AGENTS.md', 'CLAUDE.md'], ['CLAUDE.md'], exists)
184
+ assert.deepEqual(files, ['CLAUDE.md', 'pinokio/CLAUDE.md'])
185
+
186
+ const root = path.resolve(home, 'api', 'root')
187
+ await write(root, 'pinokio.js')
188
+ await write(root, 'pinokio/pinokio.js')
189
+ const rootLauncher = { root, script: { pre: [{ icon: 'icon.png', href: 'setup.js' }] } }
190
+ const rootFiles = []
191
+ assert.equal(await NestedLayout.preLauncher(kernel, rootLauncher), rootLauncher)
192
+ assert.equal(await NestedLayout.addAiFiles(kernel, root, ['CLAUDE.md'], rootFiles, exists), rootFiles)
193
+ assert.deepEqual(rootFiles, [])
194
+
195
+ const iconpath = { invalid: true }
196
+ const rootMeta = { icon: iconpath, iconpath }
197
+ NestedLayout.setMetaIcon(rootMeta, 'root', root, '', () => true)
198
+ assert.strictEqual(rootMeta.iconpath, iconpath)
199
+ })
200
+ })
201
+
202
+ test('nested menus and shortcuts resolve relative paths from pinokio/', async () => {
203
+ await withHome(async home => {
204
+ const kernel = kernelFor(home)
205
+ const workspace = path.resolve(home, 'api', 'nested')
206
+ const launcher = path.resolve(workspace, 'pinokio')
207
+ await write(launcher, 'pinokio.js')
208
+ const checked = []
209
+ kernel.status = target => {
210
+ checked.push(target)
211
+ return false
212
+ }
213
+ const server = Object.assign(Object.create(Server.prototype), { kernel })
214
+ const config = {
215
+ menu: [
216
+ { text: 'Start', href: 'start.js' },
217
+ { text: 'Shell', run: 'echo ready' },
218
+ { text: 'Stop', action: { method: 'stop', uri: 'start.js' } },
219
+ { text: 'Absolute', action: { method: 'stop', uri: path.resolve('/abs', 'stop.js') } },
220
+ { text: 'State', when: 'start.js', off: 'off' }
221
+ ],
222
+ shortcuts: [
223
+ { action: { method: 'stop', uri: 'start.js' } },
224
+ { action: { method: 'stop', uri: path.resolve('/abs', 'stop.js') } }
225
+ ]
226
+ }
227
+
228
+ await server.renderMenu({ launcher_root: 'pinokio', $source: {} }, kernel.path('api'), 'nested', config, [])
229
+ assert.equal(config.menu[0].href, '/api/nested/pinokio/start.js')
230
+ assert.equal(config.menu[1].cwd, launcher)
231
+ assert.equal(config.menu[2].action.uri, '~/api/nested/pinokio/start.js')
232
+ assert.equal(config.menu[3].action.uri, '~/api/nested/abs/stop.js')
233
+ assert(checked.includes(path.resolve(launcher, 'start.js')))
234
+
235
+ await server.renderShortcuts(kernel.path('api'), 'nested', config, [], 'pinokio')
236
+ assert.equal(config.shortcuts[0].action.uri, '~/api/nested/pinokio/start.js')
237
+ assert.equal(config.shortcuts[1].action.uri, '~/api/nested/abs/stop.js')
238
+ })
239
+ })
240
+
241
+ test('root launcher load fallback keeps its legacy menu paths', async () => {
242
+ await withHome(async home => {
243
+ const kernel = kernelFor(home)
244
+ const workspace = path.resolve(home, 'api', 'fallback')
245
+ await write(workspace, 'pinokio.js')
246
+ await write(workspace, 'pinokio/pinokio.js')
247
+ await write(workspace, 'pinokio/start.js')
248
+ await write(workspace, 'pinokio.json')
249
+ kernel.loaded.set(path.resolve(workspace, 'pinokio/pinokio.js'), { menu: [] })
250
+ kernel.loaded.set(path.resolve(workspace, 'pinokio.json'), { menu: [{ href: '/api/fallback/start.js' }] })
251
+ const launcher = await kernel.api.launcher('fallback')
252
+ assert.equal(launcher.launcher_root, 'pinokio')
253
+ assert.equal(await kernel.api.launcher_path(workspace), workspace)
254
+
255
+ const checked = []
256
+ kernel.status = target => {
257
+ checked.push(target)
258
+ return false
259
+ }
260
+ const server = Object.assign(Object.create(Server.prototype), { kernel })
261
+ const config = {
262
+ menu: [
263
+ { text: 'Shell', run: 'echo ready' },
264
+ { text: 'Stop', action: { method: 'stop', uri: 'start.js' } },
265
+ { text: 'State', when: 'start.js', off: 'off' }
266
+ ],
267
+ shortcuts: [{ action: { method: 'stop', uri: 'start.js' } }]
268
+ }
269
+ await server.renderMenu({ launcher_root: launcher.launcher_root, $source: {} }, kernel.path('api'), 'fallback', config, [])
270
+ await server.renderShortcuts(kernel.path('api'), 'fallback', config, [], path.relative(workspace, await kernel.api.launcher_path(workspace)))
271
+
272
+ assert.equal(config.menu[0].cwd, path.resolve(home, 'api', 'pinokio', 'fallback'))
273
+ assert.equal(config.menu[1].action.uri, '~/api/fallback/start.js')
274
+ assert(checked.includes(path.resolve(workspace, 'start.js')))
275
+ assert.equal(config.shortcuts[0].action.uri, '~/api/fallback/start.js')
276
+
277
+ const app = (await kernel.api.listApps()).find(item => item.id === 'fallback')
278
+ const candidates = await new ServerAutolaunch(server).buildCandidates(app)
279
+ assert.equal(app.launcher_root, '')
280
+ assert(candidates.menu.some(item => item.script === 'pinokio/start.js'))
281
+ })
282
+ })
283
+
284
+ test('nested logs use launcher-relative paths in storage, display, and reports', async () => {
285
+ await withHome(async home => {
286
+ const kernel = kernelFor(home)
287
+ const workspace = path.resolve(home, 'api', 'nested')
288
+ const launcher = path.resolve(workspace, 'pinokio')
289
+ const script = await write(launcher, 'start.js')
290
+ await write(launcher, 'pinokio.js')
291
+
292
+ const socket = Object.assign(Object.create(Socket.prototype), { parent: { kernel } })
293
+ const logDir = await socket.resolveLogDir(script)
294
+ assert.equal(logDir, path.resolve(launcher, 'logs', 'api', 'start.js'))
295
+ const logFile = await write(logDir, 'latest', 'nested live content\n')
296
+ const sessionLog = await write(logDir, '123', 'nested report content\n')
297
+
298
+ const sessions = new AppLogSessions({ kernel, randomHex: () => 'nested' })
299
+ const run = await sessions.startRun({ path: script })
300
+ assert.equal(run.appRoot, launcher)
301
+ assert.equal(run.script, 'start.js')
302
+ await sessions.recordLogFile({ scriptPath: run.scriptPath, logFile: sessionLog, run })
303
+
304
+ const registry = new AppRegistryService({ kernel })
305
+ const selected = await new AppLogService({ registry }).resolveAppLogFile(workspace, '', ['pinokio/start.js'])
306
+ assert.equal(selected.script, 'start.js')
307
+ assert.equal(selected.file, logFile)
308
+
309
+ const server = Object.assign(Object.create(Server.prototype), { kernel })
310
+ assert.equal((await server.resolveLogsRoot({ workspace: 'nested' })).logsRoot, path.resolve(launcher, 'logs'))
311
+
312
+ const report = await new AppLogReportService({ registry, kernel }).buildReport({ appId: 'nested', status: { path: workspace, title: 'Nested' } })
313
+ assert.equal(report.sections[0].script, 'start.js')
314
+ assert.match(report.markdown, /nested report content/)
315
+ })
316
+ })
317
+
318
+ test('nested environment initialization adds parent repository excludes without touching root-layout repositories', async () => {
319
+ await withHome(async home => {
320
+ const kernel = kernelFor(home)
321
+ const nested = path.resolve(home, 'api', 'nested')
322
+ const root = path.resolve(home, 'api', 'root')
323
+ await write(nested, 'pinokio/pinokio.js')
324
+ await fs.mkdir(path.resolve(nested, '.git'), { recursive: true })
325
+ await write(root, 'pinokio.js')
326
+ await fs.mkdir(path.resolve(root, 'pinokio'), { recursive: true })
327
+ await fs.mkdir(path.resolve(root, '.git', 'info'), { recursive: true })
328
+ await write(root, '.git/info/exclude', 'existing\n')
329
+
330
+ await Environment.init({ name: 'nested' }, kernel)
331
+ assert.match(await fs.readFile(path.resolve(nested, '.git', 'info', 'exclude'), 'utf8'), /^\/pinokio\/ENVIRONMENT$/m)
332
+
333
+ await Environment.init({ name: 'root' }, kernel)
334
+ assert.equal(await fs.readFile(path.resolve(root, '.git', 'info', 'exclude'), 'utf8'), 'existing\n')
335
+
336
+ const worktree = path.resolve(home, 'api', 'worktree')
337
+ await write(worktree, 'pinokio/pinokio.js')
338
+ await write(worktree, '.git', 'gitdir: /tmp/example\n')
339
+ await Environment.init({ name: 'worktree' }, kernel)
340
+ assert.equal(await fs.readFile(path.resolve(worktree, '.git'), 'utf8'), 'gitdir: /tmp/example\n')
341
+ })
342
+ })