pinokiod 7.2.16 → 7.2.18

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/agent_instructions.js +166 -0
  2. package/kernel/api/index.js +137 -12
  3. package/kernel/bin/huggingface.js +1 -1
  4. package/kernel/environment.js +23 -9
  5. package/kernel/plugin_sources.js +57 -4
  6. package/kernel/prototype.js +4 -0
  7. package/kernel/shell.js +2 -0
  8. package/kernel/watch/index.js +31 -4
  9. package/package.json +1 -1
  10. package/server/features/index.js +4 -4
  11. package/server/features/{drafts → notes}/index.js +9 -9
  12. package/server/features/{drafts → notes}/parser.js +12 -7
  13. package/server/features/notes/public/notes.css +955 -0
  14. package/server/features/notes/public/notes.js +1149 -0
  15. package/server/features/{drafts → notes}/registry_import.js +59 -74
  16. package/server/features/notes/routes.js +156 -0
  17. package/server/features/notes/service.js +326 -0
  18. package/server/features/{drafts → notes}/watcher.js +14 -16
  19. package/server/index.js +61 -30
  20. package/server/lib/content_validation.js +19 -8
  21. package/server/lib/workspace_catalog.js +18 -18
  22. package/server/public/task-launcher.css +11 -3
  23. package/server/public/tasker.css +336 -0
  24. package/server/public/tasker.js +407 -0
  25. package/server/views/d.ejs +33 -2
  26. package/server/views/partials/menu.ejs +1 -1
  27. package/server/views/partials/workspace_row.ejs +11 -11
  28. package/server/views/pre.ejs +1 -1
  29. package/server/views/task_launch.ejs +10 -10
  30. package/server/views/tasker.ejs +40 -0
  31. package/server/views/terminal.ejs +15 -6
  32. package/server/views/terminals.ejs +0 -1
  33. package/server/views/workspaces.ejs +2 -1
  34. package/system/plugin/antigravity/pinokio.js +2 -4
  35. package/system/plugin/claude/pinokio.js +2 -4
  36. package/system/plugin/claude-auto/pinokio.js +2 -4
  37. package/system/plugin/claude-desktop/pinokio.js +2 -4
  38. package/system/plugin/codex/pinokio.js +2 -4
  39. package/system/plugin/codex-auto/pinokio.js +2 -4
  40. package/system/plugin/codex-desktop/pinokio.js +2 -4
  41. package/system/plugin/crush/pinokio.js +2 -4
  42. package/system/plugin/cursor/pinokio.js +2 -4
  43. package/system/plugin/gemini/pinokio.js +2 -4
  44. package/system/plugin/gemini-auto/pinokio.js +2 -4
  45. package/system/plugin/qwen/pinokio.js +2 -4
  46. package/system/plugin/vscode/pinokio.js +2 -4
  47. package/system/plugin/windsurf/pinokio.js +2 -4
  48. package/test/plugin-sources.test.js +45 -0
  49. package/server/features/drafts/public/drafts.js +0 -1569
  50. package/server/features/drafts/routes.js +0 -68
  51. package/server/features/drafts/service.js +0 -261
