@cyber-dash-tech/revela 0.18.8 → 0.18.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **English** | [中文](README.zh-CN.md)
4
4
 
5
- [![npm version](https://img.shields.io/npm/v/@cyber-dash-tech/revela)](https://www.npmjs.com/package/@cyber-dash-tech/revela) [![license](https://img.shields.io/npm/l/@cyber-dash-tech/revela)](LICENSE) [![tests](https://img.shields.io/badge/tests-722%20passing-brightgreen)](tests/) [![OpenCode plugin](https://img.shields.io/badge/OpenCode-plugin-blue)](https://opencode.ai) [![Bun](https://img.shields.io/badge/Bun-%E2%89%A51.0-orange)](https://bun.sh)
5
+ [![npm version](https://img.shields.io/npm/v/@cyber-dash-tech/revela)](https://www.npmjs.com/package/@cyber-dash-tech/revela) [![license](https://img.shields.io/npm/l/@cyber-dash-tech/revela)](LICENSE) [![tests](https://img.shields.io/badge/tests-726%20passing-brightgreen)](tests/) [![OpenCode plugin](https://img.shields.io/badge/OpenCode-plugin-blue)](https://opencode.ai) [![Bun](https://img.shields.io/badge/Bun-%E2%89%A51.0-orange)](https://bun.sh)
6
6
 
7
7
  <p align="center">
8
8
  <img src="assets/img/logo.png" alt="Revela" width="560" />
@@ -34,7 +34,7 @@ To install globally, add the same entry to `~/.config/opencode/opencode.json`.
34
34
  Requirements:
35
35
 
36
36
  - The Codex CLI must be installed and the `codex` command must be available in your shell.
37
- - Your environment must be able to run `npx`; Revela uses `npx -y @cyber-dash-tech/revela@0.18.8 mcp` to start the MCP server.
37
+ - Your environment must be able to run `npx`; Revela uses `npx -y @cyber-dash-tech/revela@0.18.10 mcp` to start the MCP server.
38
38
  - For interactive Review Apply actions, `codex exec` must also work because the Review UI uses it after saved comments are applied.
39
39
 
40
40
  Optional preflight:
@@ -55,11 +55,11 @@ npm_config_cache=/tmp/revela-npm-cache bun run smoke:mcp-pack
55
55
  Install Revela through the Codex Git marketplace:
56
56
 
57
57
  ```bash
58
- codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.18.8
58
+ codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.18.10
59
59
  codex plugin add revela@revela
60
60
  ```
61
61
 
62
- The Git marketplace install provides the Codex plugin shell, skills, hooks, and MCP configuration. When Codex starts the Revela MCP server for the first time, it runs `npx -y @cyber-dash-tech/revela@0.18.8 mcp` so npm can fetch the published package and its dependencies.
62
+ The Git marketplace install provides the Codex plugin shell, skills, hooks, and MCP configuration. When Codex starts the Revela MCP server for the first time, it runs `npx -y @cyber-dash-tech/revela@0.18.10 mcp` so npm can fetch the published package and its dependencies.
63
63
 
64
64
  You do not need to run `bun install` inside the Codex marketplace clone.
65
65
 
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | **中文**
4
4
 
5
- [![npm version](https://img.shields.io/npm/v/@cyber-dash-tech/revela)](https://www.npmjs.com/package/@cyber-dash-tech/revela) [![license](https://img.shields.io/npm/l/@cyber-dash-tech/revela)](LICENSE) [![tests](https://img.shields.io/badge/tests-722%20passing-brightgreen)](tests/) [![OpenCode plugin](https://img.shields.io/badge/OpenCode-plugin-blue)](https://opencode.ai) [![Bun](https://img.shields.io/badge/Bun-%E2%89%A51.0-orange)](https://bun.sh)
5
+ [![npm version](https://img.shields.io/npm/v/@cyber-dash-tech/revela)](https://www.npmjs.com/package/@cyber-dash-tech/revela) [![license](https://img.shields.io/npm/l/@cyber-dash-tech/revela)](LICENSE) [![tests](https://img.shields.io/badge/tests-726%20passing-brightgreen)](tests/) [![OpenCode plugin](https://img.shields.io/badge/OpenCode-plugin-blue)](https://opencode.ai) [![Bun](https://img.shields.io/badge/Bun-%E2%89%A51.0-orange)](https://bun.sh)
6
6
 
7
7
  <p align="center">
8
8
  <img src="assets/img/logo.png" alt="Revela" width="560" />
@@ -34,7 +34,7 @@ Revela 可在 [OpenCode](https://opencode.ai) 和 Codex 中使用,把来源材
34
34
  环境要求:
35
35
 
36
36
  - 需要已安装 Codex CLI,并且 shell 中可以执行 `codex`。
37
- - 环境中需要可以执行 `npx`;Revela 会用 `npx -y @cyber-dash-tech/revela@0.18.8 mcp` 启动 MCP server。
37
+ - 环境中需要可以执行 `npx`;Revela 会用 `npx -y @cyber-dash-tech/revela@0.18.10 mcp` 启动 MCP server。
38
38
  - 如果使用 Review UI 的 Apply,需要 `codex exec` 可用;评论会先保存,点击 Apply 后才执行修复。
39
39
 
40
40
  可选的安装前检查:
@@ -55,11 +55,11 @@ npm_config_cache=/tmp/revela-npm-cache bun run smoke:mcp-pack
55
55
  通过 Codex Git marketplace 安装 Revela:
56
56
 
57
57
  ```bash
58
- codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.18.8
58
+ codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.18.10
59
59
  codex plugin add revela@revela
60
60
  ```
61
61
 
62
- Git marketplace 安装的是 Codex plugin 壳、skills、hooks 和 MCP 配置。Codex 第一次启动 Revela MCP server 时,会运行 `npx -y @cyber-dash-tech/revela@0.18.8 mcp`,由 npm 获取已发布 package 及其 dependencies。
62
+ Git marketplace 安装的是 Codex plugin 壳、skills、hooks 和 MCP 配置。Codex 第一次启动 Revela MCP server 时,会运行 `npx -y @cyber-dash-tech/revela@0.18.10 mcp`,由 npm 获取已发布 package 及其 dependencies。
63
63
 
64
64
  不需要在 Codex marketplace clone 里运行 `bun install`。
65
65
 
package/bin/revela.ts CHANGED
@@ -32,6 +32,8 @@ else {
32
32
  else if (command === "design-use") result = runtime.designActivate(required(options, ["name"]))
33
33
  else if (command === "design-create") result = runtime.designCreate(required(options, ["name", "designMd", "previewHtml"]))
34
34
  else if (command === "design-validate") result = runtime.designValidate(required(options, ["name"]))
35
+ else if (command === "design-pack") result = runtime.designPack(required(options, ["name"]))
36
+ else if (command === "design-install-archive") result = runtime.designInstallArchive(required(options, ["archivePath"]))
35
37
  else if (command === "domain-list") result = runtime.domainList()
36
38
  else if (command === "domain-read") result = runtime.domainRead(options)
37
39
  else if (command === "domain-use") result = runtime.domainActivate(required(options, ["name"]))
@@ -0,0 +1,167 @@
1
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs"
2
+ import { basename, dirname, join, relative, resolve, sep } from "path"
3
+ import { gunzipSync, gzipSync } from "zlib"
4
+
5
+ export interface TarEntry {
6
+ path: string
7
+ bytes: Buffer
8
+ mode?: number
9
+ mtime?: number
10
+ }
11
+
12
+ const BLOCK_SIZE = 512
13
+
14
+ export function writeTarArchive(entries: TarEntry[], targetPath: string, gzip: boolean): void {
15
+ const chunks: Buffer[] = []
16
+ for (const entry of entries) {
17
+ const normalized = safeArchivePath(entry.path)
18
+ const bytes = entry.bytes
19
+ const header = Buffer.alloc(BLOCK_SIZE, 0)
20
+ writeString(header, 0, 100, normalized)
21
+ writeOctal(header, 100, 8, entry.mode ?? 0o644)
22
+ writeOctal(header, 108, 8, 0)
23
+ writeOctal(header, 116, 8, 0)
24
+ writeOctal(header, 124, 12, bytes.byteLength)
25
+ writeOctal(header, 136, 12, Math.floor(entry.mtime ?? Date.now() / 1000))
26
+ header.fill(0x20, 148, 156)
27
+ header[156] = "0".charCodeAt(0)
28
+ writeString(header, 257, 6, "ustar")
29
+ writeString(header, 263, 2, "00")
30
+ const checksum = header.reduce((sum, value) => sum + value, 0)
31
+ writeOctal(header, 148, 8, checksum)
32
+ chunks.push(header, bytes)
33
+ const remainder = bytes.byteLength % BLOCK_SIZE
34
+ if (remainder !== 0) chunks.push(Buffer.alloc(BLOCK_SIZE - remainder, 0))
35
+ }
36
+ chunks.push(Buffer.alloc(BLOCK_SIZE * 2, 0))
37
+ const tar = Buffer.concat(chunks as any)
38
+ mkdirSync(dirname(targetPath), { recursive: true })
39
+ writeFileSync(targetPath, (gzip ? gzipSync(tar as any) : tar) as any)
40
+ }
41
+
42
+ export function readTarArchive(archivePath: string): TarEntry[] {
43
+ const raw = readFileSync(archivePath)
44
+ const bytes = (archivePath.endsWith(".gz") || archivePath.endsWith(".tgz") ? gunzipSync(raw as any) : raw) as Buffer
45
+ const entries: TarEntry[] = []
46
+ let offset = 0
47
+ while (offset + BLOCK_SIZE <= bytes.byteLength) {
48
+ const header = bytes.subarray(offset, offset + BLOCK_SIZE)
49
+ offset += BLOCK_SIZE
50
+ if (header.every((value) => value === 0)) break
51
+ const rawPath = readString(header, 0, 100)
52
+ const size = readOctal(header, 124, 12)
53
+ const type = String.fromCharCode(header[156] || 0)
54
+ if (type === "5") {
55
+ const dirPath = rawPath.replace(/\/+$/, "")
56
+ if (dirPath) safeArchivePath(dirPath)
57
+ offset += paddedSize(size)
58
+ continue
59
+ }
60
+ const path = safeArchivePath(rawPath)
61
+ if (type === "2") throw new Error(`Archive symlinks are not supported: ${path}`)
62
+ if (type !== "0" && type !== "\0" && type !== "") {
63
+ offset += paddedSize(size)
64
+ continue
65
+ }
66
+ if (offset + size > bytes.byteLength) throw new Error(`Archive entry is truncated: ${path}`)
67
+ entries.push({ path, bytes: bytes.subarray(offset, offset + size), mode: readOctal(header, 100, 8), mtime: readOctal(header, 136, 12) })
68
+ offset += paddedSize(size)
69
+ }
70
+ return entries
71
+ }
72
+
73
+ export function collectDirectoryEntries(sourceDir: string, prefix = ""): TarEntry[] {
74
+ const root = resolve(sourceDir)
75
+ const entries: TarEntry[] = []
76
+ walk(root)
77
+ return entries.sort((a, b) => a.path.localeCompare(b.path))
78
+
79
+ function walk(dir: string): void {
80
+ for (const entry of readdirSync(dir).sort()) {
81
+ if (entry === ".DS_Store" || entry.startsWith(".")) continue
82
+ const abs = join(dir, entry)
83
+ const stat = lstatSync(abs)
84
+ if (stat.isSymbolicLink()) throw new Error(`Design archives cannot include symlinks: ${abs}`)
85
+ if (stat.isDirectory()) {
86
+ walk(abs)
87
+ continue
88
+ }
89
+ if (!stat.isFile()) continue
90
+ const rel = relative(root, abs).split(sep).join("/")
91
+ entries.push({
92
+ path: safeArchivePath(prefix ? `${prefix}/${rel}` : rel),
93
+ bytes: readFileSync(abs),
94
+ mode: stat.mode & 0o777,
95
+ mtime: Math.floor(stat.mtimeMs / 1000),
96
+ })
97
+ }
98
+ }
99
+ }
100
+
101
+ export function extractEntriesToDirectory(entries: TarEntry[], targetDir: string): string[] {
102
+ const root = resolve(targetDir)
103
+ if (existsSync(root)) rmSync(root, { recursive: true, force: true })
104
+ mkdirSync(root, { recursive: true })
105
+ const files: string[] = []
106
+ for (const entry of entries) {
107
+ const rel = safeArchivePath(entry.path)
108
+ const target = resolve(root, rel)
109
+ if (target !== root && !target.startsWith(root + sep)) throw new Error(`Archive path escapes target directory: ${entry.path}`)
110
+ mkdirSync(dirname(target), { recursive: true })
111
+ writeFileSync(target, entry.bytes as any)
112
+ files.push(rel)
113
+ }
114
+ return files.sort()
115
+ }
116
+
117
+ export function normalizePackageArchiveEntries(entries: TarEntry[]): TarEntry[] {
118
+ const files = entries.filter((entry) => entry.path && !entry.path.endsWith("/"))
119
+ if (files.some((entry) => basename(entry.path) === "DESIGN.md")) {
120
+ if (files.some((entry) => entry.path === "DESIGN.md")) return files
121
+ const top = commonTopLevel(files)
122
+ if (top && files.some((entry) => entry.path === `${top}/DESIGN.md`)) {
123
+ return files.map((entry) => ({ ...entry, path: entry.path.slice(top.length + 1) }))
124
+ }
125
+ }
126
+ throw new Error("No DESIGN.md found in design archive")
127
+ }
128
+
129
+ function commonTopLevel(entries: TarEntry[]): string | null {
130
+ const first = entries[0]?.path.split("/")[0]
131
+ if (!first || first === entries[0]?.path) return null
132
+ return entries.every((entry) => entry.path.startsWith(`${first}/`)) ? first : null
133
+ }
134
+
135
+ function safeArchivePath(input: string): string {
136
+ const normalized = input.replace(/\\/g, "/").replace(/^\.\/+/, "")
137
+ if (!normalized || normalized.startsWith("/") || normalized.includes("\0")) throw new Error(`Invalid archive path: ${input}`)
138
+ const parts = normalized.split("/")
139
+ if (parts.some((part) => !part || part === "." || part === "..")) throw new Error(`Invalid archive path: ${input}`)
140
+ if (Buffer.byteLength(normalized) > 100) throw new Error(`Archive path is too long for v1 tar support: ${normalized}`)
141
+ return normalized
142
+ }
143
+
144
+ function writeString(buffer: Buffer, offset: number, length: number, value: string): void {
145
+ buffer.write(value, offset, Math.min(length, Buffer.byteLength(value)), "utf8")
146
+ }
147
+
148
+ function readString(buffer: Buffer, offset: number, length: number): string {
149
+ const slice = buffer.subarray(offset, offset + length)
150
+ const end = slice.indexOf(0)
151
+ return slice.subarray(0, end === -1 ? length : end).toString("utf8").trim()
152
+ }
153
+
154
+ function writeOctal(buffer: Buffer, offset: number, length: number, value: number): void {
155
+ const text = value.toString(8).padStart(length - 1, "0").slice(0, length - 1)
156
+ buffer.write(text, offset, length - 1, "ascii")
157
+ buffer[offset + length - 1] = 0
158
+ }
159
+
160
+ function readOctal(buffer: Buffer, offset: number, length: number): number {
161
+ const text = readString(buffer, offset, length).replace(/\0/g, "").trim()
162
+ return text ? parseInt(text, 8) : 0
163
+ }
164
+
165
+ function paddedSize(size: number): number {
166
+ return Math.ceil(size / BLOCK_SIZE) * BLOCK_SIZE
167
+ }
@@ -9,6 +9,7 @@
9
9
  import {
10
10
  cpSync,
11
11
  existsSync,
12
+ lstatSync,
12
13
  mkdirSync,
13
14
  readdirSync,
14
15
  readFileSync,
@@ -16,9 +17,10 @@ import {
16
17
  statSync,
17
18
  writeFileSync,
18
19
  } from "fs"
19
- import { dirname, join, resolve, basename } from "path"
20
+ import { dirname, join, resolve, basename, relative, sep } from "path"
20
21
  import { tmpdir } from "os"
21
22
  import { parseFrontmatter } from "../frontmatter"
23
+ import { collectDirectoryEntries, extractEntriesToDirectory, normalizePackageArchiveEntries, readTarArchive, writeTarArchive } from "./archive"
22
24
  import {
23
25
  DESIGNS_DIR,
24
26
  DEFAULT_DESIGN,
@@ -50,6 +52,7 @@ export interface CreateDesignPackageArgs {
50
52
  base?: string
51
53
  designMd: string
52
54
  previewHtml: string
55
+ assets?: DesignPackageAssetInput[]
53
56
  overwrite?: boolean
54
57
  }
55
58
 
@@ -62,6 +65,7 @@ export interface CreateDesignPackageResult {
62
65
  name: string
63
66
  path: string
64
67
  files: string[]
68
+ assets: DesignPackageAssetInfo[]
65
69
  base?: string
66
70
  overwritten: boolean
67
71
  }
@@ -86,9 +90,52 @@ export interface ValidateDesignPackageResult {
86
90
  sections: string[]
87
91
  layouts: string[]
88
92
  components: string[]
93
+ assets: DesignPackageAssetInfo[]
89
94
  errors: string[]
90
95
  }
91
96
 
97
+ export interface DesignPackageAssetInput {
98
+ path: string
99
+ content?: string
100
+ contentBase64?: string
101
+ sourcePath?: string
102
+ }
103
+
104
+ export interface DesignPackageAssetInfo {
105
+ path: string
106
+ kind: "cover-background" | "closing-background" | "background" | "logo" | "asset"
107
+ mimeType: string
108
+ bytes: number
109
+ }
110
+
111
+ export interface PackDesignPackageArgs {
112
+ workspaceRoot?: string
113
+ name: string
114
+ source?: "draft" | "installed"
115
+ outputPath?: string
116
+ format?: "tar.gz" | "tar"
117
+ overwrite?: boolean
118
+ }
119
+
120
+ export interface PackDesignPackageResult {
121
+ ok: true
122
+ name: string
123
+ archivePath: string
124
+ format: "tar.gz" | "tar"
125
+ files: string[]
126
+ assets: DesignPackageAssetInfo[]
127
+ }
128
+
129
+ export interface InstallDesignArchiveArgs {
130
+ archivePath: string
131
+ name?: string
132
+ overwrite?: boolean
133
+ }
134
+
135
+ export interface InstallDesignArchiveResult extends CreateDesignPackageResult {
136
+ archivePath: string
137
+ }
138
+
92
139
  export interface DesignPreviewInfo {
93
140
  name: string
94
141
  designDir: string
@@ -283,6 +330,7 @@ export function createDesignPackage(args: CreateDesignPackageArgs): CreateDesign
283
330
  mkdirSync(target, { recursive: true })
284
331
  writeFileSync(join(target, "DESIGN.md"), `${designMd}\n`, "utf-8")
285
332
  writeFileSync(join(target, "preview.html"), `${previewHtml}\n`, "utf-8")
333
+ writeDesignAssets(target, args.assets)
286
334
 
287
335
  const validation = validateDesignPackage(name)
288
336
  if (!validation.ok) {
@@ -293,7 +341,8 @@ export function createDesignPackage(args: CreateDesignPackageArgs): CreateDesign
293
341
  ok: true,
294
342
  name,
295
343
  path: target,
296
- files: ["DESIGN.md", "preview.html"],
344
+ files: listDesignPackageFiles(target),
345
+ assets: listDesignAssetsInDir(target),
297
346
  base: args.base,
298
347
  overwritten: existed,
299
348
  }
@@ -321,6 +370,7 @@ export function createDesignDraftPackage(args: CreateDesignDraftArgs): CreateDes
321
370
  mkdirSync(target, { recursive: true })
322
371
  writeFileSync(join(target, "DESIGN.md"), `${designMd}\n`, "utf-8")
323
372
  writeFileSync(join(target, "preview.html"), `${previewHtml}\n`, "utf-8")
373
+ writeDesignAssets(target, args.assets)
324
374
 
325
375
  const validation = validateDesignDraftPackage(args.workspaceRoot, name)
326
376
  if (!validation.ok) {
@@ -331,7 +381,8 @@ export function createDesignDraftPackage(args: CreateDesignDraftArgs): CreateDes
331
381
  ok: true,
332
382
  name,
333
383
  path: target,
334
- files: ["DESIGN.md", "preview.html"],
384
+ files: listDesignPackageFiles(target),
385
+ assets: listDesignAssetsInDir(target),
335
386
  base: args.base,
336
387
  overwritten: existed,
337
388
  }
@@ -378,11 +429,74 @@ export function installDesignDraftPackage(args: InstallDesignDraftArgs): Install
378
429
  name,
379
430
  path: target,
380
431
  sourcePath,
381
- files: ["DESIGN.md", "preview.html"],
432
+ files: listDesignPackageFiles(target),
433
+ assets: listDesignAssetsInDir(target),
382
434
  overwritten: existed,
383
435
  }
384
436
  }
385
437
 
438
+ /** Package an installed design or workspace draft as a shareable .tar or .tar.gz archive. */
439
+ export function packDesignPackage(args: PackDesignPackageArgs): PackDesignPackageResult {
440
+ const name = normalizeDesignName(args.name)
441
+ const source = args.source ?? (args.workspaceRoot && designDraftExists(args.workspaceRoot, name) ? "draft" : "installed")
442
+ const sourceDir = source === "draft" ? designDraftDir(args.workspaceRoot || process.cwd(), name) : resolveDesignDir(name)
443
+ if (!sourceDir || !existsSync(sourceDir)) throw new Error(`Design ${source} '${name}' is not available`)
444
+ const validation = source === "draft" ? validateDesignDraftPackage(args.workspaceRoot || process.cwd(), name) : validateDesignPackage(name)
445
+ if (!validation.ok) throw new Error(`Design ${source} is invalid: ${validation.errors.join("; ")}`)
446
+
447
+ const format = args.format ?? "tar.gz"
448
+ const archivePath = resolve(args.outputPath || join(args.workspaceRoot || process.cwd(), ".revela", "design-archives", `${name}.${format}`))
449
+ if (existsSync(archivePath) && !args.overwrite) throw new Error(`Archive already exists: ${archivePath}`)
450
+ const entries = collectDirectoryEntries(sourceDir, name)
451
+ writeTarArchive(entries, archivePath, format === "tar.gz")
452
+ return {
453
+ ok: true,
454
+ name,
455
+ archivePath,
456
+ format,
457
+ files: listDesignPackageFiles(sourceDir),
458
+ assets: listDesignAssetsInDir(sourceDir),
459
+ }
460
+ }
461
+
462
+ /** Install a .tar or .tar.gz design archive into the user-level design registry. */
463
+ export function installDesignArchive(args: InstallDesignArchiveArgs): InstallDesignArchiveResult {
464
+ const archivePath = resolve(args.archivePath)
465
+ if (!existsSync(archivePath)) throw new Error(`Archive does not exist: ${archivePath}`)
466
+ if (!archivePath.endsWith(".tar") && !archivePath.endsWith(".tar.gz") && !archivePath.endsWith(".tgz")) {
467
+ throw new Error("Design archive must be .tar, .tar.gz, or .tgz")
468
+ }
469
+
470
+ const entries = normalizePackageArchiveEntries(readTarArchive(archivePath))
471
+ const tmp = join(tmpdir(), `revela-design-install-${Date.now()}`)
472
+ try {
473
+ extractEntriesToDirectory(entries, tmp)
474
+ const info = parseDesignFile(join(tmp, "DESIGN.md"))
475
+ const name = normalizeDesignName(args.name || info?.name || basename(archivePath).replace(/\.tar\.gz$|\.tgz$|\.tar$/i, ""))
476
+ const validation = validateDesignPackageAt(name, tmp)
477
+ if (!validation.ok) throw new Error(`Design archive is invalid: ${validation.errors.join("; ")}`)
478
+
479
+ const target = join(DESIGNS_DIR, name)
480
+ const existed = existsSync(target)
481
+ if (existed && !args.overwrite) throw new Error(`Design '${name}' already exists. Pass overwrite=true to replace it.`)
482
+
483
+ mkdirSync(DESIGNS_DIR, { recursive: true })
484
+ if (existed) rmSync(target, { recursive: true, force: true })
485
+ cpSync(tmp, target, { recursive: true })
486
+ return {
487
+ ok: true,
488
+ name,
489
+ path: target,
490
+ archivePath,
491
+ files: listDesignPackageFiles(target),
492
+ assets: listDesignAssetsInDir(target),
493
+ overwritten: existed,
494
+ }
495
+ } finally {
496
+ rmSync(tmp, { recursive: true, force: true })
497
+ }
498
+ }
499
+
386
500
  function hasDataAttribute(html: string, attr: string, value: string): boolean {
387
501
  const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
388
502
  return new RegExp(`${attr}\\s*=\\s*(["'])${escaped}\\1`).test(html)
@@ -451,10 +565,12 @@ function validateDesignPackageAt(nameInput: string, dir: string): ValidateDesign
451
565
  let sections: string[] = []
452
566
  let layouts: string[] = []
453
567
  let components: string[] = []
568
+ let assets: DesignPackageAssetInfo[] = []
454
569
 
455
570
  if (!existsSync(dir)) errors.push(`Design directory does not exist: ${dir}`)
456
571
  if (!hasDesignMd) errors.push("DESIGN.md is missing")
457
572
  if (!hasPreview) errors.push("preview.html is missing")
573
+ if (existsSync(dir)) assets = listDesignAssetsInDir(dir)
458
574
 
459
575
  if (hasDesignMd) {
460
576
  const info = parseDesignFile(mdPath)
@@ -501,6 +617,7 @@ function validateDesignPackageAt(nameInput: string, dir: string): ValidateDesign
501
617
  sections,
502
618
  layouts,
503
619
  components,
620
+ assets,
504
621
  errors,
505
622
  }
506
623
  }
@@ -509,6 +626,107 @@ function designDraftDir(workspaceRoot: string, name: string): string {
509
626
  return resolve(workspaceRoot, ".revela", "drafts", "designs", name)
510
627
  }
511
628
 
629
+ function designDraftExists(workspaceRoot: string, name: string): boolean {
630
+ const dir = designDraftDir(workspaceRoot, name)
631
+ return existsSync(dir) && statSync(dir).isDirectory() && existsSync(join(dir, "DESIGN.md"))
632
+ }
633
+
634
+ function writeDesignAssets(targetDir: string, assets?: DesignPackageAssetInput[]): void {
635
+ if (!assets || assets.length === 0) return
636
+ for (const asset of assets) {
637
+ const rel = normalizeAssetPath(asset.path)
638
+ const target = resolve(targetDir, rel)
639
+ if (target !== resolve(targetDir) && !target.startsWith(resolve(targetDir) + sep)) {
640
+ throw new Error(`Asset path escapes design package: ${asset.path}`)
641
+ }
642
+ let bytes: Buffer
643
+ if (asset.contentBase64 !== undefined) bytes = Buffer.from(asset.contentBase64, "base64")
644
+ else if (asset.content !== undefined) bytes = Buffer.from(asset.content, "utf-8")
645
+ else if (asset.sourcePath !== undefined) {
646
+ const source = resolve(asset.sourcePath)
647
+ if (!existsSync(source) || !statSync(source).isFile()) throw new Error(`Asset source file does not exist: ${asset.sourcePath}`)
648
+ bytes = readFileSync(source)
649
+ } else {
650
+ throw new Error(`Asset '${asset.path}' requires content, contentBase64, or sourcePath`)
651
+ }
652
+ mkdirSync(dirname(target), { recursive: true })
653
+ writeFileSync(target, bytes as any)
654
+ }
655
+ }
656
+
657
+ function normalizeAssetPath(pathInput: string): string {
658
+ const normalized = pathInput.replace(/\\/g, "/").replace(/^\.\/+/, "")
659
+ if (!normalized.startsWith("assets/")) throw new Error(`Design asset paths must start with assets/: ${pathInput}`)
660
+ if (normalized.includes("\0") || normalized.startsWith("/") || normalized.split("/").some((part) => !part || part === "." || part === "..")) {
661
+ throw new Error(`Invalid design asset path: ${pathInput}`)
662
+ }
663
+ return normalized
664
+ }
665
+
666
+ function listDesignPackageFiles(dir: string): string[] {
667
+ if (!existsSync(dir)) return []
668
+ const root = resolve(dir)
669
+ const files: string[] = []
670
+ walk(root)
671
+ return files.sort()
672
+
673
+ function walk(current: string): void {
674
+ for (const entry of readdirSync(current).sort()) {
675
+ if (entry === ".DS_Store" || entry.startsWith(".")) continue
676
+ const abs = join(current, entry)
677
+ const stat = lstatSync(abs)
678
+ if (stat.isSymbolicLink()) continue
679
+ if (stat.isDirectory()) {
680
+ walk(abs)
681
+ continue
682
+ }
683
+ if (!stat.isFile()) continue
684
+ files.push(relative(root, abs).split(sep).join("/"))
685
+ }
686
+ }
687
+ }
688
+
689
+ function listDesignAssetsInDir(dir: string): DesignPackageAssetInfo[] {
690
+ const files = listDesignPackageFiles(dir).filter((file) => file.startsWith("assets/"))
691
+ return files.map((file) => {
692
+ const abs = join(dir, ...file.split("/"))
693
+ return {
694
+ path: file,
695
+ kind: inferAssetKind(file),
696
+ mimeType: mimeTypeForDesignAsset(file),
697
+ bytes: statSync(abs).size,
698
+ }
699
+ })
700
+ }
701
+
702
+ export function listDesignAssets(nameInput?: string): DesignPackageAssetInfo[] {
703
+ const name = normalizeDesignName(nameInput || activeDesign())
704
+ const designDir = resolveDesignDir(name)
705
+ if (!designDir) throw new Error(`Design '${name}' is not installed`)
706
+ return listDesignAssetsInDir(designDir)
707
+ }
708
+
709
+ function inferAssetKind(path: string): DesignPackageAssetInfo["kind"] {
710
+ const lower = path.toLowerCase()
711
+ if (lower.includes("cover") && lower.includes("background")) return "cover-background"
712
+ if ((lower.includes("closing") || lower.includes("close")) && lower.includes("background")) return "closing-background"
713
+ if (lower.includes("background") || lower.includes("/bg-") || lower.includes("-bg.")) return "background"
714
+ if (lower.includes("logo")) return "logo"
715
+ return "asset"
716
+ }
717
+
718
+ function mimeTypeForDesignAsset(path: string): string {
719
+ const lower = path.toLowerCase()
720
+ if (lower.endsWith(".png")) return "image/png"
721
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"
722
+ if (lower.endsWith(".webp")) return "image/webp"
723
+ if (lower.endsWith(".gif")) return "image/gif"
724
+ if (lower.endsWith(".svg")) return "image/svg+xml"
725
+ if (lower.endsWith(".css")) return "text/css"
726
+ if (lower.endsWith(".json")) return "application/json"
727
+ return "application/octet-stream"
728
+ }
729
+
512
730
  // ---------------------------------------------------------------------------
513
731
  // Marker-based section / component parsing
514
732
  // ---------------------------------------------------------------------------
@@ -553,6 +771,7 @@ export interface DesignInventory {
553
771
  sections: string[]
554
772
  layouts: DesignInventoryLayout[]
555
773
  components: DesignInventoryComponent[]
774
+ assets: DesignPackageAssetInfo[]
556
775
  hasMarkers: boolean
557
776
  }
558
777
 
@@ -690,6 +909,7 @@ export function getDesignInventory(designName?: string): DesignInventory {
690
909
  description: designBlockDescription(content),
691
910
  nesting: inferComponentNesting(componentName),
692
911
  })),
912
+ assets: listDesignAssetsInDir(designDir),
693
913
  hasMarkers,
694
914
  }
695
915
  }