pinokiod 7.3.14 → 7.4.0

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.
Files changed (51) hide show
  1. package/kernel/api/index.js +111 -15
  2. package/kernel/api/script/index.js +10 -0
  3. package/kernel/autolaunch.js +111 -0
  4. package/kernel/environment.js +89 -1
  5. package/kernel/git.js +9 -19
  6. package/kernel/index.js +142 -43
  7. package/kernel/launch_requirements.js +1115 -0
  8. package/kernel/ready.js +231 -0
  9. package/kernel/script.js +16 -0
  10. package/kernel/shells.js +9 -1
  11. package/kernel/workspace_status.js +111 -45
  12. package/package.json +1 -1
  13. package/server/autolaunch.js +725 -0
  14. package/server/index.js +244 -411
  15. package/server/public/logs.js +15 -1
  16. package/server/public/style.css +99 -31
  17. package/server/views/app.ejs +263 -159
  18. package/server/views/autolaunch.ejs +363 -75
  19. package/server/views/index.ejs +550 -26
  20. package/server/views/logs.ejs +14 -12
  21. package/server/views/partials/app_autolaunch_dependency_events.ejs +99 -0
  22. package/server/views/partials/app_autolaunch_dependency_save.ejs +10 -0
  23. package/server/views/partials/app_autolaunch_dependency_styles.ejs +357 -0
  24. package/server/views/partials/app_autolaunch_modal_helpers.ejs +347 -0
  25. package/server/views/partials/autolaunch_dependency_helpers.ejs +204 -0
  26. package/server/views/partials/autolaunch_dependency_save.ejs +13 -0
  27. package/server/views/partials/autolaunch_dependency_styles.ejs +347 -0
  28. package/server/views/partials/home_action_modal.ejs +4 -1
  29. package/server/views/partials/launch_requirements_status_client.ejs +271 -0
  30. package/server/views/partials/launch_requirements_status_styles.ejs +171 -0
  31. package/server/views/partials/launch_settings_dependency_save_factory.ejs +45 -0
  32. package/server/views/partials/launch_settings_dependency_script_loader_factory.ejs +25 -0
  33. package/server/views/terminal.ejs +196 -2
  34. package/test/home-autolaunch-live-ui.test.js +455 -0
  35. package/test/launch-requirements-browser.test.js +579 -0
  36. package/test/launch-requirements-contract-coverage.test.js +627 -0
  37. package/test/launch-requirements-real-browser.js +625 -0
  38. package/test/launch-requirements-status-client.test.js +132 -0
  39. package/test/launch-requirements.test.js +1806 -0
  40. package/test/launch-settings-ui.test.js +370 -0
  41. package/test/ready-state.test.js +49 -0
  42. package/test/server-autolaunch.test.js +1052 -0
  43. package/test/startup-git-index-benchmark.js +409 -0
  44. package/test/startup-git-index-browser.js +320 -0
  45. package/test/startup-git-index-performance.test.js +380 -0
  46. package/test/startup-git-index-refactor.test.js +450 -0
  47. package/test/startup-git-index-route.test.js +588 -0
  48. package/test/universal-launcher.smoke.spec.js +10 -9
  49. package/test/workspace-gitignore-benchmark.js +815 -0
  50. package/test/workspace-gitignore-path-scoped.test.js +256 -0
  51. package/spec/INSTRUCTION_SYNC.md +0 -432
