@cyber-dash-tech/revela 0.18.9 → 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-723%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.9 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.9
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.9 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-723%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.9 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.9
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.9 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
  }
@@ -11,11 +11,15 @@ import {
11
11
  getDesignLayout,
12
12
  getDesignSection,
13
13
  getDesignSkillMd,
14
+ installDesignArchive,
14
15
  installDesignDraftPackage,
16
+ listDesignAssets,
15
17
  listDesigns,
18
+ packDesignPackage,
16
19
  seedBuiltinDesigns,
17
20
  validateDesignDraftPackage,
18
21
  validateDesignPackage,
22
+ type DesignPackageAssetInput,
19
23
  } from "../design/designs"
20
24
  import { createDeckFoundation as createDeckFoundationShell } from "../deck-html/foundation"
21
25
  import { activeDomain, activateDomain, createDomainDraftPackage, createDomainPackage, getDomainSkillMd, installDomainDraftPackage, listDomains, seedBuiltinDomains, validateDomainDraftPackage, validateDomainPackage } from "../domain/domains"
@@ -85,6 +89,7 @@ export interface RuntimeDesignCreateInput {
85
89
  base?: string
86
90
  designMd: string
87
91
  previewHtml: string
92
+ assets?: DesignPackageAssetInput[]
88
93
  overwrite?: boolean
89
94
  }
90
95
 
@@ -103,6 +108,21 @@ export interface RuntimeDraftInstallInput extends RuntimeWorkspaceInput {
103
108
  overwrite?: boolean
104
109
  }
105
110
 
111
+ export interface RuntimeDesignPackInput extends RuntimeWorkspaceInput {
112
+ name: string
113
+ source?: "draft" | "installed"
114
+ outputPath?: string
115
+ format?: "tar.gz" | "tar"
116
+ overwrite?: boolean
117
+ }
118
+
119
+ export interface RuntimeDesignArchiveInstallInput {
120
+ archivePath: string
121
+ name?: string
122
+ overwrite?: boolean
123
+ activate?: boolean
124
+ }
125
+
106
126
  export interface RuntimeNameInput {
107
127
  name: string
108
128
  }
@@ -296,6 +316,7 @@ export function designRead(input: RuntimeDesignReadInput = {}) {
296
316
  name,
297
317
  section: input.section,
298
318
  markdown,
319
+ assets: listDesignAssets(name),
299
320
  }
300
321
  if (input.section === "rules") recordDesignRulesRead(root(input.workspaceRoot), name, markdown)
301
322
  return result
@@ -304,6 +325,7 @@ export function designRead(input: RuntimeDesignReadInput = {}) {
304
325
  ok: true,
305
326
  name,
306
327
  markdown: getDesignSkillMd(name),
328
+ assets: listDesignAssets(name),
307
329
  }
308
330
  }
309
331
 
@@ -341,6 +363,7 @@ export function designCreate(input: RuntimeDesignCreateInput) {
341
363
  base: input.base,
342
364
  designMd: requiredString(input?.designMd, "designMd"),
343
365
  previewHtml: requiredString(input?.previewHtml, "previewHtml"),
366
+ assets: input.assets,
344
367
  overwrite: input.overwrite ?? false,
345
368
  })
346
369
  }
@@ -356,6 +379,7 @@ export function designDraftCreate(input: RuntimeDesignDraftCreateInput) {
356
379
  base: input.base,
357
380
  designMd: requiredString(input?.designMd, "designMd"),
358
381
  previewHtml: requiredString(input?.previewHtml, "previewHtml"),
382
+ assets: input.assets,
359
383
  overwrite: input.overwrite ?? false,
360
384
  })
361
385
  }
@@ -373,6 +397,31 @@ export function designDraftInstall(input: RuntimeDraftInstallInput) {
373
397
  })
374
398
  }
375
399
 