@@ -0,0 +1,326 @@
1
+ const fs = require("fs")
2
+ const path = require("path")
3
+ const crypto = require("crypto")
4
+ const {
5
+ RESULT_RELATIVE_DIR,
6
+ NOTE_FILENAME,
7
+ METADATA_FILENAME,
8
+ buildExcerpt,
9
+ describeMediaRefs,
10
+ extractTitle,
11
+ parseNoteMetadata
12
+ } = require("./parser")
13
+
14
+ const MAX_MARKDOWN_BYTES = 5 * 1024 * 1024
15
+
16
+ function createHash(value) {
17
+ return crypto.createHash("sha256").update(String(value)).digest("hex").slice(0, 24)
18
+ }
19
+
20
+ function clonePlainObject(value) {
21
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
22
+ return null
23
+ }
24
+ try {
25
+ return JSON.parse(JSON.stringify(value))
26
+ } catch (_) {
27
+ return null
28
+ }
29
+ }
30
+
31
+ function normalizeRelativePath(value, fallback) {
32
+ const raw = String(value || fallback || "").trim().replace(/\\/g, "/")
33
+ if (raw.startsWith("/") || /^[a-zA-Z]:/.test(raw)) {
34
+ return fallback
35
+ }
36
+ const normalized = path.posix.normalize(raw)
37
+ if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
38
+ return fallback
39
+ }
40
+ return normalized
41
+ }
42
+
43
+ function isInside(candidate, parent) {
44
+ const relative = path.relative(path.resolve(parent), path.resolve(candidate))
45
+ return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative))
46
+ }
47
+
48
+ function normalizeNoteConfig(config = {}) {
49
+ const params = config && typeof config === "object" ? config : {}
50
+ return {
51
+ path: normalizeRelativePath(params.path, RESULT_RELATIVE_DIR),
52
+ content: normalizeRelativePath(params.content, ""),
53
+ ready: normalizeRelativePath(params.ready, METADATA_FILENAME),
54
+ description: typeof params.description === "string" ? params.description.trim() : "",
55
+ publish: clonePlainObject(params.publish)
56
+ }
57
+ }
58
+
59
+ async function findMetadataFiles(rootDir, filename) {
60
+ const root = path.resolve(rootDir)
61
+ const stats = await fs.promises.stat(root).catch(() => null)
62
+ if (!stats || !stats.isDirectory()) {
63
+ return []
64
+ }
65
+ const target = String(filename || METADATA_FILENAME)
66
+ const results = []
67
+ const visit = async (dir) => {
68
+ const entries = await fs.promises.readdir(dir, { withFileTypes: true }).catch(() => null)
69
+ if (!entries) return
70
+ for (const entry of entries) {
71
+ const fullPath = path.resolve(dir, entry.name)
72
+ if (entry.isDirectory()) {
73
+ await visit(fullPath)
74
+ } else if (entry.isFile() && entry.name === target) {
75
+ results.push(fullPath)
76
+ }
77
+ }
78
+ }
79
+ await visit(root)
80
+ return results
81
+ }
82
+
83
+ function createNoteService({ kernel, taskWorkspaceLinks } = {}) {
84
+ if (!kernel) {
85
+ throw new Error("kernel is required")
86
+ }
87
+
88
+ const resultsByBundle = new Map()
89
+
90
+ async function readMarkdown(notePath, stats) {
91
+ const noteStats = stats || await fs.promises.stat(notePath)
92
+ if (noteStats.size > MAX_MARKDOWN_BYTES) {
93
+ throw new Error(`note markdown is too large (${noteStats.size} bytes)`)
94
+ }
95
+ return fs.promises.readFile(notePath, "utf8")
96
+ }
97
+
98
+ async function readNoteMetadata(metadataPath) {
99
+ if (path.extname(metadataPath).toLowerCase() !== ".json") {
100
+ return {}
101
+ }
102
+ const raw = await fs.promises.readFile(metadataPath, "utf8")
103
+ return parseNoteMetadata(raw)
104
+ }
105
+
106
+ async function inspectNoteBundle({ taskId, ref, cwd, note, bundlePath, metadataPath } = {}) {
107
+ if (typeof cwd !== "string" || !cwd.trim()) {
108
+ return null
109
+ }
110
+ const workspacePath = path.resolve(cwd.trim())
111
+ const noteConfig = normalizeNoteConfig(note)
112
+ const rootDir = path.resolve(workspacePath, noteConfig.path)
113
+ const resultDir = path.resolve(bundlePath || rootDir)
114
+ const readyPath = path.resolve(metadataPath || path.resolve(resultDir, noteConfig.ready))
115
+ const readyStats = await fs.promises.stat(readyPath).catch(() => null)
116
+ if (!readyStats || !readyStats.isFile()) {
117
+ resultsByBundle.delete(resultDir)
118
+ return null
119
+ }
120
+ let metadata = {}
121
+ try {
122
+ metadata = await readNoteMetadata(readyPath)
123
+ } catch (_) {
124
+ resultsByBundle.delete(resultDir)
125
+ return null
126
+ }
127
+
128
+ const contentPath = normalizeRelativePath(metadata.content || noteConfig.content, NOTE_FILENAME)
129
+ const notePath = path.resolve(resultDir, contentPath)
130
+ const noteStats = await fs.promises.stat(notePath).catch(() => null)
131
+ if (!noteStats || !noteStats.isFile()) {
132
+ resultsByBundle.delete(resultDir)
133
+ return null
134
+ }
135
+
136
+ const markdown = await readMarkdown(notePath, noteStats)
137
+ const workspaceName = path.basename(workspacePath)
138
+ const bundleName = path.basename(resultDir)
139
+ const media = await describeMediaRefs(markdown, resultDir)
140
+ const updatedAtMs = Math.max(readyStats.mtimeMs || 0, noteStats.mtimeMs || 0)
141
+ const id = createHash(`${workspacePath}|${resultDir}|${notePath}|${readyPath}`)
142
+ const previous = resultsByBundle.get(resultDir) || null
143
+ const publish = noteConfig.publish || (previous && previous.publish) || null
144
+ const description = noteConfig.description || (previous && previous.description) || ""
145
+ const mediaRevision = media
146
+ .map((item) => `${item.ref}:${item.exists ? item.bytes : "missing"}:${item.mtimeMs || 0}`)
147
+ .join("|")
148
+ const revision = createHash(`${noteStats.size}|${noteStats.mtimeMs}|${readyStats.size}|${readyStats.mtimeMs}|${mediaRevision}`)
149
+ const result = {
150
+ id,
151
+ revision,
152
+ taskId,
153
+ ref,
154
+ cwd: workspacePath,
155
+ workspaceName,
156
+ bundleName,
157
+ title: metadata.title || extractTitle(markdown, bundleName || workspaceName),
158
+ markdown,
159
+ excerpt: buildExcerpt(markdown),
160
+ resultDir,
161
+ watchRoot: rootDir,
162
+ notePath,
163
+ contentPath: notePath,
164
+ readyPath,
165
+ metadataPath: readyPath,
166
+ metadata,
167
+ publish,
168
+ description,
169
+ noteBytes: noteStats.size,
170
+ media: media.map((item, index) => ({
171
+ index,
172
+ ref: item.ref,
173
+ path: item.path,
174
+ bytes: item.bytes,
175
+ mtimeMs: item.mtimeMs,
176
+ exists: item.exists,
177
+ ext: path.extname(item.ref || "").toLowerCase()
178
+ })),
179
+ mediaCount: media.length,
180
+ missingMediaCount: media.filter((item) => !item.exists).length,
181
+ mediaBytes: media.reduce((total, item) => total + (Number.isFinite(item.bytes) ? item.bytes : 0), 0),
182
+ updatedAt: new Date(updatedAtMs || Date.now()).toISOString()
183
+ }
184
+ resultsByBundle.set(resultDir, result)
185
+ return result
186
+ }
187
+
188
+ async function inspectWorkspace({ taskId, ref, cwd, note } = {}) {
189
+ if (typeof cwd !== "string" || !cwd.trim()) {
190
+ return null
191
+ }
192
+ const workspacePath = path.resolve(cwd.trim())
193
+ const noteConfig = normalizeNoteConfig(note)
194
+ const rootDir = path.resolve(workspacePath, noteConfig.path)
195
+ const metadataFiles = await findMetadataFiles(rootDir, noteConfig.ready)
196
+ const seen = new Set()
197
+ const results = []
198
+ for (const metadataPath of metadataFiles) {
199
+ const bundlePath = path.dirname(metadataPath)
200
+ const result = await inspectNoteBundle({
201
+ taskId,
202
+ ref,
203
+ cwd: workspacePath,
204
+ note: noteConfig,
205
+ bundlePath,
206
+ metadataPath
207
+ })
208
+ if (result) {
209
+ seen.add(result.resultDir)
210
+ results.push(result)
211
+ }
212
+ }
213
+ for (const [bundlePath, result] of Array.from(resultsByBundle.entries())) {
214
+ if (result && result.cwd === workspacePath && result.watchRoot === rootDir && !seen.has(bundlePath)) {
215
+ resultsByBundle.delete(bundlePath)
216
+ }
217
+ }
218
+ return results[0] || null
219
+ }
220
+
221
+ async function trackWorkspace({ taskId, ref, cwd } = {}) {
222
+ let resolvedCwd = cwd
223
+ if (!resolvedCwd && ref && taskWorkspaceLinks && typeof taskWorkspaceLinks.resolveWorkspaceRef === "function") {
224
+ resolvedCwd = taskWorkspaceLinks.resolveWorkspaceRef(ref)
225
+ }
226
+ if (!resolvedCwd) {
227
+ return null
228
+ }
229
+ return inspectWorkspace({ taskId, ref, cwd: resolvedCwd })
230
+ }
231
+
232
+ async function listPending(options = {}) {
233
+ const filterCwd = typeof options.cwd === "string" && options.cwd.trim()
234
+ ? path.resolve(options.cwd.trim())
235
+ : ""
236
+ return Array.from(resultsByBundle.values())
237
+ .filter((result) => !filterCwd || result.cwd === filterCwd)
238
+ .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
239
+ }
240
+
241
+ async function getPendingById(id) {
242
+ const normalizedId = typeof id === "string" ? id.trim() : ""
243
+ if (!normalizedId) {
244
+ return null
245
+ }
246
+ return Array.from(resultsByBundle.values()).find((item) => item.id === normalizedId) || null
247
+ }
248
+
249
+ async function refreshPendingItem(item) {
250
+ if (!item || !item.cwd || !item.resultDir || !item.metadataPath) {
251
+ return null
252
+ }
253
+ return inspectNoteBundle({
254
+ taskId: item.taskId,
255
+ ref: item.ref,
256
+ cwd: item.cwd,
257
+ note: {
258
+ description: item.description,
259
+ publish: item.publish
260
+ },
261
+ bundlePath: item.resultDir,
262
+ metadataPath: item.metadataPath
263
+ })
264
+ }
265
+
266
+ async function savePendingById(id, options = {}) {
267
+ const item = await getPendingById(id)
268
+ if (!item) {
269
+ return null
270
+ }
271
+ const markdown = typeof options.markdown === "string" ? options.markdown : null
272
+ if (markdown === null) {
273
+ throw new Error("markdown is required")
274
+ }
275
+ if (Buffer.byteLength(markdown, "utf8") > MAX_MARKDOWN_BYTES) {
276
+ const error = new Error(`note markdown is too large; limit is ${MAX_MARKDOWN_BYTES} bytes`)
277
+ error.code = "NOTE_TOO_LARGE"
278
+ throw error
279
+ }
280
+
281
+ const current = await refreshPendingItem(item)
282
+ if (!current) {
283
+ return null
284
+ }
285
+ const expectedRevision = typeof options.revision === "string" ? options.revision.trim() : ""
286
+ if (expectedRevision && current.revision !== expectedRevision) {
287
+ const error = new Error("Note changed on disk. Reload it before saving.")
288
+ error.code = "NOTE_CONFLICT"
289
+ error.item = current
290
+ throw error
291
+ }
292
+
293
+ const notePath = path.resolve(current.notePath)
294
+ const resultDir = path.resolve(current.resultDir)
295
+ if (!isInside(notePath, resultDir)) {
296
+ const error = new Error("Note path is outside the note folder")
297
+ error.code = "NOTE_INVALID_PATH"
298
+ throw error
299
+ }
300
+
301
+ await fs.promises.writeFile(notePath, markdown, "utf8")
302
+ return refreshPendingItem(current)
303
+ }
304
+
305
+ async function start() {
306
+ }
307
+
308
+ async function stop() {
309
+ }
310
+
311
+ return {
312
+ RESULT_RELATIVE_DIR,
313
+ getPendingById,
314
+ inspectNoteBundle,
315
+ inspectWorkspace,
316
+ listPending,
317
+ savePendingById,
318
+ start,
319
+ stop,
320
+ trackWorkspace
321
+ }
322
+ }
323
+
324
+ module.exports = {
325
+ createNoteService
326
+ }
@@ -5,21 +5,19 @@ function isInside(candidate, parent) {
5
5
  return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative))