@@ -0,0 +1,725 @@
1
+ const path = require('path')
2
+ const fs = require('fs')
3
+ const Environment = require('../kernel/environment')
4
+ const Util = require('../kernel/util')
5
+
6
+ const ex = fn => (req, res, next) => {
7
+ Promise.resolve(fn(req, res, next)).catch(next)
8
+ }
9
+
10
+ const cloneWithFunctionRefs = (value, seen = new WeakMap()) => {
11
+ if (value === null || typeof value !== "object") {
12
+ return value
13
+ }
14
+ if (seen.has(value)) {
15
+ return seen.get(value)
16
+ }
17
+ if (Array.isArray(value)) {
18
+ const clone = []
19
+ seen.set(value, clone)
20
+ for (const entry of value) {
21
+ clone.push(typeof entry === "function" ? entry : cloneWithFunctionRefs(entry, seen))
22
+ }
23
+ return clone
24
+ }
25
+ const clone = {}
26
+ seen.set(value, clone)
27
+ for (const key of Object.keys(value)) {
28
+ const entry = value[key]
29
+ clone[key] = typeof entry === "function" ? entry : cloneWithFunctionRefs(entry, seen)
30
+ }
31
+ return clone
32
+ }
33
+
34
+ const safeStructuredClone = (value) => {
35
+ try {
36
+ return structuredClone(value)
37
+ } catch (error) {
38
+ return cloneWithFunctionRefs(value)
39
+ }
40
+ }
41
+
42
+ class ServerAutolaunch {
43
+ constructor(server) {
44
+ this.server = server
45
+ this.kernel = server.kernel
46
+ }
47
+ normalizeAppId(value) {
48
+ if (typeof value !== "string") {
49
+ return ""
50
+ }
51
+ const id = value.trim()
52
+ if (!id || id === "." || id === ".." || id.includes("\0") || /[\\/]/.test(id)) {
53
+ return ""
54
+ }
55
+ return id
56
+ }
57
+ compareApps(a, b) {
58
+ const aTitle = a && (a.title || a.name || a.id) ? (a.title || a.name || a.id) : ""
59
+ const bTitle = b && (b.title || b.name || b.id) ? (b.title || b.name || b.id) : ""
60
+ return String(aTitle).localeCompare(String(bTitle), undefined, { sensitivity: "base", numeric: true }) ||
61
+ String((a && a.id) || "").localeCompare(String((b && b.id) || ""), undefined, { sensitivity: "base", numeric: true })
62
+ }
63
+ parseDependencyValue(value) {
64
+ return Environment.parseAutolaunchList(value)
65
+ .map((id) => this.normalizeAppId(id))
66
+ .filter(Boolean)
67
+ }
68
+ normalizeDependencies(ids, apps, currentAppId) {
69
+ const appList = Array.isArray(apps) ? apps : []
70
+ const installedIds = new Set()
71
+ for (const app of appList) {
72
+ if (app && app.id) {
73
+ installedIds.add(app.id)
74
+ }
75
+ }
76
+ const seen = new Set()
77
+ const normalized = []
78
+ for (const id of Array.isArray(ids) ? ids : []) {
79
+ const appId = this.normalizeAppId(id)
80
+ if (!appId || appId === currentAppId || seen.has(appId)) {
81
+ continue
82
+ }
83
+ if (!installedIds.has(appId)) {
84
+ continue
85
+ }
86
+ seen.add(appId)
87
+ normalized.push(appId)
88
+ }
89
+ return normalized
90
+ }
91
+ sortAppStates(states) {
92
+ return states.slice().sort((a, b) => {
93
+ const enabledDelta = Number(!!b.autolaunch_enabled) - Number(!!a.autolaunch_enabled)
94
+ if (enabledDelta !== 0) {
95
+ return enabledDelta
96
+ }
97
+ return this.compareApps(a, b)
98
+ })
99
+ }
100
+ normalizeScriptPath(value) {
101
+ if (typeof value !== "string") {
102
+ return ""
103
+ }
104
+ let script = value.trim().replace(/\\/g, "/")
105
+ if (!script || script.includes("\0")) {
106
+ return ""
107
+ }
108
+ script = script.split("#")[0].split("?")[0].trim()
109
+ if (!script || /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(script) || script.startsWith("//")) {
110
+ return ""
111
+ }
112
+ if (script.startsWith("/")) {
113
+ return ""
114
+ }
115
+ const normalized = path.posix.normalize(script).replace(/^\.\/+/, "")
116
+ if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../") || path.posix.isAbsolute(normalized)) {
117
+ return ""
118
+ }
119
+ return normalized
120
+ }
121
+ isScriptFilename(filename) {
122
+ const ext = path.extname(filename || "").toLowerCase()
123
+ if (![".js", ".json", ".mjs", ".cjs"].includes(ext)) {
124
+ return false
125
+ }
126
+ const base = path.basename(filename || "").toLowerCase()
127
+ return !["package.json", "pinokio.js", "pinokio.json", "pinokio_meta.json"].includes(base)
128
+ }
129
+ stripLabel(value) {
130
+ if (typeof value !== "string") {
131
+ return ""
132
+ }
133
+ return value
134
+ .replace(/<[^>]*>/g, " ")
135
+ .replace(/\s+/g, " ")
136
+ .trim()
137
+ }
138
+ async getAppById(appId) {
139
+ const id = this.normalizeAppId(appId)
140
+ if (!id) {
141
+ return null
142
+ }
143
+ const apps = await this.kernel.api.listApps()
144
+ return apps.find((app) => app && app.id === id) || null
145
+ }
146
+ async getEnvInfo(app) {
147
+ const appRoot = path.resolve(this.kernel.api.userdir, app.id)
148
+ const gotRoot = await Environment.get_root({ path: appRoot }, this.kernel)
149
+ const envRoot = gotRoot && gotRoot.root ? gotRoot.root : appRoot
150
+ const envPath = path.resolve(envRoot, "ENVIRONMENT")
151
+ const env = await Util.parse_env(envPath)
152
+ const value = Environment.getScriptAutolaunch(env)
153
+ const launch = value
154
+ const dependencies = Environment.getScriptRequirements(env)
155
+ const enabled = Environment.getScriptAutolaunchEnabled(env)
156
+ const exists = await this.server.exists(envPath)
157
+ return {
158
+ appRoot,
159
+ envRoot,
160
+ envPath,
161
+ envRelpath: gotRoot && gotRoot.relpath ? gotRoot.relpath : "",
162
+ exists,
163
+ value,
164
+ launch,
165
+ dependencies,
166
+ enabled
167
+ }
168
+ }
169
+ async buildAppState(app) {
170
+ const envInfo = await this.getEnvInfo(app)
171
+ return {
172
+ id: app.id,
173
+ name: app.name,
174
+ title: app.title || app.name || app.id,
175
+ description: app.description || "",
176
+ icon: app.icon || "/pinokio-black.png",
177
+ workspace_path: app.workspace_path,
178
+ launcher_path: app.launcher_path,
179
+ launcher_root: app.launcher_root || "",
180
+ env_path: envInfo.envPath,
181
+ autolaunch: envInfo.launch,
182
+ autolaunch_startup: envInfo.enabled ? envInfo.launch : "",
183
+ autolaunch_depends: envInfo.dependencies,
184
+ autolaunch_enabled: envInfo.enabled
185
+ }
186
+ }
187
+ async buildAppsStateRaw() {
188
+ const apps = await this.kernel.api.listApps()
189
+ const states = []
190
+ for (const app of apps) {
191
+ states.push(await this.buildAppState(app))
192
+ }
193
+ return states
194
+ }
195
+ async buildAppsState() {
196
+ const states = await this.buildAppsStateRaw()
197
+ return this.sortAppStates(states)
198
+ }
199
+ async buildHomeStartupDisplayGraph() {
200
+ if (this.kernel.launch_complete) {
201
+ return new Map()
202
+ }
203
+ const states = await this.buildAppsStateRaw()
204
+ const byId = new Map()
205
+ for (const state of states) {
206
+ if (state && state.id) {
207
+ byId.set(state.id, state)
208
+ }
209
+ }
210
+ const graph = new Map()
211
+ const visited = new Set()
212
+ const isCancelled = (appId) => {
213
+ return !!(this.kernel.launchRequirements &&
214
+ typeof this.kernel.launchRequirements.isCancelled === "function" &&
215
+ this.kernel.launchRequirements.isCancelled(appId))
216
+ }
217
+ const addApp = (appId, startupRoot = false) => {
218
+ const id = this.normalizeAppId(appId)
219
+ if (!id || visited.has(id) || isCancelled(id)) {
220
+ return
221
+ }
222
+ const state = byId.get(id)
223
+ if (!state || !state.autolaunch) {
224
+ return
225
+ }
226
+ visited.add(id)
227
+ const dependencies = Array.isArray(state.autolaunch_depends)
228
+ ? state.autolaunch_depends.map((dependencyId) => this.normalizeAppId(dependencyId)).filter(Boolean)
229
+ : []
230
+ graph.set(id, {
231
+ id,
232
+ title: state.title || state.name || id,
233
+ script: state.autolaunch,
234
+ dependencies,
235
+ waiting_for: dependencies,
236
+ startup_root: !!startupRoot
237
+ })
238
+ for (const dependencyId of dependencies) {
239
+ addApp(dependencyId, false)
240
+ }
241
+ }
242
+ for (const state of states) {
243
+ if (state && state.autolaunch_enabled && state.autolaunch) {
244
+ addApp(state.id, true)
245
+ }
246
+ }
247
+ return graph
248
+ }
249
+ async buildDependencyOptions(appId, states = null) {
250
+ const appStates = Array.isArray(states) ? states : await this.buildAppsStateRaw()
251
+ return this.sortAppStates(appStates)
252
+ .filter((app) => app && app.id !== appId)
253
+ .map((app) => ({
254
+ id: app.id,
255
+ title: app.title || app.name || app.id,
256
+ icon: app.icon || "/pinokio-black.png",
257
+ workspace_path: app.workspace_path,
258
+ launcher_path: app.launcher_path,
259
+ autolaunch: app.autolaunch,
260
+ autolaunch_enabled: !!app.autolaunch_enabled
261
+ }))
262
+ }
263
+ async resolveScript(appRoot, script) {
264
+ const normalized = this.normalizeScriptPath(script)
265
+ if (!normalized || !this.isScriptFilename(normalized)) {
266
+ return null
267
+ }
268
+ const scriptPath = path.resolve(appRoot, normalized)
269
+ if (!this.server.is_subpath(appRoot, scriptPath)) {
270
+ return null
271
+ }
272
+ let stat
273
+ try {
274
+ stat = await fs.promises.stat(scriptPath)
275
+ } catch (_) {
276
+ return null
277
+ }
278
+ if (!stat || !stat.isFile()) {
279
+ return null
280
+ }
281
+ return {
282
+ script: normalized,
283
+ path: scriptPath
284
+ }
285
+ }
286
+ flattenMenu(menu, trail = []) {
287
+ const items = []
288
+ if (!Array.isArray(menu)) {
289
+ return items
290
+ }
291
+ for (const menuitem of menu) {
292
+ if (!menuitem || typeof menuitem !== "object") {
293
+ continue
294
+ }
295
+ const label = this.stripLabel(menuitem.text || menuitem.name || menuitem.html || "")
296
+ const nextTrail = label ? trail.concat(label) : trail
297
+ if (Array.isArray(menuitem.menu)) {
298
+ items.push(...this.flattenMenu(menuitem.menu, nextTrail))
299
+ } else {
300
+ items.push({ item: menuitem, group: trail.join(" / ") })
301
+ }
302
+ }
303
+ return items
304
+ }
305
+ async addCandidate(candidates, seen, candidate, appRoot) {
306
+ const resolved = await this.resolveScript(appRoot, candidate.script)
307
+ if (!resolved || seen.has(resolved.script)) {
308
+ return null
309
+ }
310
+ seen.add(resolved.script)
311
+ const label = this.stripLabel(candidate.label || "") || resolved.script
312
+ const menuDefault = !!candidate.menu_default
313
+ const item = {
314
+ script: resolved.script,
315
+ label,
316
+ group: candidate.group || "",
317
+ icon: candidate.icon || "",
318
+ source: candidate.source || "local",
319
+ menu_default: menuDefault,
320
+ has_params: !!candidate.has_params
321
+ }
322
+ candidates.push(item)
323
+ return item
324
+ }
325
+ async collectScriptFiles(root, appRoot) {
326
+ const results = []
327
+ const ignoredDirs = new Set([
328
+ ".git",
329
+ ".venv",
330
+ "__pycache__",
331
+ "app",
332
+ "cache",
333
+ "data",
334
+ "env",
335
+ "logs",
336
+ "models",
337
+ "node_modules",
338
+ "output",
339
+ "outputs",
340
+ "venv"
341
+ ])
342
+ const maxResults = 500
343
+ const walk = async (dir, depth) => {
344
+ if (depth > 4 || results.length >= maxResults) {
345
+ return
346
+ }
347
+ let entries
348
+ try {
349
+ entries = await fs.promises.readdir(dir, { withFileTypes: true })
350
+ } catch (_) {
351
+ return
352
+ }
353
+ for (const entry of entries) {
354
+ if (!entry || !entry.name || entry.name.includes("\0")) {
355
+ continue
356
+ }
357
+ const fullPath = path.resolve(dir, entry.name)
358
+ if (entry.isDirectory()) {
359
+ if (!ignoredDirs.has(entry.name)) {
360
+ await walk(fullPath, depth + 1)
361
+ }
362
+ continue
363
+ }
364
+ if (!entry.isFile() || !this.isScriptFilename(entry.name)) {
365
+ continue
366
+ }
367
+ if (!this.server.is_subpath(appRoot, fullPath)) {
368
+ continue
369
+ }
370
+ const rel = path.relative(appRoot, fullPath).split(path.sep).join("/")
371
+ results.push(rel)
372
+ }
373
+ }
374
+ await walk(root, 0)
375
+ results.sort((a, b) => a.localeCompare(b))
376
+ return results
377
+ }
378
+ async buildCandidates(app) {
379
+ const envInfo = await this.getEnvInfo(app)
380
+ const appRoot = envInfo.appRoot
381
+ const launcher = await this.kernel.api.launcher(app.id)
382
+ const launcherRoot = launcher && launcher.launcher_root
383
+ ? path.resolve(appRoot, launcher.launcher_root)
384
+ : appRoot
385
+ const menuCandidates = []
386
+ const otherCandidates = []
387
+ const seen = new Set()
388
+
389
+ try {
390
+ let config = await this.kernel.api.meta(app.id)
391
+ config = await this.server.processMenu(app.id, safeStructuredClone(config || {}))
392
+ const flat = this.flattenMenu(config && config.menu ? config.menu : [])
393
+ for (const entry of flat) {
394
+ const menuitem = entry.item
395
+ if (!menuitem || typeof menuitem.href !== "string") {
396
+ continue
397
+ }
398
+ let href = menuitem.href.trim()
399
+ const apiPrefix = `/api/${app.id}/`
400
+ if (href.startsWith(apiPrefix)) {
401
+ href = href.slice(apiPrefix.length)
402
+ } else if (href.startsWith("/")) {
403
+ continue
404
+ }
405
+ const localScript = this.normalizeScriptPath(href)
406
+ if (!localScript) {
407
+ continue
408
+ }
409
+ const scriptPath = path.resolve(launcherRoot, localScript)
410
+ if (!this.server.is_subpath(appRoot, scriptPath)) {
411
+ continue
412
+ }
413
+ const script = path.relative(appRoot, scriptPath).split(path.sep).join("/")
414
+ await this.addCandidate(menuCandidates, seen, {
415
+ script,
416
+ label: menuitem.text || menuitem.name || script,
417
+ group: entry.group,
418
+ icon: typeof menuitem.icon === "string" ? menuitem.icon : "",
419
+ source: "menu",
420
+ menu_default: !!menuitem.default,
421
+ has_params: !!(menuitem.params && typeof menuitem.params === "object")
422
+ }, appRoot)
423
+ }
424
+ } catch (error) {
425
+ console.warn("[autolaunch] failed to resolve menu candidates", app.id, error && error.message ? error.message : error)
426
+ }
427
+
428
+ if (envInfo.launch) {
429
+ await this.addCandidate(menuCandidates, seen, {
430
+ script: envInfo.launch,
431
+ label: envInfo.launch,
432
+ source: "current"
433
+ }, appRoot)
434
+ }
435
+
436
+ const localScripts = await this.collectScriptFiles(launcherRoot, appRoot)
437
+ for (const script of localScripts) {
438
+ await this.addCandidate(otherCandidates, seen, {
439
+ script,
440
+ label: script,
441
+ source: "local"
442
+ }, appRoot)
443
+ }
444
+
445
+ return {
446
+ app: await this.buildAppState(app),
447
+ dependency_apps: await this.buildDependencyOptions(app.id),
448
+ launcher_root: path.relative(appRoot, launcherRoot).split(path.sep).join("/"),
449
+ menu: menuCandidates,
450
+ other: otherCandidates,
451
+ current: envInfo.launch
452
+ }
453
+ }
454
+ async applyHomeStartingState(item, index, homeStartupDisplayGraph = null) {
455
+ let autolaunchInfo = null
456
+ let autolaunchStatus = null
457
+ let startupDisplayInfo = null
458
+ const appId = this.normalizeAppId(item && (item.uri || item.name))
459
+ if (!appId) {
460
+ return false
461
+ }
462
+ try {
463
+ autolaunchStatus = this.kernel.autolaunch_status && this.kernel.autolaunch_status.apps
464
+ ? this.kernel.autolaunch_status.apps[appId]
465
+ : null
466
+ if (!this.kernel.launch_complete || autolaunchStatus) {
467
+ autolaunchInfo = await this.getEnvInfo({ id: appId })
468
+ }
469
+ } catch (error) {
470
+ console.warn("[home] failed to read autolaunch state", appId, error && error.message ? error.message : error)
471
+ }
472
+ if (!autolaunchStatus && !this.kernel.launch_complete) {
473
+ const displayGraph = homeStartupDisplayGraph || await this.buildHomeStartupDisplayGraph()
474
+ startupDisplayInfo = displayGraph && typeof displayGraph.get === "function"
475
+ ? displayGraph.get(appId)
476
+ : null
477
+ }
478
+ const ready = autolaunchStatus && autolaunchStatus.state === "ready"
479
+ const hasActiveStatus = autolaunchStatus && !ready
480
+ const hasConfiguredStartup = !this.kernel.launch_complete && autolaunchInfo && autolaunchInfo.enabled
481
+ const hasStartupDisplayInfo = !this.kernel.launch_complete && !!startupDisplayInfo
482
+ if (ready || (!hasActiveStatus && !hasConfiguredStartup && !hasStartupDisplayInfo)) {
483
+ return false
484
+ }
485
+ const launchScript = (autolaunchStatus && autolaunchStatus.script) ||
486
+ (startupDisplayInfo && startupDisplayInfo.script) ||
487
+ (autolaunchInfo ? autolaunchInfo.value || autolaunchInfo.launch : "")
488
+ const launchPath = autolaunchStatus && autolaunchStatus.launch_path
489
+ ? autolaunchStatus.launch_path
490
+ : (launchScript && autolaunchInfo ? path.resolve(autolaunchInfo.appRoot, launchScript) : "")
491
+ const progress = autolaunchStatus && Number.isInteger(Number(autolaunchStatus.step_current)) && Number.isInteger(Number(autolaunchStatus.step_total))
492
+ ? {
493
+ step_current: Number(autolaunchStatus.step_current),
494
+ step_total: Number(autolaunchStatus.step_total)
495
+ }
496
+ : (this.kernel && typeof this.kernel.getScriptProgress === "function" ? this.kernel.getScriptProgress(launchPath) : null)
497
+ const progressLabel = progress && progress.step_total > 1
498
+ ? ` (${progress.step_current}/${progress.step_total})`
499
+ : ""
500
+ const statusWaitingFor = autolaunchStatus && Array.isArray(autolaunchStatus.waiting_for) ? autolaunchStatus.waiting_for : []
501
+ const configuredDependencies = startupDisplayInfo && Array.isArray(startupDisplayInfo.waiting_for)
502
+ ? startupDisplayInfo.waiting_for
503
+ : (autolaunchInfo && Array.isArray(autolaunchInfo.dependencies) ? autolaunchInfo.dependencies : [])
504
+ const waitingFor = autolaunchStatus ? statusWaitingFor : configuredDependencies
505
+ const waitingLabels = []
506
+ for (const id of waitingFor) {
507
+ const status = this.kernel.autolaunch_status && this.kernel.autolaunch_status.apps
508
+ ? this.kernel.autolaunch_status.apps[id]
509
+ : null
510
+ if (status && status.title) {
511
+ waitingLabels.push(status.title)
512
+ continue
513
+ }
514
+ try {
515
+ const dependencyLauncher = await this.kernel.api.launcher(id)
516
+ waitingLabels.push(dependencyLauncher && dependencyLauncher.script && dependencyLauncher.script.title
517
+ ? dependencyLauncher.script.title
518
+ : id)
519
+ } catch (_) {
520
+ waitingLabels.push(id)
521
+ }
522
+ }
523
+ const waitingLabel = waitingLabels.length > 0 ? `Waiting for ${waitingLabels.join(", ")}` : ""
524
+ item.running = true
525
+ item.autolaunch_starting = true
526
+ item.autolaunch_blocked = autolaunchStatus && autolaunchStatus.state === "blocked"
527
+ item.autolaunch_waiting = !item.autolaunch_blocked && waitingFor.length > 0
528
+ item.autolaunch_status_label = item.autolaunch_blocked
529
+ ? (autolaunchStatus.blocked_reason || "Startup blocked")
530
+ : (item.autolaunch_waiting && waitingLabel ? waitingLabel : `Starting ${launchScript || "automatically"}${progressLabel}`)
531
+ item.autolaunch_script = launchScript
532
+ if (progress) {
533
+ item.autolaunch_step_current = progress.step_current
534
+ item.autolaunch_step_total = progress.step_total
535
+ }
536
+ item.index = index
537
+ return true
538
+ }
539
+ registerRoutes() {
540
+ this.server.app.get("/autolaunch/candidates", ex(async (req, res) => {
541
+ const app = await this.getAppById(req.query.app)
542
+ if (!app) {
543
+ res.status(404).json({ ok: false, error: "App not found." })
544
+ return
545
+ }
546
+ const state = await this.buildCandidates(app)
547
+ res.json({ ok: true, ...state })
548
+ }))
549
+ this.server.app.post("/autolaunch/dependencies", ex(async (req, res) => {
550
+ const app = await this.getAppById(req.body && req.body.app)
551
+ if (!app) {
552
+ res.status(404).json({ ok: false, error: "App not found." })
553
+ return
554
+ }
555
+ const requestedDependencies = req.body && Array.isArray(req.body.dependencies) ? req.body.dependencies : null
556
+ if (!requestedDependencies) {
557
+ res.status(400).json({ ok: false, error: "Dependencies must be an array of app IDs." })
558
+ return
559
+ }
560
+ const states = await this.buildAppsStateRaw()
561
+ const installedIds = new Set(states.map((state) => state && state.id).filter(Boolean))
562
+ const seen = new Set()
563
+ const normalized = []
564
+ for (const rawId of requestedDependencies) {
565
+ const dependencyId = this.normalizeAppId(rawId)
566
+ if (!dependencyId) {
567
+ res.status(400).json({ ok: false, error: "Requirement app id is invalid." })
568
+ return
569
+ }
570
+ if (dependencyId === app.id) {
571
+ res.status(400).json({ ok: false, error: "An app cannot require itself." })
572
+ return
573
+ }
574
+ if (seen.has(dependencyId)) {
575
+ res.status(400).json({ ok: false, error: "Requirement app ids must be unique." })
576
+ return
577
+ }
578
+ if (!installedIds.has(dependencyId)) {
579
+ res.status(400).json({ ok: false, error: `Required app is not installed: ${dependencyId}` })
580
+ return
581
+ }
582
+ seen.add(dependencyId)
583
+ normalized.push(dependencyId)
584
+ }
585
+ if (normalized.length > 0) {
586
+ const envInfo = await this.getEnvInfo(app)
587
+ if (!envInfo.launch) {
588
+ res.status(400).json({ ok: false, error: "Choose this app's launch script before adding requirements." })
589
+ return
590
+ }
591
+ const stateById = new Map(states.map((state) => [state.id, state]))
592
+ const missing = normalized
593
+ .map((id) => stateById.get(id))
594
+ .find((state) => !state || !state.autolaunch)
595
+ if (missing) {
596
+ res.status(400).json({
597
+ ok: false,
598
+ error: `Choose ${missing.title || missing.name || missing.id}'s launch script before adding it as a requirement.`
599
+ })
600
+ return
601
+ }
602
+ }
603
+ const initialized = await Environment.init({ name: app.id }, this.kernel)
604
+ await Util.update_env(initialized.env_path, {
605
+ [Environment.SCRIPT_REQUIREMENTS_KEY]: Environment.formatAutolaunchList(normalized)
606
+ })
607
+ if (this.kernel && typeof this.kernel.clearLaunchRequirementsStatus === "function") {
608
+ this.kernel.clearLaunchRequirementsStatus(app.id)
609
+ }
610
+ res.json({
611
+ ok: true,
612
+ app: await this.buildAppState(app)
613
+ })
614
+ }))
615
+ this.server.app.post("/autolaunch", ex(async (req, res) => {
616
+ const app = await this.getAppById(req.body && req.body.app)
617
+ if (!app) {
618
+ res.status(404).json({ ok: false, error: "App not found." })
619
+ return
620
+ }
621
+ const requestedScript = typeof req.body.script === "string" ? req.body.script.trim() : ""
622
+ const clearScript = !!(req.body && req.body.clear_script === true)
623
+ const hasEnabled = req.body && typeof req.body.enabled === "boolean"
624
+ if (!clearScript && requestedScript && !hasEnabled) {
625
+ res.status(400).json({
626
+ ok: false,
627
+ error: "Startup enabled must be explicit when selecting a launch script."
628
+ })
629
+ return
630
+ }
631
+ const enabled = hasEnabled ? req.body.enabled : false
632
+ if (clearScript) {
633
+ const envInfo = await this.getEnvInfo(app)
634
+ if (envInfo.dependencies && envInfo.dependencies.length > 0) {
635
+ res.status(400).json({
636
+ ok: false,
637
+ error: "Remove requirements before clearing this app's launch script."
638
+ })
639
+ return
640
+ }
641
+ if (envInfo.exists) {
642
+ await Util.update_env(envInfo.envPath, {
643
+ [Environment.SCRIPT_AUTOLAUNCH_KEY]: "",
644
+ [Environment.SCRIPT_AUTOLAUNCH_ENABLED_KEY]: ""
645
+ })
646
+ }
647
+ if (this.kernel && typeof this.kernel.clearLaunchRequirementsStatus === "function") {
648
+ this.kernel.clearLaunchRequirementsStatus(app.id)
649
+ }
650
+ res.json({
651
+ ok: true,
652
+ app: await this.buildAppState(app)
653
+ })
654
+ return
655
+ }
656
+ if (!requestedScript) {
657
+ res.status(400).json({
658
+ ok: false,
659
+ error: "Use clear_script: true to clear this app's launch script."
660
+ })
661
+ return
662
+ }
663
+
664
+ const envInfo = await this.getEnvInfo(app)
665
+ const resolved = await this.resolveScript(envInfo.appRoot, requestedScript)
666
+ if (!resolved) {
667
+ res.status(400).json({
668
+ ok: false,
669
+ error: "Select an existing local script inside the app."
670
+ })
671
+ return
672
+ }
673
+ const initialized = await Environment.init({ name: app.id }, this.kernel)
674
+ await Util.update_env(initialized.env_path, {
675
+ [Environment.SCRIPT_AUTOLAUNCH_KEY]: resolved.script,
676
+ [Environment.SCRIPT_AUTOLAUNCH_ENABLED_KEY]: enabled ? "true" : "false"
677
+ })
678
+ if (this.kernel && typeof this.kernel.clearLaunchRequirementsStatus === "function") {
679
+ this.kernel.clearLaunchRequirementsStatus(app.id)
680
+ }
681
+ res.json({
682
+ ok: true,
683
+ app: await this.buildAppState(app)
684
+ })
685
+ }))
686
+ this.server.app.post("/autolaunch/disable-all", ex(async (req, res) => {
687
+ const apps = await this.kernel.api.listApps()
688
+ let disabled = 0
689
+ for (const app of apps) {
690
+ const envInfo = await this.getEnvInfo(app)
691
+ if (envInfo.exists && envInfo.enabled) {
692
+ await Util.update_env(envInfo.envPath, {
693
+ [Environment.SCRIPT_AUTOLAUNCH_ENABLED_KEY]: "false"
694
+ })
695
+ disabled++
696
+ }
697
+ }
698
+ res.json({
699
+ ok: true,
700
+ disabled,
701
+ apps: await this.buildAppsState()
702
+ })
703
+ }))
704
+ this.server.app.get("/autolaunch", ex(async (req, res) => {
705
+ const peerAccess = await this.server.composePeerAccessPayload()
706
+ const list = this.server.getPeers()
707
+ const apps = await this.buildAppsState()
708
+ const appsJson = JSON.stringify(apps).replace(/</g, "\\u003c")
709
+ res.render("autolaunch", {
710
+ current_host: this.kernel.peer.host,
711
+ ...peerAccess,
712
+ apps,
713
+ appsJson,
714
+ enabledCount: apps.filter((app) => app.autolaunch_enabled).length,
715
+ portal: this.server.portal,
716
+ logo: this.server.logo,
717
+ theme: this.server.theme,
718
+ agent: req.agent,
719
+ list,
720
+ })
721
+ }))
722
+ }
723
+ }
724
+
725
+ module.exports = ServerAutolaunch