400
+ export function designPack(input: RuntimeDesignPackInput) {
401
+ return packDesignPackage({
402
+ workspaceRoot: root(input.workspaceRoot),
403
+ name: requiredName(input, "design"),
404
+ source: input.source,
405
+ outputPath: input.outputPath,
406
+ format: input.format,
407
+ overwrite: input.overwrite ?? false,
408
+ })
409
+ }
410
+
411
+ export function designInstallArchive(input: RuntimeDesignArchiveInstallInput) {
412
+ seedBuiltinDesigns()
413
+ const installed = installDesignArchive({
414
+ archivePath: requiredString(input?.archivePath, "archivePath"),
415
+ name: input.name,
416
+ overwrite: input.overwrite ?? false,
417
+ })
418
+ if (input.activate) {
419
+ activateDesign(installed.name)
420
+ return { ...installed, activated: true, activeDesign: installed.name }
421
+ }
422
+ return { ...installed, activated: false }
423
+ }
424
+
376
425
  export interface DesignRulesReadinessResult {
377
426
  ok: boolean
378
427
  activeDesign: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.18.9",
3
+ "version": "0.18.10",
4
4
  "description": "OpenCode plugin for trusted narrative artifacts from local sources, research, and evidence",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "revela": {
4
4
  "command": "npx",
5
- "args": ["-y", "@cyber-dash-tech/revela@0.18.9", "mcp"]
5
+ "args": ["-y", "@cyber-dash-tech/revela@0.18.10", "mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -28,6 +28,8 @@ type RuntimeModule = {
28
28
  designDraftCreate(input: any): any
29
29
  designDraftValidate(input: any): any
30
30
  designDraftInstall(input: any): any
31
+ designPack(input: any): any
32
+ designInstallArchive(input: any): any
31
33
  domainList(): any
32
34
  domainRead(input?: any): any
33
35
  domainActivate(input: any): any
@@ -175,6 +177,7 @@ const tools = [
175
177
  base: stringProp("Optional base design used as structural scaffold."),
176
178
  designMd: requiredStringProp("Complete DESIGN.md content."),
177
179
  previewHtml: requiredStringProp("Complete preview.html content."),
180
+ assets: designAssetsProp(),
178
181
  overwrite: booleanProp("Whether to replace an existing local design package. Defaults to false."),
179
182
  }, ["name", "designMd", "previewHtml"]),
180
183
  },
@@ -192,6 +195,7 @@ const tools = [
192
195
  base: stringProp("Optional base design used as structural scaffold."),
193
196
  designMd: requiredStringProp("Complete DESIGN.md content."),
194
197
  previewHtml: requiredStringProp("Complete preview.html content."),
198
+ assets: designAssetsProp(),
195
199
  overwrite: booleanProp("Whether to replace an existing workspace draft. Defaults to false."),
196
200
  }, ["name", "designMd", "previewHtml"]),
197
201
  },
@@ -212,6 +216,28 @@ const tools = [
212
216
  overwrite: booleanProp("Whether to replace an existing user-level design package. Defaults to false."),
213
217
  }, ["name"]),
214
218
  },