6
6
  }
7
7
 
8
- class DraftWatcher {
8
+ class NoteWatcher {
9
9
  constructor(options = {}) {
10
- this.drafts = options.drafts
10
+ this.notes = options.notes
11
11
  }
12
12
 
13
- async ready(ctx, params = {}) {
14
- if (!this.drafts || typeof this.drafts.inspectWorkspace !== "function") {
15
- throw new Error("draft service is unavailable")
13
+ async watch(ctx, params = {}) {
14
+ if (!this.notes || typeof this.notes.inspectWorkspace !== "function") {
15
+ throw new Error("note service is unavailable")
16
16
  }
17
17
 
18
- const draftDir = ctx.resolve(params.path || ".pinokio/draft")
19
- const draftConfig = {
20
- path: params.path || ".pinokio/draft",
21
- content: params.content,
22
- ready: params.ready,
18
+ const noteDir = ctx.resolve(params.path || ".pinokio/notes")
19
+ const noteConfig = {
20
+ path: params.path || ".pinokio/notes",
23
21
  description: params.description,
24
22
  publish: params.publish
25
23
  }
@@ -28,7 +26,7 @@ class DraftWatcher {
28
26
 
29
27
  const inspect = async () => {
30
28
  if (disposed) return
31
- await this.drafts.inspectWorkspace({ cwd: ctx.cwd, draft: draftConfig })
29
+ await this.notes.inspectWorkspace({ cwd: ctx.cwd, note: noteConfig })
32
30
  }
33
31
 
34
32
  const scheduleInspect = () => {
@@ -37,7 +35,7 @@ class DraftWatcher {
37
35
  timer = setTimeout(() => {
38
36
  timer = null
39
37
  inspect().catch((error) => {
40
- console.warn("[drafts] failed to inspect workspace", error && error.message ? error.message : error)
38
+ console.warn("[notes] failed to inspect workspace", error && error.message ? error.message : error)
41
39
  })
42
40
  }, 250)
43
41
  }
@@ -46,12 +44,12 @@ class DraftWatcher {
46
44
  const stopPoll = ctx.poll(params.interval || 1500, inspect, {
47
45
  immediate: false,
48
46
  onError: (error) => {
49
- console.warn("[drafts] poll failed", error && error.message ? error.message : error)
47
+ console.warn("[notes] poll failed", error && error.message ? error.message : error)
50
48
  }
51
49
  })
52
50
  const unsubscribe = await ctx.watch.fs(ctx.cwd, (events) => {
53
51
  if (!Array.isArray(events)) return
54
- if (events.some((event) => event && event.path && isInside(path.resolve(event.path), draftDir))) {
52
+ if (events.some((event) => event && event.path && isInside(path.resolve(event.path), noteDir))) {
55
53
  scheduleInspect()
56
54
  }
57
55
  })
@@ -61,7 +59,7 @@ class DraftWatcher {
61
59
  clearTimeout(timer)
62
60
  timer = null
63
61
  }
64
- await this.drafts.inspectWorkspace({ cwd: ctx.cwd, draft: draftConfig }).catch(() => {})
62
+ await this.notes.inspectWorkspace({ cwd: ctx.cwd, note: noteConfig }).catch(() => {})
65
63
  disposed = true
66
64
  if (typeof stopPoll === "function") {
67
65
  await stopPoll()
@@ -73,4 +71,4 @@ class DraftWatcher {
73
71
  }
74
72
  }
75
73
 
76
- module.exports = DraftWatcher
74
+ module.exports = NoteWatcher
package/server/index.js CHANGED
@@ -2758,10 +2758,12 @@ class Server {
2758
2758
  } else {
2759
2759
  resolved = runner(this.kernel, this.kernel.info)
2760
2760
  }
2761
- runnable = resolved && Array.isArray(resolved[actionKey]) && resolved[actionKey].length > 0
2761
+ const action = resolved ? resolved[actionKey] : null
2762
+ runnable = typeof action === "function" || (Array.isArray(action) && action.length > 0)
2762
2763
  } else {
2763
- runnable = runner && Array.isArray(runner[actionKey]) && runner[actionKey].length > 0
2764
2764
  resolved = runner
2765
+ const action = resolved ? resolved[actionKey] : null
2766
+ runnable = typeof action === "function" || (Array.isArray(action) && action.length > 0)
2765
2767
  }
2766
2768
 
2767
2769
  let template = "terminal"
@@ -2870,10 +2872,10 @@ class Server {
2870
2872
  const protectionPreference = protectionAppId && this.appPreferences && typeof this.appPreferences.getPreference === "function"
2871
2873
  ? await this.appPreferences.getPreference(protectionAppId)
2872
2874
  : null
2873
- const draftWatchEnabled = this.kernel.watch && typeof this.kernel.watch.hasHandler === "function"
2874
- ? this.kernel.watch.hasHandler(resolved, "draft")
2875
+ const noteWatchEnabled = this.kernel.watch && typeof this.kernel.watch.hasHandler === "function"
2876
+ ? this.kernel.watch.hasHandler(resolved, "note")
2875
2877
  : false
2876
- const draftWatchCwd = draftWatchEnabled
2878
+ const noteWatchCwd = noteWatchEnabled
2877
2879
  ? (req.query.cwd || path.dirname(filepath))
2878
2880
  : ""
2879
2881
  const activeProcessWait = this.kernel.activeProcessWaits && this.kernel.activeProcessWaits[filepath]
@@ -2884,8 +2886,8 @@ class Server {
2884
2886
  projectName: (pathComponents.length > 0 ? pathComponents[0] : ''),
2885
2887
  protection_app_id: protectionAppId,
2886
2888
  protection_enabled: protectionPreference ? protectionPreference.protection_enabled !== false : false,
2887
- draft_watch_enabled: draftWatchEnabled,
2888
- draft_watch_cwd: draftWatchCwd,
2889
+ note_watch_enabled: noteWatchEnabled,
2890
+ note_watch_cwd: noteWatchCwd,
2889
2891
  active_process_wait: activeProcessWait ? {
2890
2892
  title: activeProcessWait.title,
2891
2893
  description: activeProcessWait.description,
@@ -5311,15 +5313,7 @@ class Server {
5311
5313
  return normalized
5312
5314
  }
5313
5315
  isValidBundledPluginConfig(pluginConfig) {
5314
- if (!pluginConfig || !Array.isArray(pluginConfig.run)) {
5315
- return false
5316
- }
5317
- for (const key of Object.keys(pluginConfig)) {
5318
- if (typeof pluginConfig[key] === "function") {
5319
- return false
5320
- }
5321
- }
5322
- return true
5316
+ return PluginSources.isValidPluginConfig(pluginConfig)
5323
5317
  }
5324
5318
  isPathInsideRootForBundledPlugin(candidatePath, rootPath) {
5325
5319
  const relative = path.relative(rootPath, candidatePath)
@@ -6857,9 +6851,9 @@ class Server {
6857
6851
  cwd: typeof pluginItem.ownerApp.cwd === "string" ? pluginItem.ownerApp.cwd : "",
6858
6852
  }
6859
6853
  : null,
6860
- hasInstall: Array.isArray(pluginItem?.install),
6861
- hasUninstall: Array.isArray(pluginItem?.uninstall),
6862
- hasUpdate: Array.isArray(pluginItem?.update),
6854
+ hasInstall: PluginSources.isAction(pluginItem?.install),
6855
+ hasUninstall: PluginSources.isAction(pluginItem?.uninstall),
6856
+ hasUpdate: PluginSources.isAction(pluginItem?.update),
6863
6857
  category,
6864
6858
  categoryTitle: category === "ide" ? "Desktop Plugin" : "Terminal Plugin",
6865
6859
  categorySubtitle: category === "ide" ? "Launch externally" : "Launch in Pinokio",
@@ -8415,12 +8409,12 @@ class Server {
8415
8409
  defaultRegistryUrl: DEFAULT_REGISTRY_URL
8416
8410
  })
8417
8411
  this.features = features
8418
- const drafts = features.drafts.service
8419
- this.drafts = drafts
8412
+ const notes = features.notes.service
8413
+ this.notes = notes
8420
8414
  const workspaceCatalog = createWorkspaceCatalogService({
8421
8415
  kernel: this.kernel,
8422
8416
  workspaceRuntime,
8423
- drafts
8417
+ notes
8424
8418
  })
8425
8419
  registerWorkspacesRoutes(this.app, {
8426
8420
  workspaceCatalog,
@@ -9744,6 +9738,13 @@ class Server {
9744
9738
  })
9745
9739
  }))
9746
9740
 
9741
+ this.app.get("/tasker", ex(async (req, res) => {
9742
+ res.render("tasker", {
9743
+ theme: this.theme,
9744
+ agent: req.agent,
9745
+ })
9746
+ }))
9747
+
9747
9748
  this.app.get("/tasks/new", ex(async (req, res) => {
9748
9749
  const sourceWorkspace = normalizeTaskBuilderSourceWorkspace(req.query.sourceWorkspaceCwd)
9749
9750
  const lockTargetSelection = req.query.lockTarget === "1" || req.query.lockTarget === "true"
@@ -13415,9 +13416,21 @@ class Server {
13415
13416
  // }
13416
13417
  // }))
13417
13418
  this.app.post("/env", ex(async (req, res) => {
13418
- let fullpath = path.resolve(this.kernel.homedir, req.body.filepath, "ENVIRONMENT")
13419
+ const requestFilepath = typeof req.body.filepath === "string" ? req.body.filepath : ""
13420
+ const requestRoot = path.resolve(this.kernel.homedir, requestFilepath)
13421
+ let fullpath = path.resolve(requestRoot, "ENVIRONMENT")
13422
+ if (!(await this.kernel.exists(fullpath))) {
13423
+ const normalizedFilepath = requestFilepath.replace(/\\/g, "/")
13424
+ const filepathParts = normalizedFilepath.split("/").filter(Boolean)
13425
+ if (filepathParts[0] === "api" && filepathParts[1] && filepathParts.length === 2) {
13426
+ const launcher = await this.kernel.api.launcher(filepathParts[1])
13427
+ if (launcher && launcher.launcher_root) {
13428
+ fullpath = path.resolve(launcher.root, launcher.launcher_root, "ENVIRONMENT")
13429
+ }
13430
+ }
13431
+ }
13419
13432
  let updated = req.body.vals
13420
- let hosts = req.body.hosts
13433
+ let hosts = req.body.hosts || {}
13421
13434
  await Util.update_env(fullpath, updated)
13422
13435
  const normalizedFilepath = typeof req.body.filepath === "string"
13423
13436
  ? req.body.filepath.replace(/\\/g, "/")
@@ -13570,14 +13583,15 @@ class Server {
13570
13583
  this.app.get("/pre/api/:name", ex(async (req, res) => {
13571
13584
  let launcher = await this.kernel.api.launcher(req.params.name)
13572
13585
  let config = launcher.script
13573
- if (config && config.pre) {
13574
- config.pre.forEach((item) => {
13575
- if (item.icon) {
13586
+ if (config && Array.isArray(config.pre)) {
13587
+ const items = config.pre.filter((item) => item && typeof item === "object")
13588
+ items.forEach((item) => {
13589
+ if (typeof item.icon === "string" && item.icon) {
13576
13590
  item.icon = `/api/${req.params.name}/${item.icon}?raw=true`
13577
13591
  } else {
13578
13592
  item.icon = "/pinokio-black.png"
13579
13593
  }
13580
- if (!item.href.startsWith("http")) {
13594
+ if (typeof item.href === "string" && item.href && !item.href.startsWith("http")) {
13581
13595
  item.href = path.resolve(this.kernel.homedir, "api", req.params.name, item.href)
13582
13596
  }
13583
13597
  })
@@ -13588,7 +13602,7 @@ class Server {
13588
13602
  theme: this.theme,
13589
13603
  agent: req.agent,
13590
13604
  name: req.params.name,
13591
- items: config.pre,
13605
+ items,
13592
13606
  env
13593
13607
  })
13594
13608
  } else {
@@ -14074,7 +14088,7 @@ class Server {
14074
14088
  mode = "launch_type.desktop"
14075
14089
  } else if (launchType === "terminal") {
14076
14090
  mode = "launch_type.terminal"
14077
- } else {
14091
+ } else if (Array.isArray(item.run)) {
14078
14092
  for(let step of item.run) {
14079
14093
  if (step.method === "exec") {
14080
14094
  mode = "exec"
@@ -14089,6 +14103,8 @@ class Server {
14089
14103
  break
14090
14104
  }
14091
14105
  }
14106
+ } else if (typeof item.run === "function") {
14107
+ mode = "shell"
14092
14108
  }
14093
14109
  if (mode === "launch_type.desktop" || mode === "exec" || mode === "launch") {
14094
14110
  item.type = "Open"
@@ -14141,6 +14157,20 @@ class Server {
14141
14157
  spec = await fs.promises.readFile(path.resolve(filepath, "SPEC.md"), "utf8")
14142
14158
  } catch (e) {
14143
14159
  }
14160
+ const registryEnabled = await this.isRegistryEnabled().catch(() => false)
14161
+ let registry_parent_url = ""
14162
+ if (registryEnabled) {
14163
+ try {
14164
+ registry_parent_url = await git.getConfig({
14165
+ fs,
14166
+ http,
14167
+ dir: filepath,
14168
+ path: "remote.origin.url"
14169
+ }) || ""
14170
+ } catch (_) {
14171
+ registry_parent_url = ""
14172
+ }
14173
+ }
14144
14174
  res.render("d", {
14145
14175
  filepath,
14146
14176
  spec,
@@ -14151,6 +14181,7 @@ class Server {
14151
14181
  install: this.install,
14152
14182
  agent: req.agent,
14153
14183
  theme: this.theme,
14184
+ registry_parent_url,
14154
14185
  //dynamic: plugin_menu
14155
14186
  dynamic,
14156
14187
  })
@@ -147,9 +147,9 @@ function createContentValidationService({ kernel }) {
147
147
  absolutePath,
148
148
  dir: pluginDir,
149
149
  config,
150
- hasInstall: Array.isArray(config && config.install),
151
- hasUpdate: Array.isArray(config && config.update),
152
- hasUninstall: Array.isArray(config && config.uninstall),
150
+ hasInstall: PluginSources.isAction(config && config.install),
151
+ hasUpdate: PluginSources.isAction(config && config.update),
152
+ hasUninstall: PluginSources.isAction(config && config.uninstall),
153
153
  image: null,
154
154
  }
155
155
 
@@ -252,21 +252,32 @@ function createContentValidationService({ kernel }) {
252
252
  { file: absolutePath }
253
253
  ))
254
254
  } else {
255
- if (!Array.isArray(config.run)) {
255
+ if (!PluginSources.isAction(config.run)) {
256
256
  errors.push(buildError(
257
- "Plugins must define a top-level run array.",
258
- "Add `run: [...]` to pinokio.js.",
257
+ "Plugins must define a top-level run array or function.",
258
+ "Add `run: [...]` or `run: async (ctx) => [...]` to pinokio.js.",
259
259
  { file: absolutePath }
260
260
  ))
261
261
  }
262
- const topLevelFunctionKeys = Object.keys(config).filter((key) => typeof config[key] === "function")
262
+ const topLevelFunctionKeys = Object.keys(config).filter((key) => {
263
+ return typeof config[key] === "function" && !PluginSources.ACTION_KEYS.has(key)
264
+ })
263
265
  if (topLevelFunctionKeys.length > 0) {
264
266
  errors.push(buildError(
265
267
  `Top-level function fields are not supported: ${topLevelFunctionKeys.join(", ")}.`,
266
- "Move those functions out of pinokio.js or replace them with data.",
268
+ "Only action fields such as run, install, uninstall, and update may be functions.",
267
269
  { file: absolutePath }
268
270
  ))
269
271
  }
272
+ for (const key of PluginSources.ACTION_KEYS) {
273
+ if (key in config && !PluginSources.isAction(config[key])) {
274
+ errors.push(buildError(
275
+ `Plugin action ${key} must be an array or function.`,
276
+ `Set ${key} to an array or async function returning an array.`,
277
+ { file: absolutePath }
278
+ ))
279
+ }
280
+ }
270
281
  if (normalizedPath.startsWith("/plugin/")) {
271
282
  const declaredPath = typeof config.path === "string" ? config.path.trim() : ""
272
283
  if (declaredPath !== "plugin") {