pinokiod 7.2.8 → 7.2.9

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.
@@ -4,6 +4,11 @@ const path = require("path")
4
4
  const axios = require("axios")
5
5
  const FormData = require("form-data")
6
6
  const mime = require("mime-types")
7
+ const {
8
+ describeMediaRefs,
9
+ extractTitleAndBody,
10
+ normalizeTitle
11
+ } = require("./parser")
7
12
 
8
13
  const DEFAULT_MAX_FILES = 10
9
14
  const DEFAULT_MAX_FILE_BYTES = 25 * 1024 * 1024
@@ -77,7 +82,7 @@ function renderImportLauncher(res, { authorizeUrl, draftId, registryOrigin, auto
77
82
  }
78
83
 
79
84
  function openRegistry() {
80
- registryWindow = window.open(authorizeUrl, "pinokioRegistryDraftImport", "popup,width=760,height=820");
85
+ registryWindow = window.open(authorizeUrl, "_blank");
81
86
  if (!registryWindow) {
82
87
  setStatus("The registry window was blocked. Click Open registry to continue.");
83
88
  } else {
@@ -145,65 +150,6 @@ function requestOrigin(req) {
145
150
  return `${req.protocol || "http"}://${host}`
146
151
  }
147
152
 
148
- function isExternalRef(value) {
149
- return /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(value)
150
- }
151
-
152
- function normalizeMarkdownRef(value) {
153
- const raw = String(value || "").trim().replace(/^<|>$/g, "")
154
- if (!raw || raw.includes("\0") || isExternalRef(raw) || path.isAbsolute(raw)) {
155
- return ""
156
- }
157
- const withoutHash = raw.split("#")[0]
158
- const withoutQuery = withoutHash.split("?")[0]
159
- if (!withoutQuery) {
160
- return ""
161
- }
162
- let decoded = withoutQuery
163
- try {
164
- decoded = decodeURIComponent(withoutQuery)
165
- } catch (_) {
166
- }
167
- const normalized = path.posix.normalize(decoded.replace(/\\/g, "/"))
168
- if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
169
- return ""
170
- }
171
- return normalized
172
- }
173
-
174
- function collectMarkdownRefs(markdown) {
175
- const refs = []
176
- const seen = new Set()
177
- const patterns = [
178
- /!\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g,
179
- /\[(?:video|audio|media|image|screenshot|file|asset)[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/gi,
180
- /\[[^\]]+]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g
181
- ]
182
- for (const pattern of patterns) {
183
- let match = null
184
- while ((match = pattern.exec(markdown))) {
185
- const ref = normalizeMarkdownRef(match[1])
186
- if (!ref || seen.has(ref)) continue
187
- seen.add(ref)
188
- refs.push(ref)
189
- }
190
- }
191
- return refs
192
- }
193
-
194
- function extractTitleAndBody(markdown, fallbackTitle) {
195
- const lines = String(markdown || "").split(/\r?\n/)
196
- for (let i = 0; i < lines.length; i += 1) {
197
- const match = lines[i].match(/^#\s+(.+?)\s*#*\s*$/)
198
- if (!match || !match[1]) continue
199
- const title = match[1].replace(/\s+/g, " ").trim().slice(0, 160)
200
- const bodyLines = [...lines.slice(0, i), ...lines.slice(i + 1)]
201
- while (bodyLines.length && !bodyLines[0].trim()) bodyLines.shift()
202
- return { title: title || fallbackTitle, body: bodyLines.join("\n").trim() }
203
- }
204
- return { title: fallbackTitle, body: String(markdown || "").trim() }
205
- }
206
-
207
153
  async function findDraftById(drafts, id) {
208
154
  const normalized = String(id || "").trim()
209
155
  if (!normalized) return null
@@ -211,32 +157,40 @@ async function findDraftById(drafts, id) {
211
157
  return (items || []).find((item) => item && item.id === normalized) || null
212
158
  }
213
159
 
214
- async function describeMedia(markdown, baseDir) {
215
- const refs = collectMarkdownRefs(markdown)
216
- const media = []
217
- for (const ref of refs) {
218
- const filePath = path.resolve(baseDir, ref)
219
- const relative = path.relative(baseDir, filePath)
220
- if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) continue
221
- const stats = await fs.promises.stat(filePath).catch(() => null)
222
- if (!stats || !stats.isFile()) {
223
- media.push({ ref, path: filePath, exists: false, bytes: 0 })
224
- continue
225
- }
226
- media.push({ ref, path: filePath, exists: true, bytes: stats.size })
227
- }
228
- return media
160
+ function isRegistryPostPublish(publish) {
161
+ if (!publish || typeof publish !== "object") return false
162
+ const target = String(publish.target || "").trim().toLowerCase()
163
+ const type = String(publish.type || "post").trim().toLowerCase()
164
+ return target === "registry" && type === "post"
165
+ }
166
+
167
+ function normalizeParent(parent) {
168
+ if (!parent || typeof parent !== "object" || Array.isArray(parent)) return null
169
+ const type = String(parent.type || "").trim().toLowerCase()
170
+ const url = String(parent.url || parent.repoUrl || "").trim()
171
+ if (type !== "app" || !url) return null
172
+ return { type: "app", url }
229
173
  }
230
174
 
231
175
  async function buildDraftBundle(item, query = {}) {
232
176
  const markdown = await fs.promises.readFile(item.postPath, "utf8")
233
177
  const resultDir = path.dirname(item.postPath)
234
178
  const titleFallback = item.title || (item.workspaceName ? `Draft for ${item.workspaceName}` : "Draft")
235
- const { title, body } = extractTitleAndBody(markdown, titleFallback)
236
- const media = await describeMedia(markdown, resultDir)
179
+ const extracted = extractTitleAndBody(markdown, titleFallback)
180
+ const metadataTitle = item.metadata && typeof item.metadata.title === "string"
181
+ ? normalizeTitle(item.metadata.title)
182
+ : ""
183
+ const title = metadataTitle || extracted.title
184
+ const body = metadataTitle && extracted.title && normalizeTitle(extracted.title) === metadataTitle
185
+ ? extracted.body
186
+ : (!metadataTitle ? extracted.body : String(markdown || "").trim())
187
+ const publish = item.publish && typeof item.publish === "object" ? item.publish : null
188
+ const media = await describeMediaRefs(markdown, resultDir, { mediaOnly: false })
237
189
  return {
238
190
  title,
239
191
  body,
192
+ publish,
193
+ parent: normalizeParent(publish && publish.parent),
240
194
  appSlug: String(query.app || "").trim(),
241
195
  media
242
196
  }
@@ -248,6 +202,9 @@ function preflightBundle(bundle, options = {}) {
248
202
  if (!bundle.title) {
249
203
  return "Draft title is missing."
250
204
  }
205
+ if (!isRegistryPostPublish(bundle.publish)) {
206
+ return "This draft is not configured for registry publishing."
207
+ }
251
208
  if (bundle.media.length > maxFiles) {
252
209
  return `Draft has ${bundle.media.length} media files. The registry limit is ${maxFiles}.`
253
210
  }
@@ -268,6 +225,7 @@ async function uploadBundle(registryBase, token, bundle) {
268
225
  title: bundle.title,
269
226
  body: bundle.body,
270
227
  app: bundle.appSlug || "",
228
+ parent: bundle.parent || null,
271
229
  media: bundle.media.map((item) => ({ path: item.ref }))
272
230
  })
273
231
  form.append("metadata_b64", Buffer.from(metadata, "utf8").toString("base64"))
@@ -0,0 +1,68 @@
1
+ const express = require("express")
2
+ const path = require("path")
3
+ const registerDraftImportRoutes = require("./registry_import")
4
+
5
+ function asyncHandler(fn) {
6
+ return (req, res, next) => {
7
+ Promise.resolve(fn(req, res, next)).catch(next)
8
+ }
9
+ }
10
+
11
+ function registerDraftRoutes(app, options = {}) {
12
+ const drafts = options.drafts
13
+ if (!drafts) {
14
+ throw new Error("drafts is required")
15
+ }
16
+
17
+ const router = express.Router()
18
+ router.get("/drafts", asyncHandler(async (req, res) => {
19
+ const cwd = typeof req.query.cwd === "string" ? req.query.cwd : ""
20
+ const items = await drafts.listPending({ cwd })
21
+ res.json({
22
+ ok: true,
23
+ items
24
+ })
25
+ }))
26
+
27
+ router.post("/drafts/:id/dismiss", asyncHandler(async (req, res) => {
28
+ const ok = await drafts.dismiss(req.params.id, req.body && req.body.revision)
29
+ res.json({ ok })
30
+ }))
31
+
32
+ router.get("/drafts/:id/media/:index", asyncHandler(async (req, res) => {
33
+ const item = typeof drafts.getPendingById === "function"
34
+ ? await drafts.getPendingById(req.params.id)
35
+ : null
36
+ if (!item) {
37
+ res.status(404).send("Draft not found")
38
+ return
39
+ }
40
+ const index = Number(req.params.index)
41
+ const media = Array.isArray(item.media) && Number.isInteger(index)
42
+ ? item.media[index]
43
+ : null
44
+ if (!media || !media.exists || !media.path) {
45
+ res.status(404).send("Media not found")
46
+ return
47
+ }
48
+ const filePath = path.resolve(media.path)
49
+ const basePath = path.resolve(item.resultDir)
50
+ const relative = path.relative(basePath, filePath)
51
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
52
+ res.status(403).send("Media path is outside the draft")
53
+ return
54
+ }
55
+ res.setHeader("Cache-Control", "no-store")
56
+ res.sendFile(filePath)
57
+ }))
58
+
59
+ router.get("/drafts.js", (req, res) => {
60
+ res.setHeader("Cache-Control", "no-store")
61
+ res.sendFile(path.resolve(__dirname, "public", "drafts.js"))
62
+ })
63
+
64
+ app.use(router)
65
+ registerDraftImportRoutes(app, options)
66
+ }
67
+
68
+ module.exports = registerDraftRoutes
@@ -0,0 +1,261 @@
1
+ const fs = require("fs")
2
+ const path = require("path")
3
+ const crypto = require("crypto")
4
+ const {
5
+ RESULT_RELATIVE_DIR,
6
+ POST_FILENAME,
7
+ DEFAULT_READY_FILENAME,
8
+ buildExcerpt,
9
+ describeMediaRefs,
10
+ extractTitle,
11
+ parseDraftMetadata
12
+ } = require("./parser")
13
+
14
+ const STATE_FILENAME = "drafts.json"
15
+ const MAX_PREVIEW_BYTES = 256 * 1024
16
+
17
+ function createHash(value) {
18
+ return crypto.createHash("sha256").update(String(value)).digest("hex").slice(0, 24)
19
+ }
20
+
21
+ function dismissalKey(id, revision) {
22
+ const normalizedId = typeof id === "string" ? id.trim() : ""
23
+ const normalizedRevision = typeof revision === "string" ? revision.trim() : ""
24
+ return normalizedRevision ? `${normalizedId}:${normalizedRevision}` : normalizedId
25
+ }
26
+
27
+ function clonePlainObject(value) {
28
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
29
+ return null
30
+ }
31
+ try {
32
+ return JSON.parse(JSON.stringify(value))
33
+ } catch (_) {
34
+ return null
35
+ }
36
+ }
37
+
38
+ function normalizeRelativePath(value, fallback) {
39
+ const raw = String(value || fallback || "").trim().replace(/\\/g, "/")
40
+ if (raw.startsWith("/") || /^[a-zA-Z]:/.test(raw)) {
41
+ return fallback
42
+ }
43
+ const normalized = path.posix.normalize(raw)
44
+ if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
45
+ return fallback
46
+ }
47
+ return normalized
48
+ }
49
+
50
+ function normalizeDraftConfig(config = {}) {
51
+ const params = config && typeof config === "object" ? config : {}
52
+ return {
53
+ path: normalizeRelativePath(params.path, RESULT_RELATIVE_DIR),
54
+ content: normalizeRelativePath(params.content || params.post, POST_FILENAME),
55
+ ready: normalizeRelativePath(params.ready, DEFAULT_READY_FILENAME),
56
+ description: typeof params.description === "string" ? params.description.trim() : "",
57
+ publish: clonePlainObject(params.publish)
58
+ }
59
+ }
60
+
61
+ function createDraftService({ kernel, taskWorkspaceLinks } = {}) {
62
+ if (!kernel) {
63
+ throw new Error("kernel is required")
64
+ }
65
+
66
+ const statePath = () => path.resolve(kernel.path("tasks"), STATE_FILENAME)
67
+ const resultsByWorkspace = new Map()
68
+ const dismissedIds = new Set()
69
+ let started = false
70
+ let stateLoaded = false
71
+
72
+ async function ensureStateLoaded() {
73
+ if (stateLoaded) return
74
+ stateLoaded = true
75
+ try {
76
+ const raw = await fs.promises.readFile(statePath(), "utf8")
77
+ const parsed = JSON.parse(raw)
78
+ if (parsed && Array.isArray(parsed.dismissed)) {
79
+ parsed.dismissed.forEach((id) => {
80
+ if (typeof id === "string" && id.trim()) {
81
+ dismissedIds.add(id.trim())
82
+ }
83
+ })
84
+ }
85
+ } catch (_) {
86
+ }
87
+ }
88
+
89
+ async function saveState() {
90
+ await fs.promises.mkdir(path.dirname(statePath()), { recursive: true })
91
+ const payload = {
92
+ version: 1,
93
+ dismissed: Array.from(dismissedIds).slice(-500)
94
+ }
95
+ await fs.promises.writeFile(statePath(), JSON.stringify(payload, null, 2))
96
+ }
97
+
98
+ async function readMarkdownPreview(postPath) {
99
+ const handle = await fs.promises.open(postPath, "r")
100
+ try {
101
+ const buffer = Buffer.alloc(MAX_PREVIEW_BYTES)
102
+ const read = await handle.read(buffer, 0, MAX_PREVIEW_BYTES, 0)
103
+ return buffer.slice(0, read.bytesRead).toString("utf8")
104
+ } finally {
105
+ await handle.close().catch(() => {})
106
+ }
107
+ }
108
+
109
+ async function readDraftMetadata(metadataPath) {
110
+ if (path.extname(metadataPath).toLowerCase() !== ".json") {
111
+ return {}
112
+ }
113
+ const raw = await fs.promises.readFile(metadataPath, "utf8")
114
+ return parseDraftMetadata(raw)
115
+ }
116
+
117
+ async function inspectWorkspace({ taskId, ref, cwd, draft } = {}) {
118
+ await ensureStateLoaded()
119
+ if (typeof cwd !== "string" || !cwd.trim()) {
120
+ return null
121
+ }
122
+ const workspacePath = path.resolve(cwd.trim())
123
+ const draftConfig = normalizeDraftConfig(draft)
124
+ const resultDir = path.resolve(workspacePath, draftConfig.path)
125
+ const readyPath = path.resolve(resultDir, draftConfig.ready)
126
+ const postPath = path.resolve(resultDir, draftConfig.content)
127
+ const readyStats = await fs.promises.stat(readyPath).catch(() => null)
128
+ const postStats = await fs.promises.stat(postPath).catch(() => null)
129
+ if (!readyStats || !readyStats.isFile() || !postStats || !postStats.isFile()) {
130
+ resultsByWorkspace.delete(workspacePath)
131
+ return null
132
+ }
133
+ let metadata = {}
134
+ try {
135
+ metadata = await readDraftMetadata(readyPath)
136
+ } catch (_) {
137
+ resultsByWorkspace.delete(workspacePath)
138
+ return null
139
+ }
140
+
141
+ const markdown = await readMarkdownPreview(postPath)
142
+ const workspaceName = path.basename(workspacePath)
143
+ const media = await describeMediaRefs(markdown, resultDir)
144
+ const updatedAtMs = Math.max(readyStats.mtimeMs || 0, postStats.mtimeMs || 0)
145
+ const id = createHash(`${workspacePath}|${resultDir}|${postPath}|${readyPath}`)
146
+ const mediaRevision = media
147
+ .map((item) => `${item.ref}:${item.exists ? item.bytes : "missing"}:${item.mtimeMs || 0}`)
148
+ .join("|")
149
+ const revision = createHash(`${postStats.size}|${postStats.mtimeMs}|${readyStats.size}|${readyStats.mtimeMs}|${mediaRevision}`)
150
+ const result = {
151
+ id,
152
+ revision,
153
+ taskId,
154
+ ref,
155
+ cwd: workspacePath,
156
+ workspaceName,
157
+ title: metadata.title || extractTitle(markdown, workspaceName),
158
+ markdown,
159
+ excerpt: buildExcerpt(markdown),
160
+ resultDir,
161
+ postPath,
162
+ contentPath: postPath,
163
+ readyPath,
164
+ metadataPath: readyPath,
165
+ metadata,
166
+ publish: draftConfig.publish,
167
+ description: draftConfig.description,
168
+ postBytes: postStats.size,
169
+ media: media.map((item, index) => ({
170
+ index,
171
+ ref: item.ref,
172
+ path: item.path,
173
+ bytes: item.bytes,
174
+ mtimeMs: item.mtimeMs,
175
+ exists: item.exists,
176
+ ext: path.extname(item.ref || "").toLowerCase()
177
+ })),
178
+ mediaCount: media.length,
179
+ missingMediaCount: media.filter((item) => !item.exists).length,
180
+ mediaBytes: media.reduce((total, item) => total + (Number.isFinite(item.bytes) ? item.bytes : 0), 0),
181
+ updatedAt: new Date(updatedAtMs || Date.now()).toISOString()
182
+ }
183
+ resultsByWorkspace.set(workspacePath, result)
184
+ return result
185
+ }
186
+
187
+ async function trackWorkspace({ taskId, ref, cwd } = {}) {
188
+ let resolvedCwd = cwd
189
+ if (!resolvedCwd && ref && taskWorkspaceLinks && typeof taskWorkspaceLinks.resolveWorkspaceRef === "function") {
190
+ resolvedCwd = taskWorkspaceLinks.resolveWorkspaceRef(ref)
191
+ }
192
+ if (!resolvedCwd) {
193
+ return null
194
+ }
195
+ return inspectWorkspace({ taskId, ref, cwd: resolvedCwd })
196
+ }
197
+
198
+ async function listPending(options = {}) {
199
+ await ensureStateLoaded()
200
+ const filterCwd = typeof options.cwd === "string" && options.cwd.trim()
201
+ ? path.resolve(options.cwd.trim())
202
+ : ""
203
+ return Array.from(resultsByWorkspace.values())
204
+ .filter((result) => !dismissedIds.has(dismissalKey(result.id, result.revision)) && !dismissedIds.has(result.id))
205
+ .filter((result) => !filterCwd || result.cwd === filterCwd)
206
+ .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
207
+ }
208
+
209
+ async function getPendingById(id) {
210
+ await ensureStateLoaded()
211
+ const normalizedId = typeof id === "string" ? id.trim() : ""
212
+ if (!normalizedId) {
213
+ return null
214
+ }
215
+ const result = Array.from(resultsByWorkspace.values()).find((item) => item.id === normalizedId) || null
216
+ if (!result || dismissedIds.has(dismissalKey(result.id, result.revision)) || dismissedIds.has(result.id)) {
217
+ return null
218
+ }
219
+ return result
220
+ }
221
+
222
+ async function dismiss(id, revision) {
223
+ await ensureStateLoaded()
224
+ const normalizedId = typeof id === "string" ? id.trim() : ""
225
+ if (!normalizedId) {
226
+ return false
227
+ }
228
+ dismissedIds.add(dismissalKey(normalizedId, revision))
229
+ await saveState()
230
+ return true
231
+ }
232
+
233
+ async function refreshLinkedWorkspaces() {
234
+ await ensureStateLoaded()
235
+ }
236
+
237
+ async function start() {
238
+ if (started) return
239
+ started = true
240
+ await ensureStateLoaded()
241
+ }
242
+
243
+ async function stop() {
244
+ }
245
+
246
+ return {
247
+ RESULT_RELATIVE_DIR,
248
+ dismiss,
249
+ getPendingById,
250
+ inspectWorkspace,
251
+ listPending,
252
+ refreshLinkedWorkspaces,
253
+ start,
254
+ stop,
255
+ trackWorkspace
256
+ }
257
+ }
258
+
259
+ module.exports = {
260
+ createDraftService
261
+ }
@@ -0,0 +1,76 @@
1
+ const path = require("path")
2
+
3
+ function isInside(candidate, parent) {
4
+ const relative = path.relative(parent, candidate)
5
+ return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative))
6
+ }
7
+
8
+ class DraftWatcher {
9
+ constructor(options = {}) {
10
+ this.drafts = options.drafts
11
+ }
12
+
13
+ async ready(ctx, params = {}) {
14
+ if (!this.drafts || typeof this.drafts.inspectWorkspace !== "function") {
15
+ throw new Error("draft service is unavailable")
16
+ }
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,
23
+ description: params.description,
24
+ publish: params.publish
25
+ }
26
+ let timer = null
27
+ let disposed = false
28
+
29
+ const inspect = async () => {
30
+ if (disposed) return
31
+ await this.drafts.inspectWorkspace({ cwd: ctx.cwd, draft: draftConfig })
32
+ }
33
+
34
+ const scheduleInspect = () => {
35
+ if (disposed) return
36
+ if (timer) clearTimeout(timer)
37
+ timer = setTimeout(() => {
38
+ timer = null
39
+ inspect().catch((error) => {
40
+ console.warn("[drafts] failed to inspect workspace", error && error.message ? error.message : error)
41
+ })
42
+ }, 250)
43
+ }
44
+
45
+ await inspect()
46
+ const stopPoll = ctx.poll(params.interval || 1500, inspect, {
47
+ immediate: false,
48
+ onError: (error) => {
49
+ console.warn("[drafts] poll failed", error && error.message ? error.message : error)
50
+ }
51
+ })
52
+ const unsubscribe = await ctx.watch.fs(ctx.cwd, (events) => {
53
+ if (!Array.isArray(events)) return
54
+ if (events.some((event) => event && event.path && isInside(path.resolve(event.path), draftDir))) {
55
+ scheduleInspect()
56
+ }
57
+ })
58
+
59
+ return async () => {
60
+ if (timer) {
61
+ clearTimeout(timer)
62
+ timer = null
63
+ }
64
+ await this.drafts.inspectWorkspace({ cwd: ctx.cwd, draft: draftConfig }).catch(() => {})
65
+ disposed = true
66
+ if (typeof stopPoll === "function") {
67
+ await stopPoll()
68
+ }
69
+ if (typeof unsubscribe === "function") {
70
+ await unsubscribe()
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ module.exports = DraftWatcher
@@ -0,0 +1,13 @@
1
+ const { createDraftFeature } = require("./drafts")
2
+
3
+ async function mountFeatures(options = {}) {
4
+ const drafts = createDraftFeature(options)
5
+ await drafts.start()
6
+ return {
7
+ drafts
8
+ }
9
+ }
10
+
11
+ module.exports = {
12
+ mountFeatures
13
+ }