219
+ {
220
+ name: "revela_design_pack",
221
+ description: "Package an installed or workspace-draft Revela design as a shareable .tar or .tar.gz archive.",
222
+ inputSchema: objectSchema({
223
+ workspaceRoot: stringProp("Optional workspace root. Used for draft source lookup and default output path."),
224
+ name: requiredStringProp("Design name in kebab-case."),
225
+ source: enumProp(["draft", "installed"], "Package a workspace draft or installed design. Defaults to draft when a matching draft exists."),
226
+ outputPath: stringProp("Optional archive output path. Defaults to .revela/design-archives/{name}.tar.gz under the workspace."),
227
+ format: enumProp(["tar.gz", "tar"], "Archive format. Defaults to tar.gz."),
228
+ overwrite: booleanProp("Whether to replace an existing archive. Defaults to false."),
229
+ }, ["name"]),
230
+ },
231
+ {
232
+ name: "revela_design_install_archive",
233
+ description: "Install a local .tar or .tar.gz Revela design archive into the user-level design registry.",
234
+ inputSchema: objectSchema({
235
+ archivePath: requiredStringProp("Local path to a .tar, .tar.gz, or .tgz design archive."),
236
+ name: stringProp("Optional installed design name override."),
237
+ overwrite: booleanProp("Whether to replace an existing user-level design package. Defaults to false."),
238
+ activate: booleanProp("Whether to activate the design after installation. Defaults to false."),
239
+ }, ["archivePath"]),
240
+ },
215
241
  {
216
242
  name: "revela_domain_list",
217
243
  description: "List installed Revela narrative domains and the active domain.",
@@ -410,6 +436,8 @@ async function callTool(name: string, args: any): Promise<any> {
410
436
  if (name === "revela_design_draft_create") return r.designDraftCreate(args)
411
437
  if (name === "revela_design_draft_validate") return r.designDraftValidate(args)
412
438
  if (name === "revela_design_draft_install") return r.designDraftInstall(args)
439
+ if (name === "revela_design_pack") return r.designPack(args)
440
+ if (name === "revela_design_install_archive") return r.designInstallArchive(args)
413
441
  if (name === "revela_domain_list") return r.domainList()
414
442
  if (name === "revela_domain_read") return r.domainRead(args)
415
443
  if (name === "revela_domain_activate") return r.domainActivate(args)
@@ -495,6 +523,24 @@ function arrayObjectProp(description: string) {
495
523
  }
496
524
  }
497
525
 
526
+ function designAssetsProp() {
527
+ return {
528
+ type: "array",
529
+ description: "Optional design-owned assets to write under assets/**. Each item must use path plus content, contentBase64, or sourcePath.",
530
+ items: {
531
+ type: "object",
532
+ properties: {
533
+ path: { type: "string", description: "Package-relative asset path. Must start with assets/." },
534
+ content: { type: "string", description: "UTF-8 text asset content." },
535
+ contentBase64: { type: "string", description: "Base64-encoded binary asset content." },
536
+ sourcePath: { type: "string", description: "Local file path to copy into the design asset." },
537
+ },
538
+ required: ["path"],
539
+ additionalProperties: false,
540
+ },
541
+ }
542
+ }
543
+
498
544
  function componentPlanArrayProp() {
499
545
  const childSchema: any = {
500
546
  type: "object",
@@ -1,17 +1,19 @@
1
1
  ---
2
2
  name: revela-design
3
- description: Create, edit, validate, install, activate, inspect, or list Revela design packages in Codex using design MCP tools.
3
+ description: Create, edit, validate, package, share, install, activate, inspect, or list Revela design packages in Codex using design MCP tools.
4
4
  ---
5
5
 
6
6
  # Revela Design
7
7
 
8
- Use this skill when the user asks to create, customize, edit, validate, install, activate, inspect, list, or switch a Revela design.
8
+ Use this skill when the user asks to create, customize, edit, validate, package, share, install, activate, inspect, list, or switch a Revela design.
9
9
 
10
10
  ## Contract
11
11
 
12
12
  - Designs define deck visual systems: rules, foundation, layouts, components, chart rules, and preview coverage.
13
+ - Designs may include package-owned `assets/**` such as cover or closing backgrounds; design tools surface these as design elements, not source evidence.
13
14
  - Default authoring is workspace draft first, then validate, then install only when appropriate.
14
15
  - Direct user-level creation is reserved for explicit create/install-now requests.
16
+ - Shareable design archives are `.tar` or `.tar.gz`; install archives only from trusted local paths.
15
17
  - Do not use domain tools for visual design work.
16
18
  - Do not generate deck HTML while authoring a design.
17
19
 
@@ -34,6 +36,14 @@ For new or edited designs:
34
36
  7. Call `revela_design_draft_install` only after the draft validates and the user intent is to install it.
35
37
  8. Call `revela_design_activate` only when the user asks to make it active.
36
38
 
39
+ For sharing or installing design archives:
40
+
41
+ 1. Call `revela_design_draft_validate` or `revela_design_validate` before packaging.
42
+ 2. Call `revela_design_pack` to create a `.tar.gz` archive from a workspace draft or installed design.
43
+ 3. Call `revela_design_install_archive` to install a local `.tar` or `.tar.gz` archive.
44
+ 4. After archive installation, call `revela_design_inventory` or `revela_design_read` to confirm the design and assets are readable.
45
+ 5. Call `revela_design_activate` only when the user asks to make the installed design active, or use `activate: true` on archive install when the request is explicit.
46
+
37
47
  Use `revela_design_create` only when the user explicitly requests direct local creation outside the workspace draft workflow. Follow it with `revela_design_validate`.
38
48
 
39
49
  ## Design Package Requirements
@@ -41,6 +51,7 @@ Use `revela_design_create` only when the user explicitly requests direct local c
41
51
  - Use a kebab-case design name.
42
52
  - `DESIGN.md` must include valid frontmatter and complete design marker sections.
43
53
  - Include design rules, foundation guidance, at least one layout, and at least one component.
54
+ - Optional assets must live under `assets/**`; reference them as package-relative paths like `assets/cover-background.png`.
44
55
  - `preview.html` must use the fixed Revela preview canvas contract and visibly preview the design.
45
56
  - Preview must include cover and closing examples and showcase every component.
46
57
  - Preserve source inspiration and limitations explicitly; do not copy copyrighted design text or assets into the package.
@@ -48,6 +59,8 @@ Use `revela_design_create` only when the user explicitly requests direct local c
48
59
  ## Outputs
49
60
 
50
61
  - Design draft path/status or installed design name.
62
+ - Archive path/status when packaging or installing a shareable design.
63
+ - Asset metadata surfaced by read/inventory tools when `assets/**` exists.
51
64
  - Validation result and any remaining diagnostics.
52
65
  - Whether the design was activated.
53
66
  - Next step, usually `revela-research` for planning with the design or `revela-make-deck` when a valid `deck-plan.md` already exists.
@@ -48,12 +48,21 @@ Use this skill to turn user intent and available workspace context into a concis
48
48
  - `## Audience`
49
49
  - `## Decision Or Action`
50
50
  - `## Output`
51
+ - `## Language`
52
+ - `## Domain / Use Case`
53
+ - `## Design`
51
54
  - `## Constraints`
52
55
  - `## Available Materials`
53
56
  - `## Known Gaps`
54
57
  - `## Acceptance Criteria`
55
58
  - `## Recommended Next Step`
56
59
 
60
+ Section expectations:
61
+
62
+ - `## Language`: output language, terminology preference, and localization notes.
63
+ - `## Domain / Use Case`: active or requested domain, business/use-case context, and decision context.
64
+ - `## Design`: active or requested design, visual direction, and brand/style constraints.
65
+
57
66
  Use explicit `Unknown` or `Pending user input` entries instead of inventing requirements.
58
67
 
59
68
  ## Outputs